{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "select",
  "title": "Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/select/select.component.ts",
      "content": "import {\n  AfterViewChecked,\n  ChangeDetectorRef,\n  Component,\n  DestroyRef,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  OnInit,\n  Output,\n  TemplateRef,\n  ViewChild,\n  ViewContainerRef,\n  ViewEncapsulation,\n  booleanAttribute,\n  forwardRef,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms'\nimport { cn } from '@/lib/utils'\nimport {\n  BodyPortal,\n  autoPlace,\n  lockScroll,\n  pushDismissableLayer,\n  uniqueId,\n  type PopperAlign,\n  type PopperSide,\n} from '@/ui/popper/popper'\n\nexport type SelectTriggerSize = 'sm' | 'default' | 'lg'\nexport type SelectTriggerState = 'default' | 'error' | 'success'\nexport type SelectPosition = 'popper' | 'item-aligned'\n\n/** Radix CONTENT_MARGIN: gap kept between the listbox and the viewport edge. */\nconst CONTENT_MARGIN = 10\n\n/** Radix Select typeahead: accumulate keys for 1s, repeated single keys cycle matches. */\nclass Typeahead {\n  search = ''\n  private timer?: ReturnType<typeof setTimeout>\n\n  push(key: string): string {\n    this.search += key\n    clearTimeout(this.timer)\n    if (this.search) this.timer = setTimeout(() => (this.search = ''), 1000)\n    return this.search\n  }\n\n  reset(): void {\n    this.search = ''\n    clearTimeout(this.timer)\n  }\n}\n\nfunction findNextItem<T extends { textValue: string }>(items: T[], search: string, current?: T): T | undefined {\n  const isRepeated = search.length > 1 && Array.from(search).every((c) => c === search[0])\n  const normalized = isRepeated ? search[0]! : search\n  const index = current ? items.indexOf(current) : -1\n  let wrapped = items.map((_, i) => items[(Math.max(index, 0) + i) % items.length]!)\n  if (normalized.length === 1) wrapped = wrapped.filter((item) => item !== current)\n  const next = wrapped.find((item) => item.textValue.toLowerCase().startsWith(normalized.toLowerCase()))\n  return next !== current ? next : undefined\n}\n\n/**\n * Angular port of UIPKGE Select, behaving like the Radix Select the React component\n * wraps: a `role=\"combobox\"` trigger showing the selected item's text (or a placeholder),\n * a body-portalled listbox positioned against the trigger (`position=\"popper\"`, the React\n * default, or `item-aligned`), check indicators, groups / labels / separators, scroll\n * up / down buttons, typeahead (trigger + open list), arrow / Home / End keys, Escape and\n * outside-click dismiss, focus returned to the trigger, and a hidden native `<select>`\n * for form submission. Registers NG_VALUE_ACCESSOR so [formControl] / ngModel bind.\n * Class strings are identical to the React source.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select, [ui-select]',\n  standalone: true,\n  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => UiSelectComponent), multi: true }],\n  host: {\n    '[attr.data-slot]': '\"select\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n    // Radix Root renders no element: keep the wrapper out of layout.\n    class: 'contents',\n  },\n  template: `\n    <ng-content />\n    @if (isFormControl) {\n      <select\n        aria-hidden=\"true\"\n        tabindex=\"-1\"\n        class=\"sr-only\"\n        [attr.name]=\"name ?? null\"\n        [attr.form]=\"form ?? null\"\n        [attr.autocomplete]=\"autoComplete ?? null\"\n        [required]=\"required\"\n        [disabled]=\"disabled\"\n        (change)=\"onNativeChange($event)\"\n      >\n        <option value=\"\"></option>\n        @for (item of items(); track item) {\n          <option [value]=\"item.value\" [selected]=\"item.value === value\">{{ item.textValue }}</option>\n        }\n      </select>\n    }\n  `,\n})\nexport class UiSelectComponent implements ControlValueAccessor, OnInit, OnChanges {\n  // Zoneless-safe: form writes happen outside template events, so schedule a repaint.\n  private readonly cdr = inject(ChangeDetectorRef)\n  private readonly _value = signal<string | undefined>(undefined)\n  private readonly _open = signal<boolean | null>(null)\n\n  @Input()\n  set value(v: string | null | undefined) {\n    this._value.set(v ?? undefined)\n  }\n  get value(): string | undefined {\n    return this._value()\n  }\n  @Input() defaultValue?: string\n  @Output() valueChange = new EventEmitter<string>()\n  @Input() open?: boolean\n  @Input() defaultOpen = false\n  @Output() openChange = new EventEmitter<boolean>()\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: booleanAttribute }) required = false\n  @Input() name?: string\n  @Input() form?: string\n  @Input() autoComplete?: string\n  @Input() dir: 'ltr' | 'rtl' = 'ltr'\n\n  readonly contentId = uniqueId('select-content')\n  readonly items = signal<UiSelectItemComponent[]>([])\n  trigger?: HTMLElement\n  content?: UiSelectContentComponent\n  readonly typeahead = new Typeahead()\n\n  private onChange: (v: string) => void = () => {}\n  private onTouched: () => void = () => {}\n\n  get isOpen(): boolean {\n    if (this.open !== undefined) return this.open\n    return this._open() ?? this.defaultOpen\n  }\n\n  /** Radix shouldShowPlaceholder: nothing selected yet (undefined or ''). */\n  get showPlaceholder(): boolean {\n    return this.value === undefined || this.value === ''\n  }\n\n  /** Radix renders the bubble <select> only inside a form (or with a `form` id). */\n  get isFormControl(): boolean {\n    return !!this.form || !!this.trigger?.closest('form')\n  }\n\n  ngOnInit(): void {\n    if (this._value() === undefined && this.defaultValue !== undefined) this._value.set(this.defaultValue)\n  }\n\n  ngOnChanges(): void {\n    this.content?.sync()\n  }\n\n  setOpen(value: boolean): void {\n    if (value && this.disabled) return\n    if (value === this.isOpen) return\n    this._open.set(value)\n    this.openChange.emit(value)\n    this.content?.sync()\n  }\n\n  /** Select a value (item click / Enter / trigger typeahead). */\n  select(value: string): void {\n    if (value === this.value) return\n    this._value.set(value)\n    this.valueChange.emit(value)\n    this.onChange(value)\n  }\n\n  register(item: UiSelectItemComponent): void {\n    this.items.update((list) => [...list, item])\n  }\n\n  unregister(item: UiSelectItemComponent): void {\n    this.items.update((list) => list.filter((i) => i !== item))\n  }\n\n  /**\n   * Items in template order. Registration order is used (not DOM order): while the list is\n   * closed the projected items are detached nodes, which have no document order.\n   */\n  orderedItems(): UiSelectItemComponent[] {\n    return this.items()\n  }\n\n  selectedItem(): UiSelectItemComponent | undefined {\n    return this.items().find((i) => i.value === this.value)\n  }\n\n  /** Closed-trigger typeahead: selects the next matching item without opening (Radix). */\n  typeaheadSelect(key: string): void {\n    const search = this.typeahead.push(key)\n    const enabled = this.orderedItems().filter((i) => !i.disabled)\n    const next = findNextItem(\n      enabled,\n      search,\n      enabled.find((i) => i.value === this.value),\n    )\n    if (next) this.select(next.value)\n  }\n\n  markTouched(): void {\n    this.onTouched()\n  }\n\n  onNativeChange(event: Event): void {\n    this.select((event.target as HTMLSelectElement).value)\n  }\n\n  writeValue(v: string | null): void {\n    this.value = v\n    this.cdr.markForCheck()\n  }\n\n  registerOnChange(fn: (v: string) => void): void {\n    this.onChange = fn\n  }\n\n  registerOnTouched(fn: () => void): void {\n    this.onTouched = fn\n  }\n\n  setDisabledState(isDisabled: boolean): void {\n    this.disabled = isDisabled\n    this.cdr.markForCheck()\n  }\n}\n\n/** Radix Select.Group: `role=\"group\"` labelled by its SelectLabel. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-group, [ui-select-group]',\n  standalone: true,\n  host: {\n    '[attr.role]': '\"group\"',\n    '[attr.aria-labelledby]': 'labelId ?? null',\n    class: 'block',\n  },\n  template: `<ng-content />`,\n})\nexport class UiSelectGroupComponent {\n  labelId?: string\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-value, [ui-select-value]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"select-value\"',\n  },\n  template: ``,\n})\nexport class UiSelectValueComponent implements AfterViewChecked {\n  private readonly select = inject(UiSelectComponent)\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  @Input() placeholder?: string\n  private rendered: string | null = null\n\n  constructor() {\n    // Radix Value style: the text never intercepts clicks meant for the trigger.\n    this.el.style.pointerEvents = 'none'\n  }\n\n  /**\n   * Radix portals the selected ItemText into the Value; here the selected item's text\n   * nodes are cloned in (they exist while the list is closed: projected content is\n   * instantiated eagerly), so icons inside item text carry over like in React.\n   */\n  ngAfterViewChecked(): void {\n    const item = this.select.showPlaceholder ? undefined : this.select.selectedItem()\n    const key = item ? `item:${item.value}:${item.textEl?.innerHTML}` : `placeholder:${this.placeholder ?? ''}`\n    if (key === this.rendered) return\n    this.rendered = key\n    if (item?.textEl) {\n      const clones = [...item.textEl.childNodes]\n        .filter((n) => n.nodeType !== Node.COMMENT_NODE)\n        .map((n) => n.cloneNode(true))\n      this.el.replaceChildren(...clones)\n    } else this.el.textContent = this.select.showPlaceholder ? (this.placeholder ?? '') : ''\n  }\n}\n\nconst triggerSizeClasses: Record<SelectTriggerSize, 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<SelectTriggerState, 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/** Put it on a `<button>`: `<button ui-select-trigger><ui-select-value placeholder=\"...\" /></button>`. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-trigger, [ui-select-trigger]',\n  standalone: true,\n  host: {\n    '[attr.type]': 'isButton ? \"button\" : null',\n    '[attr.role]': '\"combobox\"',\n    '[attr.aria-controls]': 'select.contentId',\n    '[attr.aria-expanded]': 'select.isOpen',\n    '[attr.aria-required]': 'select.required || null',\n    '[attr.aria-autocomplete]': '\"none\"',\n    '[attr.aria-busy]': 'loading',\n    '[attr.dir]': 'select.dir',\n    '[attr.tabindex]': 'isButton ? null : isDisabled ? -1 : 0',\n    '[attr.data-state]': 'select.isOpen ? \"open\" : \"closed\"',\n    '[attr.disabled]': 'isDisabled && isButton ? \"\" : null',\n    '[attr.data-disabled]': 'isDisabled ? \"\" : null',\n    '[attr.data-placeholder]': 'select.showPlaceholder ? \"\" : null',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"select-trigger\"',\n    '[attr.data-size]': 'size',\n    '[attr.data-state-value]': 'state',\n    '[class]': 'hostClass',\n    '(pointerdown)': 'onPointerDown($event)',\n    '(click)': 'onClick()',\n    '(keydown)': 'onKeydown($event)',\n    '(blur)': 'select.markTouched()',\n  },\n  template: `\n    <ng-content />\n    @if (loading) {\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        class=\"lucide lucide-loader size-4 animate-spin opacity-50\"\n        aria-hidden=\"true\"\n      >\n        <path d=\"M12 2v4\" />\n        <path d=\"m16.2 7.8 2.9-2.9\" />\n        <path d=\"M18 12h4\" />\n        <path d=\"m16.2 16.2 2.9 2.9\" />\n        <path d=\"M12 18v4\" />\n        <path d=\"m4.9 19.1 2.9-2.9\" />\n        <path d=\"M2 12h4\" />\n        <path d=\"m4.9 4.9 2.9 2.9\" />\n      </svg>\n    } @else {\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        class=\"lucide lucide-chevron-down size-4 opacity-50\"\n        aria-hidden=\"true\"\n      >\n        <path d=\"m6 9 6 6 6-6\" />\n      </svg>\n    }\n  `,\n})\nexport class UiSelectTriggerComponent {\n  readonly select = inject(UiSelectComponent)\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  readonly isButton = this.el.tagName === 'BUTTON'\n  // Radix starts at 'touch' so programmatic / touch clicks open; mouse opens on pointerdown.\n  private pointerType = 'touch'\n\n  @Input() size: SelectTriggerSize = 'default'\n  @Input() state: SelectTriggerState = 'default'\n  @Input({ transform: booleanAttribute }) loading = false\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input('class') className?: string\n\n  constructor() {\n    this.select.trigger = this.el\n  }\n\n  get isDisabled(): boolean {\n    return this.select.disabled || this.disabled || this.loading\n  }\n\n  get hostClass(): string {\n    return 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],\n      triggerStateClasses[this.state],\n      this.className,\n    )\n  }\n\n  private handleOpen(): void {\n    if (this.isDisabled) return\n    this.select.typeahead.reset()\n    this.select.setOpen(true)\n  }\n\n  /** Radix opens on mouse pointerdown (not click) and keeps focus off the trigger; a second press closes. */\n  onPointerDown(event: PointerEvent): void {\n    this.pointerType = event.pointerType ?? 'mouse'\n    if (event.button === 0 && !event.ctrlKey && this.pointerType === 'mouse') {\n      event.preventDefault()\n      if (this.select.isOpen) this.select.setOpen(false)\n      else this.handleOpen()\n    }\n  }\n\n  /** Touch / pen (and synthetic clicks) open on click, like Radix. */\n  onClick(): void {\n    this.el.focus()\n    if (this.pointerType !== 'mouse') this.handleOpen()\n  }\n\n  onKeydown(event: KeyboardEvent): void {\n    if (this.isDisabled) return\n    const typingAhead = this.select.typeahead.search !== ''\n    const modifier = event.ctrlKey || event.altKey || event.metaKey\n    if (!modifier && event.key.length === 1) this.select.typeaheadSelect(event.key)\n    if (typingAhead && event.key === ' ') return\n    if ([' ', 'Enter', 'ArrowUp', 'ArrowDown'].includes(event.key)) {\n      event.preventDefault()\n      this.handleOpen()\n    }\n  }\n}\n\n/** Radix injects this to hide the viewport scrollbar (scroll buttons replace it). */\nconst VIEWPORT_CSS =\n  '[data-radix-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-select-viewport]::-webkit-scrollbar{display:none}'\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-content, [ui-select-content]',\n  standalone: true,\n  encapsulation: ViewEncapsulation.None,\n  styles: [VIEWPORT_CSS],\n  // The host stays where it is declared (no box); the listbox renders in a body portal.\n  host: { class: 'hidden' },\n  imports: [forwardRef(() => UiSelectScrollUpButtonComponent), forwardRef(() => UiSelectScrollDownButtonComponent)],\n  template: `\n    <ng-template #panel>\n      <div\n        [id]=\"select.contentId\"\n        role=\"listbox\"\n        tabindex=\"-1\"\n        data-uipkge=\"\"\n        data-slot=\"select-content\"\n        [attr.data-state]=\"state()\"\n        [attr.dir]=\"select.dir\"\n        [class]=\"panelClass\"\n        (keydown)=\"onKeydown($event)\"\n        (contextmenu)=\"$event.preventDefault()\"\n      >\n        <ui-select-scroll-up-button />\n        <div data-radix-select-viewport=\"\" role=\"presentation\" [class]=\"viewportClass\" (scroll)=\"updateScrollButtons()\">\n          <ng-content />\n        </div>\n        <ui-select-scroll-down-button />\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiSelectContentComponent implements OnChanges, OnDestroy {\n  readonly select = inject(UiSelectComponent)\n  private readonly portal = new BodyPortal(inject(ViewContainerRef))\n\n  @Input('class') className?: string\n  @Input() position: SelectPosition = 'popper'\n  @Input() side: PopperSide = 'bottom'\n  @Input() sideOffset = 0\n  @Input() align: PopperAlign = 'start'\n  @Input() alignOffset = 0\n  @Input({ transform: booleanAttribute }) avoidCollisions = true\n  @Input() collisionPadding = CONTENT_MARGIN\n\n  @ViewChild('panel', { static: true }) panelTpl!: TemplateRef<unknown>\n  readonly state = signal<'open' | 'closed'>('closed')\n  readonly canScrollUp = signal(false)\n  readonly canScrollDown = signal(false)\n  panelEl?: HTMLElement\n  viewportEl?: HTMLElement\n  private readonly typeahead = new Typeahead()\n  private cleanups: (() => void)[] = []\n\n  constructor() {\n    this.select.content = this\n  }\n\n  get panelClass(): string {\n    return cn(\n      '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 relative z-50 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',\n      this.position === 'popper' &&\n        'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n      this.className,\n    )\n  }\n\n  get viewportClass(): string {\n    return cn(\n      'p-1',\n      this.position === 'popper' &&\n        'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',\n    )\n  }\n\n  ngOnChanges(): void {\n    if (this.portal.attached) this.sync()\n  }\n\n  /** Mirrors root open state into the portal (called by the root on every change). */\n  sync(): void {\n    if (this.select.isOpen && !this.portal.attached) this.show()\n    else if (!this.select.isOpen && this.portal.attached) this.hide()\n  }\n\n  /** Enabled option elements in DOM order. */\n  optionEls(): HTMLElement[] {\n    return [...(this.panelEl?.querySelectorAll<HTMLElement>('[role=\"option\"]') ?? [])].filter(\n      (el) => !el.hasAttribute('data-disabled'),\n    )\n  }\n\n  private show(): void {\n    const trigger = this.select.trigger\n    if (!trigger) return\n    this.state.set('open')\n    const host = this.portal.attach(this.panelTpl)\n    const panel = host.querySelector<HTMLElement>('[data-slot=\"select-content\"]')!\n    const viewport = panel.querySelector<HTMLElement>('[data-radix-select-viewport]')!\n    this.panelEl = panel\n    this.viewportEl = viewport\n    // Radix Select inline styles (content is a flex column; the viewport is the scroller).\n    Object.assign(panel.style, { display: 'flex', flexDirection: 'column', outline: 'none', boxSizing: 'border-box' })\n    Object.assign(viewport.style, { position: 'relative', flex: '1', overflow: 'hidden auto' })\n    const close = () => this.select.setOpen(false)\n    this.cleanups.push(\n      pushDismissableLayer({\n        contains: (t) => panel.contains(t) || trigger.contains(t),\n        onEscape: close,\n        onPointerDownOutside: close,\n      }),\n      lockScroll(),\n      disableOutsidePointerEvents(panel),\n    )\n    if (this.position === 'popper') {\n      this.cleanups.push(\n        autoPlace(\n          trigger,\n          panel,\n          () => this.placeOptions(),\n          'select',\n          () => this.updateScrollButtons(),\n        ),\n      )\n    } else {\n      this.placeItemAligned(trigger, panel)\n    }\n    window.addEventListener('blur', close)\n    window.addEventListener('resize', close)\n    this.cleanups.push(() => {\n      window.removeEventListener('blur', close)\n      window.removeEventListener('resize', close)\n    })\n    queueMicrotask(() => {\n      const selected = panel.querySelector<HTMLElement>('[role=\"option\"][data-state=\"checked\"]')\n      const target = selected ?? this.optionEls()[0]\n      ;(target ?? panel).focus({ preventScroll: true })\n      target?.scrollIntoView?.({ block: 'nearest' })\n      this.updateScrollButtons()\n    })\n  }\n\n  private hide(): void {\n    // Radix Select content has no exit Presence: it unmounts as soon as it closes.\n    this.state.set('closed')\n    this.typeahead.reset()\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.portal.detach()\n    this.panelEl = undefined\n    this.viewportEl = undefined\n    this.canScrollUp.set(false)\n    this.canScrollDown.set(false)\n    this.select.trigger?.focus({ preventScroll: true })\n  }\n\n  private placeOptions() {\n    return {\n      side: this.side,\n      align: this.align,\n      sideOffset: this.sideOffset,\n      alignOffset: this.alignOffset,\n      collisionPadding: this.collisionPadding,\n      avoidCollisions: this.avoidCollisions,\n    }\n  }\n\n  /** Radix item-aligned: the selected item sits over the trigger, clamped to the viewport. */\n  private placeItemAligned(trigger: HTMLElement, panel: HTMLElement): void {\n    const t = trigger.getBoundingClientRect()\n    Object.assign(panel.style, {\n      position: 'fixed',\n      minWidth: `${t.width}px`,\n      maxHeight: `${window.innerHeight - CONTENT_MARGIN * 2}px`,\n    })\n    const selected =\n      panel.querySelector<HTMLElement>('[role=\"option\"][data-state=\"checked\"]') ?? this.optionEls()[0] ?? null\n    const p = panel.getBoundingClientRect()\n    const offset = selected ? selected.getBoundingClientRect().top - p.top + selected.offsetHeight / 2 : p.height / 2\n    const top = t.top + t.height / 2 - offset\n    const maxTop = window.innerHeight - CONTENT_MARGIN - panel.offsetHeight\n    const maxLeft = window.innerWidth - CONTENT_MARGIN - panel.offsetWidth\n    panel.style.top = `${Math.round(Math.max(CONTENT_MARGIN, Math.min(top, maxTop)))}px`\n    panel.style.left = `${Math.round(Math.max(CONTENT_MARGIN, Math.min(t.left, maxLeft)))}px`\n    this.updateScrollButtons()\n  }\n\n  updateScrollButtons(): void {\n    const v = this.viewportEl\n    if (!v) return\n    this.canScrollUp.set(v.scrollTop > 0)\n    this.canScrollDown.set(Math.ceil(v.scrollTop) < v.scrollHeight - v.clientHeight)\n  }\n\n  /** Auto-scroll step used by the scroll buttons: one item height, like Radix. */\n  scrollBy(direction: -1 | 1): void {\n    const v = this.viewportEl\n    if (!v) return\n    const item =\n      this.panelEl?.querySelector<HTMLElement>('[role=\"option\"][data-state=\"checked\"]') ?? this.optionEls()[0]\n    v.scrollTop += direction * (item?.offsetHeight ?? 32)\n    this.updateScrollButtons()\n  }\n\n  /** Radix onItemLeave: pointer left an item, park focus on the listbox. */\n  focusContent(): void {\n    this.panelEl?.focus({ preventScroll: true })\n  }\n\n  onKeydown(event: KeyboardEvent): void {\n    const modifier = event.ctrlKey || event.altKey || event.metaKey\n    if (event.key === 'Tab') event.preventDefault()\n    if (!modifier && event.key.length === 1) this.typeaheadFocus(event.key)\n    if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {\n      let candidates = this.optionEls()\n      if (event.key === 'ArrowUp' || event.key === 'End') candidates = candidates.slice().reverse()\n      if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {\n        const i = candidates.indexOf(event.target as HTMLElement)\n        candidates = candidates.slice(i + 1)\n      }\n      // Radix does not loop: at either end the focus stays put.\n      const next = candidates[0]\n      if (next) {\n        next.focus({ preventScroll: true })\n        next.scrollIntoView?.({ block: 'nearest' })\n      }\n      event.preventDefault()\n    }\n  }\n\n  get typeaheadSearch(): string {\n    return this.typeahead.search\n  }\n\n  private typeaheadFocus(key: string): void {\n    const search = this.typeahead.push(key)\n    const enabled = this.select.orderedItems().filter((i) => !i.disabled)\n    const current = enabled.find((i) => i.element === document.activeElement)\n    const next = findNextItem(enabled, search, current)\n    if (next) {\n      next.element.focus({ preventScroll: true })\n      next.element.scrollIntoView?.({ block: 'nearest' })\n    }\n  }\n\n  ngOnDestroy(): void {\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.portal.detach()\n  }\n}\n\n/** DismissableLayer disableOutsidePointerEvents: the rest of the page ignores the pointer while open. */\nfunction disableOutsidePointerEvents(layer: HTMLElement): () => void {\n  const body = document.body\n  const previous = body.style.pointerEvents\n  body.style.pointerEvents = 'none'\n  layer.style.pointerEvents = 'auto'\n  return () => {\n    body.style.pointerEvents = previous\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-label, [ui-select-label]',\n  standalone: true,\n  host: {\n    '[attr.id]': 'id',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"select-label\"',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiSelectLabelComponent {\n  readonly id = uniqueId('select-label')\n  @Input('class') className?: string\n\n  constructor() {\n    const group = inject(UiSelectGroupComponent, { optional: true })\n    if (group) group.labelId = this.id\n  }\n\n  get hostClass(): string {\n    return cn('block', 'text-muted-foreground px-2 py-1.5 text-xs', this.className)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-item, [ui-select-item]',\n  standalone: true,\n  host: {\n    '[attr.role]': '\"option\"',\n    '[attr.aria-labelledby]': 'textId',\n    '[attr.data-highlighted]': 'focused() ? \"\" : null',\n    '[attr.aria-selected]': 'isSelected && focused()',\n    '[attr.data-state]': 'isSelected ? \"checked\" : \"unchecked\"',\n    '[attr.aria-disabled]': 'disabled || null',\n    '[attr.data-disabled]': 'disabled ? \"\" : null',\n    '[attr.tabindex]': 'disabled ? null : -1',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"select-item\"',\n    '[class]': 'hostClass',\n    '(focus)': 'focused.set(true)',\n    '(blur)': 'focused.set(false)',\n    '(click)': 'handleSelect()',\n    '(keydown)': 'onKeydown($event)',\n    '(pointermove)': 'onPointerMove()',\n    '(pointerleave)': 'onPointerLeave()',\n  },\n  template: `\n    <span class=\"absolute right-2 flex size-3.5 items-center justify-center\">\n      @if (isSelected) {\n        <span aria-hidden=\"true\"\n          ><svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"24\"\n            height=\"24\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"lucide lucide-check size-4\"\n            aria-hidden=\"true\"\n          >\n            <path d=\"M20 6 9 17l-5-5\" /></svg\n        ></span>\n      }\n    </span>\n    <span #text [id]=\"textId\"><ng-content /></span>\n  `,\n})\nexport class UiSelectItemComponent {\n  private readonly select = inject(UiSelectComponent)\n  private readonly content = inject(UiSelectContentComponent, { optional: true })\n  readonly element = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  readonly textId = uniqueId('select-item-text')\n  readonly focused = signal(false)\n  @ViewChild('text', { static: true }) textRef?: ElementRef<HTMLElement>\n\n  @Input({ required: true }) value!: string\n  @Input({ transform: booleanAttribute }) disabled = false\n  /** Typeahead text (defaults to the item's text content), as Radix `textValue`. */\n  @Input('textValue') textValueOverride?: string\n  @Input('class') className?: string\n\n  constructor() {\n    this.select.register(this)\n    inject(DestroyRef).onDestroy(() => this.select.unregister(this))\n  }\n\n  get textEl(): HTMLElement | undefined {\n    return this.textRef?.nativeElement\n  }\n\n  get textValue(): string {\n    return this.textValueOverride ?? this.textEl?.textContent?.trim() ?? ''\n  }\n\n  get isSelected(): boolean {\n    return this.select.value === this.value\n  }\n\n  get hostClass(): string {\n    return cn(\n      \"focus:bg-accent focus: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      this.className,\n    )\n  }\n\n  handleSelect(): void {\n    if (this.disabled) return\n    this.select.select(this.value)\n    this.select.setOpen(false)\n  }\n\n  onKeydown(event: KeyboardEvent): void {\n    if ((this.content?.typeaheadSearch ?? '') !== '' && event.key === ' ') return\n    if (event.key === 'Enter' || event.key === ' ') {\n      event.preventDefault()\n      this.handleSelect()\n    }\n  }\n\n  private hovered = false\n\n  onPointerMove(): void {\n    this.hovered = true\n    if (this.disabled) this.content?.focusContent()\n    else if (document.activeElement !== this.element) this.element.focus({ preventScroll: true })\n  }\n\n  /** Only a real hover-out parks focus on the list (re-portalled nodes can get a stray pointerleave). */\n  onPointerLeave(): void {\n    if (!this.hovered) return\n    this.hovered = false\n    if (document.activeElement === this.element) this.content?.focusContent()\n  }\n}\n\n/** Radix Select.ItemText: the part of an item shown in the trigger. SelectItem already renders one. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-item-text, [ui-select-item-text]',\n  standalone: true,\n  template: `<ng-content />`,\n})\nexport class UiSelectItemTextComponent {}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-separator, [ui-select-separator]',\n  standalone: true,\n  host: {\n    '[attr.aria-hidden]': '\"true\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"select-separator\"',\n    '[class]': 'hostClass',\n  },\n  template: ``,\n})\nexport class UiSelectSeparatorComponent {\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn('block', 'bg-border pointer-events-none -mx-1 my-1 h-px', this.className)\n  }\n}\n\n/** Shared scroll-button behaviour: renders only while the viewport can scroll that way, auto-scrolls on hover. */\n@Directive()\nabstract class SelectScrollButtonBase implements OnDestroy {\n  protected readonly content = inject(UiSelectContentComponent)\n  protected abstract readonly direction: -1 | 1\n  @Input('class') className?: string\n  private timer?: ReturnType<typeof setInterval>\n\n  constructor() {\n    inject<ElementRef<HTMLElement>>(ElementRef).nativeElement.style.flexShrink = '0'\n  }\n\n  get visible(): boolean {\n    return this.direction < 0 ? this.content.canScrollUp() : this.content.canScrollDown()\n  }\n\n  get hostClass(): string {\n    return this.visible ? cn('flex cursor-default items-center justify-center py-1', this.className) : 'hidden'\n  }\n\n  start(): void {\n    if (this.timer) return\n    this.timer = setInterval(() => {\n      this.content.scrollBy(this.direction)\n      if (!this.visible) this.stop()\n    }, 50)\n  }\n\n  onPointerMove(): void {\n    this.content.focusContent()\n    this.start()\n  }\n\n  stop(): void {\n    clearInterval(this.timer)\n    this.timer = undefined\n  }\n\n  ngOnDestroy(): void {\n    this.stop()\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-scroll-up-button, [ui-select-scroll-up-button]',\n  standalone: true,\n  host: {\n    '[attr.aria-hidden]': '\"true\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"select-scroll-up-button\"',\n    '[class]': 'hostClass',\n    '(pointerdown)': 'start()',\n    '(pointermove)': 'onPointerMove()',\n    '(pointerleave)': 'stop()',\n  },\n  template: `\n    @if (visible) {\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        class=\"lucide lucide-chevron-up size-4\"\n        aria-hidden=\"true\"\n      >\n        <path d=\"m18 15-6-6-6 6\" />\n      </svg>\n    }\n  `,\n})\nexport class UiSelectScrollUpButtonComponent extends SelectScrollButtonBase {\n  protected readonly direction = -1 as const\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-select-scroll-down-button, [ui-select-scroll-down-button]',\n  standalone: true,\n  host: {\n    '[attr.aria-hidden]': '\"true\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"select-scroll-down-button\"',\n    '[class]': 'hostClass',\n    '(pointerdown)': 'start()',\n    '(pointermove)': 'onPointerMove()',\n    '(pointerleave)': 'stop()',\n  },\n  template: `\n    @if (visible) {\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        class=\"lucide lucide-chevron-down size-4\"\n        aria-hidden=\"true\"\n      >\n        <path d=\"m6 9 6 6 6-6\" />\n      </svg>\n    }\n  `,\n})\nexport class UiSelectScrollDownButtonComponent extends SelectScrollButtonBase {\n  protected readonly direction = 1 as const\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/select/select.component.ts"
    },
    {
      "path": "packages/registry-angular/components/select/native-select.component.ts",
      "content": "import {\n  ChangeDetectorRef,\n  Component,\n  EventEmitter,\n  Input,\n  Output,\n  booleanAttribute,\n  forwardRef,\n  inject,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms'\nimport { cn } from '@/lib/utils'\n\nexport interface NativeSelectOption {\n  label: string\n  value: string | number\n  disabled?: boolean\n}\n\nexport type NativeSelectSize = 'sm' | 'md' | 'lg'\n\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\n/**\n * Angular port of UIPKGE NativeSelect: a zero-JS styled native `<select>` with a chevron,\n * the lightweight sibling of the Radix-style `ui-select`. The host is the wrapper\n * (`native-select-wrapper`); `class` styles the wrapper and `selectClassName` the\n * `<select>`, as in React. Options come from `options`, or projected `<option>`s when\n * `options` is empty. Registers NG_VALUE_ACCESSOR so [formControl] / ngModel bind.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-native-select, [ui-native-select]',\n  standalone: true,\n  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => UiNativeSelectComponent), multi: true }],\n  host: {\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"native-select-wrapper\"',\n    '[class]': 'hostClass',\n  },\n  template: `\n    <select\n      data-slot=\"native-select\"\n      [disabled]=\"disabled\"\n      [attr.id]=\"id ?? null\"\n      [attr.name]=\"name ?? null\"\n      [required]=\"required\"\n      [attr.aria-label]=\"ariaLabel ?? null\"\n      [class]=\"selectClasses\"\n      (change)=\"onSelect($event)\"\n      (blur)=\"onTouched()\"\n    >\n      @if (normalizedOptions.length) {\n        @for (opt of normalizedOptions; track opt.value) {\n          <option [value]=\"opt.value\" [disabled]=\"!!opt.disabled\" [selected]=\"isSelected(opt.value)\">\n            {{ opt.label }}\n          </option>\n        }\n      } @else {\n        <ng-content />\n      }\n    </select>\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      stroke-width=\"2\"\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      data-slot=\"native-select-icon\"\n      aria-hidden=\"true\"\n      [attr.class]=\"iconClasses\"\n    >\n      <path d=\"m6 9 6 6 6-6\" />\n    </svg>\n  `,\n})\nexport class UiNativeSelectComponent implements ControlValueAccessor {\n  // Zoneless-safe: form writes happen outside template events, so schedule a repaint.\n  private readonly cdr = inject(ChangeDetectorRef)\n\n  @Input() value?: string | number | null\n  @Input() defaultValue?: string | number\n  @Output() valueChange = new EventEmitter<string>()\n  @Input() options: (NativeSelectOption | string)[] = []\n  @Input() sizeVariant: NativeSelectSize = 'md'\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: booleanAttribute }) required = false\n  @Input() name?: string\n  @Input() id?: string\n  @Input() ariaLabel?: string\n  @Input() selectClassName?: string\n  @Input('class') className?: string\n\n  private onChange: (v: string) => void = () => {}\n  onTouched: () => void = () => {}\n\n  get normalizedOptions(): NativeSelectOption[] {\n    return (this.options ?? []).map((opt) => (typeof opt === 'string' ? { label: opt, value: opt } : opt))\n  }\n\n  get hostClass(): string {\n    return cn('relative inline-flex w-full items-center', this.className)\n  }\n\n  get selectClasses(): string {\n    return 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[this.sizeVariant],\n      this.selectClassName,\n    )\n  }\n\n  get iconClasses(): string {\n    return cn(\n      'lucide lucide-chevron-down',\n      'text-muted-foreground pointer-events-none absolute transition-opacity',\n      this.disabled && 'opacity-50',\n      iconSizes[this.sizeVariant],\n    )\n  }\n\n  isSelected(v: string | number): boolean {\n    const current = this.value ?? this.defaultValue\n    return current !== undefined && current !== null && String(current) === String(v)\n  }\n\n  onSelect(event: Event): void {\n    if (this.disabled) return\n    const v = (event.target as HTMLSelectElement).value\n    this.value = v\n    this.onChange(v)\n    this.valueChange.emit(v)\n  }\n\n  writeValue(v: string | number | null): void {\n    this.value = v ?? null\n    this.cdr.markForCheck()\n  }\n\n  registerOnChange(fn: (v: string) => void): void {\n    this.onChange = fn\n  }\n\n  registerOnTouched(fn: () => void): void {\n    this.onTouched = fn\n  }\n\n  setDisabledState(isDisabled: boolean): void {\n    this.disabled = isDisabled\n    this.cdr.markForCheck()\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/select/native-select.component.ts"
    },
    {
      "path": "packages/registry-angular/components/select/index.ts",
      "content": "export {\n  UiSelectComponent,\n  UiSelectContentComponent,\n  UiSelectGroupComponent,\n  UiSelectItemComponent,\n  UiSelectItemTextComponent,\n  UiSelectLabelComponent,\n  UiSelectScrollDownButtonComponent,\n  UiSelectScrollUpButtonComponent,\n  UiSelectSeparatorComponent,\n  UiSelectTriggerComponent,\n  UiSelectValueComponent,\n  type SelectPosition,\n  type SelectTriggerSize,\n  type SelectTriggerState,\n} from './select.component'\nexport { UiNativeSelectComponent, type NativeSelectOption, type NativeSelectSize } from './native-select.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/select/index.ts"
    }
  ],
  "dependencies": [
    "@angular/forms"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Dropdown select primitive — Radix-style single-select (trigger + body-portalled listbox with groups, labels, separators, check indicators, scroll buttons, typeahead and keyboard navigation, popper or item-aligned positioning, form binding), plus a zero-JS styled NativeSelect for lightweight/mobile use.",
  "categories": [
    "control"
  ]
}