{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type HTMLMotionProps,\n  type MotionValue,\n  type Variants,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface FilterChip {\n  id: string;\n  label: string;\n  value?: string;\n}\n/** One value a field can take. `hint` sits at the end of the row, for example a count. */\nexport interface FilterOption {\n  value: string;\n  label?: string;\n  hint?: string | number;\n  icon?: ReactNode;\n}\nexport interface FilterField {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  options: (string | FilterOption)[];\n}\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?: {\n    fields: FilterField[];\n    onAdd: (filter: FilterChip, field: FilterField) => void;\n    label?: string;\n    align?: \"start\" | \"end\";\n  };\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;\n/** Press feedback for the chip and Clear all buttons: colours fade, the press springs back, and pressing shortens the scale to an instant. */\nconst pressTransition =\n  \"[transition:background_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] active:[transition-duration:var(--duration-fast),var(--duration-fast),var(--duration-instant)] active:[transition-timing-function:var(--ease-standard),var(--ease-standard),var(--ease-standard)] motion-reduce:transition-none\";\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\n    ? { opacity: 0, transition: still }\n    : { 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),\n        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);\n      else width.jump(next);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  return (\n    <motion.span\n      className=\"relative inline-flex overflow-x-clip overflow-y-visible align-bottom whitespace-nowrap\"\n      style={{ width }}\n    >\n      <span\n        ref={measure}\n        className=\"pointer-events-none invisible absolute top-0 left-0 whitespace-nowrap\"\n        aria-hidden=\"true\"\n      >\n        {text}\n      </span>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span key={text} className=\"inline-block flex-none whitespace-nowrap\" {...textMotion(reduced)}>\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\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<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) {\n        known = true;\n        height.jump(next);\n        return;\n      }\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,\n          y = child.offsetTop,\n          track = tracks.get(child);\n        if (!track) {\n          tracks.set(child, { x, y, dx: 0, dy: 0 });\n          continue;\n        }\n        const jx = track.x - x,\n          jy = track.y - y;\n        track.x = x;\n        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,\n          fromY = track.dy + jy;\n        const apply = (progress: number) => {\n          track.dx = fromX * (1 - progress);\n          track.dy = fromY * (1 - progress);\n          child.style.translate = progress >= 1 ? \"\" : `${track.dx}px ${track.dy}px`;\n        };\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 = () => {\n      for (const child of Array.from(node.children)) observer.observe(child);\n    };\n    const mutations = new MutationObserver(() => {\n      watch();\n      check();\n    });\n    observer.observe(node);\n    watch();\n    mutations.observe(node, { childList: true });\n    return () => {\n      observer.disconnect();\n      mutations.disconnect();\n      for (const track of tracks.values()) track.stop?.();\n    };\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 [\n    ...added.map((filter) => `Added ${text(filter)}`),\n    ...changed.map((filter) => `${filter.label} changed to ${filter.value ?? \"any\"}`),\n    ...removed.map((filter) => `Removed ${text(filter)}`),\n  ].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({\n  rows,\n  labelledBy,\n  onChoose,\n  onBack,\n  onLeave,\n  radio,\n}: {\n  rows: Row[];\n  labelledBy: string;\n  onChoose: (row: Row, keyboard: boolean) => void;\n  onBack?: (keyboard: boolean) => void;\n  onLeave: () => void;\n  radio?: boolean;\n}) {\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(() =>\n    Math.max(\n      0,\n      rows.findIndex((row) => row.checked),\n    ),\n  );\n  const [highlight, setHighlight] = useState<{\n    top: number;\n    height: number;\n    glide: boolean;\n    ring: boolean;\n    shown: boolean;\n  } | 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)\n      node.scrollTop = item.offsetTop + item.offsetHeight - node.clientHeight;\n  };\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const all = items(),\n      index = all.indexOf(document.activeElement as HTMLElement);\n    const go = (next: number) => {\n      event.preventDefault();\n      pointer.current = false;\n      move(all[next]);\n    };\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) {\n      event.preventDefault();\n      onChoose(rows[index], true);\n      return;\n    }\n    if (event.key === \"ArrowLeft\" && onBack) {\n      event.preventDefault();\n      onBack(true);\n      return;\n    }\n    if (event.key.length === 1 && /\\S/.test(event.key) && !event.metaKey && !event.ctrlKey && !event.altKey) {\n      const now = performance.now();\n      typed.current = {\n        text: now - typed.current.at < 700 ? typed.current.text + event.key.toLowerCase() : event.key.toLowerCase(),\n        at: now,\n      };\n      const order = [\n        ...all.slice(index + (typed.current.text.length > 1 ? 0 : 1)),\n        ...all.slice(0, index + (typed.current.text.length > 1 ? 0 : 1)),\n      ];\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,\n      ring = !pointer.current && item.matches(\":focus-visible\");\n    setHighlight((previous) => ({\n      top: item.offsetTop,\n      height: item.offsetHeight,\n      glide: glide && !!previous?.shown,\n      ring,\n      shown: true,\n    }));\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))\n      setHighlight((previous) => previous && { ...previous, glide: false, ring: false, shown: false });\n  }\n  return (\n    // oxlint-disable-next-line jsx-a11y/interactive-supports-focus -- roving menu: focus lands on one item at a time, never on the list\n    <div\n      ref={list}\n      className=\"relative grid max-h-[min(20rem,55vh)] overflow-y-auto overscroll-contain [scrollbar-width:thin]\"\n      role=\"menu\"\n      aria-labelledby={labelledBy}\n      data-current={present ? \"\" : undefined}\n      onKeyDown={onKeyDown}\n      onFocus={onFocus}\n      onBlur={onBlur}\n      onPointerLeave={() => {\n        if (pointer.current && list.current?.contains(document.activeElement)) onLeave();\n      }}\n    >\n      <motion.span\n        className=\"pointer-events-none absolute top-0 right-0 left-0 rounded-[calc(var(--radius-panel)_-_6px)] bg-[color-mix(in_oklab,var(--ink)_6%,transparent)] opacity-0\"\n        data-ring={highlight?.ring || undefined}\n        aria-hidden=\"true\"\n        initial={false}\n        animate={\n          highlight ? { y: highlight.top, height: highlight.height, opacity: highlight.shown ? 1 : 0 } : { opacity: 0 }\n        }\n        transition={{\n          default: highlight?.glide ? motionTokens.spring.snappy : still,\n          opacity: { duration: reduced ? 0 : 0.08 },\n        }}\n      />\n      {rows.map((row, index) => (\n        <button\n          key={row.key}\n          type=\"button\"\n          className=\"relative flex min-h-control-sm min-w-0 cursor-pointer items-center gap-2.5 rounded-[calc(var(--radius-panel)_-_6px)] border-0 bg-transparent px-2.5 text-left text-(length:--text-sm) tracking-body text-ink outline-none [-webkit-tap-highlight-color:transparent]\"\n          role={radio ? \"menuitemradio\" : \"menuitem\"}\n          aria-checked={radio ? !!row.checked : undefined}\n          tabIndex={index === current ? 0 : -1}\n          data-row={row.key}\n          data-text={row.label}\n          onPointerMove={(event) => {\n            pointer.current = true;\n            if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });\n          }}\n          onClick={(event: MouseEvent<HTMLButtonElement>) => onChoose(row, event.detail === 0)}\n        >\n          {row.icon ? (\n            <span className=\"inline-flex min-w-4 flex-none items-center justify-center text-ink-2\" aria-hidden=\"true\">\n              {row.icon}\n            </span>\n          ) : null}\n          <span className=\"min-w-0 flex-1 truncate\">{row.label}</span>\n          {row.meta ? (\n            <span className=\"inline-flex max-w-[50%] min-w-0 flex-none items-center gap-1.5 text-(length:--text-xs) text-ink-3 tabular-nums\">\n              {row.meta}\n            </span>\n          ) : null}\n        </button>\n      ))}\n    </div>\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\") {\n      room.left = Math.max(room.left, box.left);\n      room.right = Math.min(room.right, box.right);\n    }\n    if (style.overflowY !== \"visible\") {\n      room.top = Math.max(room.top, box.top);\n      room.bottom = Math.min(room.bottom, box.bottom);\n    }\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(),\n    panelId = `${id}panel`,\n    titleId = `${id}title`;\n  const root = useRef<HTMLDivElement>(null),\n    trigger = useRef<HTMLButtonElement>(null),\n    panel = useRef<HTMLDivElement>(null);\n  const phaseRef = useRef<Phase>(\"closed\"),\n    run = useRef(0),\n    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%\"),\n    height = useMotionValue<number | string>(\"100%\");\n  const left = useMotionValue(0),\n    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),\n    to = useMotionValue(0);\n  const progress = useTransform(() => {\n    const now = width.get(),\n      start = from.get(),\n      end = to.get();\n    return typeof now !== \"number\" ? 0 : end > start ? clamp((now - start) / (end - start)) : 1;\n  });\n  const faceOut = useTransform(() => clamp(progress.get() / 0.35)),\n    contentIn = useTransform(() => clamp((progress.get() - 0.4) / 0.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 = {\n    opacity: useTransform(() => 1 - faceOut.get()),\n    scale: useTransform(() => 1 - faceOut.get() * 0.04),\n    filter: useTransform(() => (faceOut.get() ? blur(faceOut.get() * motionTokens.blur.subtle) : \"none\")),\n  };\n  const contentStyle = {\n    opacity: contentIn,\n    transformOrigin: origin,\n    scale: useTransform(() => 0.96 + contentIn.get() * 0.04),\n    y: useTransform(() => (1 - contentIn.get()) * (up ? -6 : 6)),\n    filter: useTransform(() => (contentIn.get() === 1 ? \"none\" : blur((1 - contentIn.get()) * motionTokens.blur.soft))),\n  };\n  const open = phase === \"open\",\n    field = fields.find((entry) => entry.id === fieldId) ?? null;\n\n  const openMenu = useCallback(\n    (focus: FocusRequest) => {\n      const button = trigger.current,\n        content = panel.current;\n      if (!button || !content) return;\n      const rect = button.getBoundingClientRect(),\n        panelWidth = content.offsetWidth + 2,\n        panelHeight = content.offsetHeight + 2;\n      const room = roomFor(root.current ?? button),\n        gutter = 16;\n      const min = room.left + gutter - rect.left,\n        max = room.right - gutter - rect.left - panelWidth,\n        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,\n        above = rect.bottom - room.top;\n      const nextUp = below < panelHeight + gutter && above > below;\n      if (phaseRef.current === \"closed\") {\n        width.jump(rect.width);\n        height.jump(rect.height);\n        left.jump(0);\n      }\n      target.jump(x);\n      from.jump(rect.width);\n      to.jump(panelWidth);\n      phaseRef.current = \"open\";\n      run.current += 1;\n      focusNext.current = focus;\n      setPhase(\"open\");\n      setUp(nextUp);\n      setFieldId(null);\n      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    },\n    [align, from, height, left, reduced, target, to, width],\n  );\n\n  const closeMenu = useCallback(\n    (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, {\n        ...transition,\n        onComplete: () => {\n          if (token !== run.current) return;\n          phaseRef.current = \"closed\";\n          width.jump(\"100%\");\n          height.jump(\"100%\");\n          setPhase(\"closed\");\n          setFieldId(null);\n        },\n      });\n    },\n    [height, left, reduced, width],\n  );\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,\n      content = panel.current;\n    if (!request || !content || phase !== \"open\") return;\n    focusNext.current = null;\n    if (request === \"panel\") {\n      content.focus({ preventScroll: true });\n      return;\n    }\n    const items = Array.from(content.querySelectorAll<HTMLElement>(\"[data-current] [data-row]\"));\n    const next =\n      request === \"first\"\n        ? items[0]\n        : request === \"last\"\n          ? items.at(-1)\n          : request === \"checked\"\n            ? (items.find((item) => item.getAttribute(\"aria-checked\") === \"true\") ?? items[0])\n            : (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,\n        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]\"))\n        window.setTimeout(() => {\n          const focused = document.activeElement;\n          if (!focused || focused === document.body || node.contains(focused))\n            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\") {\n      event.preventDefault();\n      (event.key === \"ArrowDown\" ? items[0] : items.at(-1))?.focus({ preventScroll: true });\n    }\n    if (event.key === \"ArrowLeft\" && field) {\n      event.preventDefault();\n      goBack(true);\n    }\n  }\n  function goBack(keyboard: boolean) {\n    if (!field) return;\n    focusNext.current = keyboard ? { key: field.id } : \"panel\";\n    setDirection(-1);\n    setFieldId(null);\n  }\n  function chooseField(row: Row, keyboard: boolean) {\n    focusNext.current = keyboard ? \"checked\" : \"panel\";\n    setDirection(1);\n    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) => ({\n    key: entry.id,\n    label: entry.label,\n    icon: entry.icon,\n    drill: true,\n    meta: (\n      <>\n        {current(entry) ? <span className=\"truncate\">{current(entry)}</span> : null}\n        <ChevronRight size={15} strokeWidth={1.8} aria-hidden=\"true\" />\n      </>\n    ),\n  }));\n  const valueRows: Row[] = field\n    ? field.options.map(toOption).map((entry) => {\n        const text = optionText(entry),\n          checked = current(field) === text;\n        return {\n          key: entry.value,\n          label: text,\n          icon: entry.icon,\n          checked,\n          meta:\n            entry.hint != null || checked ? (\n              <>\n                {entry.hint != null ? <span className=\"min-w-[2ch] text-right\">{entry.hint}</span> : null}\n                <span\n                  className={cn(\"inline-flex w-[15px] text-ink\", checked ? \"opacity-100\" : \"opacity-0\")}\n                  data-on={checked || undefined}\n                >\n                  <Check size={15} strokeWidth={2} aria-hidden=\"true\" />\n                </span>\n              </>\n            ) : null,\n        };\n      })\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) =>\n      reduced ? { opacity: 0 } : { opacity: 0, x: dir * 18, filter: blur(motionTokens.blur.soft) },\n    center: {\n      opacity: 1,\n      x: 0,\n      filter: blur(0),\n      transition: reduced ? fade : { ...enter, x: motionTokens.spring.smooth },\n    },\n    exit: (dir: number) =>\n      reduced\n        ? { opacity: 0, transition: still }\n        : { opacity: 0, x: dir * -18, filter: blur(motionTokens.blur.subtle), transition: leave },\n  };\n\n  const closed = phase === \"closed\";\n  // The trigger is transparent and the surface behind it is the button's rounded rectangle. Opening springs that same\n  // surface to the panel size, so the button itself becomes the menu.\n  return (\n    // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- the root is not a control; it catches Escape and focus leaving the trigger and panel inside it\n    <div\n      ref={root}\n      className={cn(\"relative isolate inline-flex flex-none\", !closed && \"z-30\")}\n      data-state={phase}\n      data-y={up ? \"up\" : \"down\"}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\" && phaseRef.current === \"open\") {\n          event.preventDefault();\n          event.stopPropagation();\n          closeMenu(\"keyboard\");\n        }\n      }}\n      onBlur={(event) => {\n        const next = event.relatedTarget as Node | null;\n        if (phaseRef.current === \"open\" && next && !root.current?.contains(next)) closeMenu(false);\n      }}\n    >\n      <button\n        ref={trigger}\n        type=\"button\"\n        className={cn(\n          \"peer relative z-1 inline-flex min-h-control-sm cursor-pointer items-center rounded-button border border-transparent bg-transparent px-3 text-(length:--text-sm) leading-body font-medium tracking-body whitespace-nowrap text-ink [-webkit-tap-highlight-color:transparent]\",\n          open && \"pointer-events-none\",\n        )}\n        data-filter-trigger=\"\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-controls={panelId}\n        tabIndex={open ? -1 : undefined}\n        onClick={(event) => {\n          if (phaseRef.current !== \"open\") openMenu(event.detail === 0 ? \"first\" : \"panel\");\n        }}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n            event.preventDefault();\n            openMenu(event.key === \"ArrowDown\" ? \"first\" : \"last\");\n          }\n        }}\n      >\n        <motion.span className=\"inline-flex items-center gap-2 [&_svg]:flex-none\" style={faceStyle}>\n          <Plus size={15} strokeWidth={1.8} aria-hidden=\"true\" />\n          {label}\n        </motion.span>\n      </button>\n      {/* clip, not hidden: focus and scripts cannot scroll the panel inside the growing shape. The trigger anchors the panel,\n        so a press answers in color and never scales. */}\n      <motion.div\n        className={cn(\n          \"absolute top-0 z-0 box-border overflow-clip border border-line [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)] motion-reduce:transition-none\",\n          up && \"top-auto bottom-0\",\n          // Closed, the pill radius is clamped to half the trigger height, so it springs to the panel radius instead of\n          // staying round until the last frame (9999px interpolates through values that all still read as a pill).\n          open\n            ? \"rounded-panel bg-surface shadow-floating\"\n            : \"rounded-[min(var(--radius-button),calc(var(--spacing-control-sm)/2))] bg-surface\",\n          closed &&\n            \"peer-hover:bg-hover peer-hover:shadow-resting peer-active:bg-[color-mix(in_oklab,var(--hover),var(--line)_55%)]\",\n        )}\n        style={{ left, width, height }}\n      >\n        <motion.div\n          ref={panel}\n          id={panelId}\n          className={cn(\n            \"absolute top-0 left-0 w-[min(var(--filter-menu-width,16rem),calc(100vw_-_var(--space-8)))] p-1.5 outline-none\",\n            up && \"top-auto bottom-0\",\n            closed && \"invisible\",\n          )}\n          role=\"dialog\"\n          aria-labelledby={titleId}\n          tabIndex={-1}\n          inert={!open}\n          style={{ x: panelX }}\n          onKeyDown={onPanelKeyDown}\n        >\n          <motion.div style={contentStyle}>\n            <div className={cn(\"flex h-8 items-center pr-2 pl-2.5\", up && \"mt-1\")}>\n              <AnimatePresence initial={false}>\n                {field ? (\n                  <motion.button\n                    key=\"back\"\n                    type=\"button\"\n                    className=\"-ml-1.5 grid size-7 flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-2 transition-[background-color,color] duration-fast ease-standard hover:bg-hover hover:text-ink active:bg-hover active:text-ink motion-reduce:transition-none\"\n                    aria-label=\"Back to fields\"\n                    onClick={(event) => goBack(event.detail === 0)}\n                    initial={\n                      reduced\n                        ? { opacity: 0, width: 28, marginRight: 2 }\n                        : { opacity: 0, width: 0, marginRight: 0, scale: 0.6 }\n                    }\n                    animate={{ opacity: 1, width: 28, marginRight: 2, scale: 1 }}\n                    exit={\n                      reduced\n                        ? { opacity: 0, transition: still }\n                        : {\n                            opacity: 0,\n                            width: 0,\n                            marginRight: 0,\n                            scale: 0.6,\n                            transition: { ...motionTokens.spring.snappy, opacity: leave },\n                          }\n                    }\n                    transition={reduced ? fade : { ...motionTokens.spring.snappy, opacity: enter }}\n                  >\n                    <ChevronLeft size={16} strokeWidth={1.8} aria-hidden=\"true\" />\n                  </motion.button>\n                ) : null}\n              </AnimatePresence>\n              <span\n                className=\"relative flex min-w-0 text-(length:--text-sm) font-medium tracking-body whitespace-nowrap text-ink\"\n                id={titleId}\n              >\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <motion.span key={field?.id ?? \"fields\"} className=\"block\" {...textMotion(reduced)}>\n                    {field ? field.label : label}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            </div>\n            <div className=\"relative mt-0.5\">\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n                <motion.div\n                  key={field?.id ?? \"fields\"}\n                  className=\"w-full\"\n                  custom={direction}\n                  variants={step}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                >\n                  {field ? (\n                    <MenuList\n                      rows={valueRows}\n                      labelledBy={titleId}\n                      radio\n                      onChoose={chooseValue}\n                      onBack={goBack}\n                      onLeave={() => panel.current?.focus({ preventScroll: true })}\n                    />\n                  ) : (\n                    <MenuList\n                      rows={fieldRows}\n                      labelledBy={titleId}\n                      onChoose={chooseField}\n                      onLeave={() => panel.current?.focus({ preventScroll: true })}\n                    />\n                  )}\n                </motion.div>\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        </motion.div>\n      </motion.div>\n    </div>\n  );\n}\n\nexport function FilterToolbar({\n  filters,\n  onRemove,\n  onClearAll,\n  children,\n  label = \"Active filters\",\n  addFilter,\n}: FilterToolbarProps) {\n  const reduced = useReducedMotion() ?? false;\n  const root = useRef<HTMLDivElement>(null),\n    list = useRef<HTMLDivElement>(null);\n  const armed = useRef(0),\n    pendingFocus = useRef<number | null>(null);\n  const [seen, setSeen] = useState(filters);\n  const [message, setMessage] = useState(\"\");\n  if (seen !== filters) {\n    setSeen(filters);\n    const next = describeChange(seen, filters);\n    if (next) setMessage(next);\n  }\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(() => {\n    armed.current = performance.now() + 900;\n  }, [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,\n      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\n      ? node.querySelector<HTMLElement>(`[data-chip-remove=\"${CSS.escape(next.id)}\"]`)\n      : 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  // 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  const slotClass = \"inline-flex max-w-full min-w-0 flex-none rounded-full\";\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\n      ? { opacity: 0, transition: still }\n      : {\n          width: 0,\n          marginRight: -gap,\n          overflow: \"clip\",\n          \"--slot-moving\": 1,\n          pointerEvents: \"none\",\n          transition: { ...motionTokens.spring.smooth, \"--slot-moving\": still },\n        },\n    transition: reduced ? still : motionTokens.spring.smooth,\n  });\n  const chip = {\n    initial: reduced ? (false as const) : { opacity: 0, scale: 0.9, filter: blur(motionTokens.blur.soft) },\n    animate: { opacity: 1, scale: 1, filter: blur(0) },\n    exit: reduced\n      ? { opacity: 0, transition: still }\n      : { opacity: 0, scale: 0.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 * 0.75 } };\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  return (\n    <div\n      ref={root}\n      className=\"relative flex min-h-control-lg items-start justify-between gap-4 rounded-panel border border-line bg-surface px-4 py-3 max-[520px]:flex-col max-[520px]:items-stretch\"\n      role=\"group\"\n      aria-label={label}\n    >\n      {/* The frame springs to the chip list height, so a new line of chips opens the toolbar instead of snapping it. */}\n      <motion.div className=\"min-w-0 flex-1 max-[520px]:flex-none\" style={{ height: frameHeight }}>\n        <div ref={list} className=\"relative flex min-h-control-sm min-w-0 flex-wrap items-center gap-2\">\n          <AnimatePresence initial={false}>\n            {filters.map((filter, index) => (\n              <motion.span className={slotClass} key={filter.id} {...slot(8)}>\n                <motion.span\n                  className=\"inline-flex max-w-[calc(100%_+_var(--slot-moving,0)_*_100vw)] min-h-control-sm flex-none items-center gap-2 rounded-full border border-line bg-hover pr-2 pl-3 text-(length:--text-xs) text-ink-2\"\n                  {...chip}\n                >\n                  <span className=\"overflow-x-clip overflow-y-visible text-ellipsis whitespace-nowrap\">\n                    {filter.label}\n                    {filter.value ? (\n                      <span className=\"font-medium text-ink\">\n                        <span className=\"font-normal text-ink-2\"> · </span>\n                        <MorphText text={filter.value} />\n                      </span>\n                    ) : null}\n                  </span>\n                  <button\n                    type=\"button\"\n                    className={cn(\n                      \"grid size-6 flex-[0_0_24px] cursor-pointer place-items-center rounded-button border-0 bg-transparent text-ink-3 hover:bg-line hover:text-ink motion-safe:active:[transform:scale(.96)]\",\n                      pressTransition,\n                    )}\n                    data-chip-remove={filter.id}\n                    aria-label={`Remove ${filter.label}${filter.value ? `: ${filter.value}` : \"\"}`}\n                    onClick={(event) => {\n                      if (document.activeElement === event.currentTarget) pendingFocus.current = index;\n                      onRemove(filter.id);\n                    }}\n                  >\n                    <X size={14} strokeWidth={1.8} aria-hidden=\"true\" />\n                  </button>\n                </motion.span>\n              </motion.span>\n            ))}\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 : (\n              <motion.span\n                key=\"empty\"\n                className=\"pointer-events-none absolute top-0 bottom-0 left-0 flex max-w-full items-center overflow-hidden text-(length:--text-sm) whitespace-nowrap text-ink-3\"\n                {...emptyText}\n              >\n                No filters applied\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n      </motion.div>\n      <div className=\"inline-flex flex-[0_0_auto] items-center gap-3 max-[520px]:justify-end\">\n        {addFilter ? (\n          <FilterMenu\n            fields={addFilter.fields}\n            onSelect={addFilter.onAdd}\n            active={filters}\n            label={addFilter.label}\n            align={addFilter.align}\n          />\n        ) : null}\n        {children}\n        <AnimatePresence initial={false}>\n          {filters.length > 0 ? (\n            <motion.span key=\"clear\" className={slotClass} {...slot(12)}>\n              <button\n                className={cn(\n                  \"min-h-control-sm cursor-pointer rounded-button border-0 bg-transparent px-3 text-(length:--text-sm) leading-body tracking-body whitespace-nowrap text-ink-3 hover:bg-hover hover:text-ink motion-safe:active:[transform:scale(.97)]\",\n                  pressTransition,\n                )}\n                type=\"button\"\n                onClick={() => {\n                  pendingFocus.current = -1;\n                  onClearAll?.();\n                }}\n              >\n                <motion.span className=\"block\" {...text}>\n                  Clear all\n                </motion.span>\n              </button>\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      </div>\n      <span className=\"sr-only\" role=\"status\">\n        {message}\n      </span>\n    </div>\n  );\n}\n\nexport default FilterToolbar;\n",
      "type": "registry:component",
      "target": "@components/iris/filter-toolbar/filter-toolbar.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/filter-toolbar",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "FilterToolbar",
    "tags": [
      "data",
      "filter"
    ],
    "docs": "https://irisui.dev/components/filter-toolbar"
  }
}
