{"name":"select","type":"registry:component","title":"Select","description":"Displays a list of options for the user to pick from—triggered by a button. Built with Lit and styled using Tailwind CSS. Features keyboard navigation, typeahead search, and form integration.","categories":["ui","form","select","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","registryDependencies":["@lit/popover"],"dependencies":["lucide-static"],"files":[{"path":"registry/ui/select/select.ts","type":"registry:ui","content":"import { html, LitElement, nothing, type PropertyValues } from \"lit\";\nimport {\n  customElement,\n  property,\n  query,\n  queryAssignedElements,\n  state,\n} from \"lit/decorators.js\";\nimport { unsafeSVG } from \"lit/directives/unsafe-svg.js\";\nimport { Check, ChevronDown } from \"lucide-static\";\nimport { TW } from \"@/registry/lib/tailwindMixin\";\n\nexport interface SelectChangeEvent extends CustomEvent {\n  detail: { value: string };\n}\n\nexport interface SelectProperties {\n  value?: string;\n  name?: string;\n  disabled?: boolean;\n  required?: boolean;\n  open?: boolean;\n}\n\n@customElement(\"ui-select\")\nexport class Select extends TW(LitElement) implements SelectProperties {\n  static formAssociated = true;\n  private internals: ElementInternals;\n\n  @property({ type: String }) value = \"\";\n  @property({ type: String }) name = \"\";\n  @property({ type: Boolean }) disabled = false;\n  @property({ type: Boolean }) required = false;\n  @property({ type: Boolean, reflect: true }) open = false;\n  @property({ type: String, attribute: \"aria-label\", reflect: true })\n  ariaLabel: string | null = null;\n  @property({ type: String, attribute: \"aria-invalid\", reflect: true })\n  ariaInvalid: string | null = null;\n\n  @query(\"ui-select-trigger\") triggerElement?: HTMLElement;\n  @query(\"ui-popover\") popupElement?: HTMLElement;\n\n  private clickAwayHandler = (e: MouseEvent) => {\n    const popup = this.popupElement;\n    if (\n      this.open &&\n      !this.contains(e.target as Node) &&\n      (!popup || !popup.contains(e.target as Node))\n    ) {\n      this.open = false;\n    }\n  };\n\n  constructor() {\n    super();\n    this.internals = this.attachInternals();\n  }\n\n  override firstUpdated() {\n    setTimeout(() => {\n      this.updateTriggerAttributes();\n      this.updateTriggerAriaExpanded();\n    }, 0);\n  }\n\n  connectedCallback() {\n    super.connectedCallback();\n\n    this.addEventListener(\"trigger-click\", this.handleTriggerClick);\n    this.addEventListener(\"select-item-click\", this.handleItemClick);\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    this.removeEventListener(\"trigger-click\", this.handleTriggerClick);\n    this.removeEventListener(\"select-item-click\", this.handleItemClick);\n    document.removeEventListener(\"click\", this.clickAwayHandler);\n  }\n\n  private handleItemClick = (e: CustomEvent) => {\n    e.stopPropagation();\n    const newValue = e.detail.value;\n    const oldValue = this.value;\n\n    if (newValue !== oldValue) {\n      this.value = newValue;\n      this.internals.setFormValue(newValue);\n\n      this.dispatchEvent(\n        new CustomEvent(\"change\", {\n          detail: { value: newValue },\n          bubbles: true,\n          composed: true,\n        }),\n      );\n    }\n\n    this.open = false;\n  };\n\n  private handleTriggerClick = (e: Event) => {\n    e.stopPropagation();\n    if (!this.disabled) {\n      this.open = !this.open;\n    }\n  };\n\n  private handleKeyDown = (e: KeyboardEvent) => {\n    if (this.disabled) return;\n\n    if (\n      e.target instanceof HTMLElement &&\n      e.target.tagName === \"UI-SELECT-CONTENT\"\n    ) {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        this.open = false;\n        const trigger = this.querySelector(\"ui-select-trigger\");\n        const button = trigger?.shadowRoot?.querySelector(\"button\");\n        button?.focus();\n      }\n      return;\n    }\n\n    switch (e.key) {\n      case \"Enter\":\n      case \" \":\n        if (!this.open) {\n          e.preventDefault();\n          this.open = true;\n        }\n        break;\n      case \"Escape\":\n        if (this.open) {\n          e.preventDefault();\n          this.open = false;\n          const trigger = this.querySelector(\"ui-select-trigger\");\n          const button = trigger?.shadowRoot?.querySelector(\"button\");\n          button?.focus();\n        }\n        break;\n      case \"ArrowDown\":\n      case \"ArrowUp\":\n        if (!this.open) {\n          e.preventDefault();\n          this.open = true;\n        }\n        break;\n    }\n  };\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"value\")) {\n      this.internals.setFormValue(this.value);\n    }\n\n    if (changedProperties.has(\"open\")) {\n      this.updateTriggerAriaExpanded();\n      if (this.open) {\n        const contentElement = this.querySelector(\"ui-select-content\");\n\n        setTimeout(() => {\n          const listbox =\n            contentElement?.shadowRoot?.querySelector<HTMLElement>(\n              '[role=\"listbox\"]',\n            );\n          listbox?.focus();\n        }, 0);\n\n        setTimeout(() => {\n          document.addEventListener(\"click\", this.clickAwayHandler);\n        }, 0);\n      } else {\n        document.removeEventListener(\"click\", this.clickAwayHandler);\n      }\n    }\n\n    if (\n      changedProperties.has(\"disabled\") ||\n      changedProperties.has(\"ariaLabel\") ||\n      changedProperties.has(\"ariaInvalid\")\n    ) {\n      setTimeout(() => {\n        this.updateTriggerAttributes();\n      }, 0);\n    }\n  }\n\n  private updateTriggerAriaExpanded() {\n    const trigger = this.querySelector(\"ui-select-trigger\");\n    const button = trigger?.shadowRoot?.querySelector(\"button\");\n    if (button) {\n      button.setAttribute(\"aria-expanded\", String(this.open));\n    }\n  }\n\n  private updateTriggerAttributes() {\n    const trigger = this.querySelector(\"ui-select-trigger\");\n    const button = trigger?.shadowRoot?.querySelector(\"button\");\n    if (button) {\n      if (this.ariaLabel) {\n        button.setAttribute(\"aria-label\", this.ariaLabel);\n      } else {\n        button.removeAttribute(\"aria-label\");\n      }\n\n      if (this.ariaInvalid) {\n        button.setAttribute(\"aria-invalid\", this.ariaInvalid);\n      } else {\n        button.removeAttribute(\"aria-invalid\");\n      }\n\n      button.disabled = this.disabled;\n    }\n  }\n\n  formResetCallback() {\n    this.value = \"\";\n  }\n\n  formStateRestoreCallback(state: string) {\n    this.value = state;\n  }\n\n  override render() {\n    return html`\n      <ui-popover\n        .active=${this.open}\n        .anchor=${this.triggerElement}\n        placement=\"bottom-start\"\n        .distance=${8}\n        flip\n        shift\n        @keydown=${this.handleKeyDown}\n        @blur=${(e: FocusEvent) => {\n          if (!this.contains(e.relatedTarget as Node)) {\n            this.open = false;\n          }\n        }}\n      >\n        <slot name=\"trigger\" slot=\"anchor\"></slot>\n        <slot name=\"content\"></slot>\n      </ui-popover>\n    `;\n  }\n}\n\n@customElement(\"ui-select-trigger\")\nexport class SelectTrigger extends TW(LitElement) {\n  @property({ type: Boolean }) disabled = false;\n\n  private handleButtonClick = (e: Event) => {\n    if (this.disabled) return;\n    e.preventDefault();\n    this.dispatchEvent(\n      new CustomEvent(\"trigger-click\", {\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  override render() {\n    return html`\n      <button\n        type=\"button\"\n        role=\"combobox\"\n        aria-haspopup=\"listbox\"\n        class=\"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-all placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-[3px] focus:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30\"\n        ?disabled=${this.disabled}\n        @click=${this.handleButtonClick}\n      >\n        <slot></slot>\n        <span class=\"opacity-50\" aria-hidden=\"true\">\n          ${unsafeSVG(ChevronDown)}\n        </span>\n      </button>\n    `;\n  }\n}\n\n@customElement(\"ui-select-value\")\nexport class SelectValue extends TW(LitElement) {\n  @property({ type: String }) placeholder = \"Select...\";\n  @state() private selectedText = \"\";\n\n  connectedCallback() {\n    super.connectedCallback();\n    this.updateSelectedText();\n    this.addEventListener(\"value-changed\", this.updateSelectedText);\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    this.removeEventListener(\"value-changed\", this.updateSelectedText);\n  }\n\n  private updateSelectedText = () => {\n    const select = this.closest(\"ui-select\");\n    if (!select) return;\n\n    const items = Array.from(select.querySelectorAll(\"ui-select-item\"));\n    const selectedItem = items.find((item) => item.value === select.value);\n\n    this.selectedText = selectedItem?.textContent?.trim() || \"\";\n  };\n\n  override render() {\n    const hasValue = this.selectedText !== \"\";\n\n    return html`\n      <span class=${hasValue ? \"\" : \"text-muted-foreground\"}>\n        ${hasValue ? this.selectedText : this.placeholder}\n      </span>\n    `;\n  }\n}\n\n@customElement(\"ui-select-content\")\nexport class SelectContent extends TW(LitElement) {\n  @state() private isOpen = false;\n  @state() private highlightedIndex = -1;\n  @state() private triggerWidth = \"auto\";\n\n  @queryAssignedElements({ selector: \"ui-select-item\" })\n  items!: Array<SelectItem>;\n\n  connectedCallback() {\n    super.connectedCallback();\n    this.updateOpenState();\n    this.updateTriggerWidth();\n    this.addEventListener(\"slotchange\", this.handleSlotChange);\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    this.removeEventListener(\"slotchange\", this.handleSlotChange);\n  }\n\n  private handleSlotChange = () => {\n    this.requestUpdate();\n  };\n\n  private updateTriggerWidth() {\n    const select = this.closest(\"ui-select\");\n    if (!select) return;\n\n    const trigger = select.querySelector(\"ui-select-trigger\");\n    if (trigger) {\n      const width = trigger.getBoundingClientRect().width;\n      this.triggerWidth = `${width}px`;\n    }\n  }\n\n  private updateOpenState() {\n    const select = this.closest(\"ui-select\");\n    if (!select) return;\n\n    const observer = new MutationObserver(() => {\n      this.isOpen = select.open;\n      if (this.isOpen) {\n        this.updateHighlightedIndex();\n        this.updateTriggerWidth();\n      }\n    });\n\n    observer.observe(select, { attributes: true, attributeFilter: [\"open\"] });\n    this.isOpen = select.open;\n  }\n\n  private updateHighlightedIndex() {\n    const select = this.closest(\"ui-select\");\n    if (!select) return;\n\n    const items = this.items.filter((item) => !item.disabled);\n    const selectedIndex = items.findIndex(\n      (item) => item.value === select.value,\n    );\n    this.highlightedIndex = selectedIndex >= 0 ? selectedIndex : 0;\n\n    if (items[this.highlightedIndex]) {\n      items[this.highlightedIndex].highlighted = true;\n    }\n  }\n\n  private handleKeyDown = (e: KeyboardEvent) => {\n    const items = this.items.filter((item) => !item.disabled);\n    if (items.length === 0) return;\n\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        e.stopPropagation();\n        this.moveHighlight(1, items);\n        break;\n      case \"ArrowUp\":\n        e.preventDefault();\n        e.stopPropagation();\n        this.moveHighlight(-1, items);\n        break;\n      case \"Enter\":\n      case \" \":\n        e.preventDefault();\n        e.stopPropagation();\n        if (this.highlightedIndex >= 0 && items[this.highlightedIndex]) {\n          const item = items[this.highlightedIndex];\n          const div =\n            item.shadowRoot?.querySelector<HTMLElement>('[role=\"option\"]');\n          if (div) {\n            div.click();\n          } else {\n            item.dispatchEvent(\n              new CustomEvent(\"select-item-click\", {\n                detail: { value: item.value },\n                bubbles: true,\n                composed: true,\n              }),\n            );\n          }\n        }\n        break;\n      case \"Home\":\n        e.preventDefault();\n        e.stopPropagation();\n        this.highlightedIndex = 0;\n        this.updateHighlightAttributes(items);\n        break;\n      case \"End\":\n        e.preventDefault();\n        e.stopPropagation();\n        this.highlightedIndex = items.length - 1;\n        this.updateHighlightAttributes(items);\n        break;\n      case \"Escape\":\n        break;\n      default:\n        if (e.key.length === 1) {\n          e.stopPropagation();\n          this.handleTypeahead(e.key, items);\n        }\n    }\n  };\n\n  private moveHighlight(direction: number, items: SelectItem[]) {\n    this.highlightedIndex = Math.max(\n      0,\n      Math.min(items.length - 1, this.highlightedIndex + direction),\n    );\n    this.updateHighlightAttributes(items);\n  }\n\n  private updateHighlightAttributes(items: SelectItem[]) {\n    items.forEach((item, index) => {\n      if (index === this.highlightedIndex) {\n        item.highlighted = true;\n        item.scrollIntoView({ block: \"nearest\" });\n      } else {\n        item.highlighted = false;\n      }\n    });\n  }\n\n  private typeaheadTimeout?: number;\n  private typeaheadString = \"\";\n\n  private handleTypeahead(key: string, items: SelectItem[]) {\n    clearTimeout(this.typeaheadTimeout);\n    this.typeaheadString += key.toLowerCase();\n\n    const matchIndex = items.findIndex((item) =>\n      item.textContent?.toLowerCase().trim().startsWith(this.typeaheadString),\n    );\n\n    if (matchIndex >= 0) {\n      this.highlightedIndex = matchIndex;\n      this.updateHighlightAttributes(items);\n    }\n\n    this.typeaheadTimeout = window.setTimeout(() => {\n      this.typeaheadString = \"\";\n    }, 500);\n  }\n\n  override render() {\n    if (!this.isOpen) return nothing;\n\n    return html`\n      <div\n        role=\"listbox\"\n        tabindex=\"0\"\n        style=\"width: ${this.triggerWidth};\"\n        class=\"overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md animate-in fade-in-80\"\n        @keydown=${this.handleKeyDown}\n      >\n        <div class=\"max-h-[300px] overflow-y-auto p-1\">\n          <slot></slot>\n        </div>\n      </div>\n    `;\n  }\n}\n\n@customElement(\"ui-select-item\")\nexport class SelectItem extends TW(LitElement) {\n  @property({ type: String }) value = \"\";\n  @property({ type: Boolean }) disabled = false;\n  @property({ type: Boolean }) highlighted = false;\n\n  private handleClick = () => {\n    if (this.disabled) return;\n\n    this.dispatchEvent(\n      new CustomEvent(\"select-item-click\", {\n        detail: { value: this.value },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n\n    const select = this.closest(\"ui-select\");\n    const valueElement = select?.querySelector(\"ui-select-value\");\n    if (valueElement) {\n      valueElement.dispatchEvent(new CustomEvent(\"value-changed\"));\n    }\n  };\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    const select = this.closest(\"ui-select\");\n    if (select && changedProperties.has(\"value\")) {\n      const isSelected = this.value === select.value;\n      this.setAttribute(\"aria-selected\", String(isSelected));\n      if (isSelected) {\n        this.setAttribute(\"data-state\", \"checked\");\n      } else {\n        this.setAttribute(\"data-state\", \"unchecked\");\n      }\n    }\n  }\n\n  override render() {\n    const select = this.closest(\"ui-select\");\n    const isSelected = this.value === select?.value || false;\n\n    return html`\n      <div\n        role=\"option\"\n        aria-selected=${isSelected}\n        class=\"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground\"\n        ?data-disabled=${this.disabled}\n        ?data-highlighted=${this.highlighted}\n        @click=${this.handleClick}\n      >\n        <slot></slot>\n        ${\n          isSelected\n            ? html`\n              <span\n                class=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\"\n              >\n                ${unsafeSVG(Check)}\n              </span>\n            `\n            : nothing\n        }\n      </div>\n    `;\n  }\n}\n\n@customElement(\"ui-select-group\")\nexport class SelectGroup extends TW(LitElement) {\n  override render() {\n    return html`\n      <div role=\"group\" class=\"py-1\">\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n@customElement(\"ui-select-label\")\nexport class SelectLabel extends TW(LitElement) {\n  override render() {\n    return html`\n      <div class=\"px-2 py-1.5 text-sm font-semibold text-muted-foreground\">\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n@customElement(\"ui-select-separator\")\nexport class SelectSeparator extends TW(LitElement) {\n  override render() {\n    return html`\n      <div\n        role=\"separator\"\n        class=\"-mx-1 my-1 h-px bg-border\"\n        aria-orientation=\"horizontal\"\n      ></div>\n    `;\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-select\": Select;\n    \"ui-select-trigger\": SelectTrigger;\n    \"ui-select-value\": SelectValue;\n    \"ui-select-content\": SelectContent;\n    \"ui-select-item\": SelectItem;\n    \"ui-select-group\": SelectGroup;\n    \"ui-select-label\": SelectLabel;\n    \"ui-select-separator\": SelectSeparator;\n  }\n\n  interface HTMLElementEventMap {\n    \"select-item-click\": CustomEvent<{ value: string }>;\n    \"trigger-click\": CustomEvent;\n  }\n}\n"},{"path":"registry/ui/select/select.stories.ts","type":"registry:ui","content":"import \"./select\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport type { SelectProperties } from \"./select\";\n\ntype SelectArgs = SelectProperties & {\n  placeholder?: string;\n};\n\n/**\n * Displays a list of options for the user to pick from—triggered by a button.\n */\nconst meta: Meta<SelectArgs> = {\n  title: \"ui/Select\",\n  component: \"ui-select\",\n  tags: [\"autodocs\"],\n  parameters: {\n    layout: \"centered\",\n  },\n  argTypes: {\n    value: { control: \"text\" },\n    disabled: { control: \"boolean\" },\n    required: { control: \"boolean\" },\n    open: { control: \"boolean\" },\n    placeholder: { control: \"text\" },\n  },\n  args: {\n    value: \"\",\n    disabled: false,\n    required: false,\n    open: false,\n    placeholder: \"Select a fruit\",\n  },\n  render: (args) => html`\n    <ui-select\n      class=\"w-96\"\n      .value=${args.value || \"\"}\n      .disabled=${args.disabled || false}\n      .required=${args.required || false}\n      .open=${args.open || false}\n    >\n      <ui-select-trigger slot=\"trigger\">\n        <ui-select-value\n          placeholder=${args.placeholder || \"\"}\n        ></ui-select-value>\n      </ui-select-trigger>\n      <ui-select-content slot=\"content\">\n        <ui-select-group>\n          <ui-select-label>Fruits</ui-select-label>\n          <ui-select-item value=\"apple\">Apple</ui-select-item>\n          <ui-select-item value=\"banana\">Banana</ui-select-item>\n          <ui-select-item value=\"blueberry\">Blueberry</ui-select-item>\n          <ui-select-item value=\"grapes\">Grapes</ui-select-item>\n          <ui-select-item value=\"pineapple\">Pineapple</ui-select-item>\n        </ui-select-group>\n        <ui-select-separator></ui-select-separator>\n        <ui-select-group>\n          <ui-select-label>Vegetables</ui-select-label>\n          <ui-select-item value=\"aubergine\">Aubergine</ui-select-item>\n          <ui-select-item value=\"broccoli\">Broccoli</ui-select-item>\n          <ui-select-item value=\"carrot\" .disabled=${true}\n            >Carrot</ui-select-item\n          >\n          <ui-select-item value=\"courgette\">Courgette</ui-select-item>\n          <ui-select-item value=\"leek\">Leek</ui-select-item>\n        </ui-select-group>\n        <ui-select-separator></ui-select-separator>\n        <ui-select-group>\n          <ui-select-label>Meat</ui-select-label>\n          <ui-select-item value=\"beef\">Beef</ui-select-item>\n          <ui-select-item value=\"chicken\">Chicken</ui-select-item>\n          <ui-select-item value=\"lamb\">Lamb</ui-select-item>\n          <ui-select-item value=\"pork\">Pork</ui-select-item>\n        </ui-select-group>\n      </ui-select-content>\n    </ui-select>\n  `,\n};\n\nexport default meta;\n\ntype Story = StoryObj<SelectArgs>;\n\n/**\n * The default form of the select.\n */\nexport const Default: Story = {};\n"}]}