{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bar-select",
  "title": "方条下拉选择",
  "description": "方条下拉选择，支持搜索、多选、分组与触屏底部面板。",
  "dependencies": [
    "lucide-react",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "combobox",
    "input-group",
    "sheet",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/ui/bar-select/bar-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronDown, Search, X } from \"lucide-react\"\nimport { cva } from \"class-variance-authority\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Combobox,\n  ComboboxTrigger,\n  ComboboxInput,\n  ComboboxContent,\n  ComboboxList,\n  ComboboxItem,\n  ComboboxGroup,\n  ComboboxLabel,\n} from \"@/components/ui/combobox\"\nimport { InputGroupAddon } from \"@/components/ui/input-group\"\nimport {\n  Sheet,\n  SheetContent,\n  SheetTitle,\n  SheetDescription,\n} from \"@/components/ui/sheet\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { defaultLabels } from \"./config\"\nimport type { BarSelectProps } from \"./types\"\n\nconst barSelectVariants = cva(\n  \"flex min-w-0 items-center rounded-md border bg-muted/60 transition-colors\",\n  {\n    // 高度含边框，与方条输入、分段选择同一张表；标签模式换行时可以长高\n    variants: {\n      size: {\n        sm: \"min-h-7 text-xs pointer-coarse:min-h-9\",\n        default: \"min-h-8 text-sm pointer-coarse:min-h-10\",\n        lg: \"min-h-9 text-sm pointer-coarse:min-h-11\",\n      },\n    },\n    defaultVariants: { size: \"default\" },\n  },\n)\nfunction subscribePointer(notify: () => void) {\n  const media = window.matchMedia(\"(pointer: coarse)\")\n  media.addEventListener(\"change\", notify)\n  return () => media.removeEventListener(\"change\", notify)\n}\nfunction BarSelect(props: BarSelectProps) {\n  const {\n    options,\n    label,\n    placeholder = \"请选择\",\n    searchable = options.length > 8,\n    clearable = true,\n    size = \"default\",\n    error,\n    description,\n    name,\n    form,\n    disabled,\n    labels: labelsProp,\n    contentClassName,\n    className,\n    id: idProp,\n    multiple = false,\n    value: valueProp,\n    defaultValue,\n    onValueChange,\n    ...rest\n  } = props\n  // 模式属性不透传到 DOM。\n  const { display: displayProp, ...domProps } = rest as typeof rest & {\n    display?: string\n  }\n  const display = props.multiple ? (displayProp ?? \"first-plus\") : \"first-plus\"\n  const labels = { ...defaultLabels, ...labelsProp }\n  // 调用方的 id 交给触发按钮，外部 <label htmlFor> 才能关联到可聚焦的控件\n  const autoId = React.useId()\n  const id = idProp ?? autoId\n  const anchor = React.useRef<HTMLDivElement>(null)\n  const [value, setValue] = useControllableState<string | string[] | null>({\n    prop: valueProp,\n    defaultProp: defaultValue ?? (multiple ? [] : null),\n    onChange: (next) => {\n      if (props.multiple)\n        (onValueChange as ((value: string[]) => void) | undefined)?.(\n          Array.isArray(next) ? next : [],\n        )\n      else\n        (onValueChange as ((value: string | null) => void) | undefined)?.(\n          typeof next === \"string\" ? next : null,\n        )\n    },\n  })\n  const [open, setOpen] = React.useState(false)\n  const [sheetOpen, setSheetOpen] = React.useState(false)\n  function changeSheetOpen(next: boolean) {\n    setSheetOpen(next && !disabled)\n    if (!next) setQuery(\"\")\n  }\n  const [query, setQuery] = React.useState(\"\")\n  const [wasDisabled, setWasDisabled] = React.useState(disabled)\n  if (wasDisabled !== disabled) {\n    setWasDisabled(disabled)\n    if (disabled) {\n      setOpen(false)\n      setSheetOpen(false)\n      setQuery(\"\")\n    }\n  }\n  const coarse = React.useSyncExternalStore(\n    subscribePointer,\n    () => window.matchMedia(\"(pointer: coarse)\").matches,\n    () => false,\n  )\n  const Trigger = coarse ? Button : ComboboxTrigger\n  const values = Array.isArray(value) ? value : value ? [value] : []\n  const selected = values\n    .map((v) => options.find((o) => o.value === v))\n    .filter((o) => !!o)\n  const text = (v: string) => {\n    const o = options.find((item) => item.value === v)\n    return o?.textValue ?? (typeof o?.label === \"string\" ? o.label : v)\n  }\n  const filtered = options.filter((o) =>\n    text(o.value).toLocaleLowerCase().includes(query.toLocaleLowerCase()),\n  )\n  const groups = [...new Set(filtered.map((o) => o.group ?? \"\"))]\n  const clear = () => {\n    setValue(multiple ? [] : null)\n    setQuery(\"\")\n  }\n  React.useEffect(() => {\n    const element =\n      anchor.current?.closest(\"form\") ??\n      (form ? document.getElementById(form) : null)\n    if (!element || valueProp !== undefined) return\n    const reset = () => {\n      setValue(defaultValue ?? (multiple ? [] : null))\n      setQuery(\"\")\n    }\n    element.addEventListener(\"reset\", reset)\n    return () => element.removeEventListener(\"reset\", reset)\n  }, [form, valueProp, defaultValue, multiple, setValue])\n  const contents = (\n    <>\n      {searchable && (\n        // 搜索行做成弹层顶部的一条：无边框、左侧放大镜、下方分隔线\n        <ComboboxInput\n          aria-label={labels.search}\n          placeholder={`${labels.search}…`}\n          showTrigger={false}\n          showClear={false}\n          className={cn(\n            \"m-0! h-9! rounded-none! border-0! border-b! border-border! bg-transparent! shadow-none! has-[[data-slot=input-group-control]:focus-visible]:ring-0! [&_[data-slot=input-group-addon]:empty]:hidden [&_input]:text-base md:[&_input]:text-sm\",\n            coarse && \"h-11! px-2\",\n          )}\n        >\n          <InputGroupAddon>\n            <Search aria-hidden=\"true\" className=\"size-3.5\" />\n          </InputGroupAddon>\n        </ComboboxInput>\n      )}\n      {/* 选项没有通过 items 交给 Base UI，列表会带 data-empty，官方样式随之清掉内边距，这里用 ! 保留 */}\n      <ComboboxList\n        className={cn(\n          \"overflow-auto p-1!\",\n          coarse\n            ? \"max-h-[50dvh] px-3! [&_[data-slot=combobox-item]]:min-h-11 [&_[data-slot=combobox-item]]:text-base\"\n            : \"max-h-72\",\n        )}\n      >\n        {!filtered.length && (\n          <div className=\"p-3 text-center text-sm text-muted-foreground\">\n            {labels.empty}\n          </div>\n        )}\n        {groups.map((group) => (\n          <ComboboxGroup key={group}>\n            {group && <ComboboxLabel>{group}</ComboboxLabel>}\n            {filtered\n              .filter((o) => (o.group ?? \"\") === group)\n              .map((option) => (\n                <ComboboxItem\n                  key={option.value}\n                  value={option.value}\n                  disabled={option.disabled}\n                  className={cn(\n                    size === \"sm\" ? \"text-xs\" : \"text-sm\",\n                    option.count === 0 && \"text-muted-foreground\",\n                    // 多选用左侧勾选框表达状态，隐藏官方右侧的对勾，计数靠右\n                    multiple && \"pr-1.5 [&>span.absolute]:hidden\",\n                  )}\n                >\n                  {multiple && (\n                    <span\n                      aria-hidden=\"true\"\n                      data-checked={values.includes(option.value) || undefined}\n                      className=\"flex size-3.5 shrink-0 items-center justify-center rounded-[4px] border border-input bg-background text-primary-foreground! data-checked:border-primary data-checked:bg-primary [&_*]:text-primary-foreground!\"\n                    >\n                      {values.includes(option.value) && (\n                        <Check\n                          className=\"size-2.5 text-primary-foreground!\"\n                          strokeWidth={3}\n                        />\n                      )}\n                    </span>\n                  )}\n                  {option.icon}\n                  <span className=\"min-w-0 flex-1 truncate\">\n                    {option.label}\n                  </span>\n                  {option.count !== undefined && (\n                    <span className=\"ms-2 font-mono text-xs text-muted-foreground tabular-nums\">\n                      {option.count}\n                    </span>\n                  )}\n                </ComboboxItem>\n              ))}\n          </ComboboxGroup>\n        ))}\n      </ComboboxList>\n      {coarse ? (\n        // 触屏：底部两颗等宽大按钮，留出安全区\n        <div className=\"grid grid-cols-2 gap-3 border-t border-border p-4 pb-[max(1rem,env(safe-area-inset-bottom))]\">\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"lg\"\n            disabled={disabled || !clearable || !values.length}\n            onClick={clear}\n          >\n            {labels.clear}\n          </Button>\n          <Button\n            type=\"button\"\n            size=\"lg\"\n            onClick={() => changeSheetOpen(false)}\n          >\n            {labels.done}\n          </Button>\n        </div>\n      ) : (\n        multiple && (\n          <div className=\"flex items-center justify-between gap-2 border-t border-border p-2 text-xs\">\n            <span aria-live=\"polite\">{labels.selected(values.length)}</span>\n            {clearable && (\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size={size === \"lg\" ? \"default\" : \"sm\"}\n                disabled={disabled || !values.length}\n                onClick={clear}\n              >\n                {labels.clear}\n              </Button>\n            )}\n          </div>\n        )\n      )}\n    </>\n  )\n  return (\n    <div\n      {...domProps}\n      data-slot=\"bar-select\"\n      data-size={size}\n      className={cn(\n        \"@container/bar-select w-full min-w-0\",\n        // 放进网格工具栏：铺满整格\n        \"in-data-[slot=grid-toolbar]:h-full\",\n        className,\n      )}\n    >\n      {label && (\n        <span\n          aria-hidden=\"true\"\n          className=\"mb-1.5 hidden text-xs text-muted-foreground in-data-[slot=grid-toolbar]:hidden! @max-[10rem]/bar-select:block\"\n        >\n          {label}\n        </span>\n      )}\n      <Combobox<string, boolean>\n        multiple={multiple}\n        value={value}\n        disabled={disabled}\n        open={!disabled && !coarse && open}\n        onOpenChange={(next) => {\n          if (coarse || (disabled && next)) return\n          setOpen(next)\n          if (!next) setQuery(\"\")\n        }}\n        inputValue={query}\n        onInputValueChange={setQuery}\n        itemToStringLabel={text}\n        filter={null}\n        inline={coarse}\n        onValueChange={(next) => {\n          setValue(next)\n          setQuery(\"\")\n          if (!multiple) {\n            setOpen(false)\n            changeSheetOpen(false)\n          }\n        }}\n      >\n        <div\n          ref={anchor}\n          data-slot=\"bar-select-control\"\n          className={cn(\n            barSelectVariants({ size }),\n            \"relative\",\n            error ? \"border-destructive\" : \"border-edge hover:bg-muted\",\n            disabled && \"opacity-50\",\n            // 展开时标签和值作为一个整体铺底色，不只亮触发键那一段\n            \"has-[[data-slot=combobox-trigger][aria-expanded=true]]:bg-muted in-data-[slot=grid-toolbar]:has-[[data-slot=combobox-trigger][aria-expanded=true]]:bg-muted\",\n            // 放进网格工具栏：去掉外框、圆角和底色，铺满整格\n            \"in-data-[slot=grid-toolbar]:h-full! in-data-[slot=grid-toolbar]:min-h-0! in-data-[slot=grid-toolbar]:rounded-none in-data-[slot=grid-toolbar]:border-0 in-data-[slot=grid-toolbar]:bg-transparent in-data-[slot=grid-toolbar]:hover:bg-muted\",\n          )}\n        >\n          {label && (\n            <span\n              id={`${id}-label`}\n              className=\"flex shrink-0 items-center self-stretch border-r border-edge px-3 text-muted-foreground in-data-[slot=grid-toolbar]:not-sr-only! in-data-[slot=grid-toolbar]:px-3! in-data-[slot=grid-toolbar]:whitespace-nowrap! @max-[10rem]/bar-select:sr-only\"\n            >\n              {label}\n            </span>\n          )}\n          {multiple && display === \"chips\" && selected.length > 0 && (\n            <div className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1 py-1 ps-2\">\n              {selected.map((o) => (\n                <span\n                  key={o.value}\n                  data-slot=\"bar-select-chip\"\n                  className=\"flex h-5 items-center gap-0.5 rounded-sm border border-edge bg-background ps-1.5 text-xs\"\n                >\n                  {o.label}\n                  {/* 标签内的移除键：跟随官方 combobox chip 的 icon-xs 档 */}\n                  <Button\n                    type=\"button\"\n                    size=\"icon-xs\"\n                    variant=\"ghost\"\n                    className=\"size-4.5 rounded-[3px] text-muted-foreground hover:text-foreground\"\n                    disabled={disabled}\n                    aria-label={labels.remove(text(o.value))}\n                    onClick={() =>\n                      setValue(values.filter((v) => v !== o.value))\n                    }\n                  >\n                    <X className=\"size-3\" />\n                  </Button>\n                </span>\n              ))}\n            </div>\n          )}\n          <Trigger\n            {...(coarse\n              ? {\n                  type: \"button\" as const,\n                  variant: \"ghost\" as const,\n                  size,\n                  disabled,\n                  \"aria-haspopup\": \"dialog\" as const,\n                }\n              : {\n                  render: <Button type=\"button\" variant=\"ghost\" size={size} />,\n                })}\n            data-slot=\"combobox-trigger\"\n            id={id}\n            aria-label={props[\"aria-label\"]}\n            aria-labelledby={label ? `${id}-label ${id}-value` : undefined}\n            aria-expanded={coarse ? sheetOpen : open}\n            aria-invalid={!!error}\n            // 触屏时列表内联在底部面板里，Combobox 不再自己弹出，由触发键打开面板\n            onClick={() => {\n              if (coarse) changeSheetOpen(true)\n            }}\n            aria-describedby={\n              [error && `${id}-error`, description && `${id}-description`]\n                .filter(Boolean)\n                .join(\" \") || undefined\n            }\n            className={cn(\n              \"h-auto min-w-0 flex-1 justify-between self-stretch rounded-none font-normal focus-visible:ring-inset\",\n              error &&\n                \"focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-ring/50 focus-visible:outline-solid\",\n              multiple && display === \"chips\" && selected.length && \"flex-none\",\n            )}\n          >\n            <span\n              id={`${id}-value`}\n              className={cn(\n                \"flex min-w-0 flex-1 text-start\",\n                clearable && values.length > 0 && \"pe-6\",\n              )}\n            >\n              {!selected.length ? (\n                <span className=\"truncate\">{placeholder}</span>\n              ) : multiple && display === \"summary\" ? (\n                <span className=\"truncate\">\n                  {labels.selected(values.length)}\n                </span>\n              ) : multiple && display === \"chips\" ? null : (\n                <>\n                  {/* 宽度不够时只截断首项文字，「+N」始终完整显示 */}\n                  <span className=\"truncate\">{selected[0]?.label}</span>\n                  {values.length > 1 && (\n                    <span className=\"shrink-0 ps-1 font-mono text-xs text-muted-foreground tabular-nums\">\n                      +{values.length - 1}\n                    </span>\n                  )}\n                </>\n              )}\n            </span>\n            {coarse && (\n              <ChevronDown\n                aria-hidden=\"true\"\n                className=\"size-4 text-muted-foreground\"\n              />\n            )}\n          </Trigger>\n          {/* 清除键叠在触发条上、下拉箭头左侧（兄弟元素，避免按钮嵌套） */}\n          {clearable && values.length > 0 && (\n            <Button\n              type=\"button\"\n              size=\"icon-xs\"\n              variant=\"ghost\"\n              aria-label={labels.clear}\n              disabled={disabled}\n              className=\"absolute end-8 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n              onClick={clear}\n            >\n              <X className=\"size-3.5\" />\n            </Button>\n          )}\n        </div>\n        {coarse ? (\n          <Sheet open={!disabled && sheetOpen} onOpenChange={changeSheetOpen}>\n            <SheetContent\n              side=\"bottom\"\n              className={cn(\"max-h-[85dvh] overflow-auto\", contentClassName)}\n            >\n              <SheetTitle className=\"px-4 pt-4\">\n                {label ?? props[\"aria-label\"]}\n              </SheetTitle>\n              <SheetDescription\n                aria-live=\"polite\"\n                className={cn(\"px-4\", !multiple && \"sr-only\")}\n              >\n                {multiple ? labels.selected(values.length) : labels.search}\n              </SheetDescription>\n              {contents}\n            </SheetContent>\n          </Sheet>\n        ) : (\n          <ComboboxContent\n            anchor={anchor}\n            className={cn(\n              \"w-auto max-w-[min(var(--available-width),calc(100vw-2rem))] min-w-(--anchor-width) outline-none data-[chips=true]:min-w-(--anchor-width)\",\n              contentClassName,\n            )}\n          >\n            {contents}\n          </ComboboxContent>\n        )}\n      </Combobox>\n      {name &&\n        (multiple ? values : [typeof value === \"string\" ? value : \"\"]).map(\n          (v, index) => (\n            <input\n              key={index}\n              type=\"hidden\"\n              name={name}\n              form={form}\n              value={v}\n              disabled={disabled}\n            />\n          ),\n        )}\n      {error && (\n        <p id={`${id}-error`} className=\"mt-1.5 text-xs text-destructive\">\n          {error}\n        </p>\n      )}\n      {description && (\n        <p\n          id={`${id}-description`}\n          className=\"mt-1.5 text-xs text-muted-foreground\"\n        >\n          {description}\n        </p>\n      )}\n    </div>\n  )\n}\nexport { BarSelect, barSelectVariants }\nexport type { BarSelectProps, BarSelectOption, BarSelectLabels } from \"./types\"\n",
      "type": "registry:ui",
      "target": "@ui/bar-select/bar-select.tsx"
    },
    {
      "path": "src/registry/ui/bar-select/types.ts",
      "content": "import type * as React from \"react\"\n\ntype BarSelectOption = {\n  value: string\n  label: React.ReactNode\n  textValue?: string\n  group?: string\n  count?: number\n  icon?: React.ReactNode\n  disabled?: boolean\n}\ntype BarSelectLabels = {\n  clear: string\n  search: string\n  empty: string\n  selected: (count: number) => string\n  remove: (label: string) => string\n  done: string\n}\ntype Common = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** 可选项 */\n  options: BarSelectOption[]\n  /** 可见标签；省略时必须提供 aria-label */\n  label?: React.ReactNode\n  /** 空值占位 */\n  placeholder?: string\n  /** 搜索开关；默认超过 8 项开启 */\n  searchable?: boolean\n  /** 显示清除 @default true */\n  clearable?: boolean\n  /** 高度档 @default \"default\" */\n  size?: \"sm\" | \"default\" | \"lg\"\n  error?: React.ReactNode\n  description?: React.ReactNode\n  name?: string\n  form?: string\n  disabled?: boolean\n  labels?: Partial<BarSelectLabels>\n  /** 弹层（含触屏底部面板）的额外类名，例如在紧凑工具栏里统一缩小字号 */\n  contentClassName?: string\n}\ntype BarSelectProps = Common &\n  (\n    | {\n        multiple?: false\n        value?: string | null\n        defaultValue?: string | null\n        onValueChange?: (value: string | null) => void\n      }\n    | {\n        multiple: true\n        value?: string[]\n        defaultValue?: string[]\n        onValueChange?: (value: string[]) => void\n        display?: \"summary\" | \"first-plus\" | \"chips\"\n      }\n  )\nexport type { BarSelectOption, BarSelectLabels, BarSelectProps }\n",
      "type": "registry:ui",
      "target": "@ui/bar-select/types.ts"
    },
    {
      "path": "src/registry/ui/bar-select/config.ts",
      "content": "import type { BarSelectLabels } from \"./types\"\nconst defaultLabels: BarSelectLabels = {\n  clear: \"清除\",\n  search: \"搜索\",\n  empty: \"无匹配结果\",\n  done: \"完成\",\n  selected: (count) => `已选 ${count} 项`,\n  remove: (label) => `移除「${label}」`,\n}\nexport { defaultLabels }\n",
      "type": "registry:ui",
      "target": "@ui/bar-select/config.ts"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
