{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "keysort-filter",
  "title": "Keysort filter",
  "description": "Edge-notched cards from the 1950s office: each hole along the top is an attribute, notched open if the card has it. Push needles down the columns and the cards notched for all of them fall free. AND filtering you can see. For documents, tickets and records with tags.",
  "dependencies": [
    "@base-ui/react",
    "cn"
  ],
  "registryDependencies": [
    "https://bevel-ui.vercel.app/r/bevel.json"
  ],
  "files": [
    {
      "path": "registry/bevel/ui/keysort-filter.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Toggle } from \"@base-ui/react/toggle\"\nimport { cn } from \"cn\"\n\ntype Needle = { value: string; label: string }\n\ntype KeysortFilterContextValue = {\n  needles: Needle[]\n  registerNeedle: (needle: Needle) => () => void\n  selected: string[]\n  toggle: (value: string, on: boolean) => void\n  cards: Record<string, string[]>\n  registerCard: (id: string, notches: string[]) => () => void\n}\n\nconst KeysortFilterContext = React.createContext<KeysortFilterContextValue | null>(null)\n\nfunction useKeysortFilter() {\n  const context = React.useContext(KeysortFilterContext)\n  if (!context) throw new Error(\"KeysortFilter parts must be inside <KeysortFilter>\")\n  return context\n}\n\nfunction matches(notches: string[], selected: string[]) {\n  return selected.length > 0 && selected.every((s) => notches.includes(s))\n}\n\ntype KeysortFilterProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\"> & {\n  /** The needles pushed in, by value. */\n  value?: string[]\n  defaultValue?: string[]\n  onValueChange?: (value: string[]) => void\n}\n\n/**\n * Edge-notched cards. Each column of holes is an attribute; push needles\n * down the columns and the cards notched for all of them fall free. AND\n * filtering you can see. Give it an aria-label.\n */\nfunction KeysortFilter({ className, value, defaultValue = [], onValueChange, style, children, ...props }: KeysortFilterProps) {\n  const [needles, setNeedles] = React.useState<Needle[]>([])\n  const [cards, setCards] = React.useState<Record<string, string[]>>({})\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const selected = value ?? uncontrolled\n\n  const registerNeedle = React.useCallback((needle: Needle) => {\n    // Needles keep the order they mounted in, which is their column order.\n    setNeedles((all) =>\n      all.some((n) => n.value === needle.value)\n        ? all.map((n) => (n.value === needle.value ? needle : n))\n        : [...all, needle]\n    )\n    return () => setNeedles((all) => all.filter((n) => n.value !== needle.value))\n  }, [])\n  const registerCard = React.useCallback((id: string, notches: string[]) => {\n    setCards((all) => ({ ...all, [id]: notches }))\n    return () =>\n      setCards((all) => {\n        const next = { ...all }\n        delete next[id]\n        return next\n      })\n  }, [])\n  const toggle = React.useCallback(\n    (needle: string, on: boolean) => {\n      const next = on ? [...selected.filter((s) => s !== needle), needle] : selected.filter((s) => s !== needle)\n      setUncontrolled(next)\n      onValueChange?.(next)\n    },\n    [selected, onValueChange]\n  )\n\n  const context = React.useMemo(\n    () => ({ needles, registerNeedle, selected, toggle, cards, registerCard }),\n    [needles, registerNeedle, selected, toggle, cards, registerCard]\n  )\n  return (\n    <KeysortFilterContext value={context}>\n      <div\n        role=\"group\"\n        data-slot=\"keysort-filter\"\n        data-sorting={selected.length ? \"\" : undefined}\n        className={cn(\n          \"grid w-full grid-cols-[minmax(6rem,1fr)_repeat(var(--keysort-filter-needles),var(--keysort-filter-column))] gap-y-3\",\n          className\n        )}\n        style={{ \"--keysort-filter-needles\": Math.max(needles.length, 1), ...style } as React.CSSProperties}\n        {...props}\n      >\n        {children}\n      </div>\n    </KeysortFilterContext>\n  )\n}\n\n/** The needle handles, one above each column of holes. */\nfunction KeysortFilterNeedles({ className, children, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Needles\"\n      data-slot=\"keysort-filter-needles\"\n      className={cn(\"col-span-full grid grid-cols-subgrid items-end\", className)}\n      {...props}\n    >\n      <span aria-hidden />\n      {children}\n    </div>\n  )\n}\n\ntype KeysortFilterNeedleProps = Omit<Toggle.Props, \"value\" | \"children\" | \"pressed\"> & {\n  value: string\n  /** The attribute's name. Keep it to a word or two. */\n  children: string\n}\n\n/** One needle: a toggle. Pushed in, it catches every card not notched for it. */\nfunction KeysortFilterNeedle({ className, value, children, ...props }: KeysortFilterNeedleProps) {\n  const { registerNeedle, selected, toggle } = useKeysortFilter()\n  React.useEffect(() => registerNeedle({ value, label: children }), [value, children, registerNeedle])\n  return (\n    <Toggle\n      data-slot=\"keysort-filter-needle\"\n      pressed={selected.includes(value)}\n      onPressedChange={(on) => toggle(value, on)}\n      className={cn(\n        \"group/needle flex flex-col items-center gap-1 rounded-(--bevel-radius-sm) px-0.5 pb-1 text-center text-(length:--bevel-fine) leading-tight sm:text-xs font-medium outline-none\",\n        \"focus-visible:ring-3 focus-visible:ring-ring/60 data-pressed:font-semibold\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      {/* The ring handle: it sinks as the needle goes in. */}\n      <span\n        aria-hidden\n        className=\"bevel-metal size-4 rounded-full border-3 border-(--keysort-filter-needle-edge) bg-transparent transition-[translate] duration-(--bevel-duration-small) ease-(--bevel-ease-spring) group-data-pressed/needle:translate-y-1.5 motion-reduce:transition-none\"\n      />\n    </Toggle>\n  )\n}\n\n/** The deck. The needles run down it, behind the cards. */\nfunction KeysortFilterDeck({ className, children, ...props }: React.ComponentProps<\"ul\">) {\n  const { needles, selected } = useKeysortFilter()\n  return (\n    <ul\n      data-slot=\"keysort-filter-deck\"\n      className={cn(\"relative col-span-full grid grid-cols-subgrid gap-y-1.5\", className)}\n      {...props}\n    >\n      {needles.map((n, i) => (\n        <span\n          key={n.value}\n          aria-hidden\n          data-in={selected.includes(n.value) ? \"\" : undefined}\n          className=\"bevel-metal pointer-events-none absolute -top-2 bottom-0 w-1 origin-top scale-y-0 rounded-full transition-[scale] duration-(--bevel-duration-move) ease-(--bevel-ease-out) data-in:scale-y-100 motion-reduce:transition-none\"\n          style={{ right: `calc(var(--keysort-filter-column) * ${needles.length - 1 - i} + var(--keysort-filter-column) / 2 - var(--spacing) / 2)` }}\n        />\n      ))}\n      {children}\n    </ul>\n  )\n}\n\ntype KeysortFilterCardProps = React.ComponentProps<\"li\"> & {\n  /** The attributes this card has: their holes are notched open. */\n  notches: string[]\n}\n\n/** One card. It falls free when it's notched for every needle in, and says so. */\nfunction KeysortFilterCard({ className, notches, children, ...props }: KeysortFilterCardProps) {\n  const { needles, selected, registerCard } = useKeysortFilter()\n  const id = React.useId()\n  const key = notches.join(\"\\u0000\")\n  React.useEffect(() => registerCard(id, key ? key.split(\"\\u0000\") : []), [id, key, registerCard])\n  const match = matches(notches, selected)\n  return (\n    <li\n      data-slot=\"keysort-filter-card\"\n      data-card={selected.length === 0 ? undefined : match ? \"match\" : \"held\"}\n      className={cn(\n        \"bevel-paper relative col-span-full grid grid-cols-subgrid items-center rounded-(--bevel-radius-xs) bg-(--keysort-filter-card) py-2 text-(--keysort-filter-card-foreground)\",\n        \"data-[card=held]:bg-(--keysort-filter-card-held) data-[card=held]:shadow-none\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"flex min-w-0 items-center gap-2 pl-3\">\n        {/* A tab marks the cards that fell free. */}\n        <span\n          aria-hidden\n          className=\"h-4 w-1 shrink-0 scale-y-0 rounded-full bg-(--keysort-filter-match) transition-[scale] duration-(--bevel-duration-small) ease-(--bevel-ease-spring) in-data-[card=match]:scale-y-100 motion-reduce:transition-none\"\n        />\n        <span className=\"line-clamp-2 text-sm leading-tight font-semibold\">{children}</span>\n        <span className=\"sr-only\">{match ? \"Matches.\" : \"\"}</span>\n      </span>\n      {needles.map((n) => {\n        const notched = notches.includes(n.value)\n        const caught = selected.includes(n.value) && !notched\n        return (\n          <span key={n.value} aria-hidden className=\"relative flex h-full justify-center\">\n            {/* A notched hole is cut open to the top edge; a caught card has the needle through its hole. */}\n            {notched ? (\n              <span className=\"absolute -top-2 h-4 w-2.5 rounded-b-full bg-(--keysort-filter-hole)\" />\n            ) : (\n              <span className={cn(\"size-2.5 rounded-full\", caught ? \"bevel-metal\" : \"bg-(--keysort-filter-hole)\")} />\n            )}\n          </span>\n        )\n      })}\n    </li>\n  )\n}\n\n/** “3 of 12 cards match NDA and Signed.” Announced as needles go in and out. */\nfunction KeysortFilterCount({ className, ...props }: React.ComponentProps<\"p\">) {\n  const { needles, selected, cards } = useKeysortFilter()\n  const all = Object.values(cards)\n  const count = all.filter((n) => matches(n, selected)).length\n  const names = selected.map((s) => needles.find((n) => n.value === s)?.label ?? s)\n  const list = names.length > 1 ? `${names.slice(0, -1).join(\", \")} and ${names.at(-1)}` : names[0]\n  return (\n    <p\n      role=\"status\"\n      data-slot=\"keysort-filter-count\"\n      className={cn(\"col-span-full text-sm text-muted-foreground\", className)}\n      {...props}\n    >\n      {selected.length === 0\n        ? `${all.length} cards. Push a needle in to sort.`\n        : `${count} of ${all.length} cards match ${list}.`}\n    </p>\n  )\n}\n\nexport { KeysortFilter, KeysortFilterNeedles, KeysortFilterNeedle, KeysortFilterDeck, KeysortFilterCard, KeysortFilterCount }\nexport type { KeysortFilterProps, KeysortFilterNeedleProps, KeysortFilterCardProps }\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    ":root": {
      "--keysort-filter-card": "oklch(0.93 0.04 85)",
      "--keysort-filter-card-held": "oklch(0.86 0.02 85)",
      "--keysort-filter-card-foreground": "oklch(0.3 0.04 70)",
      "--keysort-filter-hole": "oklch(0.3 0.02 70)",
      "--keysort-filter-match": "oklch(0.58 0.15 150)",
      "--keysort-filter-needle": "oklch(0.8 0.01 260)",
      "--keysort-filter-needle-edge": "oklch(0.5 0.01 260)",
      "--keysort-filter-column": "2.5rem"
    },
    "@media (width >= 40rem)": {
      ":root": {
        "--keysort-filter-column": "3.5rem"
      }
    },
    "[data-slot=\"keysort-filter\"]": {
      "--bevel-material": "var(--keysort-filter-needle)",
      "--bevel-material-edge": "var(--keysort-filter-needle-edge)"
    }
  },
  "categories": [
    "records"
  ],
  "type": "registry:ui"
}