{"slug":"describe-filters","category":"tables","job":"ask","added":"2026-11-03T10:55Z","title":"Describe Filters","description":"A Describe button in a table toolbar that turns a plain-language request into removable filter chips — column filters where a column exists, marked semantic matches where none does.","url":"https://ai-patterns.app/patterns/tables/describe-filters","markdown":"https://ai-patterns.app/patterns/tables/describe-filters.md","json":"https://ai-patterns.app/api/patterns/tables/describe-filters","seenIn":[{"team":"Linear","source":"https://linear.app/docs/filters"}],"spec":"# Describe Filters\n\n## Summary\nA \"Describe\" button in a table's toolbar that turns into a free-text field where the user says, in plain language, which rows they want (\"Show me patterns for agent oversight\"). The model translates the description into ordinary, removable filter chips. When a request maps onto a real column it becomes a column filter (Status · is · Beta); when no column covers it, it becomes a semantic filter (About · matches · agent oversight) that matches rows by meaning, visibly marked as such.\n\n## When to use\n- Tables with many columns or operators, where building a filter by hand takes several clicks.\n- Requests that cut across the schema — topics, intents, or qualities that no single column captures (\"gear for video calls\", \"customers at risk\", \"patterns for agent oversight\").\n- Alongside the existing keyword search and manual filter UI, as a faster path — never as the only way to filter.\n\n## When not to use\n- Small tables where a single dropdown or the keyword search already does the job.\n- When results must be exact and auditable (financial reconciliation, compliance exports) and you can't express everything as column filters — don't let a semantic match silently decide what's in the set.\n- As a chat interface. The output is filters on this table, not an answer or a summary.\n\n## Anatomy\n- **Describe button** — a pill with a sparkle icon, first in the toolbar, next to keyword search.\n- **Describe field** — replaces the toolbar in place: cancel (×), text input, submit arrow.\n- **Suggestions** — a short list of example descriptions that expands inline under the field (pushing the table down, never overlaying it), shown only while the field is empty.\n- **Pending state** — inline \"Building filters\" status in the field; the table dims but keeps its rows.\n- **Filter chips** — Field · operator · value, each removable. Chips from the last description carry a small sparkle; semantic chips use a dashed border and an italic field name.\n- **Interpretation bar** — a thin line under the toolbar: the description that produced the filters, the resulting row count (\"6 of 24\"), and an Edit action.\n- **Empty state** — \"No rows match these filters\" with an \"Edit description\" action.\n\n## Behavior\n- Clicking Describe swaps the toolbar for the describe field with a short cross-fade and focuses the input. Esc or × restores the toolbar and returns focus to the Describe button.\n- Enter (or a suggestion click) submits. While pending, the input is read-only and the table dims; existing rows stay visible so the layout doesn't jump.\n- On success, the toolbar returns and the new chips animate in with a small stagger, then rows filter out with a fade and the remaining rows slide up.\n- A new description replaces the chips from the previous description but keeps filters the user added by hand.\n- Every generated chip is an ordinary filter: removable one by one, with \"Clear all\" when more than one is active. Removing the last generated chip hides the interpretation bar.\n- Prefer column filters. Only fall back to a semantic filter when no column, or combination of columns, expresses the request — and say so on the chip (tooltip: \"No column covers this — rows were matched by meaning\").\n- Edit (in the interpretation bar or the empty state) reopens the describe field pre-filled with the last description.\n- On failure, keep the field open with the text intact and show an inline error under it — never clear what the user typed.\n\n## Content guidelines\n- Button label: \"Describe\". Placeholder speaks in the user's voice and names the table's subject: \"Show me patterns for…\", \"Show me orders where…\".\n- Suggestions should demonstrate range: one that needs a semantic match, one that combines a concept with a column, one that only uses columns.\n- Chip values are short and literal: the column's own vocabulary and formatting for column filters (\"Oct 1, 2026\", not \"the start of October\"); a 1–3 word concept for semantic ones.\n- The interpretation bar quotes the description verbatim — don't rewrite what the user said.\n\n## Accessibility\n- The Describe button, chip remove buttons, and Edit are real `<button>`s; each remove button is labelled with the full filter (\"Remove filter: Status is Beta\").\n- Active filters are a labelled list. The generated-chip sparkle has an accessible label (\"Generated\"); the semantic marker isn't conveyed by the dashed border alone — the italic field name and tooltip back it up, and the field name itself (\"About\") says it's not a column.\n- The pending status is a `role=\"status\"` region and the table carries `aria-busy` while pending; the interpretation bar is `aria-live=\"polite\"` so the new row count is announced.\n- Focus moves into the field on open and back to the Describe button on close or apply.\n- Animations are short opacity/position transitions; respect `prefers-reduced-motion` by dropping the stagger and row slide.\n\n## Related patterns\n- **Command Palette** — Use it instead when people want to jump to a known item by keyword rather than narrow a set by intent.\n- **Natural Language Query** — Use it instead for people who can read SQL and want the query itself.\n- **Refinement Controls** — Use it instead when the free-form instruction changes generated text rather than a table.\n- **Confidence Indicator** — Use it as well on semantic chips when the model can report how sure the match is.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ArrowUp, ListFilter, Search, Sparkles, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface DescribeFiltersColumn<Row> {\n  key: string;\n  label: string;\n  /** Renders the cell; defaults to `String(row[key])`. */\n  render?: (row: Row) => React.ReactNode;\n  className?: string;\n}\n\nexport interface DescribedFilter<Row> {\n  id: string;\n  /** Column label, or a concept label like \"About\" when no column covers the request. */\n  field: string;\n  operator: string;\n  value: string;\n  /**\n   * `column` filters map onto a real column. `semantic` filters have no\n   * column behind them — the model matched rows by meaning — so the chip is\n   * marked differently and the user knows it can't be edited like a column.\n   */\n  kind: \"column\" | \"semantic\";\n  test: (row: Row) => boolean;\n}\n\nexport interface DescribeResult<Row> {\n  filters: DescribedFilter<Row>[];\n}\n\nexport interface DescribeFiltersTableProps<Row extends { id: string }> {\n  columns: DescribeFiltersColumn<Row>[];\n  rows: Row[];\n  /** Turns a plain-language description into filters. */\n  onDescribe: (description: string) => Promise<DescribeResult<Row>>;\n  /** Used by the plain keyword search box. */\n  searchText?: (row: Row) => string;\n  /** Example descriptions shown while the describe field is empty. */\n  suggestions?: string[];\n  initialFilters?: DescribedFilter<Row>[];\n  describePlaceholder?: string;\n  className?: string;\n}\n\nexport function DescribeFiltersTable<Row extends { id: string }>({\n  columns,\n  rows,\n  onDescribe,\n  searchText,\n  suggestions = [],\n  initialFilters = [],\n  describePlaceholder = \"Describe the rows you want to see…\",\n  className,\n}: DescribeFiltersTableProps<Row>) {\n  const [mode, setMode] = React.useState<\"idle\" | \"describing\" | \"pending\">(\"idle\");\n  const [draft, setDraft] = React.useState(\"\");\n  const [lastDescription, setLastDescription] = React.useState<string | null>(null);\n  const [filters, setFilters] = React.useState(initialFilters);\n  /** Ids of filters produced by the last description — they get the AI marker. */\n  const [generatedIds, setGeneratedIds] = React.useState<Set<string>>(new Set());\n  const [query, setQuery] = React.useState(\"\");\n  const [error, setError] = React.useState<string | null>(null);\n\n  const describeRef = React.useRef<HTMLInputElement>(null);\n  const describeButtonRef = React.useRef<HTMLButtonElement>(null);\n  const restoreFocus = React.useRef(false);\n\n  React.useEffect(() => {\n    if (mode === \"describing\") describeRef.current?.focus();\n    if (mode === \"idle\" && restoreFocus.current) {\n      restoreFocus.current = false;\n      describeButtonRef.current?.focus();\n    }\n  }, [mode]);\n\n  const visibleRows = React.useMemo(() => {\n    const q = query.trim().toLowerCase();\n    return rows.filter(\n      (row) =>\n        filters.every((f) => f.test(row)) && (!q || (searchText?.(row) ?? \"\").toLowerCase().includes(q))\n    );\n  }, [rows, filters, query, searchText]);\n\n  function openDescribe(prefill = \"\") {\n    setDraft(prefill);\n    setError(null);\n    setMode(\"describing\");\n  }\n\n  function cancelDescribe() {\n    restoreFocus.current = true;\n    setMode(\"idle\");\n  }\n\n  async function submit(description: string) {\n    const trimmed = description.trim();\n    if (!trimmed || mode === \"pending\") return;\n    setDraft(trimmed);\n    setMode(\"pending\");\n    setError(null);\n    try {\n      const result = await onDescribe(trimmed);\n      // A description replaces the previous generated set but keeps filters\n      // the user added by hand.\n      setFilters((prev) => [...prev.filter((f) => !generatedIds.has(f.id)), ...result.filters]);\n      setGeneratedIds(new Set(result.filters.map((f) => f.id)));\n      setLastDescription(trimmed);\n      restoreFocus.current = true;\n      setMode(\"idle\");\n    } catch {\n      setError(\"Couldn't turn that into filters. Try rephrasing.\");\n      setMode(\"describing\");\n    }\n  }\n\n  function removeFilter(id: string) {\n    setFilters((prev) => prev.filter((f) => f.id !== id));\n    setGeneratedIds((prev) => {\n      const next = new Set(prev);\n      next.delete(id);\n      if (next.size === 0) setLastDescription(null);\n      return next;\n    });\n  }\n\n  function clearAll() {\n    setFilters([]);\n    setGeneratedIds(new Set());\n    setLastDescription(null);\n  }\n\n  const busy = mode === \"pending\";\n  const showSuggestions = mode === \"describing\" && !draft && suggestions.length > 0;\n\n  return (\n    <div className={cn(\"w-full overflow-hidden rounded-2xl border bg-card text-sm shadow-sm\", className)}>\n      {/* Toolbar */}\n      <div className=\"border-b\">\n        <div className=\"flex min-h-14 items-center gap-2 px-3 py-2.5\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {mode === \"idle\" ? (\n              <motion.div\n                key=\"toolbar\"\n                initial={{ opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: -4 }}\n                transition={{ duration: 0.15 }}\n                className=\"flex min-w-0 flex-1 flex-wrap items-center gap-2\"\n              >\n                <button\n                  ref={describeButtonRef}\n                  type=\"button\"\n                  onClick={() => openDescribe()}\n                  className=\"flex h-8 shrink-0 items-center gap-1.5 rounded-full border bg-background px-3 font-medium transition-colors hover:bg-accent\"\n                >\n                  <Sparkles className=\"size-3.5\" aria-hidden />\n                  Describe\n                </button>\n\n                <label className=\"flex h-8 w-40 shrink-0 items-center gap-2 rounded-full border bg-background px-3 text-muted-foreground focus-within:ring-2 focus-within:ring-ring/40\">\n                  <Search className=\"size-3.5 shrink-0\" aria-hidden />\n                  <input\n                    value={query}\n                    onChange={(e) => setQuery(e.target.value)}\n                    placeholder=\"Search\"\n                    aria-label=\"Search rows\"\n                    className=\"min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground\"\n                  />\n                </label>\n\n                {filters.length > 0 && (\n                  <span className=\"flex size-8 shrink-0 items-center justify-center text-muted-foreground\" aria-hidden>\n                    <ListFilter className=\"size-4\" />\n                  </span>\n                )}\n\n                <ul className=\"contents\" aria-label=\"Active filters\">\n                  <AnimatePresence initial={false}>\n                    {filters.map((f, i) => (\n                      <FilterChip\n                        key={f.id}\n                        filter={f}\n                        generated={generatedIds.has(f.id)}\n                        delay={generatedIds.has(f.id) ? i * 0.06 : 0}\n                        onRemove={() => removeFilter(f.id)}\n                      />\n                    ))}\n                  </AnimatePresence>\n                </ul>\n\n                {filters.length > 1 && (\n                  <button\n                    type=\"button\"\n                    onClick={clearAll}\n                    className=\"h-8 shrink-0 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n                  >\n                    Clear all\n                  </button>\n                )}\n              </motion.div>\n            ) : (\n              <motion.form\n                key=\"describe\"\n                initial={{ opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: -4 }}\n                transition={{ duration: 0.15 }}\n                onSubmit={(e) => {\n                  e.preventDefault();\n                  submit(draft);\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === \"Escape\" && !busy) {\n                    e.preventDefault();\n                    cancelDescribe();\n                  }\n                }}\n                className=\"flex min-w-0 flex-1 items-center gap-2\"\n              >\n                <button\n                  type=\"button\"\n                  onClick={cancelDescribe}\n                  disabled={busy}\n                  aria-label=\"Cancel describing\"\n                  className=\"flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40\"\n                >\n                  <X className=\"size-4\" />\n                </button>\n                <div className=\"relative min-w-0 flex-1\">\n                  <input\n                    ref={describeRef}\n                    value={draft}\n                    onChange={(e) => {\n                      setDraft(e.target.value);\n                      setError(null);\n                    }}\n                    readOnly={busy}\n                    placeholder={describePlaceholder}\n                    aria-label=\"Describe filters\"\n                    aria-describedby={error ? \"describe-filters-error\" : undefined}\n                    className={cn(\n                      \"h-8 w-full bg-transparent outline-none placeholder:text-muted-foreground\",\n                      busy && \"text-muted-foreground\"\n                    )}\n                  />\n                </div>\n                {busy ? (\n                  <span className=\"flex shrink-0 items-center gap-1.5 pr-1 text-xs text-muted-foreground\" role=\"status\">\n                    <Sparkles className=\"size-3.5 animate-spin [animation-duration:2s]\" aria-hidden />\n                    <span className=\"animate-pulse\">Building filters</span>\n                  </span>\n                ) : (\n                  <button\n                    type=\"submit\"\n                    disabled={!draft.trim()}\n                    aria-label=\"Apply description\"\n                    className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-30\"\n                  >\n                    <ArrowUp className=\"size-4\" />\n                  </button>\n                )}\n              </motion.form>\n            )}\n          </AnimatePresence>\n        </div>\n\n        {/* Suggestions & errors expand inline under the field and push the\n            table down — an overlay would cover the header and spill out of\n            the card when the table is short. */}\n        <AnimatePresence initial={false}>\n          {(showSuggestions || error) && (\n            <motion.div\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0 }}\n              transition={{ duration: 0.18 }}\n              className=\"overflow-hidden\"\n            >\n              {error ? (\n                <p id=\"describe-filters-error\" role=\"alert\" className=\"px-4 pb-3 text-xs text-destructive\">\n                  {error}\n                </p>\n              ) : (\n                <div className=\"px-2 pb-2\">\n                  <p className=\"px-3 pb-1 text-xs text-muted-foreground\">Try</p>\n                  {suggestions.map((s) => (\n                    <button\n                      key={s}\n                      type=\"button\"\n                      onClick={() => submit(s)}\n                      className=\"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent\"\n                    >\n                      <Sparkles className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden />\n                      {s}\n                    </button>\n                  ))}\n                </div>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* Interpretation line: what was asked, and how to revise it. */}\n      <AnimatePresence initial={false}>\n        {lastDescription && mode === \"idle\" && (\n          <motion.div\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={{ duration: 0.18 }}\n            className=\"overflow-hidden border-b bg-muted/40\"\n          >\n            <p className=\"flex items-center gap-1.5 px-4 py-2 text-xs text-muted-foreground\" aria-live=\"polite\">\n              <Sparkles className=\"size-3 shrink-0\" aria-hidden />\n              <span className=\"min-w-0 truncate\">\n                Filtered for <span className=\"text-foreground\">“{lastDescription}”</span> · {visibleRows.length} of{\" \"}\n                {rows.length}\n              </span>\n              <button\n                type=\"button\"\n                onClick={() => openDescribe(lastDescription)}\n                className=\"ml-auto shrink-0 font-medium text-foreground underline-offset-2 hover:underline\"\n              >\n                Edit\n              </button>\n            </p>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {/* Table */}\n      <div className=\"max-h-80 overflow-auto\">\n        <table className=\"w-full border-collapse\" aria-busy={busy}>\n          <thead className=\"sticky top-0 z-[1] bg-card\">\n            <tr className=\"border-b text-left text-xs text-muted-foreground\">\n              {columns.map((c) => (\n                <th key={c.key} scope=\"col\" className={cn(\"px-4 py-2.5 font-medium\", c.className)}>\n                  {c.label}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <motion.tbody animate={{ opacity: busy ? 0.4 : 1 }} transition={{ duration: 0.2 }}>\n            <AnimatePresence initial={false}>\n              {visibleRows.map((row) => (\n                <motion.tr\n                  key={row.id}\n                  layout=\"position\"\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 0.18 }}\n                  className=\"border-b last:border-b-0\"\n                >\n                  {columns.map((c) => (\n                    <td key={c.key} className={cn(\"px-4 py-2.5\", c.className)}>\n                      {c.render ? c.render(row) : String((row as Record<string, unknown>)[c.key] ?? \"\")}\n                    </td>\n                  ))}\n                </motion.tr>\n              ))}\n            </AnimatePresence>\n          </motion.tbody>\n        </table>\n        {visibleRows.length === 0 && (\n          <div className=\"flex flex-col items-center gap-2 px-4 py-10 text-center\">\n            <p className=\"text-muted-foreground\">No rows match these filters.</p>\n            {lastDescription && (\n              <button\n                type=\"button\"\n                onClick={() => openDescribe(lastDescription)}\n                className=\"text-xs font-medium underline-offset-2 hover:underline\"\n              >\n                Edit description\n              </button>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction FilterChip<Row>({\n  filter,\n  generated,\n  delay,\n  onRemove,\n}: {\n  filter: DescribedFilter<Row>;\n  generated: boolean;\n  delay: number;\n  onRemove: () => void;\n}) {\n  const semantic = filter.kind === \"semantic\";\n  const label = `${filter.field} ${filter.operator} ${filter.value}`;\n  return (\n    <motion.li\n      layout\n      initial={{ opacity: 0, scale: 0.92 }}\n      animate={{ opacity: 1, scale: 1, transition: { delay, duration: 0.18 } }}\n      exit={{ opacity: 0, scale: 0.92, transition: { duration: 0.12 } }}\n      title={semantic ? \"No column covers this — rows were matched by meaning\" : undefined}\n      className={cn(\n        \"flex h-8 shrink-0 items-center gap-1.5 rounded-full border bg-background pl-3 pr-1\",\n        semantic && \"border-dashed\"\n      )}\n    >\n      {generated && <Sparkles className=\"size-3 shrink-0 text-muted-foreground\" aria-label=\"Generated\" />}\n      <span className={cn(semantic && \"italic\")}>{filter.field}</span>\n      <span className=\"text-muted-foreground\">· {filter.operator} ·</span>\n      <span className=\"max-w-48 truncate\">{filter.value}</span>\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        aria-label={`Remove filter: ${label}`}\n        className=\"flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n      >\n        <X className=\"size-3.5\" />\n      </button>\n    </motion.li>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}