{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-chips",
  "title": "Filter Chips",
  "description": "Filters a list you already have: chips with result counts, an all chip, a clear button, dead ends disabled and the result count announced.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/filter-chips.tsx",
      "content": "import type * as React from \"react\"\nimport { useId, useMemo, useRef, useState } from \"react\"\nimport { LayoutGroup, useReducedMotion } from \"motion/react\"\nimport { Chip, type FilterChipsVariant } from \"@/registry/miseui/ui/filter-chips-chip\"\nimport { countOptions, filterItems, toggleValue, type FilterMatch } from \"@/registry/miseui/ui/filter-chips-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype FilterChipsEffect = \"spring\"\n\ninterface FilterChipsOption {\n  value: string\n  label: React.ReactNode\n}\n\nconst ROW_CLASS: Record<FilterChipsVariant, string> = {\n  pills: \"flex flex-wrap gap-2\",\n  // Inset, not a border: a border on a scroller is clipped along with the marker that sits on it.\n  underline: \"flex overflow-x-auto [scrollbar-width:none] shadow-[inset_0_-1px_0_var(--border)] [&::-webkit-scrollbar]:hidden\",\n  list: \"grid\",\n}\n\nconst CLEAR_CLASS =\n  \"h-8 shrink-0 rounded-md px-2 text-[13px] text-muted-foreground underline-offset-4 outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:text-foreground hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:h-10\"\n\ntype FilterChipsProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  options: readonly FilterChipsOption[]\n  /** The pressed values. Empty means everything is showing. */\n  value?: readonly string[]\n  defaultValue?: readonly string[]\n  onValueChange?: (value: readonly string[]) => void\n  /** Lets several chips be pressed, and adds a clear button once one is. */\n  multiple?: boolean\n  /** Results per value. A chip with zero results is disabled unless it is pressed, since pressing it leads nowhere. */\n  counts?: Readonly<Record<string, number>>\n  /** Size of the unfiltered list, shown on the all chip. */\n  total?: number\n  /** How many results are showing now. Shown and announced politely as it changes. */\n  results?: number\n  resultsLabel?: (count: number) => string\n  /** `pills` a wrapping row, `underline` one scrolling line of text, `list` a sidebar column with the counts aligned. */\n  variant?: FilterChipsVariant\n  /** `spring` slides one marker between chips. Only with single selection. */\n  effect?: FilterChipsEffect\n  /** Name of the group, for assistive tech. */\n  label?: string\n  allLabel?: React.ReactNode\n  clearLabel?: string\n}\n\nconst defaultResultsLabel = (count: number) => (count === 1 ? \"1 result\" : `${count} results`)\n\nfunction useSelection(value: readonly string[] | undefined, defaultValue: readonly string[], onValueChange?: (next: readonly string[]) => void) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue)\n  const selected = value ?? uncontrolled\n  const select = (next: readonly string[]) => {\n    setUncontrolled(next)\n    onValueChange?.(next)\n  }\n  return [selected, select] as const\n}\n\nfunction ResultsLine({ results, resultsLabel }: Pick<FilterChipsProps, \"results\" | \"resultsLabel\">) {\n  return (\n    <p role=\"status\" data-slot=\"filter-chips-results\" className=\"text-[13px] leading-5 text-muted-foreground tabular-nums\">\n      {results === undefined ? null : (resultsLabel ?? defaultResultsLabel)(results)}\n    </p>\n  )\n}\n\nfunction FilterChips({\n  options,\n  value,\n  defaultValue = [],\n  onValueChange,\n  multiple = false,\n  counts,\n  total,\n  results,\n  resultsLabel,\n  variant = \"pills\",\n  effect,\n  label = \"Filter\",\n  allLabel = \"All\",\n  clearLabel = \"Clear\",\n  className,\n  ...props\n}: FilterChipsProps) {\n  const groupId = useId()\n  const reduceMotion = useReducedMotion() ?? false\n  const allRef = useRef<HTMLButtonElement>(null)\n  const [selected, select] = useSelection(value, defaultValue, onValueChange)\n  const shared = { variant, travelling: effect === \"spring\" && !multiple && !reduceMotion, showCheck: multiple }\n  const canClear = multiple && selected.length > 0\n  const clear = () => {\n    select([])\n    // The clear button is about to disappear, which would drop keyboard focus onto the page.\n    allRef.current?.focus()\n  }\n  return (\n    <div data-slot=\"filter-chips\" data-variant={variant} data-effect={effect} className={cn(\"grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2\", className)} {...props}>\n      <LayoutGroup id={groupId}>\n        <div role=\"group\" aria-label={label} data-slot=\"filter-chips-row\" className={ROW_CLASS[variant]}>\n          <Chip {...shared} buttonRef={allRef} label={allLabel} count={total} pressed={selected.length === 0} disabled={false} showCheck={false} onClick={() => select([])} />\n          {options.map((option) => {\n            const pressed = selected.includes(option.value)\n            const count = counts ? (counts[option.value] ?? 0) : undefined\n            return <Chip key={option.value} {...shared} label={option.label} count={count} pressed={pressed} disabled={count === 0 && !pressed} onClick={() => select(toggleValue(selected, option.value, multiple))} />\n          })}\n        </div>\n      </LayoutGroup>\n      {results !== undefined || canClear ? (\n        <div className=\"flex min-h-8 flex-wrap items-center justify-between gap-x-4\">\n          <ResultsLine results={results} resultsLabel={resultsLabel} />\n          {canClear ? (\n            <button type=\"button\" data-slot=\"filter-chips-clear\" className={CLEAR_CLASS} onClick={clear}>\n              {clearLabel}\n            </button>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\ninterface UseFilterChipsOptions<T> {\n  items: readonly T[]\n  /** The values an item can be filtered by. */\n  getValues: (item: T) => readonly string[]\n  multiple?: boolean\n  /** With several chips pressed, `any` shows items with one of them and `all` only items with every one. */\n  match?: FilterMatch\n  defaultValue?: readonly string[]\n}\n\n/** The filtering itself: spread `chips` on the component and render `visible`. */\nfunction useFilterChips<T>({ items, getValues, multiple = false, match = \"any\", defaultValue = [] }: UseFilterChipsOptions<T>) {\n  const [selected, setSelected] = useState(defaultValue)\n  const visible = useMemo(() => filterItems(items, getValues, selected, match), [items, getValues, selected, match])\n  const counts = useMemo(() => countOptions(items, getValues, selected, match), [items, getValues, selected, match])\n  return {\n    visible,\n    selected,\n    chips: { value: selected, onValueChange: setSelected, multiple, counts, total: items.length, results: visible.length },\n  }\n}\n\nexport { FilterChips, countOptions, filterItems, toggleValue, useFilterChips, type FilterChipsEffect, type FilterChipsOption, type FilterChipsProps, type FilterChipsVariant, type FilterMatch }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/filter-chips-chip.tsx",
      "content": "import type * as React from \"react\"\nimport { motion } from \"motion/react\"\nimport { Check } from \"lucide-react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype FilterChipsVariant = \"pills\" | \"underline\" | \"list\"\n\nconst CHIP_CLASS: Record<FilterChipsVariant, string> = {\n  pills: \"h-8 shrink-0 rounded-full border px-3 pointer-coarse:h-10\",\n  underline: \"h-10 shrink-0 px-3 whitespace-nowrap\",\n  list: \"h-8 w-full justify-between rounded-md px-2 text-start pointer-coarse:h-10\",\n}\n\nconst PRESSED_CLASS: Record<FilterChipsVariant, string> = {\n  pills: \"border-transparent text-background\",\n  underline: \"font-medium text-foreground\",\n  list: \"font-medium text-foreground\",\n}\n\nconst REST_CLASS: Record<FilterChipsVariant, string> = {\n  pills: \"text-muted-foreground hover:border-foreground/30 hover:text-foreground\",\n  underline: \"text-muted-foreground hover:text-foreground\",\n  list: \"text-muted-foreground hover:bg-accent/60 hover:text-foreground\",\n}\n\nconst MARKER_CLASS: Record<FilterChipsVariant, string> = {\n  pills: \"inset-0 -z-10 rounded-full bg-foreground\",\n  // Sits on the inset hairline of the row, inside the scroller, so the scroller does not clip it.\n  underline: \"inset-x-3 bottom-0 h-0.5 bg-mise\",\n  list: \"inset-0 -z-10 rounded-md bg-accent\",\n}\n\ninterface ChipProps {\n  variant: FilterChipsVariant\n  label: React.ReactNode\n  count?: number\n  pressed: boolean\n  disabled: boolean\n  /** One marker travels between chips. Only makes sense when one chip at a time can be pressed. */\n  travelling: boolean\n  showCheck: boolean\n  onClick: () => void\n  buttonRef?: React.Ref<HTMLButtonElement>\n}\n\nfunction ChipMarker({ variant, travelling }: { variant: FilterChipsVariant; travelling: boolean }) {\n  const className = cn(\"pointer-events-none absolute\", MARKER_CLASS[variant])\n  if (!travelling) return <span aria-hidden=\"true\" data-slot=\"filter-chips-marker\" className={className} />\n  return <motion.span aria-hidden=\"true\" data-slot=\"filter-chips-marker\" layoutId=\"filter-chips-marker\" className={className} transition={SPRING_DEFAULT} />\n}\n\nfunction Chip({ variant, label, count, pressed, disabled, travelling, showCheck, onClick, buttonRef }: ChipProps) {\n  return (\n    <button\n      ref={buttonRef}\n      type=\"button\"\n      data-slot=\"filter-chips-chip\"\n      aria-pressed={pressed}\n      disabled={disabled}\n      onClick={onClick}\n      className={cn(\n        \"relative isolate inline-flex min-w-0 items-center gap-2 text-[13px] leading-5 outline-none\",\n        \"transition-colors duration-(--duration-fast) ease-(--ease-mise) active:duration-0\",\n        \"focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50\",\n        CHIP_CLASS[variant],\n        pressed ? PRESSED_CLASS[variant] : REST_CLASS[variant],\n      )}\n    >\n      {pressed ? <ChipMarker variant={variant} travelling={travelling} /> : null}\n      <span className=\"flex min-w-0 items-center gap-2\">\n        {variant === \"list\" && showCheck ? (\n          <span aria-hidden=\"true\" className={cn(\"flex size-4 shrink-0 items-center justify-center rounded-sm border\", pressed && \"border-transparent bg-foreground text-background\")}>\n            {pressed ? <Check className=\"size-3\" /> : null}\n          </span>\n        ) : null}\n        <span className=\"min-w-0 truncate\">{label}</span>\n      </span>\n      {count === undefined ? null : (\n        <span data-slot=\"filter-chips-count\" className=\"shrink-0 text-xs tabular-nums opacity-70\">\n          {count}\n        </span>\n      )}\n    </button>\n  )\n}\n\nexport { Chip, type FilterChipsVariant }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/filter-chips-math.ts",
      "content": "type FilterMatch = \"any\" | \"all\"\n\nfunction matchesFilter(values: readonly string[], selected: readonly string[], match: FilterMatch) {\n  if (selected.length === 0) return true\n  return match === \"any\" ? selected.some((value) => values.includes(value)) : selected.every((value) => values.includes(value))\n}\n\nfunction filterItems<T>(items: readonly T[], getValues: (item: T) => readonly string[], selected: readonly string[], match: FilterMatch = \"any\") {\n  return items.filter((item) => matchesFilter(getValues(item), selected, match))\n}\n\n/**\n * How many results each chip stands for. With `any`, chips add to each other, so the count is simply how many items\n * carry that value. With `all`, each chip narrows further, so it is how many would be left after pressing it, and a\n * zero warns of a dead end before anyone walks into it.\n */\nfunction countOptions<T>(items: readonly T[], getValues: (item: T) => readonly string[], selected: readonly string[], match: FilterMatch = \"any\") {\n  const pool = match === \"all\" ? filterItems(items, getValues, selected, \"all\") : items\n  const counted = pool.flatMap((item) => [...new Set(getValues(item))])\n  return counted.reduce<Readonly<Record<string, number>>>((counts, value) => ({ ...counts, [value]: (counts[value] ?? 0) + 1 }), {})\n}\n\nfunction toggleValue(selected: readonly string[], value: string, multiple: boolean): readonly string[] {\n  const pressed = selected.includes(value)\n  if (!multiple) return pressed ? [] : [value]\n  return pressed ? selected.filter((entry) => entry !== value) : [...selected, value]\n}\n\nexport { countOptions, filterItems, matchesFilter, toggleValue, type FilterMatch }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}