{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/filter-toolbar/filter-toolbar.tsx","type":"registry:component","target":"@components/arc/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(null);\n const width = useMotionValue(\"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 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, next, motionTokens.spring.morph); else width.jump(next);\n });\n observer.observe(node);\n return () => observer.disconnect();\n }, [reduced, width]);\n return \n {text}\n {text}\n ;\n}\n\n/** Springs a wrapper to the height of its content while filters change; other resizes follow at once. */\nfunction useFollowHeight(list: RefObject, armed: RefObject, reduced: boolean) {\n const height = useMotionValue(\"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, 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, armed: RefObject, 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();\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(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(\"[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) {\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) {\n const item = (event.target as HTMLElement).closest(\"[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) {\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
{ if (pointer.current && list.current?.contains(document.activeElement)) onLeave(); }}>\n \n {rows.map((row, index) => )}\n
;\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(null), trigger = useRef(null), panel = useRef(null);\n const phaseRef = useRef(\"closed\"), run = useRef(0), focusNext = useRef(null);\n const [phase, setPhase] = useState(\"closed\");\n const [fieldId, setFieldId] = useState(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(\"100%\"), height = useMotionValue(\"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, panelWidth, transition);\n animate(height as MotionValue, 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, rect.height, transition);\n animate(width as MotionValue, 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, 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(\"[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) {\n if (event.defaultPrevented) return;\n const items = Array.from(panel.current?.querySelectorAll(\"[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) ? {current(entry)} : null} }));\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 ? {entry.hint} : null} : 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
{ 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 \n \n \n \n
\n \n {field ? 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 }}> : null}\n \n \n {field ? field.label : label}\n \n
\n
\n \n \n {field\n ? panel.current?.focus({ preventScroll: true })} />\n : panel.current?.focus({ preventScroll: true })} />}\n \n \n
\n
\n
\n
\n
;\n}\n\nexport function FilterToolbar({ filters, onRemove, onClearAll, children, label = \"Active filters\", addFilter }: FilterToolbarProps) {\n const reduced = useReducedMotion() ?? false;\n const root = useRef(null), list = useRef(null);\n const armed = useRef(0), pendingFocus = useRef(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(`[data-chip-remove=\"${CSS.escape(next.id)}\"]`) : node.querySelector(\"[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
\n \n
\n \n {filters.map((filter, index) => {filter.label}{filter.value ? ยท : null})}\n \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 \n {filters.length ? null : No filters applied}\n \n
\n
\n
\n {addFilter ? : null}\n {children}\n \n {filters.length > 0 ? : null}\n \n
\n {message}\n
;\n}\n\nexport default FilterToolbar;\n"},{"path":"registry/components/filter-toolbar/filter-toolbar.module.css","type":"registry:component","target":"@components/arc/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://uiarc.dev/components/filter-toolbar","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/filter-toolbar","markdown":"https://uiarc.dev/components/filter-toolbar/markdown","tags":["data","filter"]}}