{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-chips",
  "title": "Filter Chips",
  "description": "The row of pills above a list that says which filters are in force — one chip per applied condition, each with a × that takes off only that condition, plus a clear-all that offers an undo. It holds no filter state of its own: the chips are derived from the query on every render, so the row cannot disagree with the list beneath it. Reach for it above anything filterable: an admin table of users, orders, invoices or subscriptions, e-commerce search results and faceted category pages, a logs, events or audit-trail viewer, an issue or ticket tracker's saved views, a dashboard segment, a CRM contact list, a file or media library, a job board, a property or map search, a moderation queue, and any page that keeps its filters in the URL. Common asks it answers: \"filter chips react\", \"active filters component\", \"applied filters bar\", \"removable filter pills\", \"chips with a remove button react\", \"faceted search UI react\", \"clear all filters button\", \"show active filters above a table\", \"filter tags with x react\", \"shadcn filter chips\", \"shadcn active filters\", \"MUI Chip onDelete equivalent\", \"antd closable tag filters equivalent\", \"filter chips from URLSearchParams\", \"sync filters with query params react\", \"remove one value from a multi-value filter\", \"reset page when a filter changes\", \"announce filtered result count to a screen reader\". Official shadcn/ui has nothing for this: activeFilters, onRemoveFilter, clearAll, facetedFilter, URLSearchParams, useSearchParams and aria-live are each zero hits across all 62 of its fetchable items (measured 2026-09-25), its badge is a coloured label with no way to take it off, and toggle-group is an input for choosing rather than a display of what has been chosen. The one part that looks close is the Base UI combobox's ComboboxChips/ComboboxChip, and it is a different job: that is the selected-values affordance inside one text field — a bordered, focus-ringed input container whose pills are that combobox's own selection, needing @base-ui/react and a Combobox root around them — where this is a summary above the list of everything currently applied, including conditions set by other controls, by a shared link or by a saved view, which no single input's chips can represent. Distinct from the pulld pieces it sits beside: multi-select and tag-input put chips inside a field (choosing from a list, and typing freely), bulk-action-bar is about the rows you have selected rather than the filters that produced them, search-input is one filter's own control, and date-range-preset keeps its period in this same query string — declare that parameter as a chip:\"key\" field and its chip comes off like any other. Five things separate it from the twenty-line version. First, the chips are the state rather than a copy of it: keep a second array of active filters and it drifts the moment anything else touches the query — a Back button, a link, a reset elsewhere on the page — after which the row shows a pill for a condition that is no longer applied, or stays silent about one that is, which is the commonest way an admin list goes quietly wrong. Second, one filter holding several values: ?status=open&status=pending is either two chips whose × narrows the filter to the other value, or one chip whose × stops filtering by status altogether, and which is right depends on the parameter (chip:\"key\" for a range or a period), so it is declared rather than accidental. Third, the round-trip: removing a chip rebuilds the parameters instead of assembling a fresh query out of the fields, so the sort order, the tab and everything else it was never told about survive, while the page number is dropped — a cursor into the results of the old conditions is what lands a person on an empty page 7 with three chips still showing. Fourth, the naming and the outcome: a bare × is read out as \"button\", so each one's accessible name says which condition it removes, and the result count is announced from a live region that stays mounted at zero chips (a region inserted together with its text is one nobody was listening to, and the message that matters most arrives as the row empties). Fifth, focus: the button just pressed no longer exists, and a browser does not move focus off a removed element, it drops it to <body> — so focus goes to the chip that took its place, or to the row itself when the last one goes. Clear-all is the only irreversible action here, so it restores the previous query exactly, page number included, and withdraws the offer the moment the query changes from anywhere else.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/filter-chips.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The filter state, in whichever shape the page already holds it.\n *\n * All three are the same thing written differently, and every one of them is something a list page\n * already has: the query string from `location.search`, the `URLSearchParams` that\n * `useSearchParams()` hands back, or the `router.query` object of a pages-router app. What this\n * component must never be given is a separate array of chips — see `appliedFilters`.\n */\nexport type FilterQuery =\n  | string\n  | URLSearchParams\n  | Readonly<Record<string, string | readonly string[] | undefined>>\n\n/** One query parameter that counts as a filter, and how it reads to a person. */\nexport interface FilterFieldDef {\n  /** The query parameter this field owns, e.g. \"status\". */\n  key: string\n  /** The field's name as a person reads it, e.g. \"Status\". Supply translated strings here. */\n  label: string\n  /**\n   * Whether a field holding several values shows one chip per value (the default) or a single chip\n   * for the whole parameter.\n   *\n   * This is the decision that changes what the \"×\" means, and it is a product decision rather than\n   * a styling one. With `\"value\"`, `?status=open&status=pending` is two chips and removing one\n   * narrows the filter to the other — which is what a person expects of a faceted list. With\n   * `\"key\"` it is one chip reading \"Status: Open, Pending\" whose × stops filtering by status at\n   * all; that is the right shape for a parameter whose values only make sense together, such as a\n   * range (\"Price: 20–50\") or a single compound value like a date period.\n   */\n  chip?: \"value\" | \"key\"\n  /**\n   * Turns a raw value into what a person reads — an id into a name, a code into a label.\n   *\n   * Return `null` to say the value is not a filter at all. That is the escape hatch for the very\n   * common `?status=all`: a select whose \"All\" option writes a value into the URL means the page is\n   * technically filtering by something that filters nothing, and a chip reading \"Status: All\" is\n   * noise a person cannot act on. Hidden values still belong to the parameter, so a `\"key\"` chip's\n   * × and \"Clear all\" remove them along with the rest.\n   */\n  format?: (value: string) => string | null\n  /**\n   * Set when several values ride in **one** parameter, e.g. `?status=open,pending` with `\",\"`.\n   *\n   * Left unset, multiple values mean a repeated parameter (`?status=open&status=pending`), which is\n   * what `URLSearchParams` produces natively. Both conventions are everywhere in real APIs, and\n   * guessing is not possible: `?tags=a,b` is two tags for one backend and a single tag named \"a,b\"\n   * for another. Values are trimmed when a delimiter is set, since \"open, pending\" is how a human\n   * writes such a list.\n   */\n  delimiter?: string\n}\n\n/** One chip: a filter that is in force right now, and what removing it would take away. */\nexport interface AppliedFilter {\n  /** Stable within a render — used as the React key. */\n  id: string\n  /** The query parameter this chip came from. */\n  key: string\n  /** The field's `label`. */\n  fieldLabel: string\n  /** The raw values this chip stands for: one value, or every value of the parameter for a `\"key\"` chip. */\n  values: readonly string[]\n  /** The values as a person reads them, joined with \", \" for a `\"key\"` chip. */\n  text: string\n  /** True when removing this chip drops the whole parameter rather than one of its values. */\n  whole: boolean\n}\n\n/**\n * Parameters dropped whenever a filter changes, unless they are declared fields.\n *\n * A page number is not a condition, it is a cursor into the result of the conditions — so the\n * moment the conditions change it means nothing, and keeping it is the reason removing a filter on\n * page 7 so often lands on an empty list with \"no results\" showing while three chips are still on\n * screen. Extend it rather than replacing it when a page has its own cursor:\n * `resetKeys={[...DEFAULT_RESET_KEYS, \"cursor\"]}`.\n */\nexport const DEFAULT_RESET_KEYS: readonly string[] = [\"page\"]\n\n/**\n * A private, mutable copy of whatever shape the caller keeps its filters in.\n *\n * Copied rather than used in place for two reasons: the object belongs to the caller and a\n * component that mutates it would change state behind the caller's back without a re-render, and\n * the `ReadonlyURLSearchParams` a framework hands out throws on the mutating methods anyway. The\n * string round-trip is also what makes a wrapper or subclass from another realm work, where\n * `instanceof` would not.\n */\nexport function toSearchParams(query: FilterQuery): URLSearchParams {\n  if (typeof query === \"string\") return new URLSearchParams(query)\n  if (typeof (query as URLSearchParams).toString === \"function\" && \"getAll\" in query) {\n    return new URLSearchParams((query as URLSearchParams).toString())\n  }\n  const params = new URLSearchParams()\n  const record = query as Readonly<Record<string, string | readonly string[] | undefined>>\n  for (const key of Object.keys(record)) {\n    const value = record[key]\n    if (value === undefined) continue\n    if (Array.isArray(value)) {\n      // Appended one at a time on purpose: handing an array straight to URLSearchParams joins it\n      // with a comma, which silently turns two repeated values into one comma-containing value.\n      for (const one of value) params.append(key, one)\n    } else {\n      params.append(key, value as string)\n    }\n  }\n  return params\n}\n\n/** The values a field holds, in the order they appear, deduplicated and without empties. */\nfunction valuesOf(params: URLSearchParams, field: FilterFieldDef): string[] {\n  const delimiter = field.delimiter\n  const raw = params.getAll(field.key)\n  const parts = delimiter\n    ? raw.flatMap((entry) => entry.split(delimiter).map((value) => value.trim()))\n    : raw\n  const seen = new Set<string>()\n  const values: string[] = []\n  for (const value of parts) {\n    // An empty value is what a reset select writes (`?status=`), and it filters nothing.\n    // A repeat is the same condition said twice — `status in (open, open)` is `status in (open)` —\n    // so it is one chip, and removing it removes every copy.\n    if (value === \"\" || seen.has(value)) continue\n    seen.add(value)\n    values.push(value)\n  }\n  return values\n}\n\n/**\n * The chips a query deserves — derived, every time, from the query itself.\n *\n * This function is the component's whole reason for existing, and it is exported because the rule\n * it enforces is one a page can break outside the component too. The failure it rules out is\n * keeping a second array of \"active filters\" for display: the array and the URL then drift the\n * moment anything else touches the query — a browser Back, a saved view, a link, a reset button,\n * another control on the same page — and what a person sees is a pill for a condition that is no\n * longer applied, or no pill for one that is. That is the commonest way an admin list goes wrong,\n * and nothing about it looks wrong: the chips render, the count renders, they simply disagree.\n *\n * Chips follow the order of `fields`, not the order of the query string, because parameter order is\n * incidental — a router that rebuilds the URL may reorder it, and chips that reshuffle between\n * navigations read as a different set of filters. Within one field, values keep the order they\n * appear in.\n */\nexport function appliedFilters(\n  query: FilterQuery,\n  fields: readonly FilterFieldDef[]\n): AppliedFilter[] {\n  const params = toSearchParams(query)\n  const chips: AppliedFilter[] = []\n  for (const field of fields) {\n    const values = valuesOf(params, field)\n    if (values.length === 0) continue\n    const shown: Array<{ value: string; text: string }> = []\n    for (const value of values) {\n      const text = field.format ? field.format(value) : value\n      if (text === null || text === \"\") continue\n      shown.push({ value, text })\n    }\n    if (shown.length === 0) continue\n    if (field.chip === \"key\") {\n      chips.push({\n        id: field.key,\n        key: field.key,\n        fieldLabel: field.label,\n        // Every value the parameter holds, including any the format hid, because this chip stands\n        // for the parameter rather than for the text on it — a caller reading `values` to build its\n        // own query needs the ones it cannot see as much as the ones it can. What makes the ×\n        // complete is `whole` below, not this list.\n        values,\n        text: shown.map((entry) => entry.text).join(\", \"),\n        whole: true,\n      })\n      continue\n    }\n    for (const entry of shown) {\n      chips.push({\n        id: `${field.key}=${entry.value}`,\n        key: field.key,\n        fieldLabel: field.label,\n        values: [entry.value],\n        text: entry.text,\n        whole: false,\n      })\n    }\n  }\n  return chips\n}\n\n/** Drops the reset parameters, then serialises — the last step of every change made here. */\nfunction serialize(\n  params: URLSearchParams,\n  fields: readonly FilterFieldDef[],\n  resetKeys: readonly string[]\n): string {\n  const owned = new Set(fields.map((field) => field.key))\n  for (const key of resetKeys) {\n    // A declared field wins over the reset list: a chip that vanished on the next click would be\n    // worse than a stale cursor, and a caller listing the same key in both places is contradicting\n    // themselves.\n    if (!owned.has(key)) params.delete(key)\n  }\n  return params.toString()\n}\n\n/**\n * The query with one chip's filter taken out, as a query string with no leading \"?\".\n *\n * Everything the component was not told about survives — the sort order, the tab, the search term\n * that is not a declared field. That is why this rebuilds the parameters rather than assembling a\n * fresh query from the fields: `router.push(\"?\" + something)` built from the filters alone is how\n * removing one chip also throws away the column someone had sorted by.\n */\nexport function removeFilter(\n  query: FilterQuery,\n  fields: readonly FilterFieldDef[],\n  filter: AppliedFilter,\n  options: { resetKeys?: readonly string[] } = {}\n): string {\n  const field = fields.find((candidate) => candidate.key === filter.key)\n  const delimiter = field?.delimiter\n  const target = filter.values[0]\n  const params = toSearchParams(query)\n  const next = new URLSearchParams()\n  for (const [key, value] of params.entries()) {\n    if (key !== filter.key) {\n      next.append(key, value)\n      continue\n    }\n    if (filter.whole) continue\n    if (delimiter) {\n      const kept = value\n        .split(delimiter)\n        .map((part) => part.trim())\n        .filter((part) => part !== \"\" && part !== target)\n      if (kept.length > 0) next.append(key, kept.join(delimiter))\n      continue\n    }\n    if (value !== target) next.append(key, value)\n  }\n  return serialize(next, fields, options.resetKeys ?? DEFAULT_RESET_KEYS)\n}\n\n/**\n * The query with every declared filter taken out, as a query string with no leading \"?\".\n *\n * Only declared fields go. Anything else in the query is not this component's to remove.\n */\nexport function clearFilters(\n  query: FilterQuery,\n  fields: readonly FilterFieldDef[],\n  options: { resetKeys?: readonly string[] } = {}\n): string {\n  const owned = new Set(fields.map((field) => field.key))\n  const params = toSearchParams(query)\n  const next = new URLSearchParams()\n  for (const [key, value] of params.entries()) {\n    if (!owned.has(key)) next.append(key, value)\n  }\n  return serialize(next, fields, options.resetKeys ?? DEFAULT_RESET_KEYS)\n}\n\n/** Where focus should land once the tree this was set in has been replaced. */\ntype FocusIntent =\n  | { kind: \"chip\"; index: number; from: string }\n  | { kind: \"undo\"; from: string }\n  | { kind: \"row\"; from: string }\n\nexport interface FilterChipsProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"onChange\" | \"value\" | \"defaultValue\" | \"children\"\n  > {\n  /** Which query parameters are filters, and how each one reads. Chips appear in this order. */\n  fields: readonly FilterFieldDef[]\n  /**\n   * Controlled filter state — the query the page is showing right now.\n   *\n   * Pass the same thing the list itself is filtered by and nothing can drift: `useSearchParams()`\n   * in an app-router page, `router.query` in a pages-router one, `location.search` otherwise.\n   */\n  value?: FilterQuery\n  /** Starting query for an uncontrolled row. Ignored once `value` is passed. */\n  defaultValue?: FilterQuery\n  /**\n   * Called with the next query string — no leading \"?\" — whenever a filter is removed, cleared or\n   * restored.\n   *\n   * `router.push(\"?\" + next)` and `setSearchParams(next)` both take it as it stands. It is a string\n   * rather than a `URLSearchParams` so that comparing it to the current query is a comparison\n   * rather than a walk, and so nothing can mutate it after the fact.\n   */\n  onValueChange?: (query: string) => void\n  /** Parameters dropped whenever a filter changes. Defaults to `DEFAULT_RESET_KEYS` (the page number). */\n  resetKeys?: readonly string[]\n  /**\n   * How many rows the current filters match, if the page knows.\n   *\n   * Announced, not displayed — the page shows the list, the announcement is for the person who\n   * cannot see that it changed. Leave it out and only the removal itself is announced. It is read\n   * at announce time, so a count that arrives after the fetch updates the announcement when it\n   * lands; the trade is that a background refresh during the same interaction can re-announce.\n   */\n  resultCount?: number\n  /**\n   * Turns a count into the announced phrase — it ends the announcement, so punctuate it as the end\n   * of a sentence. Keep the formatting plain: the default deliberately does not run the number\n   * through `toLocaleString`, because the runtime's own locale differs between the server render and\n   * the browser's and the two would not match.\n   */\n  countLabel?: (count: number) => string\n  /** Accessible name of the chip list. */\n  label?: string\n  /** Visible text of a chip. Defaults to the field label and the value, styled as two parts. */\n  chipLabel?: (filter: AppliedFilter) => string\n  /** Accessible name of a chip's remove button — it has to name the condition, not just \"remove\". */\n  removeLabel?: (filter: AppliedFilter) => string\n  /** Announced after a removal. */\n  removedMessage?: (filter: AppliedFilter) => string\n  /** Text of the clear-all control. */\n  clearAllLabel?: string\n  /** Announced after clearing. */\n  clearedMessage?: string\n  /**\n   * How many chips have to be applied before clear-all appears. `Infinity` never shows it.\n   *\n   * Two is the default because \"Clear all\" beside a single chip offers a second button for the job\n   * the × next to it already does. `0` means \"whenever any filter is applied\" — an enabled control\n   * for clearing nothing is never shown.\n   */\n  clearAllFrom?: number\n  /**\n   * Whether clearing offers an undo. On by default, because clearing is the one irreversible\n   * action here: a × takes away one condition a person can see and retype, while clear-all takes\n   * away several at once and the screen no longer says what they were.\n   *\n   * The offer is a button in the row rather than a toast, and it does not time out — an undo that\n   * disappears on its own is one a keyboard or screen-reader user cannot reach in time. It goes\n   * away when the next thing happens: another change here, or any change to the query from\n   * elsewhere, which is the point at which restoring the old filters would no longer match what is\n   * on screen.\n   */\n  allowUndo?: boolean\n  /** Text of the undo control. */\n  undoLabel?: string\n  /** Announced after an undo. */\n  restoredMessage?: string\n  /** Disables every control in the row. */\n  disabled?: boolean\n}\n\n// Ends with a stop because this is spoken, not shown: it finishes the sentence the removal started,\n// and a screen reader reads the punctuation as a pause rather than running the next phrase into it.\nconst defaultCountLabel = (count: number) => `${count} ${count === 1 ? \"result\" : \"results\"}.`\n\n/**\n * The row of \"filters in force\" above a list or table, with a × on each and a way to clear them\n * all.\n *\n * ```tsx\n * const params = useSearchParams()\n * const router = useRouter()\n *\n * const fields = [\n *   { key: \"status\", label: \"Status\", format: (v) => (v === \"all\" ? null : STATUS[v]) },\n *   { key: \"owner\", label: \"Owner\", format: (v) => people[v]?.name ?? v },\n *   { key: \"period\", label: \"Period\", chip: \"key\" as const },\n *   { key: \"q\", label: \"Search\" },\n * ]\n *\n * <FilterChips\n *   fields={fields}\n *   value={params}\n *   resultCount={rows.length}\n *   onValueChange={(next) => router.push(`/orders?${next}`)}\n * />\n * ```\n *\n * It holds no filter state of its own: the chips are derived from the query on every render, so the\n * row cannot disagree with the list it sits above. Composes with `date-range-preset`, which keeps\n * its period in the same query string (`?period=last7d`) — declare that parameter as a `\"key\"`\n * field and the chip removes the period the same way any other filter comes off.\n */\nexport const FilterChips = React.forwardRef<HTMLDivElement, FilterChipsProps>(\n  function FilterChips(\n    {\n      className,\n      fields,\n      value: valueProp,\n      defaultValue = \"\",\n      onValueChange,\n      resetKeys = DEFAULT_RESET_KEYS,\n      resultCount,\n      countLabel = defaultCountLabel,\n      label = \"Applied filters\",\n      chipLabel,\n      removeLabel = (filter) => `Remove ${filter.fieldLabel} filter: ${filter.text}`,\n      removedMessage = (filter) => `Removed ${filter.fieldLabel}: ${filter.text}.`,\n      clearAllLabel = \"Clear all\",\n      clearedMessage = \"All filters cleared.\",\n      clearAllFrom = 2,\n      allowUndo = true,\n      undoLabel = \"Undo\",\n      restoredMessage = \"Filters restored.\",\n      disabled,\n      ...props\n    },\n    ref\n  ) {\n    const isControlled = valueProp !== undefined\n    const [uncontrolledQuery, setUncontrolledQuery] = React.useState(() =>\n      toSearchParams(defaultValue).toString()\n    )\n    const query = isControlled ? toSearchParams(valueProp).toString() : uncontrolledQuery\n\n    const chips = React.useMemo(() => appliedFilters(query, fields), [query, fields])\n\n    /** What was just done, for the live region. Empty until the person does something. */\n    const [action, setAction] = React.useState(\"\")\n    /** The query clear-all replaced, for undo. Null when there is nothing to put back. */\n    const [undoTo, setUndoTo] = React.useState<string | null>(null)\n\n    // The last query this component asked for. Anything else arriving is somebody else's change.\n    const emitted = React.useRef<string | null>(null)\n    const focusIntent = React.useRef<FocusIntent | null>(null)\n\n    /**\n     * Drops what this row was saying about the filters when the filters changed from outside it.\n     *\n     * A Back button, a link, a saved view or another control on the page can all change the query,\n     * and at that moment the undo snapshot describes a state nobody is looking at, the last\n     * announcement is about a filter that may be gone, and a queued focus move points into a row\n     * that has been rebuilt for a different reason. Written as an adjustment during render rather\n     * than in an effect so the row never paints with any of the three still in force.\n     */\n    const [lastQuery, setLastQuery] = React.useState(query)\n    if (query !== lastQuery) {\n      setLastQuery(query)\n      if (query !== emitted.current) {\n        setUndoTo(null)\n        setAction(\"\")\n        focusIntent.current = null\n      }\n    }\n\n    const rowRef = React.useRef<HTMLDivElement>(null)\n    const removeRefs = React.useRef<Array<React.RefObject<HTMLButtonElement | null>>>([])\n    const clearRef = React.useRef<HTMLButtonElement>(null)\n    const undoRef = React.useRef<HTMLButtonElement>(null)\n\n    /**\n     * One stable ref object per position in the row — by position, not by chip.\n     *\n     * Position is what focus is about: remove the second of three chips and focus belongs on\n     * whatever is second now. Object refs rather than inline callbacks so the nodes are not\n     * detached and re-attached on every render of the list.\n     */\n    const removeRef = (index: number) => {\n      while (removeRefs.current.length <= index) {\n        removeRefs.current.push(React.createRef<HTMLButtonElement>())\n      }\n      return removeRefs.current[index]\n    }\n\n    // Forwarded through the handle so the row can keep its own ref on the same node: it is the\n    // fallback focus target, and a caller's ref would otherwise have to be merged by hand.\n    React.useImperativeHandle(ref, () => rowRef.current as HTMLDivElement)\n\n    /**\n     * Puts focus somewhere sensible once the row has been rebuilt without the chip that had it.\n     *\n     * The button the person pressed is gone from the document, and a browser does not move focus\n     * when the focused element is removed — it drops to `<body>`, which for a keyboard user means\n     * tabbing starts again from the top of the page, and for a screen-reader user means nothing\n     * says what happened. So focus goes to the chip that took the removed one's place, or to the\n     * last chip when the end of the row was removed, and to the row itself when nothing is left.\n     *\n     * Gated on the query having actually changed, because in controlled use the parent may re-render\n     * this row before it applies the new query — for a route change it usually does — and focusing\n     * position 1 in a row that still holds the old chips would put focus on the very button that is\n     * about to be removed. No dependency array: the intent is set by a person's click and this has\n     * to be the first commit after it that shows the result.\n     */\n    React.useEffect(() => {\n      const intent = focusIntent.current\n      if (!intent || intent.from === query) return\n      focusIntent.current = null\n      if (intent.kind === \"undo\") {\n        ;(undoRef.current ?? rowRef.current)?.focus()\n        return\n      }\n      if (intent.kind === \"chip\" && chips.length > 0) {\n        // Clamped by the chip count rather than by which refs are attached: a ref for a position\n        // the row no longer has may still be holding its old node this early in the commit.\n        const at = Math.min(intent.index, chips.length - 1)\n        const target = removeRefs.current[at]?.current ?? clearRef.current ?? rowRef.current\n        target?.focus()\n        return\n      }\n      rowRef.current?.focus()\n    })\n\n    const emit = (next: string) => {\n      emitted.current = next\n      if (!isControlled) setUncontrolledQuery(next)\n      onValueChange?.(next)\n    }\n\n    const handleRemove = (filter: AppliedFilter, index: number) => {\n      focusIntent.current = { kind: \"chip\", index, from: query }\n      setUndoTo(null)\n      setAction(removedMessage(filter))\n      emit(removeFilter(query, fields, filter, { resetKeys }))\n    }\n\n    const handleClearAll = () => {\n      focusIntent.current = allowUndo ? { kind: \"undo\", from: query } : { kind: \"row\", from: query }\n      // The snapshot is the query as it stands, reset parameters and all, so undo is a true inverse:\n      // clearing from page 7 and undoing puts page 7 back rather than dropping the reader at page 1.\n      setUndoTo(allowUndo ? query : null)\n      setAction(clearedMessage)\n      emit(clearFilters(query, fields, { resetKeys }))\n    }\n\n    const handleUndo = () => {\n      if (undoTo === null) return\n      focusIntent.current = { kind: \"chip\", index: 0, from: query }\n      setAction(restoredMessage)\n      setUndoTo(null)\n      emit(undoTo)\n    }\n\n    const countText = resultCount === undefined ? \"\" : countLabel(resultCount)\n    // Only ever the outcome of something done here. A region that also reported counts it was not\n    // asked about would narrate every keystroke in the page's own search box.\n    const announcement = action === \"\" ? \"\" : [action, countText].filter(Boolean).join(\" \")\n\n    // Gated on the row being empty as well as on there being a snapshot, so a controlled parent that\n    // has not applied the clear yet never shows an undo beside the chips it is about to remove.\n    const showUndo = allowUndo && undoTo !== null && chips.length === 0\n    const showClearAll = chips.length > 0 && chips.length >= clearAllFrom\n\n    return (\n      <div\n        ref={rowRef}\n        // Focusable only from code: where focus goes when the last chip is removed and there is no\n        // neighbour left to take it. The browser's own ring is left alone rather than suppressed,\n        // so a keyboard user sees where they landed and a mouse user sees nothing.\n        tabIndex={-1}\n        className={cn(\"flex flex-wrap items-center gap-2\", className)}\n        {...props}\n      >\n        {chips.length > 0 ? (\n          // role=\"list\" is written out because Tailwind's preflight removes the list style, and\n          // Safari drops the list role from a list that has none — taking \"list, 3 items\" with it.\n          <ul role=\"list\" aria-label={label} className=\"flex flex-wrap items-center gap-1.5\">\n            {chips.map((filter, index) => (\n              <li key={filter.id} data-filter-key={filter.key} className=\"inline-flex max-w-full\">\n                <span className=\"inline-flex h-7 max-w-full items-center gap-1 rounded-full border border-input bg-background pl-2.5 pr-1 text-xs\">\n                  <span className=\"min-w-0 max-w-[14rem] truncate\" title={filter.text}>\n                    {chipLabel ? (\n                      chipLabel(filter)\n                    ) : (\n                      <>\n                        <span className=\"text-muted-foreground\">{filter.fieldLabel}:</span>{\" \"}\n                        <span className=\"font-medium text-foreground\">{filter.text}</span>\n                      </>\n                    )}\n                  </span>\n                  <button\n                    ref={removeRef(index)}\n                    type=\"button\"\n                    // The visible text says which condition; this says what pressing it does to\n                    // that condition. A bare \"Remove\" or an unlabelled × is read as \"button\".\n                    aria-label={removeLabel(filter)}\n                    disabled={disabled}\n                    onClick={() => handleRemove(filter, index)}\n                    className=\"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n                  >\n                    <X className=\"h-3 w-3\" aria-hidden=\"true\" />\n                  </button>\n                </span>\n              </li>\n            ))}\n          </ul>\n        ) : null}\n\n        {showClearAll ? (\n          <button\n            ref={clearRef}\n            type=\"button\"\n            disabled={disabled}\n            onClick={handleClearAll}\n            className=\"inline-flex h-7 items-center rounded-md px-2 text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n          >\n            {clearAllLabel}\n          </button>\n        ) : null}\n\n        {showUndo ? (\n          <button\n            ref={undoRef}\n            type=\"button\"\n            disabled={disabled}\n            onClick={handleUndo}\n            className=\"inline-flex h-7 items-center rounded-md px-2 text-xs font-medium text-foreground underline underline-offset-4 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n          >\n            {undoLabel}\n          </button>\n        ) : null}\n\n        {/*\n          Mounted whether or not there are chips, and never given `hidden` or wrapped in a\n          condition. A live region has to be in the accessibility tree before the text it will\n          announce arrives; one that appears together with its message is a region nobody was\n          listening to, which reads exactly like silence — and the message that matters most here is\n          the one about the last chip going away, i.e. the one that arrives as the row empties.\n        */}\n        <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announcement}\n        </p>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
