{"name":"field","type":"registry:component","title":"Field","description":"Form field components for creating accessible form layouts with labels, descriptions, and error messages. Includes fieldset, legend, and field grouping utilities.","categories":["ui","form","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","registryDependencies":["@lit/input"],"dependencies":[],"files":[{"path":"registry/ui/field/field.ts","type":"registry:ui","content":"import { cva, type VariantProps } from \"class-variance-authority\";\nimport { css, html, LitElement, nothing } from \"lit\";\nimport { customElement, property } from \"lit/decorators.js\";\nimport { TW } from \"@/lib/tailwindMixin\";\nimport { cn } from \"@/lib/utils\";\n\nconst TwLitElement = TW(LitElement);\n\n@customElement(\"ui-field-set\")\nexport class FieldSet extends TwLitElement {\n  static styles = css`\n    :host {\n      display: block;\n    }\n  `;\n\n  override render() {\n    return html`\n      <fieldset\n        part=\"fieldset\"\n        data-slot=\"field-set\"\n        class=${cn(\n          \"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </fieldset>\n    `;\n  }\n}\n\n@customElement(\"ui-field-legend\")\nexport class FieldLegend extends TwLitElement {\n  static styles = css`\n    :host {\n      display: contents;\n    }\n  `;\n\n  @property({ type: String }) variant: \"legend\" | \"label\" = \"legend\";\n\n  override render() {\n    return html`\n      <legend\n        part=\"legend\"\n        data-slot=\"field-legend\"\n        data-variant=${this.variant}\n        class=${cn(\n          \"mb-3 font-medium data-[variant=legend]:text-base data-[variant=label]:text-sm\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </legend>\n    `;\n  }\n}\n\n@customElement(\"ui-field-group\")\nexport class FieldGroup extends TwLitElement {\n  static styles = css`\n    :host {\n      display: contents;\n    }\n  `;\n\n  override render() {\n    return html`\n      <div\n        part=\"group\"\n        data-slot=\"field-group\"\n        class=${cn(\n          \"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\nconst fieldVariants = cva(\n  \"group/field flex w-full gap-3 data-[invalid=true]:text-destructive\",\n  {\n    variants: {\n      orientation: {\n        vertical: [\"flex-col [&>*]:w-full [&>.sr-only]:w-auto\"],\n        horizontal: [\n          \"flex-row items-center\",\n          \"[&>[data-slot=field-label]]:flex-auto\",\n          \"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\",\n        ],\n        responsive: [\n          \"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto\",\n          \"@md/field-group:[&>[data-slot=field-label]]:flex-auto\",\n          \"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\",\n        ],\n      },\n    },\n    defaultVariants: {\n      orientation: \"vertical\",\n    },\n  },\n);\n\ntype FieldVariants = VariantProps<typeof fieldVariants>;\n\n@customElement(\"ui-field\")\nexport class Field extends TwLitElement {\n  static styles = css`\n    :host {\n      display: contents;\n    }\n  `;\n\n  @property({ type: String }) orientation: FieldVariants[\"orientation\"] =\n    \"vertical\";\n  @property({ type: Boolean, reflect: true }) invalid = false;\n\n  private get fieldClasses() {\n    return fieldVariants({ orientation: this.orientation });\n  }\n\n  override firstUpdated() {\n    this.updateAriaAttributes();\n  }\n\n  override updated(changedProperties: Map<string, unknown>) {\n    super.updated(changedProperties);\n    if (changedProperties.has(\"invalid\")) {\n      this.updateAriaAttributes();\n    }\n  }\n\n  private updateAriaAttributes() {\n    const description = this.querySelector(\"ui-field-description\");\n    const error = this.querySelector(\"ui-field-error\");\n    const label = this.querySelector(\"label\");\n    const input = this.querySelector(\"input, textarea, ui-select\");\n\n    if (!input) return;\n\n    const ariaIds: string[] = [];\n\n    // Add description ID if present\n    if (description?.id) {\n      ariaIds.push(description.id);\n    }\n\n    // Handle error message with aria-errormessage (best practice for 2025)\n    if (error?.id) {\n      if (this.invalid) {\n        input.setAttribute(\"aria-errormessage\", error.id);\n        input.setAttribute(\"aria-invalid\", \"true\");\n      } else {\n        input.removeAttribute(\"aria-errormessage\");\n        input.removeAttribute(\"aria-invalid\");\n      }\n    }\n\n    // Set aria-describedby if we have descriptions (for native inputs)\n    if (ariaIds.length > 0 && input.tagName !== \"UI-SELECT\") {\n      input.setAttribute(\"aria-describedby\", ariaIds.join(\" \"));\n    } else if (input.tagName !== \"UI-SELECT\") {\n      input.removeAttribute(\"aria-describedby\");\n    }\n\n    // Special handling for ui-select component\n    if (input.tagName === \"UI-SELECT\") {\n      const select = input;\n\n      // Get label text for aria-label (solves shadow DOM boundary issue)\n      const labelText = label?.textContent?.trim();\n      if (labelText) {\n        select.ariaLabel = labelText;\n      }\n\n      // Set aria-invalid on the select host element\n      // The Select component will propagate this to the internal button\n      if (this.invalid) {\n        select.setAttribute(\"aria-invalid\", \"true\");\n      } else {\n        select.removeAttribute(\"aria-invalid\");\n      }\n\n      // Note: We don't set aria-describedby or aria-errormessage because they\n      // reference IDs that won't cross the shadow DOM boundary in most browsers.\n      // Future enhancement: Use Reference Target API when widely supported.\n    }\n  }\n\n  override render() {\n    return html`\n      <div\n        role=\"group\"\n        part=\"field\"\n        data-slot=\"field\"\n        data-orientation=${this.orientation || nothing}\n        data-invalid=${this.invalid}\n        class=${cn(this.fieldClasses, this.className)}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n@customElement(\"ui-field-description\")\nexport class FieldDescription extends TwLitElement {\n  static styles = css`\n    :host {\n      display: block;\n    }\n  `;\n\n  @property({ type: String }) id = `field-description-${crypto.randomUUID()}`;\n\n  override render() {\n    return html`\n      <p\n        id=${this.id}\n        part=\"description\"\n        data-slot=\"field-description\"\n        class=${cn(\n          \"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5 [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </p>\n    `;\n  }\n}\n\n@customElement(\"ui-field-error\")\nexport class FieldError extends TwLitElement {\n  static styles = css`\n    :host {\n      display: block;\n    }\n  `;\n\n  @property({ type: String }) id = `field-error-${crypto.randomUUID()}`;\n\n  override render() {\n    return html`\n      <div\n        id=${this.id}\n        role=\"alert\"\n        aria-live=\"polite\"\n        part=\"error\"\n        data-slot=\"field-error\"\n        class=${cn(\"text-destructive text-sm font-normal\", this.className)}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-field\": Field;\n    \"ui-field-description\": FieldDescription;\n    \"ui-field-error\": FieldError;\n    \"ui-field-group\": FieldGroup;\n    \"ui-field-legend\": FieldLegend;\n    \"ui-field-set\": FieldSet;\n  }\n}\n"},{"path":"registry/ui/field/field.stories.ts","type":"registry:ui","content":"import \"./field\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\n\n/**\n * Combine labels, controls, and help text to compose accessible form fields and grouped inputs.\n */\nconst meta: Meta = {\n  title: \"ui/Field\",\n  component: \"ui-field\",\n  tags: [\"autodocs\"],\n  parameters: {\n    layout: \"centered\",\n  },\n};\n\nexport default meta;\n\ntype Story = StoryObj;\n\n/**\n * Field with Input component for text input.\n */\nexport const WithInput: Story = {\n  render: () => html`\n    <ui-field-set class=\"w-96\">\n      <ui-field-group>\n        <ui-field>\n          <label for=\"username\" class=\"ui-label\">Username</label>\n          <input\n          id=\"username\"\n          class=\"ui-input\"\n            type=\"text\"\n            placeholder=\"Max Leiter\"\n          ></input>\n          <ui-field-description>\n            Choose a unique username for your account.\n          </ui-field-description>\n        </ui-field>\n      </ui-field-group>\n    </ui-field-set>\n  `,\n};\n\n/**\n * Field with error state.\n */\nexport const WithError: Story = {\n  render: () => html`\n    <ui-field-set class=\"w-96\">\n      <ui-field-group>\n        <ui-field .invalid=${true}>\n          <label for=\"email-error\" class=\"ui-label\">Email</label>\n          <input\n          id=\"email-error\"\n          class=\"ui-input\"\n            type=\"email\"\n            placeholder=\"email@example.com\"\n          ></input>\n          <ui-field-error>Please enter a valid email address.</ui-field-error>\n        </ui-field>\n      </ui-field-group>\n    </ui-field-set>\n  `,\n};\n\n/**\n * FieldSet with multiple related fields.\n */\nexport const WithFieldset: Story = {\n  render: () => html`\n    <ui-field-set class=\"w-96\">\n      <ui-field-legend>Address Information</ui-field-legend>\n      <ui-field-description>\n        We need your address to deliver your order.\n      </ui-field-description>\n      <ui-field-group>\n        <ui-field>\n          <label for=\"street\" class=\"ui-label\">Street Address</label>\n          <input\n            id=\"street\"\n            class=\"ui-input\"\n            type=\"text\"\n            placeholder=\"123 Main St\"\n          ></input>\n        </ui-field>\n        <div class=\"grid grid-cols-2 gap-4\">\n          <ui-field>\n            <label for=\"city\" class=\"ui-label\">City</label>\n            <input id=\"city\" class=\"ui-input\" type=\"text\" placeholder=\"New York\"></input>\n          </ui-field>\n          <ui-field>\n            <label for=\"zip\" class=\"ui-label\">Postal Code</label>\n            <input id=\"zip\" class=\"ui-input\" type=\"text\" placeholder=\"90502\"></input>\n          </ui-field>\n        </div>\n      </ui-field-group>\n    </ui-field-set>\n  `,\n};\n"}]}