{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "select",
  "title": "Select",
  "type": "registry:ui",
  "description": "Dropdown select primitive — single-select with optional groups, per-item descriptions, Radix UI popover mode, and zero-JS styled NativeSelect for lightweight/mobile use.",
  "categories": [
    "control"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/select/native-select.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { ChevronDown } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\nexport interface NativeSelectOption {\n  label: string\n  value: string | number\n  disabled?: boolean\n}\n\nexport type NativeSelectSize = 'sm' | 'md' | 'lg'\n\n// React's NativeSelect maps, verbatim.\nconst sizeClasses: Record<NativeSelectSize, string> = {\n  sm: 'h-8 text-xs pl-2.5 pr-8',\n  md: 'h-9 text-sm pl-3 pr-9',\n  lg: 'h-11 text-base pl-4 pr-10',\n}\n\nconst iconSizes: Record<NativeSelectSize, string> = {\n  sm: 'size-3.5 right-2.5',\n  md: 'size-4 right-3',\n  lg: 'size-5 right-3.5',\n}\n\ninterface Opt {\n  value: string\n  label: string\n  disabled: boolean\n}\n\n/**\n * <uip-native-select> — the registry NativeSelect: a real <select> styled\n * with tokens plus a chevron (no custom listbox, so mobile gets the OS picker).\n *\n *   <uip-native-select default-value=\"banana\" options='[{\"label\":\"Apple\",\"value\":\"apple\"}, …]'></uip-native-select>\n *   <uip-native-select name=\"fruit\">\n *     <option value=\"apple\">Apple</option>\n *     <optgroup label=\"Citrus\"><option value=\"lime\">Lime</option></optgroup>\n *   </uip-native-select>\n *\n * Options come from the `options` property / JSON attribute (strings or\n * `{ label, value, disabled }`) or, when that is empty, from light-DOM\n * <option> / <optgroup> children (read and re-rendered inside the shadow\n * <select>, since a <select> can't take slotted options).\n *\n * `size-variant` = React's `sizeVariant` (sm | md | lg). `value` is the live\n * value, `default-value` seeds it and is what a form reset restores. Host\n * `class` = React's `className` (the wrapper); the select itself is\n * `part=\"base\"` (React's `selectClassName` → `class=\"[&::part(base)]:…\"`), the\n * chevron `part=\"icon\"`.\n *\n * `multiple` (React inherits it from SelectHTMLAttributes): `value` /\n * `default-value` become string[] (set via property; declarative HTML can use\n * multiple `<option selected>`), the form submits one name=value entry per\n * selection, and the chevron hides (a multi-select renders as a listbox).\n * `size` (native visible-row count) is forwarded to the inner <select>.\n *\n * Form-associated (`name`, `required`, reset, disabled fieldset). Forwards\n * `aria-label`. Events: `input` and `change` (bubbling, composed; read `.value`).\n */\nexport class UipNativeSelect extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: {},\n    defaultValue: { attribute: 'default-value' },\n    options: {\n      converter: {\n        fromAttribute: (v: string | null) => {\n          if (!v) return []\n          try {\n            return JSON.parse(v)\n          } catch {\n            return []\n          }\n        },\n      },\n    },\n    sizeVariant: { attribute: 'size-variant', reflect: true },\n    multiple: { type: Boolean, reflect: true },\n    size: { type: Number, reflect: true },\n    name: { reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    required: { type: Boolean, reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    childOptions: { state: true },\n  }\n\n  value?: string | string[]\n  defaultValue?: string | string[]\n  options: (NativeSelectOption | string)[] = []\n  sizeVariant: NativeSelectSize = 'md'\n  multiple = false\n  size?: number\n  name?: string\n  disabled = false\n  required = false\n  accessibleLabel?: string\n  private childOptions: { label?: string; disabled: boolean; items: Opt[] }[] = []\n  private internals = this.attachInternals()\n  private childObserver?: MutationObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'native-select-wrapper')\n    this.readChildren()\n    this.childObserver = new MutationObserver(() => this.readChildren())\n    this.childObserver.observe(this, { childList: true, subtree: true, characterData: true, attributes: true })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.childObserver?.disconnect()\n  }\n\n  private readChildren() {\n    const toOpt = (o: HTMLOptionElement): Opt => ({ value: o.value, label: o.textContent?.trim() ?? '', disabled: o.disabled })\n    const groups: typeof this.childOptions = []\n    for (const el of this.children) {\n      if (el instanceof HTMLOptionElement) {\n        const last = groups[groups.length - 1]\n        if (last && last.label === undefined) last.items.push(toOpt(el))\n        else groups.push({ disabled: false, items: [toOpt(el)] })\n      } else if (el instanceof HTMLOptGroupElement) {\n        groups.push({ label: el.label, disabled: el.disabled, items: [...el.querySelectorAll('option')].map(toOpt) })\n      }\n    }\n    this.childOptions = groups\n    // Light-DOM <option selected> seeds the value, like a native select\n    // (all of them when `multiple`).\n    const sel = [...this.querySelectorAll<HTMLOptionElement>('option[selected]')].map((o) => o.value)\n    if (sel.length && this.value === undefined && this.defaultValue === undefined) {\n      this.defaultValue = this.multiple ? sel : sel[0]\n    }\n  }\n\n  private get select() {\n    return this.renderRoot?.querySelector('select')\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('defaultValue') && this.value === undefined) this.value = this.defaultValue\n    // Keep `value`'s shape in sync when `multiple` toggles.\n    if (changed.has('multiple') && this.value !== undefined) {\n      if (this.multiple && !Array.isArray(this.value)) this.value = this.value === '' ? [] : [this.value]\n      else if (!this.multiple && Array.isArray(this.value)) this.value = this.value[0] ?? ''\n    }\n  }\n\n  /** Live selection as an array (single-select: 0–1 entries). */\n  private selectedValues(): string[] {\n    if (Array.isArray(this.value)) return this.value\n    return this.value === undefined || this.value === '' ? [] : [this.value]\n  }\n\n  protected updated() {\n    const s = this.select\n    if (!s) return\n    // icon() takes no extra attributes; tag the chevron like React does.\n    const chevron = this.renderRoot.querySelector('svg')\n    chevron?.setAttribute('data-slot', 'native-select-icon')\n    chevron?.setAttribute('part', 'icon')\n    // Keep the rendered <select> on `value` once its options exist.\n    if (this.multiple) {\n      const want = new Set(this.selectedValues())\n      for (const o of s.options) o.selected = want.has(o.value)\n      if (this.value === undefined) this.value = [...s.selectedOptions].map((o) => o.value)\n    } else {\n      if (this.value !== undefined && s.value !== this.value) s.value = this.value as string\n      if (this.value === undefined) this.value = s.value\n    }\n    const selected = this.multiple ? [...s.selectedOptions].map((o) => o.value) : s.value\n    const empty = this.multiple ? selected.length === 0 : !selected\n    if (this.multiple) {\n      if (this.name && selected.length) {\n        const fd = new FormData()\n        for (const v of selected as string[]) fd.append(this.name, v)\n        this.internals.setFormValue(fd)\n      } else {\n        this.internals.setFormValue(null)\n      }\n    } else {\n      this.internals.setFormValue((selected as string) || null)\n    }\n    if (this.required && empty) this.internals.setValidity({ valueMissing: true }, 'Please select an option.', s)\n    else this.internals.setValidity({})\n  }\n\n  formResetCallback() {\n    if (this.defaultValue !== undefined) this.value = this.defaultValue\n    else this.value = this.multiple ? [] : this.firstEnabledValue()\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private firstEnabledValue() {\n    const opts = this.normalised().flatMap((g) => (g.disabled ? [] : g.items)).filter((o) => !o.disabled)\n    return opts[0]?.value\n  }\n\n  private normalised() {\n    if (this.options?.length)\n      return [\n        {\n          label: undefined,\n          disabled: false,\n          items: this.options.map((o): Opt =>\n            typeof o === 'string'\n              ? { value: o, label: o, disabled: false }\n              : { value: String(o.value), label: o.label, disabled: !!o.disabled },\n          ),\n        },\n      ]\n    return this.childOptions\n  }\n\n  private readSelectValue(el: HTMLSelectElement): string | string[] {\n    return this.multiple ? [...el.selectedOptions].map((o) => o.value) : el.value\n  }\n\n  private onInput(e: Event) {\n    this.value = this.readSelectValue(e.target as HTMLSelectElement)\n    // The native `input` event is composed: it already reaches the host.\n  }\n\n  private onChange(e: Event) {\n    this.value = this.readSelectValue(e.target as HTMLSelectElement)\n    // `change` is not composed — re-dispatch it from the host.\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n  }\n\n  render() {\n    const size = sizeClasses[this.sizeVariant] ? this.sizeVariant : 'md'\n    const selected = new Set(this.selectedValues())\n    const isSelected = (v: string) => (this.multiple ? selected.has(v) : v === this.value)\n    const renderOpt = (o: Opt) =>\n      html`<option value=${o.value} ?disabled=${o.disabled} ?selected=${isSelected(o.value)}>${o.label}</option>`\n    return html`<div class=\"relative inline-flex w-full items-center\">\n      <select\n        part=\"base\"\n        data-slot=\"native-select\"\n        name=${this.name ?? nothing}\n        aria-label=${this.accessibleLabel ?? nothing}\n        ?disabled=${this.disabled}\n        ?required=${this.required}\n        ?multiple=${this.multiple}\n        size=${this.size ?? nothing}\n        class=${cn(\n          'border-input bg-background w-full appearance-none rounded-md border shadow-xs transition-[color,box-shadow]',\n          'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none',\n          'disabled:bg-muted/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',\n          sizeClasses[size],\n        )}\n        @input=${this.onInput}\n        @change=${this.onChange}\n      >\n        ${this.normalised().map((g) =>\n          g.label === undefined\n            ? g.items.map(renderOpt)\n            : html`<optgroup label=${g.label} ?disabled=${g.disabled}>${g.items.map(renderOpt)}</optgroup>`,\n        )}\n      </select>\n      ${this.multiple\n        ? nothing\n        : icon(\n            ChevronDown,\n            'chevron-down',\n            cn(\n              'text-muted-foreground pointer-events-none absolute transition-opacity',\n              this.disabled && 'opacity-50',\n              iconSizes[size],\n            ),\n          )}\n    </div>`\n  }\n}\n\ncustomElements.get('uip-native-select') || customElements.define('uip-native-select', UipNativeSelect)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-native-select': UipNativeSelect\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/select/native-select.ts"
    },
    {
      "path": "packages/elements-lit/src/components/select/select.ts",
      "content": "import { LitElement, css, html, nothing, type TemplateResult } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { Check, ChevronDown, ChevronUp, Loader } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\nimport { computePosition, type Side } from '../../lib/position'\n\ninterface Opt {\n  value: string\n  label: string\n  disabled: boolean\n  part?: string\n}\n\n/** Render rows, in document order: groups (with their label) hold item indexes. */\ntype Row =\n  | { kind: 'item'; index: number }\n  | { kind: 'separator'; part?: string }\n  | { kind: 'group'; label: string; part?: string; items: number[] }\n\nexport type SelectSize = 'sm' | 'default' | 'lg'\nexport type SelectState = 'default' | 'error' | 'success'\n\n// React's SelectTrigger maps, verbatim.\nconst triggerSizeClasses: Record<SelectSize, string> = {\n  sm: 'h-8 text-sm px-2.5 py-1.5',\n  default: 'h-9 text-sm px-3 py-2',\n  lg: 'h-11 text-base px-4 py-2.5',\n}\n\nconst triggerStateClasses: Record<SelectState, string> = {\n  default: 'border-input dark:hover:bg-input/50',\n  error:\n    'border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',\n  success: 'border-success focus-visible:border-success',\n}\n\n// React's SelectItem string; `focus:` → `data-[highlighted]:` because focus\n// stays on the trigger (aria-activedescendant) instead of moving to the item.\nconst itemClasses =\n  \"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\"\n\n// Radix scrolls the viewport by one item every 50ms while the pointer is on a\n// scroll button.\nconst AUTO_SCROLL_MS = 50\n\nlet uid = 0\n\n/**\n * <uip-select> — the registry Select (Select + SelectTrigger + SelectValue +\n * SelectContent + SelectGroup/Label/Item/Separator + scroll buttons) as ONE\n * web component.\n *\n *   <uip-select placeholder=\"Choose a country\" class=\"w-56\">\n *     <optgroup label=\"Europe\">\n *       <option value=\"france\">France</option>\n *       <option value=\"spain\" disabled>Spain</option>\n *     </optgroup>\n *     <hr />\n *     <option value=\"japan\">Japan</option>\n *   </uip-select>\n *\n * Children are data, read but not rendered: `<option>` → SelectItem\n * (`disabled` → disabled item), `<optgroup label>` → SelectGroup + SelectLabel,\n * `<hr>` → SelectSeparator. The trigger (role=combobox) and the listbox live\n * in the same shadow root, so aria-controls / aria-activedescendant /\n * aria-labelledby resolve — they would NOT across a shadow boundary, which is\n * why this isn't split into Radix-style parts. Focus stays on the trigger\n * (activedescendant pattern); the listbox is a native popover (top layer,\n * light dismiss) positioned under the trigger (flipped above when there's no\n * room). When the list overflows, scroll up/down buttons appear (Radix\n * behaviour: hover to auto-scroll).\n *\n * Keyboard: ArrowUp/Down/Enter/Space open; in the list ArrowUp/Down, Home/End,\n * typeahead, Enter/Space select, Escape/Tab close. Typeahead on the closed\n * trigger selects the matching option (like Radix).\n *\n * Trigger props (React's SelectTrigger): `size` (sm | default | lg), `state`\n * (default | error | success → data-state-value), `loading` (spinner, busy,\n * disabled). `aria-invalid` is forwarded to the trigger.\n *\n * Parts (style from the host with `class=\"[&::part(content)]:max-h-56\"`):\n * `trigger`, `value`, `icon`, `content`, `viewport`, `scroll-up-button`,\n * `scroll-down-button`, `group`, `label`, `item`, `separator`. A `part`\n * attribute on an <option>, <optgroup> or <hr> is added to that row's parts.\n *\n * Form-associated: submits `name=value` with its <form>, supports `required`,\n * resets with the form. Emits `input` and `change` (value on `.value`) and\n * `open-change` (detail: { open }).\n *\n * Controlled open (React's `open` / `onOpenChange`): `open` reflects whether\n * the list is shown, `show()` / `hide()` open and close it. Setting `open`\n * directly also works (popover syncs in `updated()`); user interactions\n * (click, keys, light-dismiss) update `open` and emit `open-change`.\n */\nexport class UipSelect extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: inline-block; }`]\n\n  static properties = {\n    value: {},\n    placeholder: {},\n    name: { reflect: true },\n    size: { reflect: true },\n    state: { reflect: true },\n    loading: { type: Boolean, reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    required: { type: Boolean, reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    ariaInvalidAttr: { attribute: 'aria-invalid' },\n    options: { state: true },\n    rows: { state: true },\n    open: { type: Boolean, reflect: true },\n    active: { state: true },\n    pos: { state: true },\n    side: { state: true },\n    canScrollUp: { state: true },\n    canScrollDown: { state: true },\n  }\n\n  value = ''\n  placeholder = 'Select…'\n  name?: string\n  size: SelectSize = 'default'\n  state: SelectState = 'default'\n  loading = false\n  disabled = false\n  required = false\n  accessibleLabel?: string\n  ariaInvalidAttr?: string\n  open = false\n  private options: Opt[] = []\n  private rows: Row[] = []\n  private active = -1\n  private pos: Record<string, string> = {}\n  private side: Side = 'bottom'\n  private canScrollUp = false\n  private canScrollDown = false\n  private defaultValue = ''\n  private typeahead = ''\n  // Popover light-dismiss closes the list on pointerdown outside it — which\n  // includes the trigger — so remember the state before that click toggles.\n  private openAtPointerDown = false\n  private typeaheadTimer?: ReturnType<typeof setTimeout>\n  private autoScrollTimer?: ReturnType<typeof setInterval>\n  private readonly uidBase = `uip-select-${++uid}`\n  private internals = this.attachInternals()\n  private childObserver?: MutationObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'select')\n    this.readOptions()\n    this.defaultValue = this.value\n    // Frameworks render <option> children after the host connects, and may\n    // change them later — keep the list in sync.\n    this.childObserver = new MutationObserver(() => this.readOptions())\n    this.childObserver.observe(this, { childList: true, subtree: true, characterData: true, attributes: true })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.childObserver?.disconnect()\n    this.stopAutoScroll()\n    this.removeViewportListeners()\n  }\n\n  private readOptions() {\n    const options: Opt[] = []\n    const rows: Row[] = []\n    const toOpt = (o: HTMLOptionElement, groupDisabled = false): number => {\n      options.push({\n        value: o.value,\n        label: o.textContent?.trim() ?? '',\n        disabled: o.disabled || groupDisabled,\n        part: o.getAttribute('part') ?? undefined,\n      })\n      return options.length - 1\n    }\n    for (const el of this.children) {\n      if (el instanceof HTMLOptionElement) rows.push({ kind: 'item', index: toOpt(el) })\n      else if (el instanceof HTMLOptGroupElement)\n        rows.push({\n          kind: 'group',\n          label: el.label,\n          part: el.getAttribute('part') ?? undefined,\n          items: [...el.querySelectorAll('option')].map((o) => toOpt(o, el.disabled)),\n        })\n      else if (el instanceof HTMLHRElement) rows.push({ kind: 'separator', part: el.getAttribute('part') ?? undefined })\n    }\n    this.options = options\n    this.rows = rows\n  }\n\n  private get isDisabled() {\n    return this.disabled || this.loading\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value') || changed.has('required')) {\n      this.internals.setFormValue(this.value || null)\n      if (this.required && !this.value) {\n        this.internals.setValidity({ valueMissing: true }, 'Please select an option.', this.trigger ?? undefined)\n      } else {\n        this.internals.setValidity({})\n      }\n    }\n  }\n\n  // --- form callbacks -------------------------------------------------------\n  formResetCallback() {\n    this.value = this.defaultValue\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  // --- open / close ----------------------------------------------------------\n  private get trigger() {\n    return this.renderRoot?.querySelector<HTMLButtonElement>('[role=combobox]')\n  }\n  private get content() {\n    return this.renderRoot?.querySelector<HTMLElement>('[data-slot=select-content]')\n  }\n  private get viewport() {\n    return this.renderRoot?.querySelector<HTMLElement>('[role=listbox]')\n  }\n\n  /** Open the list (public controlled-open API; also `open = true`). */\n  show() {\n    this.setOpen(true)\n  }\n\n  /** Close the list (public controlled-open API; also `open = false`). */\n  hide() {\n    this.setOpen(false)\n  }\n\n  private setOpen(open: boolean) {\n    if (this.open === open) return\n    if (open && this.isDisabled) return\n    if (open) {\n      const selected = this.options.findIndex((o) => o.value === this.value)\n      this.active = selected >= 0 ? selected : this.nextEnabled(-1, 1)\n    }\n    this.open = open\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open }, bubbles: true, composed: true }))\n  }\n\n  private openList() {\n    this.show()\n  }\n\n  private closeList() {\n    this.hide()\n  }\n\n  private removeViewportListeners() {\n    removeEventListener('resize', this.onViewportChange)\n    removeEventListener('scroll', this.onViewportChange, true)\n  }\n\n  private onViewportChange = (e: Event) => {\n    if (e.type === 'scroll' && this.content?.contains(e.composedPath()[0] as Node)) return\n    this.closeList()\n  }\n\n  // Popover light dismiss (outside click / Escape) closes it natively.\n  private onToggle(e: ToggleEvent) {\n    if (e.newState === 'closed' && this.open) this.setOpen(false)\n  }\n\n  /**\n   * Radix `popper`: below the trigger (flipped above when there isn't room),\n   * trigger width as the minimum, height capped at the room on that side\n   * (`--radix-select-content-available-height`).\n   */\n  private place() {\n    const t = this.trigger\n    const c = this.content\n    if (!t || !c) return\n    const r = t.getBoundingClientRect()\n    const gap = 4\n    const padding = 8\n    const vars = {\n      '--radix-select-trigger-width': `${r.width}px`,\n      '--radix-select-trigger-height': `${r.height}px`,\n    }\n    const below = innerHeight - r.bottom - gap - padding\n    const above = r.top - gap - padding\n    // Measure the natural (uncapped) height, then flip above if it doesn't fit below.\n    for (const [k, v] of Object.entries(vars)) c.style.setProperty(k, v)\n    c.style.setProperty('--radix-select-content-available-height', 'none')\n    const natural = c.offsetHeight\n    const side: Side = natural > below && above > below ? 'top' : 'bottom'\n    const { style } = computePosition(t, c, { side, align: 'start', sideOffset: gap, collisionPadding: padding, matchWidth: true })\n    this.side = side\n    this.pos = {\n      ...vars,\n      ...style,\n      '--radix-select-content-available-height': `${side === 'bottom' ? below : above}px`,\n    }\n    if (side === 'top') {\n      // Anchor the bottom edge so a height capped after measuring still hugs the trigger.\n      delete this.pos.top\n      this.pos.bottom = `${innerHeight - r.top + gap}px`\n    }\n    this.updateComplete.then(() => this.updateScrollButtons())\n  }\n\n  // --- scroll buttons (Radix SelectScrollUp/DownButton) -----------------------\n  private updateScrollButtons() {\n    const vp = this.viewport\n    if (!vp) return\n    this.canScrollUp = vp.scrollTop > 0\n    this.canScrollDown = Math.ceil(vp.scrollTop) < vp.scrollHeight - vp.clientHeight\n  }\n\n  private startAutoScroll(dir: 1 | -1) {\n    if (this.autoScrollTimer) return\n    this.autoScrollTimer = setInterval(() => {\n      const vp = this.viewport\n      if (!vp) return\n      const item = this.renderRoot.querySelector<HTMLElement>('[role=option]')\n      vp.scrollTop += dir * (item?.offsetHeight ?? 32)\n      this.updateScrollButtons()\n    }, AUTO_SCROLL_MS)\n  }\n\n  private stopAutoScroll() {\n    clearInterval(this.autoScrollTimer)\n    this.autoScrollTimer = undefined\n  }\n\n  // --- selection -------------------------------------------------------------\n  private select(i: number) {\n    const opt = this.options[i]\n    if (!opt || opt.disabled) return\n    const changed = opt.value !== this.value\n    this.value = opt.value\n    this.closeList()\n    this.trigger?.focus()\n    if (changed) {\n      this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n      this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    }\n  }\n\n  private nextEnabled(from: number, dir: 1 | -1) {\n    const n = this.options.length\n    for (let i = from + dir; i >= 0 && i < n; i += dir) if (!this.options[i].disabled) return i\n    return from\n  }\n\n  private scrollActiveIntoView() {\n    const reveal = () => {\n      this.renderRoot.querySelector(`#${this.uidBase}-opt-${this.active}`)?.scrollIntoView({ block: 'nearest' })\n      // Radix: the first/last enabled item scrolls the viewport fully, so the\n      // matching scroll button goes away.\n      const vp = this.viewport\n      if (vp && this.active === this.nextEnabled(-1, 1)) vp.scrollTop = 0\n      if (vp && this.active === this.nextEnabled(this.options.length, -1)) vp.scrollTop = vp.scrollHeight\n      this.updateScrollButtons()\n    }\n    // A scroll button appearing/disappearing resizes the viewport, so reveal\n    // the item again once they have rendered.\n    this.updateComplete.then(reveal).then(() => this.updateComplete).then(reveal)\n  }\n\n  private onTriggerClick() {\n    const wasOpen = this.openAtPointerDown || this.open\n    this.openAtPointerDown = false\n    if (wasOpen) this.hide()\n    else this.show()\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    if (this.isDisabled) return\n    const k = e.key\n    if (!this.open) {\n      if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(k)) {\n        e.preventDefault()\n        this.openList()\n      } else if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {\n        const i = this.findTypeahead(k)\n        if (i >= 0) this.select(i)\n      }\n      return\n    }\n    switch (k) {\n      case 'ArrowDown':\n        e.preventDefault()\n        this.active = this.nextEnabled(this.active, 1)\n        break\n      case 'ArrowUp':\n        e.preventDefault()\n        this.active = this.nextEnabled(this.active, -1)\n        break\n      case 'Home':\n        e.preventDefault()\n        this.active = this.nextEnabled(-1, 1)\n        break\n      case 'End':\n        e.preventDefault()\n        this.active = this.nextEnabled(this.options.length, -1)\n        break\n      case 'Enter':\n        e.preventDefault()\n        this.select(this.active)\n        return\n      case ' ':\n        e.preventDefault()\n        // Space continues a typeahead search (\"south k…\") like Radix.\n        if (this.typeahead) this.onTypeahead(k)\n        else this.select(this.active)\n        return\n      case 'Escape':\n        e.preventDefault()\n        this.hide()\n        return\n      case 'Tab':\n        this.hide()\n        return\n      default:\n        if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) this.onTypeahead(k)\n        return\n    }\n    this.scrollActiveIntoView()\n  }\n\n  private findTypeahead(ch: string) {\n    clearTimeout(this.typeaheadTimer)\n    this.typeahead += ch.toLowerCase()\n    this.typeaheadTimer = setTimeout(() => (this.typeahead = ''), 1000)\n    return this.options.findIndex((o) => !o.disabled && o.label.toLowerCase().startsWith(this.typeahead))\n  }\n\n  private onTypeahead(ch: string) {\n    const i = this.findTypeahead(ch)\n    if (i >= 0) {\n      this.active = i\n      this.scrollActiveIntoView()\n    }\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    // icon() takes no extra attributes; expose the trigger's chevron/spinner as a part.\n    this.trigger?.querySelector(':scope > svg')?.setAttribute('part', 'icon')\n    // Disabling while open closes the list.\n    if ((changed.has('disabled') || changed.has('loading')) && this.isDisabled && this.open) {\n      this.setOpen(false)\n      return\n    }\n    if (!changed.has('open')) return\n    // Sync the popover with `open` — this covers direct `open = true/false`\n    // sets as well as show()/hide(); setOpen() already dispatched the event.\n    if (this.open) {\n      // Direct `open = true` skips setOpen()'s active-item init.\n      if (this.active < 0 || this.active >= this.options.length) {\n        const selected = this.options.findIndex((o) => o.value === this.value)\n        this.active = selected >= 0 ? selected : this.nextEnabled(-1, 1)\n      }\n      if (this.isDisabled) {\n        this.open = false\n        return\n      }\n      this.content?.showPopover()\n      this.place()\n      addEventListener('resize', this.onViewportChange)\n      addEventListener('scroll', this.onViewportChange, true)\n      this.scrollActiveIntoView()\n    } else {\n      this.stopAutoScroll()\n      this.removeViewportListeners()\n      if (this.content?.matches(':popover-open')) this.content.hidePopover()\n    }\n  }\n\n  // --- render ----------------------------------------------------------------\n  private renderItem(i: number) {\n    const o = this.options[i]\n    const selected = o.value === this.value\n    return html`<div\n      id=${`${this.uidBase}-opt-${i}`}\n      part=${cn('item', o.part)}\n      role=\"option\"\n      aria-selected=${selected ? 'true' : 'false'}\n      aria-disabled=${o.disabled ? 'true' : nothing}\n      data-uipkge=\"\"\n      data-slot=\"select-item\"\n      data-state=${selected ? 'checked' : 'unchecked'}\n      ?data-highlighted=${i === this.active}\n      ?data-disabled=${o.disabled}\n      class=${itemClasses}\n      @pointermove=${() => !o.disabled && this.active !== i && (this.active = i)}\n      @click=${() => this.select(i)}\n    >\n      <span class=\"absolute right-2 flex size-3.5 items-center justify-center\">\n        ${selected ? icon(Check, 'check', 'size-4') : nothing}\n      </span>\n      <span>${o.label}</span>\n    </div>`\n  }\n\n  private renderRow(row: Row, r: number): TemplateResult {\n    if (row.kind === 'item') return this.renderItem(row.index)\n    if (row.kind === 'separator')\n      return html`<div\n        part=${cn('separator', row.part)}\n        aria-hidden=\"true\"\n        data-uipkge=\"\"\n        data-slot=\"select-separator\"\n        class=\"bg-border pointer-events-none -mx-1 my-1 h-px\"\n      ></div>`\n    const labelId = `${this.uidBase}-label-${r}`\n    return html`<div part=${cn('group', row.part)} role=\"group\" aria-labelledby=${labelId}>\n      <div id=${labelId} part=\"label\" data-uipkge=\"\" data-slot=\"select-label\" class=\"text-muted-foreground px-2 py-1.5 text-xs\">\n        ${row.label}\n      </div>\n      ${row.items.map((i) => this.renderItem(i))}\n    </div>`\n  }\n\n  private renderScrollButton(dir: 1 | -1) {\n    const up = dir === -1\n    return html`<div\n      part=${up ? 'scroll-up-button' : 'scroll-down-button'}\n      aria-hidden=\"true\"\n      data-uipkge=\"\"\n      data-slot=${up ? 'select-scroll-up-button' : 'select-scroll-down-button'}\n      class=\"flex cursor-default items-center justify-center py-1\"\n      @pointerdown=${() => this.startAutoScroll(dir)}\n      @pointermove=${() => this.startAutoScroll(dir)}\n      @pointerleave=${() => this.stopAutoScroll()}\n    >\n      ${up ? icon(ChevronUp, 'chevron-up', 'size-4') : icon(ChevronDown, 'chevron-down', 'size-4')}\n    </div>`\n  }\n\n  render() {\n    const selected = this.options.find((o) => o.value === this.value)\n    const listId = `${this.uidBase}-listbox`\n    const open = this.open ? 'open' : 'closed'\n    return html`\n      <button\n        part=\"trigger\"\n        type=\"button\"\n        role=\"combobox\"\n        aria-haspopup=\"listbox\"\n        aria-expanded=${this.open ? 'true' : 'false'}\n        aria-controls=${listId}\n        aria-activedescendant=${this.open && this.active >= 0 ? `${this.uidBase}-opt-${this.active}` : nothing}\n        aria-label=${this.accessibleLabel ?? nothing}\n        aria-required=${this.required ? 'true' : nothing}\n        aria-invalid=${this.ariaInvalidAttr ?? nothing}\n        aria-busy=${this.loading ? 'true' : 'false'}\n        ?disabled=${this.isDisabled}\n        data-uipkge=\"\"\n        data-slot=\"select-trigger\"\n        data-size=${this.size}\n        data-state-value=${this.state}\n        data-state=${open}\n        ?data-disabled=${this.isDisabled}\n        ?data-placeholder=${!selected}\n        class=${cn(\n          \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n          triggerSizeClasses[this.size] ?? triggerSizeClasses.default,\n          triggerStateClasses[this.state] ?? triggerStateClasses.default,\n        )}\n        @pointerdown=${() => (this.openAtPointerDown = this.open)}\n        @click=${this.onTriggerClick}\n        @keydown=${this.onKeyDown}\n      >\n        <span part=\"value\" data-slot=\"select-value\" class=\"truncate\">${selected?.label ?? this.placeholder}</span>\n        ${this.loading\n          ? icon(Loader, 'loader', 'size-4 animate-spin opacity-50')\n          : icon(ChevronDown, 'chevron-down', 'size-4 opacity-50')}\n      </button>\n      <div\n        part=\"content\"\n        popover=\"auto\"\n        data-uipkge=\"\"\n        data-slot=\"select-content\"\n        data-state=${open}\n        data-side=${this.side}\n        data-align=\"start\"\n        style=${styleMap(this.pos)}\n        class=\"bg-popover text-popover-foreground motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 fixed inset-auto m-0 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md motion-safe:data-[state=closed]:duration-[var(--dur-exit)] motion-safe:data-[state=open]:duration-200 [&:popover-open]:flex flex-col\"\n        @toggle=${this.onToggle}\n        @mousedown=${(e: MouseEvent) => e.preventDefault()}\n      >\n        ${this.canScrollUp ? this.renderScrollButton(-1) : nothing}\n        <div\n          part=\"viewport\"\n          id=${listId}\n          role=\"listbox\"\n          tabindex=\"-1\"\n          aria-label=${this.accessibleLabel ?? nothing}\n          data-slot=\"select-viewport\"\n          class=\"relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\"\n          @scroll=${() => this.updateScrollButtons()}\n        >\n          ${this.rows.map((row, r) => this.renderRow(row, r))}\n        </div>\n        ${this.canScrollDown ? this.renderScrollButton(1) : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-select') || customElements.define('uip-select', UipSelect)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-select': UipSelect\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/select/select.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
