{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-builder",
  "type": "registry:ui",
  "title": "Filter Builder",
  "description": "Filters written as sentences, each word a dropdown, with an all-or-any switch.",
  "categories": [
    "command"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/filter-builder.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { Select } from \"@base-ui/react/select\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronDown, Plus, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* Model                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type FilterOption = { value: string; label: string; icon?: React.ReactNode };\n\nexport type FilterField = {\n  key: string;\n  label: string;\n  icon?: React.ReactNode;\n  type: \"option\" | \"text\" | \"number\";\n  /** The choices for an option field. */\n  options?: FilterOption[];\n  /** Shown before a number, e.g. \"€\". */\n  unit?: string;\n  placeholder?: string;\n};\n\nexport type FilterRule = { id: string; field: string; operator: string; value: string | string[] | null };\nexport type FilterGroup = { conjunction: \"and\" | \"or\"; rules: FilterRule[] };\n\ntype Arity = \"one\" | \"many\" | \"none\";\ntype Operator = { value: string; label: string; arity: Arity };\n\nexport const OPERATORS: Record<FilterField[\"type\"], Operator[]> = {\n  option: [\n    { value: \"is\", label: \"is\", arity: \"one\" },\n    { value: \"is_not\", label: \"is not\", arity: \"one\" },\n    { value: \"any_of\", label: \"is any of\", arity: \"many\" },\n    { value: \"none_of\", label: \"is none of\", arity: \"many\" },\n  ],\n  text: [\n    { value: \"contains\", label: \"contains\", arity: \"one\" },\n    { value: \"not_contains\", label: \"doesn’t contain\", arity: \"one\" },\n    { value: \"is\", label: \"is\", arity: \"one\" },\n    { value: \"starts_with\", label: \"starts with\", arity: \"one\" },\n    { value: \"empty\", label: \"is empty\", arity: \"none\" },\n  ],\n  number: [\n    { value: \"eq\", label: \"is\", arity: \"one\" },\n    { value: \"gt\", label: \"is more than\", arity: \"one\" },\n    { value: \"lt\", label: \"is less than\", arity: \"one\" },\n    { value: \"gte\", label: \"is at least\", arity: \"one\" },\n    { value: \"lte\", label: \"is at most\", arity: \"one\" },\n  ],\n};\n\nconst operatorOf = (field: FilterField | undefined, op: string) => (field ? OPERATORS[field.type].find((o) => o.value === op) : undefined);\n\n/** A rule is applied only once it has everything its operator needs. */\nexport function isRuleComplete(rule: FilterRule, fields: FilterField[]) {\n  const field = fields.find((f) => f.key === rule.field);\n  const op = operatorOf(field, rule.operator);\n  if (!field || !op) return false;\n  if (op.arity === \"none\") return true;\n  if (op.arity === \"many\") return Array.isArray(rule.value) && rule.value.length > 0;\n  if (typeof rule.value !== \"string\" || rule.value.trim() === \"\") return false;\n  return field.type !== \"number\" || Number.isFinite(Number(rule.value));\n}\n\n/** Tests one record against the group. Incomplete rules are ignored; an empty group matches everything. */\nexport function matchesFilters(record: Record<string, unknown>, group: FilterGroup, fields: FilterField[]) {\n  const rules = group.rules.filter((r) => isRuleComplete(r, fields));\n  if (!rules.length) return true;\n  const test = (rule: FilterRule) => {\n    const field = fields.find((f) => f.key === rule.field)!;\n    const raw = record[field.key];\n    const v = rule.value;\n    if (field.type === \"number\") {\n      const a = Number(raw);\n      const b = Number(v);\n      return { eq: a === b, gt: a > b, lt: a < b, gte: a >= b, lte: a <= b }[rule.operator as \"eq\"] ?? false;\n    }\n    if (field.type === \"text\") {\n      const a = String(raw ?? \"\").toLocaleLowerCase();\n      const b = String(v ?? \"\").toLocaleLowerCase();\n      return { contains: a.includes(b), not_contains: !a.includes(b), is: a === b, starts_with: a.startsWith(b), empty: a.trim() === \"\" }[rule.operator as \"is\"] ?? false;\n    }\n    const list = [v].flat();\n    return { is: raw === v, is_not: raw !== v, any_of: list.includes(raw as string), none_of: !list.includes(raw as string) }[rule.operator as \"is\"] ?? false;\n  };\n  return group.conjunction === \"and\" ? rules.every(test) : rules.some(test);\n}\n\n/* ------------------------------------------------------------------ */\n/* Builder                                                             */\n/* ------------------------------------------------------------------ */\n\nexport type FilterBuilderProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  fields: FilterField[];\n  value?: FilterGroup;\n  defaultValue?: FilterGroup;\n  onValueChange?: (group: FilterGroup) => void;\n  /** Portal target for every popup. Defaults to document.body. */\n  container?: HTMLElement | null;\n};\n\nexport function FilterBuilder({ fields, value, defaultValue = { conjunction: \"and\", rules: [] }, onValueChange, container, className, ...rest }: FilterBuilderProps) {\n  const reduce = !!useReducedMotion();\n  const [group, setGroup] = useControllableState({ value, defaultValue, onChange: onValueChange });\n  const [openValue, setOpenValue] = useState<string | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const addRef = useRef<HTMLButtonElement>(null);\n  const added = useRef<string | null>(null);\n  const pendingFocus = useRef<string | null>(null);\n  const counter = useRef(0);\n  const baseId = useId();\n\n  const fieldOf = (key: string) => fields.find((f) => f.key === key);\n  const update = (id: string, patch: Partial<FilterRule>) => setGroup({ ...group, rules: group.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });\n\n  const add = (key: string) => {\n    const field = fieldOf(key);\n    if (!field) return;\n    const id = `${baseId}-${++counter.current}`;\n    added.current = id;\n    setGroup({ ...group, rules: [...group.rules, { id, field: key, operator: OPERATORS[field.type][0].value, value: null }] });\n    setAnnounce(`Added a ${field.label} filter`);\n  };\n\n  const remove = (id: string) => {\n    const index = group.rules.findIndex((r) => r.id === id);\n    const next = group.rules.filter((r) => r.id !== id);\n    const neighbor = next[Math.min(index, next.length - 1)];\n    const owned = rootRef.current?.querySelector(`[data-rule=\"${CSS.escape(id)}\"]`)?.contains(document.activeElement);\n    if (owned) pendingFocus.current = neighbor?.id ?? \"add\";\n    setGroup({ ...group, rules: next });\n    const field = fieldOf(group.rules[index]?.field ?? \"\");\n    setAnnounce(`Removed the ${field?.label ?? \"\"} filter`);\n  };\n\n  // Focus follows a removal to the row that slid up into its place, or to Add filter.\n  useEffect(() => {\n    const target = pendingFocus.current;\n    if (!target) return;\n    pendingFocus.current = null;\n    if (target === \"add\") return addRef.current?.focus();\n    rootRef.current?.querySelector<HTMLElement>(`[data-rule=\"${CSS.escape(target)}\"] [data-part=remove]`)?.focus();\n  });\n\n  const toggleConjunction = () => {\n    const conjunction = group.conjunction === \"and\" ? \"or\" : \"and\";\n    setGroup({ ...group, conjunction });\n    setAnnounce(conjunction === \"and\" ? \"Matching all filters\" : \"Matching any filter\");\n  };\n\n  // After Add filter's menu closes, the new row's value is where the eye and focus go next.\n  const valueElement = (id: string) => rootRef.current?.querySelector<HTMLElement>(`[data-rule=\"${CSS.escape(id)}\"] [data-part=value]`) ?? null;\n\n  const complete = group.rules.filter((r) => isRuleComplete(r, fields)).length;\n  const incomplete = group.rules.length - complete;\n\n  return (\n    <div ref={rootRef} className={cn(\"flex min-w-0 flex-col\", className)} {...rest}>\n      <div role=\"group\" aria-label=\"Filters\" className=\"flex flex-col\">\n        <AnimatePresence initial={false}>\n          {group.rules.length === 0 && (\n            <motion.p\n              key=\"empty\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.14, ease: ease.out } }}\n              transition={{ duration: 0.22, ease: ease.out }}\n              className=\"overflow-hidden text-[12.5px] text-fg-3\"\n            >\n              <span className=\"flex h-9 items-center pl-2\">No filters yet. Add one to narrow the list.</span>\n            </motion.p>\n          )}\n          {group.rules.map((rule, index) => (\n            <motion.div\n              key={rule.id}\n              data-rule={rule.id}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n              animate={{ opacity: 1, height: \"auto\", y: 0 }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, height: 0, transition: { duration: 0.16, ease: ease.out } }}\n              transition={reduce ? { duration: 0.14 } : { duration: 0.24, ease: ease.out }}\n              // Clips while the height animates; the side padding keeps focus rings inside the clip.\n              className=\"-mx-1 overflow-hidden px-1\"\n            >\n              <Row\n                rule={rule}\n                index={index}\n                fields={fields}\n                conjunction={group.conjunction}\n                onConjunction={toggleConjunction}\n                onChange={(patch) => update(rule.id, patch)}\n                onRemove={() => remove(rule.id)}\n                valueOpen={openValue === rule.id}\n                onValueOpenChange={(o) => setOpenValue(o ? rule.id : null)}\n                container={container}\n                reduce={reduce}\n                complete={isRuleComplete(rule, fields)}\n              />\n            </motion.div>\n          ))}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"mt-1.5 flex h-8 items-center justify-between gap-2\">\n        <Menu.Root\n          onOpenChange={(open) => {\n            if (open) added.current = null;\n          }}\n          onOpenChangeComplete={(open) => {\n            const id = added.current;\n            if (open || !id) return;\n            added.current = null;\n            // Hand over to the new row: options open their list, text and numbers take the caret.\n            valueElement(id)?.focus();\n            const rule = group.rules.find((r) => r.id === id);\n            if (rule && fieldOf(rule.field)?.type === \"option\") setOpenValue(id);\n          }}\n        >\n          <Menu.Trigger ref={addRef} className={cn(ghost, \"gap-1.5 pr-2.5 pl-2 text-fg-2\")}>\n            <Plus size={14} />\n            Add filter\n          </Menu.Trigger>\n          <Menu.Portal container={container}>\n            <Menu.Positioner align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n              <Menu.Popup\n                finalFocus={() => !added.current}\n                className={cn(popupClass, \"min-w-44 p-1\")}\n              >\n                <p className=\"px-2 pt-1 pb-1.5 font-mono text-2xs tracking-[0.08em] text-fg-4 uppercase\">Filter by</p>\n                {fields.map((f) => (\n                  <Menu.Item key={f.key} onClick={() => add(f.key)} className={itemClass}>\n                    {f.icon && <span className=\"flex shrink-0 text-fg-3 [&_svg]:size-3.5\">{f.icon}</span>}\n                    <span className=\"truncate\">{f.label}</span>\n                  </Menu.Item>\n                ))}\n              </Menu.Popup>\n            </Menu.Positioner>\n          </Menu.Portal>\n        </Menu.Root>\n\n        <div className=\"flex min-w-0 items-center gap-2\">\n          <AnimatePresence initial={false}>\n            {incomplete > 0 && (\n              <motion.span\n                key=\"incomplete\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                className=\"truncate text-[12px] text-fg-4\"\n              >\n                {incomplete === 1 ? \"1 filter needs a value\" : `${incomplete} filters need a value`}\n              </motion.span>\n            )}\n          </AnimatePresence>\n          {group.rules.length > 0 && (\n            <button\n              type=\"button\"\n              onClick={() => {\n                setGroup({ ...group, rules: [] });\n                addRef.current?.focus();\n                setAnnounce(\"Cleared all filters\");\n              }}\n              className={cn(ghost, \"px-2 text-fg-3\")}\n            >\n              Clear all\n            </button>\n          )}\n        </div>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Row                                                                 */\n/* ------------------------------------------------------------------ */\n\ntype RowProps = {\n  rule: FilterRule;\n  index: number;\n  fields: FilterField[];\n  conjunction: \"and\" | \"or\";\n  onConjunction: () => void;\n  onChange: (patch: Partial<FilterRule>) => void;\n  onRemove: () => void;\n  valueOpen: boolean;\n  onValueOpenChange: (open: boolean) => void;\n  container?: HTMLElement | null;\n  reduce: boolean;\n  complete: boolean;\n};\n\nfunction Row({ rule, index, fields, conjunction, onConjunction, onChange, onRemove, valueOpen, onValueOpenChange, container, reduce, complete }: RowProps) {\n  const field = fields.find((f) => f.key === rule.field);\n  const ops = field ? OPERATORS[field.type] : [];\n  const op = operatorOf(field, rule.operator);\n\n  const changeField = (key: string) => {\n    const next = fields.find((f) => f.key === key);\n    if (!next || key === rule.field) return;\n    // A new field keeps its operator only when the new type has one with the same meaning.\n    const same = next.type === field?.type ? rule.operator : OPERATORS[next.type][0].value;\n    onChange({ field: key, operator: same, value: next.type === field?.type && next.type !== \"option\" ? rule.value : null });\n  };\n\n  const changeOperator = (value: string) => {\n    const nextOp = ops.find((o) => o.value === value);\n    if (!nextOp) return;\n    // Carry the value across when the arity changes: one becomes a list of one, a list keeps its first.\n    let v = rule.value;\n    if (nextOp.arity === \"many\") v = v == null ? null : [v].flat();\n    else if (nextOp.arity === \"one\") v = Array.isArray(v) ? (v[0] ?? null) : v;\n    else v = null;\n    onChange({ operator: value, value: v });\n  };\n\n  return (\n    <div data-complete={complete || undefined} className=\"grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-x-1.5 py-[3px]\">\n      <div className=\"flex h-7 items-center\">\n        {index === 0 ? (\n          <span className=\"pl-2 text-[12.5px] text-fg-3\">Where</span>\n        ) : (\n          <button\n            type=\"button\"\n            onClick={onConjunction}\n            aria-label={conjunction === \"and\" ? \"Match all filters. Switch to any.\" : \"Match any filter. Switch to all.\"}\n            className={cn(ghost, \"relative w-full justify-start overflow-hidden pr-1 pl-2 text-fg-2\")}\n          >\n            <span className=\"relative grid h-4 overflow-hidden\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={conjunction}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10 }}\n                  // Each row's word rolls a beat after the one above, so the change reads down the list.\n                  transition={reduce ? { duration: 0.12 } : { ...spring.snappy, delay: (index - 1) * 0.03 }}\n                  className=\"leading-4\"\n                >\n                  {conjunction}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            <ChevronDown size={12} className=\"ml-0.5 shrink-0 text-fg-4\" />\n          </button>\n        )}\n      </div>\n\n      <div className=\"flex min-w-0 flex-wrap items-center gap-1.5\">\n        <Segment\n          label=\"Field\"\n          value={rule.field}\n          items={fields.map((f) => ({ value: f.key, label: f.label, icon: f.icon }))}\n          onValueChange={(v) => v && changeField(v)}\n          container={container}\n          reduce={reduce}\n          variant=\"field\"\n        />\n        {field && (\n          <Segment\n            label=\"Operator\"\n            value={rule.operator}\n            items={ops.map((o) => ({ value: o.value, label: o.label }))}\n            onValueChange={(v) => v && changeOperator(v)}\n            container={container}\n            reduce={reduce}\n            variant=\"operator\"\n          />\n        )}\n        {field && op && op.arity !== \"none\" && (\n          <ValueControl\n            field={field}\n            arity={op.arity}\n            value={rule.value}\n            onChange={(v) => onChange({ value: v })}\n            open={valueOpen}\n            onOpenChange={onValueOpenChange}\n            container={container}\n            reduce={reduce}\n          />\n        )}\n      </div>\n\n      <button\n        type=\"button\"\n        data-part=\"remove\"\n        aria-label={`Remove ${field?.label ?? \"this\"} filter`}\n        onClick={onRemove}\n        className={cn(ghost, \"relative w-7 justify-center text-fg-3 active:scale-[0.9] pointer-coarse:after:-inset-2\")}\n      >\n        <X size={14} />\n      </button>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Segments                                                            */\n/* ------------------------------------------------------------------ */\n\ntype SegmentProps = {\n  label: string;\n  value: string | null;\n  items: FilterOption[];\n  onValueChange: (value: string | null) => void;\n  container?: HTMLElement | null;\n  reduce: boolean;\n  variant: \"field\" | \"operator\" | \"value\";\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  placeholder?: string;\n};\n\nfunction Segment({ label, value, items, onValueChange, container, reduce, variant, open, onOpenChange, placeholder = \"Select…\" }: SegmentProps) {\n  const current = items.find((i) => i.value === value);\n  return (\n    <Select.Root\n      items={items}\n      value={value}\n      onValueChange={(v) => onValueChange(v as string | null)}\n      open={open}\n      onOpenChange={onOpenChange ? (o) => onOpenChange(o) : undefined}\n    >\n      <Select.Trigger\n        aria-label={label}\n        data-part={variant === \"value\" ? \"value\" : undefined}\n        className={cn(triggerClass(variant), !current && \"border-dashed text-fg-4\")}\n      >\n        {current?.icon && <span className=\"flex shrink-0 text-fg-3 [&_svg]:size-3.5\">{current.icon}</span>}\n        <SwapText text={current?.label ?? placeholder} reduce={reduce} />\n        {variant !== \"operator\" && <ChevronDown size={12} className=\"shrink-0 text-fg-4\" />}\n      </Select.Trigger>\n      <Popup container={container} reduce={reduce}>\n        {items.map((item) => (\n          <Select.Item key={item.value} value={item.value} className={itemClass}>\n            {item.icon && <span className=\"flex shrink-0 text-fg-3 [&_svg]:size-3.5\">{item.icon}</span>}\n            <Select.ItemText className=\"min-w-0 flex-1 truncate\">{item.label}</Select.ItemText>\n            <Select.ItemIndicator className=\"flex shrink-0 text-fg-2\">\n              <Check size={14} />\n            </Select.ItemIndicator>\n          </Select.Item>\n        ))}\n      </Popup>\n    </Select.Root>\n  );\n}\n\ntype ValueControlProps = {\n  field: FilterField;\n  arity: \"one\" | \"many\";\n  value: string | string[] | null;\n  onChange: (value: string | string[] | null) => void;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  container?: HTMLElement | null;\n  reduce: boolean;\n};\n\nfunction ValueControl({ field, arity, value, onChange, open, onOpenChange, container, reduce }: ValueControlProps) {\n  const options = field.options ?? [];\n  if (field.type === \"option\" && arity === \"one\") {\n    return (\n      <Segment\n        label={`${field.label} value`}\n        value={typeof value === \"string\" ? value : null}\n        items={options}\n        onValueChange={onChange}\n        open={open}\n        onOpenChange={onOpenChange}\n        container={container}\n        reduce={reduce}\n        variant=\"value\"\n        placeholder={field.placeholder}\n      />\n    );\n  }\n\n  if (field.type === \"option\") {\n    const list = Array.isArray(value) ? value : [];\n    const labels = options.filter((o) => list.includes(o.value)).map((o) => o.label);\n    const text = labels.length === 0 ? (field.placeholder ?? \"Select…\") : labels.length <= 2 ? labels.join(\", \") : `${labels[0]} +${labels.length - 1}`;\n    return (\n      <Select.Root<string, true> multiple items={options} value={list} onValueChange={(v) => onChange(v.length ? v : null)} open={open} onOpenChange={(o) => onOpenChange(o)}>\n        <Select.Trigger aria-label={`${field.label} values`} data-part=\"value\" className={cn(triggerClass(\"value\"), !labels.length && \"border-dashed text-fg-4\")}>\n          <SwapText text={text} reduce={reduce} />\n          <ChevronDown size={12} className=\"shrink-0 text-fg-4\" />\n        </Select.Trigger>\n        <Popup container={container} reduce={reduce}>\n          {options.map((item) => (\n            <Select.Item key={item.value} value={item.value} className={itemClass}>\n              <Box on={list.includes(item.value)} reduce={reduce} />\n              {item.icon && <span className=\"flex shrink-0 text-fg-3 [&_svg]:size-3.5\">{item.icon}</span>}\n              <Select.ItemText className=\"min-w-0 flex-1 truncate\">{item.label}</Select.ItemText>\n            </Select.Item>\n          ))}\n        </Popup>\n      </Select.Root>\n    );\n  }\n\n  const number = field.type === \"number\";\n  return (\n    <label\n      data-invalid={number && typeof value === \"string\" && value !== \"\" && !Number.isFinite(Number(value)) ? \"\" : undefined}\n      className={cn(\n        \"flex h-7 min-w-0 items-center gap-1 rounded-md border border-line-2 bg-raised px-2 text-[12.5px] shadow-[var(--shadow)]\",\n        \"transition-[border-color,box-shadow] duration-150 hover:border-fg-4 focus-within:border-fg-4 focus-within:ring-2 focus-within:ring-fg/10\",\n        \"data-invalid:border-danger/70\",\n        number ? \"w-28\" : \"w-40 max-w-full\",\n      )}\n    >\n      <span className=\"sr-only\">{field.label} value</span>\n      {number && field.unit && <span aria-hidden className=\"shrink-0 text-fg-3\">{field.unit}</span>}\n      <input\n        data-part=\"value\"\n        type=\"text\"\n        inputMode={number ? \"decimal\" : \"text\"}\n        enterKeyHint=\"done\"\n        autoComplete=\"off\"\n        spellCheck={false}\n        value={typeof value === \"string\" ? value : \"\"}\n        onChange={(e) => onChange(e.target.value === \"\" ? null : e.target.value)}\n        placeholder={field.placeholder ?? (number ? \"0\" : \"Type a value\")}\n        className={cn(\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[12.5px]\", number && \"tabular\")}\n      />\n    </label>\n  );\n}\n\n// A label that swaps inside its own box: the old word lifts out as the new one settles in.\nfunction SwapText({ text, reduce }: { text: string; reduce: boolean }) {\n  return (\n    <span className=\"grid min-w-0 overflow-hidden\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={text}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n          transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n          className=\"col-start-1 row-start-1 truncate\"\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Popup({ children, container, reduce }: { children: React.ReactNode; container?: HTMLElement | null; reduce: boolean }) {\n  const { setList, y, height, opacity } = useGlide(reduce);\n  return (\n    <Select.Portal container={container}>\n      <Select.Positioner alignItemWithTrigger={false} align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none select-none\">\n        <Select.Popup className={cn(popupClass, \"min-w-(--anchor-width)\")}>\n          <Select.List ref={setList} className=\"relative max-h-[min(var(--available-height),16rem)] min-w-40 overflow-y-auto overscroll-contain p-1 outline-none\">\n            <motion.div aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line\" />\n            {children}\n          </Select.List>\n        </Select.Popup>\n      </Select.Positioner>\n    </Select.Portal>\n  );\n}\n\nfunction Box({ on, reduce }: { on: boolean; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid size-3.5 shrink-0 place-items-center rounded-[4px] border transition-[background-color,border-color,scale] duration-150 ease-out group-active/item:scale-[0.85]\",\n        on ? \"border-fg bg-fg text-frame\" : \"border-line-2 bg-frame group-data-highlighted/item:border-fg-4\",\n      )}\n    >\n      <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <motion.path\n          d=\"M2 5.2 4.1 7.2 8 2.8\"\n          initial={false}\n          animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.2, ease: ease.out }, opacity: { duration: 0.05 } }}\n        />\n      </svg>\n    </span>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Styles                                                              */\n/* ------------------------------------------------------------------ */\n\nconst focusRing = \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\";\n\nconst ghost = cn(\n  \"inline-flex h-7 shrink-0 items-center rounded-md text-[12.5px] font-medium whitespace-nowrap select-none\",\n  focusRing,\n  \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.96] active:duration-75 data-popup-open:bg-hover data-popup-open:text-fg\",\n);\n\nconst triggerClass = (variant: \"field\" | \"operator\" | \"value\") =>\n  cn(\n    \"relative inline-flex h-7 max-w-full min-w-0 shrink items-center gap-1.5 rounded-md text-[12.5px] select-none\",\n    focusRing,\n    \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n    \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2\",\n    // The operator reads as a word in the sentence; field and value read as the things being compared.\n    variant === \"operator\"\n      ? \"px-1.5 text-fg-2 hover:bg-hover hover:text-fg data-popup-open:bg-hover data-popup-open:text-fg\"\n      : \"border border-line-2 bg-raised pr-1.5 pl-2 text-fg shadow-[var(--shadow)] hover:border-fg-4 data-popup-open:border-fg-4\",\n    variant === \"field\" && \"font-medium\",\n    variant === \"value\" && \"max-w-[14rem]\",\n  );\n\nconst popupClass = cn(\n  \"origin-(--transform-origin) overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n  \"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out\",\n  \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n  \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n);\n\nconst itemClass = cn(\n  \"group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2.5 rounded-lg px-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-10\",\n  \"data-highlighted:bg-line data-disabled:text-fg-4 [[data-glide]_&]:data-highlighted:bg-transparent\",\n);\n\n// One highlight for a list: glides after the pointer, jumps for arrow keys.\nfunction useGlide(reduce: boolean) {\n  const [list, setList] = useState<HTMLDivElement | null>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!list) return;\n    list.setAttribute(\"data-glide\", \"\");\n    let keyboard = false;\n    let shown = false;\n    let running: AnimationPlaybackControls[] = [];\n    const stop = () => {\n      running.forEach((c) => c.stop());\n      running = [];\n    };\n    const place = () => {\n      const el = list.querySelector<HTMLElement>(\"[data-highlighted]\");\n      stop();\n      if (!el) {\n        shown = false;\n        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));\n        return;\n      }\n      if (!shown || keyboard || reduce) {\n        y.jump(el.offsetTop);\n        height.jump(el.offsetHeight);\n        opacity.jump(1);\n      } else {\n        running.push(animate(y, el.offsetTop, spring.follow), animate(height, el.offsetHeight, spring.follow));\n        opacity.jump(1);\n      }\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onPointer = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(list, { subtree: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    document.addEventListener(\"keydown\", onKey, true);\n    list.addEventListener(\"pointermove\", onPointer);\n    place();\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"keydown\", onKey, true);\n      list.removeEventListener(\"pointermove\", onPointer);\n      stop();\n    };\n  }, [list, reduce, y, height, opacity]);\n\n  return { setList, y, height, opacity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/filter-builder"
}
