{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "advance-select",
  "title": "Advance Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/advance-select/AdvanceSelect.vue",
      "content": "<script setup lang=\"ts\" generic=\"T extends Record<string, unknown> | string | number\">\nimport type { HTMLAttributes } from 'vue'\nimport { computed, defineComponent, ref, watch } from 'vue'\nimport { Check, ChevronDown, Loader2, Search, X } from 'lucide-vue-next'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from '@/components/ui/command'\nimport CommandSearchSync from './CommandSearchSync.vue'\nimport { Badge } from '@/components/ui/badge'\nimport { type SelectOption, readKey } from '@/components/ui/select'\nimport { cn } from '@/lib/utils'\nimport type { AdvanceSelectFieldNames } from './types'\n\ninterface Props {\n  modelValue?: unknown | unknown[]\n  options: T[]\n\n  // Mode\n  mode?: 'single' | 'multiple' | 'tags'\n\n  // Field mapping\n  fieldNames?: AdvanceSelectFieldNames\n\n  // Appearance\n  size?: 'sm' | 'default' | 'lg'\n  variant?: 'outlined' | 'filled' | 'borderless'\n  status?: 'default' | 'error' | 'warning'\n  placeholder?: string\n\n  // Search\n  showSearch?: boolean\n  searchValue?: string\n  autoClearSearchValue?: boolean\n  filterOption?: boolean | ((input: string, option: T) => boolean)\n  optionFilterProp?: string | string[]\n  filterSort?: (optionA: T, optionB: T, info: { searchValue: string }) => number\n\n  // Multiple/Tags\n  maxCount?: number\n  maxTagCount?: number\n  maxTagTextLength?: number\n  maxTagPlaceholder?: string | ((omittedValues: T[]) => string)\n  tokenSeparators?: string[]\n  hideSelected?: boolean\n  allowCreate?: boolean\n\n  // State\n  disabled?: boolean\n  loading?: boolean\n  allowClear?: boolean\n  open?: boolean\n  defaultOpen?: boolean\n  defaultActiveFirstOption?: boolean\n\n  // Customization\n  notFoundContent?: string\n  loadingText?: string\n  listHeight?: number\n  virtual?: boolean\n\n  class?: HTMLAttributes['class']\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  mode: 'single',\n  fieldNames: () => ({}),\n  size: 'default',\n  variant: 'outlined',\n  status: 'default',\n  placeholder: 'Select...',\n  showSearch: false,\n  autoClearSearchValue: true,\n  filterOption: true,\n  optionFilterProp: 'label',\n  maxTagCount: undefined,\n  maxTagTextLength: undefined,\n  tokenSeparators: () => [],\n  hideSelected: false,\n  allowCreate: false,\n  disabled: false,\n  loading: false,\n  allowClear: true,\n  defaultOpen: false,\n  defaultActiveFirstOption: true,\n  notFoundContent: 'No results.',\n  loadingText: 'Loading...',\n  listHeight: 300,\n  virtual: true,\n})\n\nconst emits = defineEmits<{\n  'update:modelValue': [value: unknown | unknown[]]\n  'update:open': [open: boolean]\n  'update:searchValue': [value: string]\n  change: [value: unknown | unknown[], option: T | T[]]\n  select: [value: unknown, option: T]\n  deselect: [value: unknown, option: T]\n  search: [value: string]\n  clear: []\n  openChange: [open: boolean]\n  focus: [event: FocusEvent]\n  blur: [event: FocusEvent]\n  popupScroll: [event: Event]\n  inputKeyDown: [event: KeyboardEvent]\n}>()\n\nconst isOpen = ref(props.defaultOpen)\n\nwatch(\n  () => props.open,\n  (v) => {\n    if (v !== undefined) isOpen.value = v\n  },\n)\n\nwatch(isOpen, (v) => {\n  emits('update:open', v)\n  emits('openChange', v)\n})\n\nconst internalQuery = ref('')\nconst query = computed({\n  get: () => props.searchValue ?? internalQuery.value,\n  set: (v) => {\n    internalQuery.value = v\n    emits('update:searchValue', v)\n    emits('search', v)\n  },\n})\n\nconst inputRef = ref<HTMLInputElement>()\n\nconst valueKey = computed(() => props.fieldNames.value ?? 'value')\nconst labelKey = computed(() => props.fieldNames.label ?? 'label')\nconst groupKey = computed(() => props.fieldNames.group ?? 'group')\nconst disabledKey = computed(() => props.fieldNames.disabled ?? 'disabled')\n\nfunction getValue(o: T): unknown {\n  return readKey(o, valueKey.value, o)\n}\nfunction getLabel(o: T): string {\n  return String(readKey(o, labelKey.value, ''))\n}\nfunction getGroup(o: T): string | undefined {\n  const g = readKey(o, groupKey.value)\n  return g == null ? undefined : String(g)\n}\nfunction isDisabled(o: T): boolean {\n  return Boolean(readKey(o, disabledKey.value, false))\n}\n\nconst isMultiple = computed(() => props.mode === 'multiple' || props.mode === 'tags')\n\nconst selectedValues = computed(() => {\n  if (props.modelValue == null) return []\n  if (isMultiple.value) {\n    return Array.isArray(props.modelValue) ? props.modelValue : []\n  }\n  return [props.modelValue]\n})\n\nconst selectedSet = computed(() => new Set(selectedValues.value))\n\nconst selectedOptions = computed(() => {\n  return selectedValues.value.map((v) => {\n    const found = props.options.find((o) => getValue(o) === v)\n    if (found) return found\n    // For created tags not in options, create a minimal option object\n    return { [labelKey.value]: String(v), [valueKey.value]: v } as T\n  })\n})\n\nfunction getOptionByValue(v: unknown): T | undefined {\n  return props.options.find((o) => getValue(o) === v)\n}\n\nfunction matchesFilter(o: T, q: string): boolean {\n  if (typeof props.filterOption === 'function') {\n    return props.filterOption(q, o)\n  }\n  if (props.filterOption === false) return true\n  const label = getLabel(o).toLowerCase()\n  const search = q.toLowerCase()\n  const propsToSearch = Array.isArray(props.optionFilterProp) ? props.optionFilterProp : [props.optionFilterProp]\n  for (const prop of propsToSearch) {\n    if (prop === 'label' && label.includes(search)) return true\n    const val = String(readKey(o, prop, '')).toLowerCase()\n    if (val.includes(search)) return true\n  }\n  return false\n}\n\nconst filteredOptions = computed(() => {\n  let result = props.options\n  const q = query.value.trim()\n\n  if (q) {\n    result = result.filter((o) => matchesFilter(o, q))\n  }\n\n  if (props.hideSelected && isMultiple.value) {\n    result = result.filter((o) => !selectedSet.value.has(getValue(o)))\n  }\n\n  if (q && props.filterSort) {\n    result = [...result].sort((a, b) => props.filterSort!(a, b, { searchValue: q }))\n  }\n\n  return result\n})\n\nconst grouped = computed(() => {\n  const groups = new Map<string, T[]>()\n  for (const opt of filteredOptions.value) {\n    const key = getGroup(opt) ?? ''\n    if (!groups.has(key)) groups.set(key, [])\n    groups.get(key)!.push(opt)\n  }\n  return Array.from(groups, ([heading, items]) => ({ heading, items }))\n})\n\nconst atMax = computed(() => {\n  if (typeof props.maxCount !== 'number') return false\n  const count = Array.isArray(props.modelValue) ? props.modelValue.length : props.modelValue ? 1 : 0\n  return count >= props.maxCount\n})\n\nconst visibleTags = computed(() => {\n  if (!isMultiple.value) return []\n  const opts = selectedOptions.value\n  if (typeof props.maxTagCount === 'number') {\n    return opts.slice(0, props.maxTagCount)\n  }\n  return opts\n})\n\nconst hiddenTagCount = computed(() => {\n  if (!isMultiple.value) return 0\n  const opts = selectedOptions.value\n  if (typeof props.maxTagCount === 'number') {\n    return Math.max(0, opts.length - props.maxTagCount)\n  }\n  return 0\n})\n\nfunction displayLabel(o: T): string {\n  let label = getLabel(o)\n  if (props.maxTagTextLength && label.length > props.maxTagTextLength) {\n    label = label.slice(0, props.maxTagTextLength) + '...'\n  }\n  return label\n}\n\nfunction selectOption(option: T) {\n  if (isDisabled(option)) return\n  const v = getValue(option)\n\n  if (!isMultiple.value) {\n    emits('update:modelValue', v)\n    emits('change', v, option)\n    emits('select', v, option)\n    isOpen.value = false\n    if (props.autoClearSearchValue) {\n      query.value = ''\n    }\n    return\n  }\n\n  const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []\n  if (selectedSet.value.has(v)) {\n    const next = current.filter((x) => x !== v)\n    emits('update:modelValue', next)\n    emits('change', next, option)\n    emits('deselect', v, option)\n  } else {\n    if (atMax.value) return\n    const next = [...current, v]\n    emits('update:modelValue', next)\n    emits('change', next, option)\n    emits('select', v, option)\n  }\n\n  if (props.autoClearSearchValue) {\n    query.value = ''\n  }\n}\n\nfunction removeTag(value: unknown, event: Event) {\n  event.stopPropagation()\n  if (props.disabled) return\n  const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []\n  const next = current.filter((x) => x !== value)\n  const option = getOptionByValue(value)\n  emits('update:modelValue', next)\n  emits('change', next, option as T)\n  if (option) emits('deselect', value, option)\n}\n\nfunction clearAll(event?: Event) {\n  event?.stopPropagation()\n  if (props.disabled) return\n  emits('clear')\n  if (isMultiple.value) {\n    emits('update:modelValue', [])\n    emits('change', [], [])\n  } else {\n    emits('update:modelValue', null)\n    emits('change', null, undefined as unknown as T)\n  }\n  query.value = ''\n}\n\nfunction createTag() {\n  if (!props.allowCreate && props.mode !== 'tags') return\n  const q = query.value.trim()\n  if (!q) return\n  // Check if already exists\n  const exists = props.options.some((o) => getLabel(o) === q || String(getValue(o)) === q)\n  if (exists) return\n\n  if (!isMultiple.value) {\n    emits('update:modelValue', q)\n    emits('change', q, { [labelKey.value]: q, [valueKey.value]: q } as T)\n    emits('select', q, { [labelKey.value]: q, [valueKey.value]: q } as T)\n    isOpen.value = false\n    query.value = ''\n    return\n  }\n\n  if (atMax.value) return\n  const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []\n  const next = [...current, q]\n  const newOption = { [labelKey.value]: q, [valueKey.value]: q } as T\n  emits('update:modelValue', next)\n  emits('change', next, newOption)\n  emits('select', q, newOption)\n  query.value = ''\n}\n\nfunction handleInputKeydown(event: KeyboardEvent) {\n  emits('inputKeyDown', event)\n  if (event.key === 'Enter' && query.value.trim() && props.mode === 'tags') {\n    event.preventDefault()\n    createTag()\n  }\n  if (props.tokenSeparators.length && props.mode === 'tags') {\n    if (props.tokenSeparators.includes(event.key)) {\n      event.preventDefault()\n      createTag()\n    }\n  }\n}\n\nfunction handleFocus(event: FocusEvent) {\n  emits('focus', event)\n}\n\nfunction handleBlur(event: FocusEvent) {\n  emits('blur', event)\n}\n\nfunction handlePopupScroll(event: Event) {\n  emits('popupScroll', event)\n}\n\n// Watch modelValue to clear query when closed\nwatch(\n  () => props.modelValue,\n  () => {\n    if (!isOpen.value && props.autoClearSearchValue) {\n      query.value = ''\n    }\n  },\n)\n\nwatch(\n  () => isOpen.value,\n  (open) => {\n    if (!open && props.autoClearSearchValue) {\n      query.value = ''\n    }\n  },\n)\n\nconst sizeClasses = {\n  sm: 'h-8 text-xs px-2.5 py-1',\n  default: 'h-9 text-sm px-3 py-1.5',\n  lg: 'h-11 text-base px-4 py-2',\n}\n\nconst variantClasses = {\n  outlined:\n    'border-input bg-transparent shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n  filled:\n    'border-transparent bg-muted/50 shadow-none focus-visible:bg-muted focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n  borderless:\n    'border-transparent bg-transparent shadow-none focus-visible:bg-muted/30 focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n}\n\nconst statusClasses = {\n  default: '',\n  error:\n    'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive',\n  warning: 'border-[var(--warning)] focus-visible:border-[var(--warning)] focus-visible:ring-[var(--warning)]/20',\n}\n\nconst triggerBaseClasses = computed(() =>\n  cn(\n    'flex w-full items-center justify-between gap-2 rounded-md border text-sm transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50',\n    sizeClasses[props.size],\n    variantClasses[props.variant],\n    statusClasses[props.status],\n    props.class,\n  ),\n)\n\nconst isEmpty = computed(() => {\n  if (isMultiple.value) {\n    return !Array.isArray(props.modelValue) || props.modelValue.length === 0\n  }\n  return props.modelValue == null || props.modelValue === ''\n})\n\nconst showClear = computed(() => {\n  return props.allowClear && !isEmpty.value && !props.disabled && !props.loading\n})\n\nconst showSearchInput = computed(() => {\n  return props.showSearch || props.mode === 'tags'\n})\n</script>\n\n<template>\n  <Popover v-model:open=\"isOpen\">\n    <PopoverTrigger as-child>\n      <button\n        type=\"button\"\n        role=\"combobox\"\n        :aria-expanded=\"isOpen\"\n        :disabled=\"disabled || loading\"\n        data-uipkge\n        data-slot=\"advance-select\"\n        :class=\"triggerBaseClasses\"\n        @focus=\"handleFocus\"\n        @blur=\"handleBlur\"\n      >\n        <!-- Prefix slot -->\n        <span v-if=\"$slots.prefix\" class=\"shrink-0\">\n          <slot name=\"prefix\" />\n        </span>\n\n        <!-- Multiple mode tags -->\n        <div\n          v-if=\"isMultiple\"\n          class=\"flex flex-1 flex-nowrap items-center gap-1 overflow-x-auto\"\n          style=\"scrollbar-width: none; -ms-overflow-style: none\"\n        >\n          <template v-if=\"selectedOptions.length\">\n            <slot\n              name=\"tag\"\n              v-for=\"opt in visibleTags\"\n              :key=\"String(getValue(opt))\"\n              :value=\"getValue(opt)\"\n              :label=\"displayLabel(opt)\"\n              :closable=\"!disabled\"\n              :on-close=\"(e: Event) => removeTag(getValue(opt), e)\"\n            >\n              <Badge variant=\"secondary\" class=\"bg-muted text-foreground h-6 gap-1 pr-1 pl-2 text-xs font-normal\">\n                <span class=\"truncate\">{{ displayLabel(opt) }}</span>\n                <button\n                  v-if=\"!disabled\"\n                  type=\"button\"\n                  class=\"hover:bg-muted-foreground/20 rounded-full p-0.5 transition-colors\"\n                  :aria-label=\"`Remove ${getLabel(opt)}`\"\n                  @click=\"removeTag(getValue(opt), $event)\"\n                >\n                  <X class=\"size-3\" />\n                </button>\n              </Badge>\n            </slot>\n            <Badge\n              v-if=\"hiddenTagCount > 0\"\n              variant=\"secondary\"\n              class=\"bg-muted text-foreground h-6 text-xs font-normal\"\n            >\n              <template v-if=\"typeof maxTagPlaceholder === 'function'\">\n                {{ maxTagPlaceholder(selectedOptions.slice(maxTagCount ?? 0)) }}\n              </template>\n              <template v-else-if=\"maxTagPlaceholder\">\n                {{ maxTagPlaceholder }}\n              </template>\n              <template v-else> +{{ hiddenTagCount }} </template>\n            </Badge>\n          </template>\n          <span v-else class=\"text-muted-foreground truncate\">{{ placeholder }}</span>\n        </div>\n\n        <!-- Single mode display -->\n        <template v-else>\n          <slot name=\"label\" :value=\"props.modelValue\" :label=\"selectedOptions[0] ? getLabel(selectedOptions[0]) : ''\">\n            <span\n              :class=\"[\n                'flex-1 truncate text-left',\n                selectedOptions.length ? 'text-foreground' : 'text-muted-foreground',\n              ]\"\n            >\n              {{ selectedOptions[0] ? getLabel(selectedOptions[0]) : placeholder }}\n            </span>\n          </slot>\n        </template>\n\n        <!-- Suffix area -->\n        <span class=\"flex shrink-0 items-center gap-1\">\n          <slot name=\"suffix\" />\n\n          <Loader2 v-if=\"loading\" class=\"text-muted-foreground size-4 animate-spin\" />\n\n          <button\n            v-else-if=\"showClear\"\n            type=\"button\"\n            class=\"text-muted-foreground hover:text-foreground rounded transition-colors\"\n            aria-label=\"Clear selection\"\n            @click=\"clearAll\"\n          >\n            <slot name=\"clearIcon\">\n              <X class=\"size-4\" aria-hidden=\"true\" />\n            </slot>\n          </button>\n\n          <slot v-else name=\"suffixIcon\">\n            <ChevronDown class=\"text-muted-foreground size-4 opacity-50\" />\n          </slot>\n        </span>\n      </button>\n    </PopoverTrigger>\n\n    <PopoverContent\n      class=\"p-0\"\n      align=\"start\"\n      :side-offset=\"4\"\n      :style=\"{ width: 'var(--reka-popover-trigger-width)', maxHeight: `${listHeight}px` }\"\n      @scroll=\"handlePopupScroll\"\n    >\n      <Command :should-filter=\"false\" class=\"flex flex-col overflow-hidden\">\n        <CommandSearchSync @update:search=\"query = $event\" />\n        <CommandInput v-if=\"showSearchInput\" v-model=\"query\" :placeholder=\"placeholder\" @keydown=\"handleInputKeydown\" />\n\n        <CommandList class=\"flex-1 overflow-y-auto\">\n          <CommandEmpty v-if=\"!loading && filteredOptions.length === 0\">\n            <slot name=\"empty\">\n              {{ notFoundContent }}\n            </slot>\n          </CommandEmpty>\n\n          <div v-if=\"loading && filteredOptions.length === 0\" class=\"py-6 text-center text-sm\">\n            {{ loadingText }}\n          </div>\n\n          <template v-for=\"(group, gi) in grouped\" :key=\"group.heading || gi\">\n            <CommandSeparator v-if=\"gi > 0\" />\n            <CommandGroup :heading=\"group.heading || undefined\">\n              <CommandItem\n                v-for=\"(opt, idx) in group.items\"\n                :key=\"String(getValue(opt))\"\n                :value=\"String(getValue(opt))\"\n                :disabled=\"isDisabled(opt) || (atMax && !selectedSet.has(getValue(opt)))\"\n                :data-active=\"gi === 0 && idx === 0 && defaultActiveFirstOption ? 'true' : undefined\"\n                :style=\"virtual && props.options.length > 100 ? { contentVisibility: 'auto' } : undefined\"\n                @select=\"selectOption(opt)\"\n              >\n                <Check\n                  :class=\"cn('mr-2 size-4 shrink-0', selectedSet.has(getValue(opt)) ? 'opacity-100' : 'opacity-0')\"\n                />\n                <slot name=\"option\" :option=\"opt\" :index=\"idx\">\n                  {{ getLabel(opt) }}\n                </slot>\n              </CommandItem>\n            </CommandGroup>\n          </template>\n\n          <!-- Create new option in tags mode -->\n          <CommandItem\n            v-if=\"query.trim() && allowCreate && !props.options.some((o) => getLabel(o) === query.trim())\"\n            :value=\"`create:${query}`\"\n            @select=\"createTag\"\n          >\n            <Check class=\"mr-2 size-4 opacity-0\" />\n            Create \"{{ query.trim() }}\"\n          </CommandItem>\n        </CommandList>\n\n        <!-- Footer for multiple mode -->\n        <div\n          v-if=\"isMultiple && selectedOptions.length\"\n          class=\"flex items-center justify-between border-t px-2 py-1.5 text-xs\"\n        >\n          <span class=\"text-muted-foreground\">{{ selectedOptions.length }} selected</span>\n          <button type=\"button\" class=\"text-muted-foreground hover:text-foreground\" @click=\"clearAll\">Clear all</button>\n        </div>\n      </Command>\n    </PopoverContent>\n  </Popover>\n</template>\n\n<style scoped>\n[data-slot='advance-select'] .overflow-x-auto::-webkit-scrollbar {\n  display: none;\n}\n</style>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/advance-select/AdvanceSelect.vue"
    },
    {
      "path": "packages/registry-vue/components/advance-select/CommandSearchSync.vue",
      "content": "<script setup lang=\"ts\">\nimport { watch } from 'vue'\nimport { useCommand } from '@/components/ui/command'\n\nconst emits = defineEmits<{\n  'update:search': [value: string]\n}>()\n\nconst { filterState } = useCommand()\n\nwatch(\n  () => filterState.search,\n  (v) => {\n    emits('update:search', v)\n  },\n)\n</script>\n\n<template>\n  <slot />\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/advance-select/CommandSearchSync.vue"
    },
    {
      "path": "packages/registry-vue/components/advance-select/types.ts",
      "content": "export type { SelectOption } from '@/components/ui/select'\n\nexport interface AdvanceSelectFieldNames {\n  label?: string\n  value?: string\n  group?: string\n  disabled?: string\n}\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/advance-select/types.ts"
    },
    {
      "path": "packages/registry-vue/components/advance-select/index.ts",
      "content": "export { default as AdvanceSelect } from './AdvanceSelect.vue'\nexport type { AdvanceSelectFieldNames, SelectOption } from './types'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/advance-select/index.ts"
    }
  ],
  "dependencies": [
    "@vueuse/core",
    "lucide-vue-next",
    "reka-ui"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/popover.json",
    "https://uipkge.dev/r/vue/command.json",
    "https://uipkge.dev/r/vue/badge.json",
    "https://uipkge.dev/r/vue/select.json"
  ],
  "description": "Searchable, async-capable select with keyboard navigation, multi-select, and option grouping. Drop in when the native `<select>` or the basic Select primitive runs out of room — large lists, debounced server-side filtering, custom rendered items.",
  "categories": [
    "form"
  ]
}