{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-builder",
  "title": "Filter Builder",
  "description": "The advanced-search panel: rows of \"field, operator, value\" stacked up, joined with AND or OR, optionally one level of grouping, and three ways out — the tree the editor holds, the JSON to send a server, and the condition as a sentence. Reach for it wherever a list page has outgrown a search box and a few dropdowns: the advanced filter of an admin table, a segment or audience builder, a cohort definition, the conditions on a report or export, filtering an audit or activity log, a saved or shared view, the \"matches these rules\" half of an automation, an alert or monitor rule, feature-flag targeting, an access or permission rule, a smart playlist or smart folder, a CRM or help-desk list filter, a product-catalogue facet editor for staff, and the filter a person expects to be able to put in a link and send to a colleague. Common asks it answers: \"filter builder react\", \"query builder react\", \"react-querybuilder alternative\", \"shadcn filter builder\", \"shadcn query builder\", \"advanced filter component react\", \"condition builder react\", \"rule builder react\", \"and or filter ui react\", \"nested filter groups react\", \"airtable style filter ui\", \"notion filter ui react\", \"segment builder ui\", \"build where clause ui\", \"filter builder tailwind\", \"operator dropdown by field type\", \"which operators for which data type filter\", \"dynamic operator list react\", \"value input depends on operator\", \"between operator two inputs react\", \"is empty operator no input\", \"in operator multiple values ui\", \"filter tree data structure typescript\", \"serialize filter to url query string\", \"encode filter in url react\", \"parse filter from search params\", \"filter state in url nextjs\", \"shareable filter link\", \"filter to sql where clause json\", \"send filter to api json\", \"incomplete filter row validation\", \"filter row missing value\", \"why does my filter return wrong count\", \"validate number input filter\", \"validate date input filter\", \"date range reversed validation\", \"accessible filter builder\", \"aria labels filter builder\", \"絞り込み 条件 ビルダー react\", \"検索条件 作成 ui\", \"and or 条件 入れ子\", \"条件ビルダー 演算子 型ごと\", \"フィルタ条件 url 共有\", \"管理画面 高度な検索\". Five things decide the shape of this component, and each one is a way the eighty-line version is quietly wrong. The operator menu belongs to the field's kind, not to the component. A string has no \"is greater than\" and a date has no \"contains\", so changing the field rebuilds the operator list and, where the kept operator takes different slots, the value editor too. The id stays the server's vocabulary while the label follows the kind: `gt` reads \"is greater than\" over a number and \"is after\" over a date, because a person reading \"greater than\" above a date picker has to translate. The value editor is the operator's arity, and the arity is load-bearing. `is between` holds two, `is any of` holds a list that can grow, `is empty` holds none. The part that is easy to get wrong is narrowing: an operator moved from `is between` back to `is greater than` must *drop* the second value, not keep it out of sight — a rule that sends `gt` with `[\"10\",\"20\"]` hands a server a bound that nothing will use and everything is entitled to. A field change is not only an operator change. Values carry over between fields of the same kind and are discarded between kinds, because \"acme\" surviving a move from Company to Amount makes a row that reads as complete and filters on a number that is not one; moving between two enums replaces a value the new option list does not offer rather than leaving one it will never match. What counts as filled in cannot be left to the language. `Number(\"\")` is `0` and `Number(\" \")` is `0`, so a numeric check written with it reads an empty box as a filter on zero; `Number(\"0x10\")` is 16 and `Number(\"1e3\")` is 1000. `Date.parse(\"2026-02-30\")` answers the 2nd of March rather than failing, which moves the boundary the person typed, and `Date.parse(\"42\")` answers a year. So numbers are a strict decimal and dates are a `YYYY-MM-DD` built and read back part by part. A range typed backwards is a separate state from an unfinished one: it is complete, it matches nothing, and it says so beside itself instead of being thrown away. Rows that are not finished come back with the query instead of vanishing from it. `buildFilterQuery` returns `{ query, dropped }`, and the second half is the one that is usually missing: a builder that silently skips the row somebody was halfway through returns a result set that does not match what is on screen, and that is read as the search being broken rather than as a row nobody completed. A query of `null` means \"nothing to filter on\", which a caller must not confuse with a filter that matches nothing. Three further decisions, each deliberate. One conjunction per group, not per row — a select on every row invites `A and B or C`, which has no single reading, so the second row carries the group's select and the rest repeat it as text. Nesting stops at one level by default (`maxDepth`), because unlimited nesting is how a saved filter becomes one nobody can interpret later, and a new group opens on the opposite conjunction since a nested group repeating its parent's join changes nothing about what is selected. A group that loses its last row is removed with it, since an empty group matches everything and reads as a box with no condition in it. The three outputs are the same tree seen from three places, which is the other half of why this is a component and not a form. `toFilterParam` / `parseFilterParam` round-trip the editor's own state — unfinished rows included, so a draft survives a reload — in a form that is readable in a shared link: `and(status:eq:open,amount:gte:100)`. Six characters are structural there and every one of them can appear in a value, so segments are escaped: a product called `A, B (2kg)` survives the trip, and a string that cannot be read loads nothing at all rather than half a filter applied silently. `buildFilterQuery` is the pruned shape for a server, with single-child groups collapsed because `and(x)` and `x` select the same rows. `describeFilter` is the condition as a sentence, using the field labels and the kind's own operator wording, for a live region, a summary line or a chip. Smaller things that are still decisions: a number is a text input with `inputMode=\"decimal\"` rather than `type=\"number\"`, because the native number input discards what it cannot parse before the component ever sees it, so a half-typed `1.` or a pasted `1,5` becomes an empty box and the row reports a value that was never missing. The kinds drawn as a select (enum, boolean) start on a legal value instead of blank, so a row cannot show \"Open\" while reporting \"Enter a value\". Every control is named for the row it is in, an unusable row marks its own box `aria-invalid` and points at the reason through `aria-describedby` rather than announcing it into a live region while it is still being typed into, and adding or removing a row is announced through one polite `sr-only` region. Official shadcn/ui has nothing of the kind, measured today rather than remembered. Fetching every entry in its registry — 65 listed, 62 fetchable, `code-block`, `questionnaire` and `trace` 404 on both style tracks — and searching all 223,287 bytes of it, `operator`, `predicate`, `conjunction`, `filterGroup` and `queryBuilder` are every one of them a zero hit. `filter` returns four, and they are worth naming rather than rounding to zero: all four are `Array.prototype.filter` inside the chart and toast internals. The check that actually settles it is the name list rather than the word count, because official can ship a component whose source says nothing about what it does: no entry among the 65 is a filter, query or condition builder of any kind. Within pulld this is the first component with an operator in it. `filter-chips` is the display side — the pills above a list, derived from the query string, with a × that takes one condition off — and the two are made to be used together: build here, show there. `search-input` and `command-palette` are free text, `cron-expression` is the other structured editor (a different grammar, the same idea of editing a machine-readable value through controls), and `sort-header` is the ordering rather than the filtering. None of them composes into this one, so it has no `registryDependencies`. The API: `fields` describes what can be filtered (`name`, `label`, `kind`, `options`, and an optional `operators` narrowing whose order becomes the menu's); `value` / `defaultValue` / `onChange` make it controlled or not; `maxDepth`, `maxRules`, `legend`, `labels` and `disabled` are the rest. The logic is exported as plain functions so the parts that matter can be used without rendering anything: `operatorsForField`, `operatorLabel`, `retargetRule`, `retargetOperator`, `coerceValues`, `ruleIssue`, `ruleIsComplete`, `buildFilterQuery`, `toFilterParam`, `parseFilterParam`, `describeFilter`, `isPlainNumber` and `isPlainDate`. Every label is replaceable one at a time for another language. One file, two lucide icons, your `cn`, and no query-builder library.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/filter-builder.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Plus, Trash2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The kind of value a field holds, which is what decides the operators it can be compared with.\n *\n * This is deliberately a short list rather than a mirror of a database's type system: the thing a\n * filter row needs to know is how many value slots to draw and what counts as a filled-in value,\n * and `varchar` / `text` / `citext` all answer that the same way.\n */\nexport type FilterFieldKind = \"text\" | \"number\" | \"date\" | \"boolean\" | \"enum\"\n\nexport interface FilterFieldOption {\n  value: string\n  label?: string\n}\n\nexport interface FilterField {\n  /** Sent to the server. Stable; not shown to anyone. */\n  name: string\n  /** Shown in the field menu. Falls back to `name`. */\n  label?: string\n  /** Defaults to `\"text\"`, the kind with the fewest assumptions baked in. */\n  kind?: FilterFieldKind\n  /** Required for `kind: \"enum\"` — the only values the field accepts. */\n  options?: readonly FilterFieldOption[]\n  /**\n   * Narrows the operator menu to these, in this order. Anything here that the kind does not\n   * support is ignored rather than drawn, because an operator with no value editor behind it is\n   * a row that cannot be completed.\n   */\n  operators?: readonly FilterOperatorId[]\n}\n\nexport type FilterOperatorId =\n  | \"eq\"\n  | \"ne\"\n  | \"contains\"\n  | \"not_contains\"\n  | \"starts_with\"\n  | \"ends_with\"\n  | \"gt\"\n  | \"gte\"\n  | \"lt\"\n  | \"lte\"\n  | \"between\"\n  | \"in\"\n  | \"not_in\"\n  | \"is_empty\"\n  | \"is_not_empty\"\n\n/**\n * How many value slots an operator takes. `\"many\"` is a list of one or more.\n *\n * This is the number the value editor is built from, and it is the reason an operator change has\n * to touch the values rather than only the label: `between` holds two, `is any of` holds a list,\n * and `is empty` holds none. See `coerceValues`.\n */\nexport type FilterArity = 0 | 1 | 2 | \"many\"\n\nexport interface FilterOperatorSpec {\n  id: FilterOperatorId\n  arity: FilterArity\n  /** The kinds that offer this operator at all. */\n  kinds: readonly FilterFieldKind[]\n  label: string\n  /**\n   * The label for kinds that say it differently. The id is what the server sees, so `gt` on a date\n   * is still `gt` — but a person reading \"greater than\" over a date picker has to translate, and\n   * \"after\" is the same operator spelled the way the field is thought about.\n   */\n  labelByKind?: Partial<Record<FilterFieldKind, string>>\n}\n\n/**\n * Every operator this component can draw, in menu order.\n *\n * Ordered so the two that need no value at all sit at the end: they are the rows that look broken\n * when they land in the middle of a list of comparisons, and they are reached for least often.\n */\nexport const FILTER_OPERATORS: readonly FilterOperatorSpec[] = [\n  { id: \"eq\", arity: 1, kinds: [\"text\", \"number\", \"date\", \"boolean\", \"enum\"], label: \"is\" },\n  { id: \"ne\", arity: 1, kinds: [\"text\", \"number\", \"date\", \"boolean\", \"enum\"], label: \"is not\" },\n  { id: \"contains\", arity: 1, kinds: [\"text\"], label: \"contains\" },\n  { id: \"not_contains\", arity: 1, kinds: [\"text\"], label: \"does not contain\" },\n  { id: \"starts_with\", arity: 1, kinds: [\"text\"], label: \"starts with\" },\n  { id: \"ends_with\", arity: 1, kinds: [\"text\"], label: \"ends with\" },\n  {\n    id: \"gt\",\n    arity: 1,\n    kinds: [\"number\", \"date\"],\n    label: \"is greater than\",\n    labelByKind: { date: \"is after\" },\n  },\n  {\n    id: \"gte\",\n    arity: 1,\n    kinds: [\"number\", \"date\"],\n    label: \"is greater than or equal to\",\n    labelByKind: { date: \"is on or after\" },\n  },\n  {\n    id: \"lt\",\n    arity: 1,\n    kinds: [\"number\", \"date\"],\n    label: \"is less than\",\n    labelByKind: { date: \"is before\" },\n  },\n  {\n    id: \"lte\",\n    arity: 1,\n    kinds: [\"number\", \"date\"],\n    label: \"is less than or equal to\",\n    labelByKind: { date: \"is on or before\" },\n  },\n  { id: \"between\", arity: 2, kinds: [\"number\", \"date\"], label: \"is between\" },\n  { id: \"in\", arity: \"many\", kinds: [\"text\", \"number\", \"enum\"], label: \"is any of\" },\n  { id: \"not_in\", arity: \"many\", kinds: [\"text\", \"number\", \"enum\"], label: \"is none of\" },\n  {\n    id: \"is_empty\",\n    arity: 0,\n    kinds: [\"text\", \"number\", \"date\", \"boolean\", \"enum\"],\n    label: \"is empty\",\n  },\n  {\n    id: \"is_not_empty\",\n    arity: 0,\n    kinds: [\"text\", \"number\", \"date\", \"boolean\", \"enum\"],\n    label: \"is not empty\",\n  },\n]\n\nconst OPERATOR_BY_ID = new Map(FILTER_OPERATORS.map((spec) => [spec.id, spec]))\n\nexport function operatorSpec(id: FilterOperatorId): FilterOperatorSpec | undefined {\n  return OPERATOR_BY_ID.get(id)\n}\n\nexport function fieldKind(field: FilterField | undefined): FilterFieldKind {\n  return field?.kind ?? \"text\"\n}\n\nexport function fieldLabel(field: FilterField | undefined): string {\n  if (!field) return \"\"\n  return field.label ?? field.name\n}\n\n/** The operators a field offers, in menu order, honouring its own `operators` narrowing. */\nexport function operatorsForField(field: FilterField | undefined): FilterOperatorSpec[] {\n  const kind = fieldKind(field)\n  const supported = FILTER_OPERATORS.filter((spec) => spec.kinds.includes(kind))\n  if (!field?.operators) return supported\n  // The field's order wins, because a field that lists its operators is saying which one a person\n  // reaches for first. Unsupported entries drop out here rather than being drawn and then failing\n  // to produce a value editor.\n  const chosen: FilterOperatorSpec[] = []\n  for (const id of field.operators) {\n    if (chosen.some((spec) => spec.id === id)) continue\n    const spec = supported.find((candidate) => candidate.id === id)\n    if (spec) chosen.push(spec)\n  }\n  return chosen\n}\n\n/** The label an operator carries for a particular kind. */\nexport function operatorLabel(spec: FilterOperatorSpec, kind: FilterFieldKind): string {\n  return spec.labelByKind?.[kind] ?? spec.label\n}\n\nexport function operatorArity(id: FilterOperatorId): FilterArity {\n  return OPERATOR_BY_ID.get(id)?.arity ?? 1\n}\n\nexport type FilterConjunction = \"and\" | \"or\"\n\nexport interface FilterRule {\n  id: string\n  kind: \"rule\"\n  /** A `FilterField[\"name\"]`, or `\"\"` for a row whose field has not been chosen yet. */\n  field: string\n  operator: FilterOperatorId\n  /**\n   * One entry per value slot. Always exactly as long as the operator's arity demands, so a value\n   * dropped by an operator change is gone rather than hidden: an operator narrowed from `between`\n   * to `gt` that kept `[\"10\", \"20\"]` would send the 20 to the server, where nothing would use it\n   * and everything would be entitled to.\n   */\n  values: string[]\n}\n\nexport interface FilterGroup {\n  id: string\n  kind: \"group\"\n  conjunction: FilterConjunction\n  children: FilterNode[]\n}\n\nexport type FilterNode = FilterRule | FilterGroup\n\nexport function isGroup(node: FilterNode): node is FilterGroup {\n  return node.kind === \"group\"\n}\n\nexport function isRule(node: FilterNode): node is FilterRule {\n  return node.kind === \"rule\"\n}\n\nlet idCounter = 0\n\n/**\n * Mints a node id.\n *\n * React keys are the reason these exist: keying a row by its index makes the state of the row\n * below move up when a row in the middle is removed, which in a form means the value you typed\n * appears against a field you did not choose. The counter is module-level so two builders on one\n * page cannot collide.\n */\nexport function createFilterId(): string {\n  idCounter += 1\n  return `fb-${idCounter}`\n}\n\nexport function createRule(\n  field: FilterField | undefined,\n  generateId: () => string = createFilterId\n): FilterRule {\n  const operators = operatorsForField(field)\n  const operator = operators[0]?.id ?? \"eq\"\n  return {\n    id: generateId(),\n    kind: \"rule\",\n    field: field?.name ?? \"\",\n    operator,\n    values: blankValues(operatorArity(operator), field),\n  }\n}\n\nexport function createGroup(\n  conjunction: FilterConjunction = \"and\",\n  children: FilterNode[] = [],\n  generateId: () => string = createFilterId\n): FilterGroup {\n  return { id: generateId(), kind: \"group\", conjunction, children }\n}\n\n/**\n * The value this kind starts a slot at.\n *\n * The kinds drawn as a select do not start blank, and that is not a convenience: a select holding\n * `\"\"` shows its first option while the rule says no value was entered, so the row contradicts\n * itself — it reads as \"Active\" and reports \"Enter a value\". Starting at the first legal option\n * makes what is shown what is there. Text, numbers and dates start empty, because guessing one\n * would put a condition into the query that the person never typed.\n */\nexport function blankValue(field?: FilterField): string {\n  switch (fieldKind(field)) {\n    case \"enum\":\n      return field?.options?.[0]?.value ?? \"\"\n    case \"boolean\":\n      return \"true\"\n    default:\n      return \"\"\n  }\n}\n\nexport function blankValues(arity: FilterArity, field?: FilterField): string[] {\n  const blank = blankValue(field)\n  if (arity === 0) return []\n  if (arity === 1) return [blank]\n  if (arity === 2) return [blank, blank]\n  return [blank]\n}\n\n/**\n * Fits `values` to an arity, keeping what the new shape can hold.\n *\n * Truncating rather than stashing is the point — see the note on `FilterRule[\"values\"]`.\n */\nexport function coerceValues(values: readonly string[], arity: FilterArity): string[] {\n  if (arity === 0) return []\n  if (arity === 1) return [values[0] ?? \"\"]\n  if (arity === 2) return [values[0] ?? \"\", values[1] ?? \"\"]\n  const kept = values.filter((value, index) => index === 0 || value !== \"\")\n  return kept.length > 0 ? [...kept] : [\"\"]\n}\n\n/**\n * Re-points a rule at a different field.\n *\n * Two things change and one of them is easy to miss. The operator has to be re-checked, because\n * `contains` does not exist on a number — that is the obvious half. The other half is the values:\n * they are only carried over when the new field is the same kind as the old one. \"acme\" surviving\n * a move from Company to Amount would make a rule that reads as complete and filters on a number\n * that is not one.\n */\nexport function retargetRule(\n  rule: FilterRule,\n  field: FilterField | undefined,\n  previous: FilterField | undefined\n): FilterRule {\n  const operators = operatorsForField(field)\n  const keptOperator = operators.find((spec) => spec.id === rule.operator)\n  const operator = keptOperator?.id ?? operators[0]?.id ?? \"eq\"\n  const arity = operatorArity(operator)\n  const sameKind = fieldKind(field) === fieldKind(previous)\n  if (!sameKind) {\n    return { ...rule, field: field?.name ?? \"\", operator, values: blankValues(arity, field) }\n  }\n  const values = coerceValues(rule.values, arity)\n  return {\n    ...rule,\n    field: field?.name ?? \"\",\n    operator,\n    // An enum's values are only meaningful against its own option list, so a move between two\n    // enums drops whatever the new one does not offer. Replaced rather than emptied, so the row\n    // stays complete the way it was.\n    values:\n      fieldKind(field) === \"enum\"\n        ? values.map((value) =>\n            field?.options?.some((option) => option.value === value) ? value : blankValue(field)\n          )\n        : values,\n  }\n}\n\n/** Applies an operator change, fitting the values to the new arity. */\nexport function retargetOperator(rule: FilterRule, operator: FilterOperatorId): FilterRule {\n  const arity = operatorArity(operator)\n  if (operatorArity(rule.operator) === arity) return { ...rule, operator }\n  return { ...rule, operator, values: coerceValues(rule.values, arity) }\n}\n\nexport function isPlainNumber(text: string): boolean {\n  const trimmed = text.trim()\n  if (trimmed === \"\") return false\n  if (!/^[+-]?(\\d+(\\.\\d+)?|\\.\\d+)$/.test(trimmed)) return false\n  return Number.isFinite(Number(trimmed))\n}\n\n/**\n * A `YYYY-MM-DD` date that exists.\n *\n * The regexp is not the check — `Date.parse(\"2026-02-30\")` answers the 2nd of March rather than\n * failing, so a filter built on it silently moves the boundary the person typed. Building the date\n * and reading its parts back is what catches that.\n */\nexport function isPlainDate(text: string): boolean {\n  const trimmed = text.trim()\n  const parts = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(trimmed)\n  if (!parts) return false\n  const year = Number(parts[1])\n  const month = Number(parts[2])\n  const day = Number(parts[3])\n  if (month < 1 || month > 12 || day < 1 || day > 31) return false\n  const built = new Date(Date.UTC(year, month - 1, day))\n  return (\n    built.getUTCFullYear() === year &&\n    built.getUTCMonth() === month - 1 &&\n    built.getUTCDate() === day\n  )\n}\n\nexport function isBooleanValue(text: string): boolean {\n  return text === \"true\" || text === \"false\"\n}\n\n/** Whether one value slot holds something the field's kind can be compared against. */\nexport function valueIsValid(value: string, field: FilterField | undefined): boolean {\n  if (value.trim() === \"\") return false\n  switch (fieldKind(field)) {\n    case \"number\":\n      return isPlainNumber(value)\n    case \"date\":\n      return isPlainDate(value)\n    case \"boolean\":\n      return isBooleanValue(value)\n    case \"enum\":\n      return Boolean(field?.options?.some((option) => option.value === value))\n    default:\n      return true\n  }\n}\n\nexport type FilterRuleIssue =\n  | \"ok\"\n  | \"no_field\"\n  | \"unknown_field\"\n  | \"missing_value\"\n  | \"invalid_number\"\n  | \"invalid_date\"\n  | \"invalid_option\"\n  | \"reversed_range\"\n\n/**\n * What is wrong with one rule, or `\"ok\"`.\n *\n * Returned as a code rather than a boolean because the four ways a row can be unusable need four\n * different things said about them, and because \"incomplete\" and \"complete but impossible\" are\n * not the same state: a range typed backwards is filled in, and matches nothing.\n */\nexport function ruleIssue(rule: FilterRule, fields: readonly FilterField[]): FilterRuleIssue {\n  if (rule.field === \"\") return \"no_field\"\n  const field = fields.find((candidate) => candidate.name === rule.field)\n  if (!field) return \"unknown_field\"\n  const arity = operatorArity(rule.operator)\n  if (arity === 0) return \"ok\"\n  const slots = arity === \"many\" ? rule.values.filter((value) => value.trim() !== \"\") : rule.values\n  if (slots.length === 0 || slots.some((value) => value.trim() === \"\")) return \"missing_value\"\n  for (const value of slots) {\n    if (valueIsValid(value, field)) continue\n    switch (fieldKind(field)) {\n      case \"number\":\n        return \"invalid_number\"\n      case \"date\":\n        return \"invalid_date\"\n      case \"enum\":\n      case \"boolean\":\n        return \"invalid_option\"\n      default:\n        return \"missing_value\"\n    }\n  }\n  if (arity === 2) {\n    const kind = fieldKind(field)\n    if (kind === \"number\" && Number(rule.values[0]) > Number(rule.values[1])) return \"reversed_range\"\n    // Dates are compared as strings on purpose: `YYYY-MM-DD` sorts correctly as text, and both\n    // slots are known to be real dates by this point, so there is no timezone to get wrong.\n    if (kind === \"date\" && rule.values[0] > rule.values[1]) return \"reversed_range\"\n  }\n  return \"ok\"\n}\n\nexport function ruleIsComplete(rule: FilterRule, fields: readonly FilterField[]): boolean {\n  const issue = ruleIssue(rule, fields)\n  // A backwards range is complete — it is a filter that matches nothing, which is a thing a person\n  // is allowed to ask for, and the row says so beside itself rather than being dropped.\n  return issue === \"ok\" || issue === \"reversed_range\"\n}\n\n/** How deep a node sits, counting the outermost group as 0. */\nexport function nodeDepth(root: FilterNode, id: string, depth = 0): number {\n  if (root.id === id) return depth\n  if (!isGroup(root)) return -1\n  for (const child of root.children) {\n    const found = nodeDepth(child, id, depth + 1)\n    if (found !== -1) return found\n  }\n  return -1\n}\n\n/** The depth of the deepest group, counting the outermost as 1. */\nexport function treeDepth(node: FilterNode): number {\n  if (!isGroup(node)) return 0\n  const deepest = node.children.reduce((max, child) => Math.max(max, treeDepth(child)), 0)\n  return deepest + 1\n}\n\nexport function countRules(node: FilterNode): number {\n  if (isRule(node)) return 1\n  return node.children.reduce((total, child) => total + countRules(child), 0)\n}\n\n/* ------------------------------------------------------------------ editing\n\n   Every edit is a replacement of the node it touches, carried out by rebuilding the path down to\n   it. Mutating in place would be shorter and would not re-render: React compares the root, and a\n   tree whose root is the same object it was is a tree that has not changed as far as it can tell.\n*/\n\nexport function replaceNode(root: FilterGroup, id: string, next: FilterNode | null): FilterGroup {\n  const children: FilterNode[] = []\n  for (const child of root.children) {\n    if (child.id === id) {\n      if (next) children.push(next)\n      continue\n    }\n    if (isGroup(child)) {\n      const replaced = replaceNode(child, id, next)\n      // A group that loses its last child is removed rather than left as an empty pair of\n      // brackets: it serialises to nothing, it matches everything, and on screen it is a box with\n      // an \"Add condition\" button and no way to say what it is for. The outermost group is the\n      // exception — it is the builder itself, and an empty one is how a filter starts.\n      if (replaced.children.length === 0) continue\n      children.push(replaced)\n      continue\n    }\n    children.push(child)\n  }\n  return { ...root, children }\n}\n\nexport function findNode(root: FilterNode, id: string): FilterNode | undefined {\n  if (root.id === id) return root\n  if (!isGroup(root)) return undefined\n  for (const child of root.children) {\n    const found = findNode(child, id)\n    if (found) return found\n  }\n  return undefined\n}\n\nexport function appendChild(root: FilterGroup, groupId: string, child: FilterNode): FilterGroup {\n  if (root.id === groupId) return { ...root, children: [...root.children, child] }\n  return {\n    ...root,\n    children: root.children.map((node) =>\n      isGroup(node) ? appendChild(node, groupId, child) : node\n    ),\n  }\n}\n\nexport function setConjunction(\n  root: FilterGroup,\n  groupId: string,\n  conjunction: FilterConjunction\n): FilterGroup {\n  if (root.id === groupId) return { ...root, conjunction }\n  return {\n    ...root,\n    children: root.children.map((node) =>\n      isGroup(node) ? setConjunction(node, groupId, conjunction) : node\n    ),\n  }\n}\n\n/* ------------------------------------------------------- output: server JSON */\n\nexport interface FilterQueryRule {\n  field: string\n  operator: FilterOperatorId\n  /** Exactly the values the operator uses: none for `is empty`, two for `between`. */\n  values: string[]\n}\n\nexport interface FilterQuery {\n  op: FilterConjunction\n  children: (FilterQuery | FilterQueryRule)[]\n}\n\nexport interface FilterDropped {\n  id: string\n  field: string\n  issue: FilterRuleIssue\n}\n\n/**\n * The filter to send, and every row left out of it.\n *\n * The second half is the part that is usually missing. A builder that quietly skips the rows that\n * are not filled in produces a result set that does not match what is on screen, and the person\n * reads that as the search being broken rather than as a row they never finished — so the rows\n * that were dropped come back with the query, for the caller to say something about.\n *\n * `null` means there is nothing to filter on, which is not the same as an empty match: a caller\n * handed `null` should fetch everything.\n */\nexport function buildFilterQuery(\n  node: FilterNode,\n  fields: readonly FilterField[]\n): { query: FilterQuery | null; dropped: FilterDropped[] } {\n  const dropped: FilterDropped[] = []\n\n  const walk = (current: FilterNode): FilterQuery | FilterQueryRule | null => {\n    if (isRule(current)) {\n      if (!ruleIsComplete(current, fields)) {\n        dropped.push({ id: current.id, field: current.field, issue: ruleIssue(current, fields) })\n        return null\n      }\n      const arity = operatorArity(current.operator)\n      const values =\n        arity === 0\n          ? []\n          : arity === \"many\"\n            ? current.values.filter((value) => value.trim() !== \"\")\n            : current.values.slice(0, arity === 1 ? 1 : 2)\n      return { field: current.field, operator: current.operator, values }\n    }\n    const children = current.children\n      .map(walk)\n      .filter((child): child is FilterQuery | FilterQueryRule => child !== null)\n    if (children.length === 0) return null\n    // A group with one surviving child is collapsed into that child. `and(x)` and `x` select the\n    // same rows, and the shorter one is what a hand-written query would say.\n    if (children.length === 1) return children[0]\n    return { op: current.conjunction, children }\n  }\n\n  const query = walk(node)\n  if (query === null) return { query: null, dropped }\n  // A single rule at the top still arrives wrapped, so a caller never has to tell the two shapes\n  // apart before reading `op`.\n  if (\"field\" in query) {\n    return { query: { op: isGroup(node) ? node.conjunction : \"and\", children: [query] }, dropped }\n  }\n  return { query, dropped }\n}\n\n/* -------------------------------------------------------- output: URL string\n\n   A filter in a URL is read by people — it is in the shared link, the bookmark, the bug report —\n   so the form is `and(status:eq:active,amount:gte:100)` rather than base64 or JSON. That makes\n   six characters structural, and every one of them can appear in a value: a product named\n   \"A, B (2kg)\" has to survive the round trip, so the segments are escaped rather than trusted.\n*/\n\nconst PARAM_SPECIALS = /[\\\\:|,()]/g\n\nexport function escapeFilterSegment(text: string): string {\n  return text.replace(PARAM_SPECIALS, (match) => `\\\\${match}`)\n}\n\nexport function toFilterParam(node: FilterNode): string {\n  if (isRule(node)) {\n    const head = `${escapeFilterSegment(node.field)}:${node.operator}`\n    if (node.values.length === 0) return head\n    return `${head}:${node.values.map(escapeFilterSegment).join(\"|\")}`\n  }\n  return `${node.conjunction}(${node.children.map(toFilterParam).join(\",\")})`\n}\n\n/** Reads one escaped segment, stopping at the first unescaped character in `stop`. */\nfunction readSegment(text: string, start: number, stop: string): { value: string; next: number } {\n  let value = \"\"\n  let i = start\n  while (i < text.length) {\n    const char = text[i]\n    if (char === \"\\\\\" && i + 1 < text.length) {\n      value += text[i + 1]\n      i += 2\n      continue\n    }\n    if (stop.includes(char)) break\n    value += char\n    i += 1\n  }\n  return { value, next: i }\n}\n\n/**\n * Parses what `toFilterParam` wrote, with fresh ids.\n *\n * Returns `null` for anything it cannot read, rather than a partial tree. A URL is the one input\n * here that arrives from outside — edited by hand, truncated by a chat client, carried over from\n * an older version of the fields — and half a filter applied silently is worse than a filter that\n * did not load, because the person cannot see what is missing.\n */\nexport function parseFilterParam(\n  text: string,\n  generateId: () => string = createFilterId\n): FilterGroup | null {\n  const parseNode = (at: number): { node: FilterNode; next: number } | null => {\n    const head = readSegment(text, at, \":(,)\")\n    if (text[head.next] === \"(\") {\n      if (head.value !== \"and\" && head.value !== \"or\") return null\n      const children: FilterNode[] = []\n      let i = head.next + 1\n      if (text[i] === \")\") return { node: createGroup(head.value, [], generateId), next: i + 1 }\n      for (;;) {\n        const child = parseNode(i)\n        if (!child) return null\n        children.push(child.node)\n        i = child.next\n        if (text[i] === \",\") {\n          i += 1\n          continue\n        }\n        if (text[i] === \")\") return { node: createGroup(head.value, children, generateId), next: i + 1 }\n        return null\n      }\n    }\n    if (text[head.next] !== \":\") return null\n    const operator = readSegment(text, head.next + 1, \":,)\")\n    if (!OPERATOR_BY_ID.has(operator.value as FilterOperatorId)) return null\n    const id = operator.value as FilterOperatorId\n    const rule: FilterRule = { id: generateId(), kind: \"rule\", field: head.value, operator: id, values: [] }\n    if (text[operator.next] !== \":\") {\n      // No value section at all. Legal only for the operators that take no value — anything else\n      // would load as a row that looks complete and filters on nothing.\n      return operatorArity(id) === 0 ? { node: rule, next: operator.next } : null\n    }\n    const values: string[] = []\n    let i = operator.next + 1\n    for (;;) {\n      const value = readSegment(text, i, \"|,)\")\n      values.push(value.value)\n      i = value.next\n      if (text[i] === \"|\") {\n        i += 1\n        continue\n      }\n      break\n    }\n    const arity = operatorArity(id)\n    if (arity === 0) return null\n    if (arity !== \"many\" && values.length !== arity) return null\n    return { node: { ...rule, values }, next: i }\n  }\n\n  if (text.trim() === \"\") return null\n  const parsed = parseNode(0)\n  if (!parsed || parsed.next !== text.length) return null\n  // A bare rule is wrapped, so the editor always has a group to add the next row to.\n  return isGroup(parsed.node) ? parsed.node : createGroup(\"and\", [parsed.node], generateId)\n}\n\n/* ----------------------------------------------------------- output: a sentence */\n\n/**\n * The filter in words, for a live region, a summary line or a chip.\n *\n * Built from the same tree the editor draws, so it cannot drift from it — and it uses the field\n * labels and the kind-specific operator labels, which is what makes \"Signed up is after 2026-01-01\"\n * read as a sentence instead of as `signed_up gt 2026-01-01`.\n */\nexport function describeFilter(node: FilterNode, fields: readonly FilterField[]): string {\n  if (isRule(node)) {\n    const field = fields.find((candidate) => candidate.name === node.field)\n    const spec = operatorSpec(node.operator)\n    const name = field ? fieldLabel(field) : node.field || \"(no field)\"\n    const operator = spec ? operatorLabel(spec, fieldKind(field)) : node.operator\n    const shown = node.values.map((value) => {\n      if (!field || fieldKind(field) !== \"enum\") return value\n      const option = field.options?.find((candidate) => candidate.value === value)\n      return option?.label ?? value\n    })\n    if (shown.length === 0) return `${name} ${operator}`\n    if (shown.length === 2 && operatorArity(node.operator) === 2) {\n      return `${name} ${operator} ${shown[0]} and ${shown[1]}`\n    }\n    return `${name} ${operator} ${shown.join(\", \")}`\n  }\n  const parts = node.children.map((child) => {\n    const text = describeFilter(child, fields)\n    // Brackets only where they change the reading: a nested group inside a group of the other\n    // conjunction is exactly the case the person needs to see grouped.\n    return isGroup(child) && child.children.length > 1 ? `(${text})` : text\n  })\n  return parts.join(node.conjunction === \"and\" ? \" and \" : \" or \")\n}\n\n/* --------------------------------------------------------------- the editor */\n\nexport interface FilterBuilderLabels {\n  /** Sits where the conjunction goes on the first row of a group. */\n  where: string\n  and: string\n  or: string\n  conjunction: string\n  addCondition: string\n  addGroup: string\n  addValue: string\n  removeCondition: (position: number) => string\n  removeGroup: (position: number) => string\n  removeValue: (slot: number, position: number) => string\n  field: (position: number) => string\n  operator: (position: number) => string\n  value: (position: number, slot: number, slots: number) => string\n  chooseField: string\n  group: (position: number, conjunction: FilterConjunction) => string\n  empty: string\n  added: (position: number) => string\n  addedGroup: (position: number) => string\n  removed: (position: number, left: number) => string\n  issue: Record<Exclude<FilterRuleIssue, \"ok\">, string>\n}\n\nexport const defaultFilterBuilderLabels: FilterBuilderLabels = {\n  where: \"Where\",\n  and: \"and\",\n  or: \"or\",\n  conjunction: \"Join conditions with\",\n  addCondition: \"Add condition\",\n  addGroup: \"Add group\",\n  addValue: \"Add value\",\n  removeCondition: (position) => `Remove condition ${position}`,\n  removeGroup: (position) => `Remove group ${position}`,\n  removeValue: (slot, position) => `Remove value ${slot} of condition ${position}`,\n  field: (position) => `Field for condition ${position}`,\n  operator: (position) => `Operator for condition ${position}`,\n  value: (position, slot, slots) =>\n    slots > 1 ? `Value ${slot} for condition ${position}` : `Value for condition ${position}`,\n  chooseField: \"Choose a field\",\n  group: (position, conjunction) =>\n    `Condition group ${position}, matching ${conjunction === \"and\" ? \"all\" : \"any\"} of`,\n  empty: \"No conditions yet.\",\n  added: (position) => `Condition ${position} added`,\n  addedGroup: (position) => `Group ${position} added`,\n  removed: (position, left) =>\n    `Condition ${position} removed, ${left} ${left === 1 ? \"condition\" : \"conditions\"} left`,\n  issue: {\n    no_field: \"Choose a field.\",\n    unknown_field: \"This field is no longer available.\",\n    missing_value: \"Enter a value.\",\n    invalid_number: \"Enter a number.\",\n    invalid_date: \"Enter a date as YYYY-MM-DD.\",\n    invalid_option: \"Choose one of the listed values.\",\n    reversed_range: \"The range is the wrong way round, so it matches nothing.\",\n  },\n}\n\nexport type FilterChange =\n  | { type: \"add-rule\"; id: string }\n  | { type: \"add-group\"; id: string }\n  | { type: \"remove\"; id: string }\n  | { type: \"update\"; id: string }\n  | { type: \"conjunction\"; id: string }\n\nexport interface FilterBuilderProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"fieldset\">,\n    \"children\" | \"onChange\" | \"defaultValue\"\n  > {\n  /** The fields a condition can be built on. The first one is what a new row starts with. */\n  fields: readonly FilterField[]\n  /** Controlled tree. Pair with `onChange`. */\n  value?: FilterGroup\n  /** Starting tree when uncontrolled. Defaults to an empty `and` group. */\n  defaultValue?: FilterGroup\n  onChange?: (next: FilterGroup, change: FilterChange) => void\n  /**\n   * How many levels of group are allowed, counting the outermost as 1. The default of 2 is one\n   * level of nesting, which covers \"all of these, and one of those\" — the shape people actually\n   * build — while keeping the condition readable. Unlimited nesting is what makes a saved filter\n   * nobody can interpret later.\n   */\n  maxDepth?: number\n  /** Optional cap on the number of conditions. */\n  maxRules?: number\n  legend?: React.ReactNode\n  labels?: Partial<FilterBuilderLabels>\n  disabled?: boolean\n  generateId?: () => string\n}\n\nexport function canAddGroupAtDepth(depth: number, maxDepth: number): boolean {\n  return depth + 2 <= maxDepth\n}\n\nconst SELECT_CLASS =\n  \"h-9 min-w-0 rounded-md border border-input bg-transparent px-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n\nconst INPUT_CLASS =\n  \"h-9 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n\n/** What the rest of this registry puts on a field whose content cannot be used. */\nconst INVALID_CLASS = \"border-destructive focus-visible:ring-destructive\"\n\nconst ICON_BUTTON_CLASS =\n  \"inline-flex size-9 flex-none items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n\nconst GHOST_BUTTON_CLASS =\n  \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium shadow-sm 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\nexport function FilterBuilder({\n  fields,\n  value,\n  defaultValue,\n  onChange,\n  maxDepth = 2,\n  maxRules,\n  legend,\n  labels: labelOverrides,\n  disabled,\n  generateId = createFilterId,\n  className,\n  ...props\n}: FilterBuilderProps) {\n  const labels = React.useMemo(\n    () => ({ ...defaultFilterBuilderLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n  const baseId = React.useId()\n  const controlled = value !== undefined\n  const [internal, setInternal] = React.useState<FilterGroup>(\n    () => defaultValue ?? createGroup(\"and\", [], generateId)\n  )\n  const tree = controlled ? value : internal\n  const [message, setMessage] = React.useState(\"\")\n\n  const total = countRules(tree)\n  const canAddRule = maxRules === undefined || total < maxRules\n\n  const commit = (next: FilterGroup, change: FilterChange) => {\n    if (!controlled) setInternal(next)\n    onChange?.(next, change)\n  }\n\n  const fieldByName = (name: string) => fields.find((candidate) => candidate.name === name)\n\n  const addRule = (groupId: string) => {\n    if (!canAddRule) return\n    const rule = createRule(fields[0], generateId)\n    commit(appendChild(tree, groupId, rule), { type: \"add-rule\", id: rule.id })\n    setMessage(labels.added(total + 1))\n  }\n\n  const addGroup = (groupId: string) => {\n    if (!canAddRule) return\n    // A new group starts with one row and the other conjunction, because a nested group that\n    // repeats its parent's conjunction changes nothing about what is selected — nesting is only\n    // worth drawing when the join inside differs from the join outside.\n    const parent = findNode(tree, groupId)\n    const conjunction: FilterConjunction =\n      parent && isGroup(parent) && parent.conjunction === \"and\" ? \"or\" : \"and\"\n    const group = createGroup(conjunction, [createRule(fields[0], generateId)], generateId)\n    commit(appendChild(tree, groupId, group), { type: \"add-group\", id: group.id })\n    setMessage(labels.addedGroup(total + 1))\n  }\n\n  const removeNode = (node: FilterNode, position: number) => {\n    const next = replaceNode(tree, node.id, null)\n    commit(next, { type: \"remove\", id: node.id })\n    setMessage(labels.removed(position, countRules(next)))\n  }\n\n  const updateRule = (rule: FilterRule) => {\n    commit(replaceNode(tree, rule.id, rule), { type: \"update\", id: rule.id })\n  }\n\n  const changeConjunction = (groupId: string, conjunction: FilterConjunction) => {\n    commit(setConjunction(tree, groupId, conjunction), { type: \"conjunction\", id: groupId })\n  }\n\n  const renderValueSlots = (rule: FilterRule, position: number) => {\n    const field = fieldByName(rule.field)\n    const arity = operatorArity(rule.operator)\n    if (arity === 0) return null\n    const issue = ruleIssue(rule, fields)\n    const invalid = issue !== \"ok\" && issue !== \"no_field\" && issue !== \"unknown_field\"\n    const issueId = `${baseId}-${rule.id}-issue`\n\n    const setSlot = (index: number, next: string) => {\n      const values = [...rule.values]\n      values[index] = next\n      updateRule({ ...rule, values })\n    }\n\n    const slot = (index: number, slots: number) => {\n      const shared = {\n        key: index,\n        \"aria-label\": labels.value(position, index + 1, slots),\n        \"aria-invalid\": invalid || undefined,\n        \"aria-describedby\": invalid ? issueId : undefined,\n        disabled,\n      }\n      const kind = fieldKind(field)\n      if (kind === \"enum\") {\n        return (\n          <select\n            {...shared}\n            value={rule.values[index] ?? \"\"}\n            onChange={(event) => setSlot(index, event.target.value)}\n            className={cn(SELECT_CLASS, \"flex-1\", invalid && INVALID_CLASS)}\n          >\n            {field?.options?.map((option) => (\n              <option key={option.value} value={option.value}>\n                {option.label ?? option.value}\n              </option>\n            ))}\n          </select>\n        )\n      }\n      if (kind === \"boolean\") {\n        return (\n          <select\n            {...shared}\n            value={rule.values[index] ?? \"\"}\n            onChange={(event) => setSlot(index, event.target.value)}\n            className={cn(SELECT_CLASS, \"flex-1\", invalid && INVALID_CLASS)}\n          >\n            <option value=\"true\">{\"true\"}</option>\n            <option value=\"false\">{\"false\"}</option>\n          </select>\n        )\n      }\n      return (\n        <input\n          {...shared}\n          type={kind === \"date\" ? \"date\" : \"text\"}\n          // A number field is a text input with a numeric keypad, not `type=\"number\"`. The native\n          // number input discards what it cannot parse before the component ever sees it, so a\n          // half-typed \"1.\" or a pasted \"1,5\" becomes an empty value with nothing said about it,\n          // and the row reads as \"no value entered\" when a value was. Validating here means the\n          // row can say what is wrong with what was actually typed.\n          inputMode={kind === \"number\" ? \"decimal\" : undefined}\n          value={rule.values[index] ?? \"\"}\n          onChange={(event) => setSlot(index, event.target.value)}\n          className={cn(INPUT_CLASS, invalid && INVALID_CLASS)}\n        />\n      )\n    }\n\n    if (arity === 1) return slot(0, 1)\n\n    if (arity === 2) {\n      return (\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          {slot(0, 2)}\n          <span className=\"flex-none text-sm text-muted-foreground\">{labels.and}</span>\n          {slot(1, 2)}\n        </div>\n      )\n    }\n\n    const slots = rule.values.length\n    return (\n      <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n        {rule.values.map((_, index) => (\n          <div key={index} className=\"flex items-center gap-2\">\n            {slot(index, slots)}\n            <button\n              type=\"button\"\n              // The last slot keeps its button but announces itself unavailable: a list that drops\n              // to zero inputs is an operator with nowhere to type, and a button that disappears\n              // under the pointer is worse than one that says why it will not act.\n              aria-disabled={slots <= 1 || undefined}\n              aria-label={labels.removeValue(index + 1, position)}\n              disabled={disabled}\n              onClick={() => {\n                if (slots <= 1) return\n                updateRule({ ...rule, values: rule.values.filter((__, i) => i !== index) })\n              }}\n              className={cn(ICON_BUTTON_CLASS, slots <= 1 && \"pointer-events-none opacity-50\")}\n            >\n              <Trash2 aria-hidden=\"true\" className=\"size-4\" />\n            </button>\n          </div>\n        ))}\n        <button\n          type=\"button\"\n          disabled={disabled}\n          onClick={() => updateRule({ ...rule, values: [...rule.values, blankValue(field)] })}\n          className={cn(GHOST_BUTTON_CLASS, \"self-start\")}\n        >\n          <Plus aria-hidden=\"true\" className=\"size-4\" />\n          {labels.addValue}\n        </button>\n      </div>\n    )\n  }\n\n  const renderRule = (rule: FilterRule, position: number) => {\n    const field = fieldByName(rule.field)\n    const issue = ruleIssue(rule, fields)\n    const issueId = `${baseId}-${rule.id}-issue`\n    const operators = operatorsForField(field)\n    return (\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n          <select\n            aria-label={labels.field(position)}\n            value={rule.field}\n            disabled={disabled}\n            onChange={(event) =>\n              updateRule(retargetRule(rule, fieldByName(event.target.value), field))\n            }\n            className={SELECT_CLASS}\n          >\n            {rule.field === \"\" || !field ? (\n              <option value={rule.field}>{rule.field || labels.chooseField}</option>\n            ) : null}\n            {fields.map((candidate) => (\n              <option key={candidate.name} value={candidate.name}>\n                {fieldLabel(candidate)}\n              </option>\n            ))}\n          </select>\n          <select\n            aria-label={labels.operator(position)}\n            value={rule.operator}\n            disabled={disabled}\n            onChange={(event) =>\n              updateRule(retargetOperator(rule, event.target.value as FilterOperatorId))\n            }\n            className={SELECT_CLASS}\n          >\n            {operators.map((spec) => (\n              <option key={spec.id} value={spec.id}>\n                {operatorLabel(spec, fieldKind(field))}\n              </option>\n            ))}\n          </select>\n          {renderValueSlots(rule, position)}\n        </div>\n        {issue !== \"ok\" ? (\n          <p\n            id={issueId}\n            // Not a live region: a message that speaks while the field is still being typed into\n            // interrupts the typing. It is reachable through aria-describedby on the input it\n            // belongs to, which is what a screen reader reads when focus arrives there.\n            className=\"mt-1 text-sm text-muted-foreground\"\n          >\n            {labels.issue[issue]}\n          </p>\n        ) : null}\n      </div>\n    )\n  }\n\n  const renderGroup = (group: FilterGroup, depth: number): React.ReactNode => {\n    const nestable = canAddGroupAtDepth(depth, maxDepth)\n    return (\n      <div className=\"space-y-2\">\n        {group.children.length === 0 ? (\n          <p className=\"rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground\">\n            {labels.empty}\n          </p>\n        ) : null}\n        {group.children.map((child, index) => (\n          <div key={child.id} className=\"flex min-w-0 items-start gap-2\">\n            <div className=\"flex-none pt-1.5\">\n              {index === 0 ? (\n                <span className=\"inline-block w-16 text-sm text-muted-foreground\">\n                  {labels.where}\n                </span>\n              ) : index === 1 ? (\n                <select\n                  aria-label={labels.conjunction}\n                  value={group.conjunction}\n                  disabled={disabled}\n                  onChange={(event) =>\n                    changeConjunction(group.id, event.target.value as FilterConjunction)\n                  }\n                  className={cn(SELECT_CLASS, \"w-16\")}\n                >\n                  <option value=\"and\">{labels.and}</option>\n                  <option value=\"or\">{labels.or}</option>\n                </select>\n              ) : (\n                // Rows after the second repeat the conjunction as text. One select per group is\n                // the whole point: a select on every row invites \"A and B or C\", which has no\n                // single reading, and the row that changed it would own a decision about rows\n                // above it that the person never made.\n                <span className=\"inline-block w-16 text-sm text-muted-foreground\">\n                  {group.conjunction === \"and\" ? labels.and : labels.or}\n                </span>\n              )}\n            </div>\n            {isGroup(child) ? (\n              <div\n                role=\"group\"\n                aria-label={labels.group(index + 1, child.conjunction)}\n                className=\"min-w-0 flex-1 rounded-md border border-dashed border-input p-3\"\n              >\n                {renderGroup(child, depth + 1)}\n              </div>\n            ) : (\n              renderRule(child, index + 1)\n            )}\n            <button\n              type=\"button\"\n              aria-label={\n                isGroup(child) ? labels.removeGroup(index + 1) : labels.removeCondition(index + 1)\n              }\n              disabled={disabled}\n              onClick={() => removeNode(child, index + 1)}\n              className={ICON_BUTTON_CLASS}\n            >\n              <Trash2 aria-hidden=\"true\" className=\"size-4\" />\n            </button>\n          </div>\n        ))}\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <button\n            type=\"button\"\n            aria-disabled={!canAddRule || undefined}\n            disabled={disabled}\n            onClick={() => addRule(group.id)}\n            className={cn(GHOST_BUTTON_CLASS, !canAddRule && \"pointer-events-none opacity-50\")}\n          >\n            <Plus aria-hidden=\"true\" className=\"size-4\" />\n            {labels.addCondition}\n          </button>\n          {nestable ? (\n            <button\n              type=\"button\"\n              aria-disabled={!canAddRule || undefined}\n              disabled={disabled}\n              onClick={() => addGroup(group.id)}\n              className={cn(GHOST_BUTTON_CLASS, !canAddRule && \"pointer-events-none opacity-50\")}\n            >\n              <Plus aria-hidden=\"true\" className=\"size-4\" />\n              {labels.addGroup}\n            </button>\n          ) : null}\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <fieldset\n      {...props}\n      disabled={disabled}\n      // min-w-0 because a fieldset is as wide as its content by default, which lets one long row\n      // stretch the form instead of wrapping inside it.\n      className={cn(\"min-w-0\", className)}\n    >\n      {legend ? <legend className=\"mb-2 text-base font-medium\">{legend}</legend> : null}\n      {renderGroup(tree, 0)}\n      {/* Adding and removing rows change no value, so nothing else here is announced. */}\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </div>\n    </fieldset>\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"
}
