{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-toolbar",
  "type": "registry:ui",
  "title": "Filter toolbar",
  "description": "Keep collection filters close and easy to reset.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/filter-toolbar/filter-toolbar.tsx",
      "type": "registry:component",
      "target": "@components/xzero/filter-toolbar/filter-toolbar.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { FocusEvent, KeyboardEvent, MouseEvent, ReactNode, RefObject } from \"react\";\nimport { Check, ChevronLeft, ChevronRight, Plus, X } from \"lucide-react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform, type HTMLMotionProps, type MotionValue, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./filter-toolbar.module.css\";\n\nexport interface FilterChip { id: string; label: string; value?: string; }\n/** One value a field can take. `hint` sits at the end of the row, for example a count. */\nexport interface FilterOption { value: string; label?: string; hint?: string | number; icon?: ReactNode; }\nexport interface FilterField { id: string; label: string; icon?: ReactNode; options: (string | FilterOption)[]; }\nexport interface FilterMenuProps {\n  fields: FilterField[];\n  /** Receives a chip whose id is the field id, so a second pick for a field replaces the first. */\n  onSelect: (filter: FilterChip, field: FilterField) => void;\n  /** Applied filters. Each field shows its current value and the value step marks it. */\n  active?: FilterChip[];\n  label?: string;\n  /** The trigger edge the panel lines up with. It flips or shifts when the viewport, or a clipping ancestor, has no room. */\n  align?: \"start\" | \"end\";\n}\nexport interface FilterToolbarProps {\n  filters: FilterChip[];\n  onRemove: (id: string) => void;\n  onClearAll?: () => void;\n  children?: ReactNode;\n  label?: string;\n  /** Adds an Add filter trigger that morphs into a two step field and value menu. */\n  addFilter?: { fields: FilterField[]; onAdd: (filter: FilterChip, field: FilterField) => void; label?: string; align?: \"start\" | \"end\" };\n}\n\nconst enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] as const };\nconst leave = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] as const };\nconst still = { duration: 0 };\nconst fade = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] as const };\nconst blur = (px: number) => `blur(${px}px)`;\nconst clamp = (value: number) => Math.min(1, Math.max(0, value));\nconst toOption = (entry: string | FilterOption): FilterOption => typeof entry === \"string\" ? { value: entry } : entry;\nconst optionText = (entry: FilterOption) => entry.label ?? entry.value;\nconst textMotion = (reduced: boolean) => ({\n  initial: reduced ? { opacity: 0 } : { opacity: 0, y: \"0.3em\", filter: blur(motionTokens.blur.soft) },\n  animate: { opacity: 1, y: 0, filter: blur(0) },\n  exit: reduced ? { opacity: 0, transition: still } : { opacity: 0, y: \"-0.3em\", filter: blur(motionTokens.blur.subtle), transition: leave },\n  transition: reduced ? fade : enter,\n});\n\n/** Text that swaps with a rise and blur while its box springs to the new width, so nothing beside it snaps. */\nfunction MorphText({ text }: { text: string }) {\n  const reduced = useReducedMotion() ?? false;\n  const measure = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const node = measure.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let last: string | null = null;\n    // The observer reports the layout size, so an entering chip's scale never shrinks the measurement.\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = Math.ceil(entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth), changed = last !== null && last !== node.textContent;\n      last = node.textContent;\n      // Only a new value morphs; the first measure and font swaps settle at once.\n      if (changed && !reduced) animate(width as MotionValue<number>, next, motionTokens.spring.morph); else width.jump(next);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  return <motion.span className={styles.morphText} style={{ width }}>\n    <span ref={measure} className={styles.measure} aria-hidden=\"true\">{text}</span>\n    <AnimatePresence mode=\"popLayout\" initial={false}><motion.span key={text} className={styles.morphValue} {...textMotion(reduced)}>{text}</motion.span></AnimatePresence>\n  </motion.span>;\n}\n\n/** Springs a wrapper to the height of its content while filters change; other resizes follow at once. */\nfunction useFollowHeight(list: RefObject<HTMLElement | null>, armed: RefObject<number>, reduced: boolean) {\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const node = list.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let known = false;\n    const observer = new ResizeObserver(() => {\n      const next = node.offsetHeight;\n      if (!known || reduced || performance.now() > armed.current) { known = true; height.jump(next); return; }\n      animate(height as MotionValue<number>, next, motionTokens.spring.smooth);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [armed, height, list, reduced]);\n  return height;\n}\n\n/** Chips glide with the slot widths, but a chip that wraps to another line would teleport. Resize observers run after\n *  layout and before paint, so each jump is offset in the same frame and springs back to zero from where it was seen. */\nfunction useReflowGlide(list: RefObject<HTMLElement | null>, armed: RefObject<number>, reduced: boolean) {\n  useLayoutEffect(() => {\n    const node = list.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    type Track = { x: number; y: number; dx: number; dy: number; stop?: () => void };\n    const tracks = new Map<Element, Track>();\n    const check = () => {\n      const now = performance.now();\n      for (const child of Array.from(node.children) as HTMLElement[]) {\n        const x = child.offsetLeft, y = child.offsetTop, track = tracks.get(child);\n        if (!track) { tracks.set(child, { x, y, dx: 0, dy: 0 }); continue; }\n        const jx = track.x - x, jy = track.y - y;\n        track.x = x; track.y = y;\n        if (reduced || now > armed.current || (Math.abs(jy) < 1 && Math.abs(jx) < 40)) continue;\n        track.stop?.();\n        const fromX = track.dx + jx, fromY = track.dy + jy;\n        const apply = (progress: number) => { track.dx = fromX * (1 - progress); track.dy = fromY * (1 - progress); child.style.translate = progress >= 1 ? \"\" : `${track.dx}px ${track.dy}px`; };\n        apply(0);\n        track.stop = animate(0, 1, { ...motionTokens.spring.smooth, onUpdate: apply, onComplete: () => apply(1) }).stop;\n      }\n      for (const child of tracks.keys()) if (child.parentElement !== node) tracks.delete(child);\n    };\n    const observer = new ResizeObserver(check);\n    const watch = () => { for (const child of Array.from(node.children)) observer.observe(child); };\n    const mutations = new MutationObserver(() => { watch(); check(); });\n    observer.observe(node); watch();\n    mutations.observe(node, { childList: true });\n    return () => { observer.disconnect(); mutations.disconnect(); for (const track of tracks.values()) track.stop?.(); };\n  }, [armed, list, reduced]);\n}\n\nfunction describeChange(before: FilterChip[], after: FilterChip[]) {\n  const text = (filter: FilterChip) => filter.value ? `${filter.label}: ${filter.value}` : filter.label;\n  const removed = before.filter(old => !after.some(filter => filter.id === old.id));\n  if (!after.length && removed.length > 1) return \"All filters cleared\";\n  const added = after.filter(filter => !before.some(old => old.id === filter.id));\n  const changed = after.filter(filter => before.some(old => old.id === filter.id && old.value !== filter.value));\n  return [...added.map(filter => `Added ${text(filter)}`), ...changed.map(filter => `${filter.label} changed to ${filter.value ?? \"any\"}`), ...removed.map(filter => `Removed ${text(filter)}`)].join(\". \");\n}\n\ntype Row = { key: string; label: string; icon?: ReactNode; meta?: ReactNode; checked?: boolean; drill?: boolean };\ntype FocusRequest = \"first\" | \"last\" | \"checked\" | \"panel\" | { key: string };\n\n/** A roving list with one highlight that glides under the pointer and jumps with the keyboard. */\nfunction MenuList({ rows, labelledBy, onChoose, onBack, onLeave, radio }: { rows: Row[]; labelledBy: string; onChoose: (row: Row, keyboard: boolean) => void; onBack?: (keyboard: boolean) => void; onLeave: () => void; radio?: boolean }) {\n  const reduced = useReducedMotion() ?? false;\n  const present = useIsPresent();\n  const list = useRef<HTMLDivElement>(null);\n  const pointer = useRef(false);\n  const typed = useRef({ text: \"\", at: 0 });\n  const [current, setCurrent] = useState(() => Math.max(0, rows.findIndex(row => row.checked)));\n  const [highlight, setHighlight] = useState<{ top: number; height: number; glide: boolean; ring: boolean; shown: boolean } | null>(null);\n  const items = () => Array.from(list.current?.querySelectorAll<HTMLElement>(\"[data-row]\") ?? []);\n  const move = (item: HTMLElement | undefined) => {\n    const node = list.current;\n    if (!item || !node) return;\n    item.focus({ preventScroll: true });\n    if (item.offsetTop < node.scrollTop) node.scrollTop = item.offsetTop;\n    else if (item.offsetTop + item.offsetHeight > node.scrollTop + node.clientHeight) node.scrollTop = item.offsetTop + item.offsetHeight - node.clientHeight;\n  };\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const all = items(), index = all.indexOf(document.activeElement as HTMLElement);\n    const go = (next: number) => { event.preventDefault(); pointer.current = false; move(all[next]); };\n    if (event.key === \"ArrowDown\") return go(index < 0 ? 0 : (index + 1) % all.length);\n    if (event.key === \"ArrowUp\") return go(index < 0 ? all.length - 1 : (index - 1 + all.length) % all.length);\n    if (event.key === \"Home\") return go(0);\n    if (event.key === \"End\") return go(all.length - 1);\n    if (event.key === \"ArrowRight\" && index >= 0 && rows[index]?.drill) { event.preventDefault(); onChoose(rows[index], true); return; }\n    if (event.key === \"ArrowLeft\" && onBack) { event.preventDefault(); onBack(true); return; }\n    if (event.key.length === 1 && /\\S/.test(event.key) && !event.metaKey && !event.ctrlKey && !event.altKey) {\n      const now = performance.now();\n      typed.current = { text: now - typed.current.at < 700 ? typed.current.text + event.key.toLowerCase() : event.key.toLowerCase(), at: now };\n      const order = [...all.slice(index + (typed.current.text.length > 1 ? 0 : 1)), ...all.slice(0, index + (typed.current.text.length > 1 ? 0 : 1))];\n      const match = order.find(item => (item.dataset.text ?? \"\").toLowerCase().startsWith(typed.current.text));\n      if (match) go(all.indexOf(match));\n    }\n  }\n  function onFocus(event: FocusEvent<HTMLDivElement>) {\n    const item = (event.target as HTMLElement).closest<HTMLElement>(\"[data-row]\");\n    if (!item) return;\n    setCurrent(items().indexOf(item));\n    // Keyboard focus also draws the focus ring inside the highlight, where the scrolling list cannot clip it.\n    const glide = pointer.current && !reduced, ring = !pointer.current && item.matches(\":focus-visible\");\n    setHighlight(previous => ({ top: item.offsetTop, height: item.offsetHeight, glide: glide && !!previous?.shown, ring, shown: true }));\n  }\n  function onBlur(event: FocusEvent<HTMLDivElement>) {\n    // The highlight fades where it is; it keeps its place so the fade is visible.\n    if (!list.current?.contains(event.relatedTarget as Node | null)) setHighlight(previous => previous && { ...previous, glide: false, ring: false, shown: false });\n  }\n  return <div ref={list} className={styles.list} role=\"menu\" aria-labelledby={labelledBy} data-current={present ? \"\" : undefined} onKeyDown={onKeyDown} onFocus={onFocus} onBlur={onBlur}\n    onPointerLeave={() => { if (pointer.current && list.current?.contains(document.activeElement)) onLeave(); }}>\n    <motion.span className={styles.highlight} data-ring={highlight?.ring || undefined} aria-hidden=\"true\" initial={false} animate={highlight ? { y: highlight.top, height: highlight.height, opacity: highlight.shown ? 1 : 0 } : { opacity: 0 }}\n      transition={{ default: highlight?.glide ? motionTokens.spring.snappy : still, opacity: { duration: reduced ? 0 : .08 } }} />\n    {rows.map((row, index) => <button key={row.key} type=\"button\" className={styles.item} role={radio ? \"menuitemradio\" : \"menuitem\"} aria-checked={radio ? !!row.checked : undefined}\n      tabIndex={index === current ? 0 : -1} data-row={row.key} data-text={row.label}\n      onPointerMove={event => { pointer.current = true; if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true }); }}\n      onClick={(event: MouseEvent<HTMLButtonElement>) => onChoose(row, event.detail === 0)}>\n      {row.icon ? <span className={styles.itemIcon} aria-hidden=\"true\">{row.icon}</span> : null}\n      <span className={styles.itemLabel}>{row.label}</span>\n      {row.meta ? <span className={styles.itemMeta}>{row.meta}</span> : null}\n    </button>)}\n  </div>;\n}\n\ntype Phase = \"closed\" | \"open\" | \"closing\";\n\n/** The room the panel may use: the viewport, narrowed on each axis by any ancestor that clips that axis, so a panel\n *  inside a scrolling or clipped container flips and shifts within what is actually visible. */\nfunction roomFor(node: HTMLElement) {\n  const room = { left: 0, top: 0, right: document.documentElement.clientWidth, bottom: window.innerHeight };\n  for (let parent = node.parentElement; parent && parent !== document.body; parent = parent.parentElement) {\n    const style = getComputedStyle(parent);\n    if (style.overflowX === \"visible\" && style.overflowY === \"visible\") continue;\n    const box = parent.getBoundingClientRect();\n    if (style.overflowX !== \"visible\") { room.left = Math.max(room.left, box.left); room.right = Math.min(room.right, box.right); }\n    if (style.overflowY !== \"visible\") { room.top = Math.max(room.top, box.top); room.bottom = Math.min(room.bottom, box.bottom); }\n  }\n  return room;\n}\n\n/** An Add filter button that grows into its own menu: pick a field, then a value. The shape springs between the\n *  button and the panel, and the panel renders in place, so keep its ancestors free of overflow clipping. */\nexport function FilterMenu({ fields, onSelect, active = [], label = \"Add filter\", align = \"end\" }: FilterMenuProps) {\n  const reduced = useReducedMotion() ?? false;\n  const id = useId(), panelId = `${id}panel`, titleId = `${id}title`;\n  const root = useRef<HTMLDivElement>(null), trigger = useRef<HTMLButtonElement>(null), panel = useRef<HTMLDivElement>(null);\n  const phaseRef = useRef<Phase>(\"closed\"), run = useRef(0), focusNext = useRef<FocusRequest | null>(null);\n  const [phase, setPhase] = useState<Phase>(\"closed\");\n  const [fieldId, setFieldId] = useState<string | null>(null);\n  const [direction, setDirection] = useState(1);\n  const [up, setUp] = useState(false);\n  // The content scales up from the trigger's center, so it reads as growing out of the button.\n  const [origin, setOrigin] = useState(\"100% 0\");\n  // The surface is the button's own rounded rectangle. At rest it fills the trigger; while open it springs to the panel.\n  const width = useMotionValue<number | string>(\"100%\"), height = useMotionValue<number | string>(\"100%\");\n  const left = useMotionValue(0), target = useMotionValue(0);\n  // The panel content stays put at its final position while the surface edge travels across it.\n  const panelX = useTransform(() => target.get() - left.get());\n  // How far the shape has grown from the button (0) to the panel (1). The label and the content read it, so they\n  // reveal with the morph, never ahead of the growing edge, and reverse with it when a close interrupts an open.\n  const from = useMotionValue(0), to = useMotionValue(0);\n  const progress = useTransform(() => { const now = width.get(), start = from.get(), end = to.get(); return typeof now !== \"number\" ? 0 : end > start ? clamp((now - start) / (end - start)) : 1; });\n  const faceOut = useTransform(() => clamp(progress.get() / .35)), contentIn = useTransform(() => clamp((progress.get() - .4) / .5));\n  // Reduced motion jumps the shape, so progress goes 0 to 1 in one frame and these in-between values never paint.\n  const faceStyle = { opacity: useTransform(() => 1 - faceOut.get()), scale: useTransform(() => 1 - faceOut.get() * .04), filter: useTransform(() => faceOut.get() ? blur(faceOut.get() * motionTokens.blur.subtle) : \"none\") };\n  const contentStyle = { opacity: contentIn, transformOrigin: origin, scale: useTransform(() => .96 + contentIn.get() * .04), y: useTransform(() => (1 - contentIn.get()) * (up ? -6 : 6)), filter: useTransform(() => contentIn.get() === 1 ? \"none\" : blur((1 - contentIn.get()) * motionTokens.blur.soft)) };\n  const open = phase === \"open\", field = fields.find(entry => entry.id === fieldId) ?? null;\n\n  const openMenu = useCallback((focus: FocusRequest) => {\n    const button = trigger.current, content = panel.current;\n    if (!button || !content) return;\n    const rect = button.getBoundingClientRect(), panelWidth = content.offsetWidth + 2, panelHeight = content.offsetHeight + 2;\n    const room = roomFor(root.current ?? button), gutter = 16;\n    const min = room.left + gutter - rect.left, max = room.right - gutter - rect.left - panelWidth, end = rect.width - panelWidth;\n    let x = align === \"end\" ? (end >= min ? end : 0 <= max ? 0 : end) : (0 <= max ? 0 : end >= min ? end : 0);\n    x = Math.round(Math.min(Math.max(x, min), Math.max(min, max)));\n    const below = room.bottom - rect.top, above = rect.bottom - room.top;\n    const nextUp = below < panelHeight + gutter && above > below;\n    if (phaseRef.current === \"closed\") { width.jump(rect.width); height.jump(rect.height); left.jump(0); }\n    target.jump(x); from.jump(rect.width); to.jump(panelWidth);\n    phaseRef.current = \"open\"; run.current += 1; focusNext.current = focus;\n    setPhase(\"open\"); setUp(nextUp); setFieldId(null); setDirection(-1);\n    setOrigin(`${Math.round(rect.width / 2 - x)}px ${nextUp ? \"100%\" : \"0\"}`);\n    const transition = reduced ? still : motionTokens.spring.morph;\n    animate(left, x, transition);\n    animate(width as MotionValue<number>, panelWidth, transition);\n    animate(height as MotionValue<number>, panelHeight, transition);\n  }, [align, from, height, left, reduced, target, to, width]);\n\n  const closeMenu = useCallback((restore: false | \"keyboard\" | \"pointer\") => {\n    const button = trigger.current;\n    if (phaseRef.current !== \"open\" || !button) return;\n    phaseRef.current = \"closing\";\n    const token = (run.current += 1);\n    setPhase(\"closing\");\n    // Focus returns to the trigger; its ring shows only when the menu was closed from the keyboard.\n    if (restore) button.focus({ preventScroll: true, focusVisible: restore === \"keyboard\" } as FocusOptions);\n    // Closing is quicker than opening: the snappy spring lands the shape back on the button.\n    const transition = reduced ? still : motionTokens.spring.snappy;\n    animate(left, 0, transition);\n    const rect = button.getBoundingClientRect();\n    animate(height as MotionValue<number>, rect.height, transition);\n    animate(width as MotionValue<number>, rect.width, { ...transition, onComplete: () => {\n      if (token !== run.current) return;\n      phaseRef.current = \"closed\";\n      width.jump(\"100%\"); height.jump(\"100%\");\n      setPhase(\"closed\"); setFieldId(null);\n    } });\n  }, [height, left, reduced, width]);\n\n  // A new step changes the panel height; the surface follows on the smooth spring instead of snapping.\n  useLayoutEffect(() => {\n    const content = panel.current;\n    if (!content || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      if (phaseRef.current !== \"open\") return;\n      animate(height as MotionValue<number>, content.offsetHeight + 2, reduced ? still : motionTokens.spring.smooth);\n    });\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, [height, reduced]);\n\n  // Focus moves into the panel once it is interactive: an item for keyboard users, the dialog itself for the pointer.\n  useLayoutEffect(() => {\n    const request = focusNext.current, content = panel.current;\n    if (!request || !content || phase !== \"open\") return;\n    focusNext.current = null;\n    if (request === \"panel\") { content.focus({ preventScroll: true }); return; }\n    const items = Array.from(content.querySelectorAll<HTMLElement>(\"[data-current] [data-row]\"));\n    const next = request === \"first\" ? items[0] : request === \"last\" ? items.at(-1) : request === \"checked\" ? items.find(item => item.getAttribute(\"aria-checked\") === \"true\") ?? items[0] : items.find(item => item.dataset.row === request.key) ?? items[0];\n    next?.focus({ preventScroll: true });\n  }, [phase, fieldId]);\n\n  useEffect(() => {\n    if (phase !== \"open\") return;\n    const onPointerDown = (event: PointerEvent) => {\n      const node = root.current, hit = event.target as Element | null;\n      if (!node || (hit && node.contains(hit))) return;\n      closeMenu(false);\n      // A press on empty space hands focus back to the trigger; a press on another control keeps its own focus.\n      if (!hit?.closest?.(\"button, a[href], input, select, textarea, [tabindex], [contenteditable]\")) window.setTimeout(() => {\n        const focused = document.activeElement;\n        if (!focused || focused === document.body || node.contains(focused)) trigger.current?.focus({ preventScroll: true, focusVisible: false } as FocusOptions);\n      }, 0);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true);\n  }, [closeMenu, phase]);\n\n  function onPanelKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (event.defaultPrevented) return;\n    const items = Array.from(panel.current?.querySelectorAll<HTMLElement>(\"[data-current] [data-row]\") ?? []);\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") { event.preventDefault(); (event.key === \"ArrowDown\" ? items[0] : items.at(-1))?.focus({ preventScroll: true }); }\n    if (event.key === \"ArrowLeft\" && field) { event.preventDefault(); goBack(true); }\n  }\n  function goBack(keyboard: boolean) {\n    if (!field) return;\n    focusNext.current = keyboard ? { key: field.id } : \"panel\";\n    setDirection(-1); setFieldId(null);\n  }\n  function chooseField(row: Row, keyboard: boolean) {\n    focusNext.current = keyboard ? \"checked\" : \"panel\";\n    setDirection(1); setFieldId(row.key);\n  }\n  function chooseValue(row: Row, keyboard: boolean) {\n    if (!field) return;\n    onSelect({ id: field.id, label: field.label, value: row.label }, field);\n    closeMenu(keyboard ? \"keyboard\" : \"pointer\");\n  }\n\n  const current = (entry: FilterField) => active.find(filter => filter.id === entry.id)?.value;\n  const fieldRows: Row[] = fields.map(entry => ({ key: entry.id, label: entry.label, icon: entry.icon, drill: true,\n    meta: <>{current(entry) ? <span className={styles.metaValue}>{current(entry)}</span> : null}<ChevronRight size={15} strokeWidth={1.8} aria-hidden=\"true\" /></> }));\n  const valueRows: Row[] = field ? field.options.map(toOption).map(entry => {\n    const text = optionText(entry), checked = current(field) === text;\n    return { key: entry.value, label: text, icon: entry.icon, checked, meta: entry.hint != null || checked ? <>{entry.hint != null ? <span className={styles.hint}>{entry.hint}</span> : null}<span className={styles.check} data-on={checked || undefined}><Check size={15} strokeWidth={2} aria-hidden=\"true\" /></span></> : null };\n  }) : [];\n  // Steps travel in the direction of the move: forward slides in from the end edge, back from the start edge.\n  const step: Variants = {\n    enter: (dir: number) => reduced ? { opacity: 0 } : { opacity: 0, x: dir * 18, filter: blur(motionTokens.blur.soft) },\n    center: { opacity: 1, x: 0, filter: blur(0), transition: reduced ? fade : { ...enter, x: motionTokens.spring.smooth } },\n    exit: (dir: number) => reduced ? { opacity: 0, transition: still } : { opacity: 0, x: dir * -18, filter: blur(motionTokens.blur.subtle), transition: leave },\n  };\n\n  return <div ref={root} className={styles.menu} data-state={phase} data-y={up ? \"up\" : \"down\"}\n    onKeyDown={event => { if (event.key === \"Escape\" && phaseRef.current === \"open\") { event.preventDefault(); event.stopPropagation(); closeMenu(\"keyboard\"); } }}\n    onBlur={event => { const next = event.relatedTarget as Node | null; if (phaseRef.current === \"open\" && next && !root.current?.contains(next)) closeMenu(false); }}>\n    <button ref={trigger} type=\"button\" className={styles.trigger} data-filter-trigger=\"\" aria-haspopup=\"dialog\" aria-expanded={open} aria-controls={panelId} tabIndex={open ? -1 : undefined}\n      onClick={event => { if (phaseRef.current !== \"open\") openMenu(event.detail === 0 ? \"first\" : \"panel\"); }}\n      onKeyDown={event => { if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") { event.preventDefault(); openMenu(event.key === \"ArrowDown\" ? \"first\" : \"last\"); } }}>\n      <motion.span className={styles.face} style={faceStyle}>\n        <Plus size={15} strokeWidth={1.8} aria-hidden=\"true\" />{label}\n      </motion.span>\n    </button>\n    <motion.div className={styles.surface} style={{ left, width, height }}>\n      <motion.div ref={panel} id={panelId} className={styles.panel} role=\"dialog\" aria-labelledby={titleId} tabIndex={-1} inert={!open} style={{ x: panelX }} onKeyDown={onPanelKeyDown}>\n        <motion.div style={contentStyle}>\n          <div className={styles.head}>\n            <AnimatePresence initial={false}>\n              {field ? <motion.button key=\"back\" type=\"button\" className={styles.back} aria-label=\"Back to fields\" onClick={event => goBack(event.detail === 0)}\n                initial={reduced ? { opacity: 0, width: 28, marginRight: 2 } : { opacity: 0, width: 0, marginRight: 0, scale: .6 }} animate={{ opacity: 1, width: 28, marginRight: 2, scale: 1 }}\n                exit={reduced ? { opacity: 0, transition: still } : { opacity: 0, width: 0, marginRight: 0, scale: .6, transition: { ...motionTokens.spring.snappy, opacity: leave } }}\n                transition={reduced ? fade : { ...motionTokens.spring.snappy, opacity: enter }}><ChevronLeft size={16} strokeWidth={1.8} aria-hidden=\"true\" /></motion.button> : null}\n            </AnimatePresence>\n            <span className={styles.title} id={titleId}>\n              <AnimatePresence mode=\"popLayout\" initial={false}><motion.span key={field?.id ?? \"fields\"} className={styles.titleText} {...textMotion(reduced)}>{field ? field.label : label}</motion.span></AnimatePresence>\n            </span>\n          </div>\n          <div className={styles.stage}>\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n              <motion.div key={field?.id ?? \"fields\"} className={styles.step} custom={direction} variants={step} initial=\"enter\" animate=\"center\" exit=\"exit\">\n                {field\n                  ? <MenuList rows={valueRows} labelledBy={titleId} radio onChoose={chooseValue} onBack={goBack} onLeave={() => panel.current?.focus({ preventScroll: true })} />\n                  : <MenuList rows={fieldRows} labelledBy={titleId} onChoose={chooseField} onLeave={() => panel.current?.focus({ preventScroll: true })} />}\n              </motion.div>\n            </AnimatePresence>\n          </div>\n        </motion.div>\n      </motion.div>\n    </motion.div>\n  </div>;\n}\n\nexport function FilterToolbar({ filters, onRemove, onClearAll, children, label = \"Active filters\", addFilter }: FilterToolbarProps) {\n  const reduced = useReducedMotion() ?? false;\n  const root = useRef<HTMLDivElement>(null), list = useRef<HTMLDivElement>(null);\n  const armed = useRef(0), pendingFocus = useRef<number | null>(null);\n  const [seen, setSeen] = useState(filters);\n  const [message, setMessage] = useState(\"\");\n  if (seen !== filters) { setSeen(filters); const next = describeChange(seen, filters); if (next) setMessage(next); }\n  const signature = filters.map(filter => `${filter.id}:${filter.value ?? \"\"}`).join(\"|\");\n  // Layout motion runs only for a moment after the filters change, so window resizes and font swaps settle at once.\n  useLayoutEffect(() => { armed.current = performance.now() + 900; }, [signature]);\n  const frameHeight = useFollowHeight(list, armed, reduced);\n  useReflowGlide(list, armed, reduced);\n  // A removed chip hands focus to its neighbor, or to the Add filter trigger once the list is empty.\n  useLayoutEffect(() => {\n    const index = pendingFocus.current, node = root.current;\n    if (index === null || !node) return;\n    pendingFocus.current = null;\n    const next = filters[Math.min(index, filters.length - 1)];\n    const target = next ? node.querySelector<HTMLElement>(`[data-chip-remove=\"${CSS.escape(next.id)}\"]`) : node.querySelector<HTMLElement>(\"[data-filter-trigger]\");\n    target?.focus({ preventScroll: true });\n  }, [filters]);\n  const still = { duration: 0 };\n  // Each chip sits in a slot whose width grows or collapses, so the chips beside it, the toolbar, and its\n  // actions all follow in the normal flow. While the slot moves it clips, and the chip keeps its natural width.\n  // (Popping chips out of the flow would glide the chips but snap the width of a content-sized toolbar.)\n  const slot = (gap: number): HTMLMotionProps<\"span\"> => ({\n    initial: reduced ? false : { width: 0, marginRight: -gap, overflow: \"clip\", \"--slot-moving\": 1 },\n    animate: { width: \"auto\", marginRight: 0, transitionEnd: { overflow: \"visible\", \"--slot-moving\": 0 } },\n    exit: reduced ? { opacity: 0, transition: still } : { width: 0, marginRight: -gap, overflow: \"clip\", \"--slot-moving\": 1, pointerEvents: \"none\", transition: { ...motionTokens.spring.smooth, \"--slot-moving\": still } },\n    transition: reduced ? still : motionTokens.spring.smooth,\n  });\n  const chip = {\n    initial: reduced ? false as const : { opacity: 0, scale: .9, filter: blur(motionTokens.blur.soft) },\n    animate: { opacity: 1, scale: 1, filter: blur(0) },\n    exit: reduced ? { opacity: 0, transition: still } : { opacity: 0, scale: .9, filter: blur(motionTokens.blur.subtle), transition: leave },\n    transition: reduced ? still : { ...enter, scale: motionTokens.spring.snappy },\n  };\n  const text = reduced ? { ...textMotion(true), initial: false as const } : textMotion(false);\n  // The note waits for the leaving chips to fade before it rises in.\n  const emptyText = reduced ? text : { ...text, transition: { ...enter, delay: motionTokens.duration.instant * .75 } };\n  return <div ref={root} className={styles.toolbar} role=\"group\" aria-label={label}>\n    <motion.div className={styles.frame} style={{ height: frameHeight }}>\n      <div ref={list} className={styles.chips}>\n        <AnimatePresence initial={false}>\n          {filters.map((filter, index) => <motion.span className={styles.slot} key={filter.id} {...slot(8)}><motion.span className={styles.chip} {...chip}><span className={styles.chipLabel}>{filter.label}{filter.value ? <span className={styles.value}><span className={styles.separator}> · </span><MorphText text={filter.value} /></span> : null}</span><button type=\"button\" data-chip-remove={filter.id} aria-label={`Remove ${filter.label}${filter.value ? `: ${filter.value}` : \"\"}`} onClick={event => { if (document.activeElement === event.currentTarget) pendingFocus.current = index; onRemove(filter.id); }}><X size={14} strokeWidth={1.8} aria-hidden=\"true\" /></button></motion.span></motion.span>)}\n        </AnimatePresence>\n        {/* The empty note sits over the leading edge instead of in the flow, so it fades in where the chips were rather than riding their collapsing slots across the row. */}\n        <AnimatePresence initial={false}>\n          {filters.length ? null : <motion.span key=\"empty\" className={styles.empty} {...emptyText}>No filters applied</motion.span>}\n        </AnimatePresence>\n      </div>\n    </motion.div>\n    <div className={styles.actions}>\n      {addFilter ? <FilterMenu fields={addFilter.fields} onSelect={addFilter.onAdd} active={filters} label={addFilter.label} align={addFilter.align} /> : null}\n      {children}\n      <AnimatePresence initial={false}>\n        {filters.length > 0 ? <motion.span key=\"clear\" className={styles.slot} {...slot(12)}><button className={styles.clear} type=\"button\" onClick={() => { pendingFocus.current = -1; onClearAll?.(); }}><motion.span className={styles.clearText} {...text}>Clear all</motion.span></button></motion.span> : null}\n      </AnimatePresence>\n    </div>\n    <span className={styles.srOnly} role=\"status\">{message}</span>\n  </div>;\n}\n\nexport default FilterToolbar;\n"
    },
    {
      "path": "registry/components/filter-toolbar/filter-toolbar.module.css",
      "type": "registry:component",
      "target": "@components/xzero/filter-toolbar/filter-toolbar.module.css",
      "content": ".toolbar { position: relative; display: flex; min-height: var(--control-height-lg); align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-3) var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); }\n/* The frame springs to the chip list height, so a new line of chips opens the toolbar instead of snapping it. */\n.frame { min-width: 0; flex: 1; }\n.chips { position: relative; display: flex; min-width: 0; min-height: var(--control-height-sm); flex-wrap: wrap; align-items: center; gap: var(--space-2); }\n/* The slot clips with a pill radius while it opens, so a growing chip reads as a pill stretching out, never as a hard cut. */\n.slot { display: inline-flex; max-width: 100%; min-width: 0; flex: none; border-radius: var(--radius-pill); }\n/* --slot-moving is 1 while the slot animates: the chip then ignores the slot width instead of squeezing. */\n.chip { display: inline-flex; max-width: calc(100% + var(--slot-moving, 0) * 100vw); flex: none; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); padding: 0 var(--space-2) 0 var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); }\n.chipLabel { overflow-x: clip; overflow-y: visible; text-overflow: ellipsis; white-space: nowrap; }\n.value { color: var(--foreground); font-weight: 500; }\n.separator { color: var(--text-secondary); font-weight: 400; }\n.morphText { position: relative; display: inline-flex; overflow-x: clip; overflow-y: visible; white-space: nowrap; vertical-align: bottom; }\n.morphValue { display: inline-block; flex: none; white-space: nowrap; }\n.measure { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }\n.chip button { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); cursor: pointer; transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.chip button:active { transform: scale(.96); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-instant); transition-timing-function: var(--ease-standard), var(--ease-standard), var(--ease-standard); }\n.empty { position: absolute; top: 0; bottom: 0; left: 0; display: flex; max-width: 100%; align-items: center; overflow: hidden; color: var(--text-muted); font-size: var(--text-sm); white-space: nowrap; pointer-events: none; }\n.actions { display: inline-flex; flex: 0 0 auto; align-items: center; gap: var(--space-3); }\n.clear { min-height: var(--control-height-sm); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); white-space: nowrap; cursor: pointer; transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.clearText { display: block; }\n.clear:active { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-instant); transition-timing-function: var(--ease-standard), var(--ease-standard), var(--ease-standard); }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n/* Add filter: the trigger is transparent and the surface behind it is the button's rounded rectangle.\n   Opening springs that same surface to the panel size, so the button itself becomes the menu. */\n.menu { position: relative; display: inline-flex; flex: none; isolation: isolate; }\n.menu:not([data-state=\"closed\"]) { z-index: 30; }\n.trigger { position: relative; z-index: 1; display: inline-flex; min-height: var(--control-height-sm); align-items: center; padding: 0 var(--space-3); border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.menu[data-state=\"open\"] .trigger { pointer-events: none; }\n.face { display: inline-flex; align-items: center; gap: var(--space-2); }\n.face svg { flex: none; }\n/* clip, not hidden: focus and scripts cannot scroll the panel inside the growing shape. */\n.surface { position: absolute; top: 0; z-index: 0; box-sizing: border-box; overflow: clip; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); transition: background-color var(--duration-fast) var(--ease-standard), border-radius var(--duration-spring) var(--ease-spring), box-shadow var(--duration-standard) var(--ease-standard); }\n.menu[data-y=\"up\"] .surface { top: auto; bottom: 0; }\n.menu[data-state=\"open\"] .surface { border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); }\n/* The trigger anchors the panel, so a press answers in color and never scales. */\n.menu[data-state=\"closed\"] .trigger:active + .surface { background: color-mix(in oklab, var(--surface-muted), var(--border) 55%); }\n@media (hover: hover) and (pointer: fine) {\n  .menu[data-state=\"closed\"] .trigger:hover + .surface { background: var(--surface-muted); box-shadow: var(--shadow-resting); }\n}\n.panel { position: absolute; top: 0; left: 0; width: min(var(--filter-menu-width, 16rem), calc(100vw - var(--space-8))); padding: 6px 6px 6px; outline: none; }\n.menu[data-y=\"up\"] .panel { top: auto; bottom: 0; }\n.menu[data-state=\"closed\"] .panel { visibility: hidden; }\n.head { display: flex; height: 32px; align-items: center; padding: 0 var(--space-2) 0 10px; }\n.menu[data-y=\"up\"] .head { margin-top: 4px; }\n.back { display: grid; width: 28px; height: 28px; flex: none; place-items: center; margin-left: -6px; padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n.back:active { background: var(--surface-muted); color: var(--foreground); }\n.title { position: relative; display: flex; min-width: 0; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; letter-spacing: var(--tracking-body); white-space: nowrap; }\n.titleText { display: block; }\n.stage { position: relative; margin-top: 2px; }\n.step { width: 100%; }\n.list { position: relative; display: grid; max-height: min(20rem, 55vh); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }\n.highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: calc(var(--radius-panel) - 6px); background: color-mix(in oklab, var(--foreground) 6%, transparent); opacity: 0; pointer-events: none; }\n.item:focus-visible { outline: none; }\n.item { position: relative; display: flex; min-width: 0; min-height: var(--control-height-sm); align-items: center; gap: 10px; padding: 0 10px; border: 0; border-radius: calc(var(--radius-panel) - 6px); background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); text-align: left; cursor: pointer; outline: none; -webkit-tap-highlight-color: transparent; }\n.itemIcon { display: inline-flex; min-width: 16px; flex: none; align-items: center; justify-content: center; color: var(--text-secondary); }\n.itemLabel { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.itemMeta { display: inline-flex; min-width: 0; flex: none; align-items: center; gap: 6px; max-width: 50%; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.metaValue { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.hint { min-width: 2ch; text-align: right; }\n.check { display: inline-flex; width: 15px; color: var(--foreground); opacity: 0; }\n.check[data-on] { opacity: 1; }\n@media (hover: hover) and (pointer: fine) {\n  .chip button:hover { background: var(--border); color: var(--foreground); }\n  .clear:hover { background: var(--surface-muted); color: var(--foreground); }\n  .back:hover { background: var(--surface-muted); color: var(--foreground); }\n}\n/* In a column the frame must size from its height, not a zero flex basis, or its spring is ignored and a new line of chips snaps. */\n@media (max-width: 520px) { .toolbar { align-items: stretch; flex-direction: column; } .frame { flex: none; } .actions { justify-content: flex-end; } }\n@media (prefers-reduced-motion: reduce) { .chip button, .clear, .surface, .back { transition: none; } .chip button:active, .clear:active { transform: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/filter-toolbar",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "filter"
    ],
    "docs": "https://ui.xzero.co.uk/components/filter-toolbar",
    "markdown": "https://ui.xzero.co.uk/components/filter-toolbar/markdown"
  }
}