{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "smart-search",
  "type": "registry:component",
  "title": "Smart Search",
  "description": "One search box that speaks filter: type status:open owner:me priority>2 webhook and it becomes chips and a filtered table, with Tab to complete fields and values, minus to negate, ranges like priority:2..3, and typos flagged right on the chip.",
  "categories": [
    "component",
    "Filters"
  ],
  "dependencies": [
    "@qnot/core",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/kbd.json"
  ],
  "files": [
    {
      "path": "registry/devl/smart-search.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  compile,\n  defineFields,\n  f,\n  fieldLabel,\n  parseInput,\n  type PathIssue,\n  type Query,\n  type QueryNode,\n  type QueryOf,\n  type ValueOf,\n  validateQuery,\n} from \"@qnot/core\";\nimport { CornerDownLeftIcon, SearchIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { cn } from \"@/lib/utils\";\n\nexport const ticketFields = defineFields({\n  status: f.enum([\n    { value: \"open\", label: \"Open\" },\n    { value: \"pending\", label: \"Pending\" },\n    { value: \"solved\", label: \"Solved\" },\n    { value: \"closed\", label: \"Closed\" },\n  ]),\n  owner: f.enum([\n    { value: \"riley\", label: \"Riley\" },\n    { value: \"noor\", label: \"Noor\" },\n    { value: \"sam\", label: \"Sam\" },\n    { value: \"kai\", label: \"Kai\" },\n    { value: \"none\", label: \"Nobody\" },\n  ]),\n  priority: f.number({ min: 1, max: 4, integer: true, description: \"1 low, 2 normal, 3 high, 4 urgent\" }),\n  channel: f.enum([\n    { value: \"email\", label: \"Email\" },\n    { value: \"chat\", label: \"Chat\" },\n    { value: \"phone\", label: \"Phone\" },\n  ]),\n  customer: f.text(),\n  title: f.text(),\n});\n\ntype TicketFields = typeof ticketFields;\n\nexport interface Ticket {\n  id: string;\n  title: string;\n  customer: string;\n  status: ValueOf<TicketFields[\"status\"]>;\n  owner: ValueOf<TicketFields[\"owner\"]>;\n  priority: number;\n  channel: ValueOf<TicketFields[\"channel\"]>;\n  updated: string;\n}\n\nexport interface SmartSearchProps {\n  tickets: readonly Ticket[];\n  me?: string;\n  defaultValue?: string;\n  examples?: string[];\n  onQueryChange?: (query: Query) => void;\n  className?: string;\n}\n\ninterface Token {\n  raw: string;\n  start: number;\n  end: number;\n  node: QueryNode;\n  label: string;\n}\n\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst glide = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst priorityNames = [\"Low\", \"Normal\", \"High\", \"Urgent\"];\nconst fieldNames = Object.keys(ticketFields) as (keyof TicketFields & string)[];\nconst opWords: Record<string, string> = { gt: \">\", gte: \"≥\", lt: \"<\", lte: \"≤\", eq: \"is\", neq: \"is not\", in: \"is any of\", notIn: \"is none of\", contains: \"has\", notContains: \"lacks\", between: \"between\" };\n\nfunction splitTokens(text: string) {\n  const out: { raw: string; start: number; end: number }[] = [];\n  const re = /(-?[\\w.]+(?::|>=|<=|>|<)\"[^\"]*\"?|\"[^\"]*\"?|\\S+)/g;\n  for (let m = re.exec(text); m; m = re.exec(text)) out.push({ raw: m[0], start: m.index, end: m.index + m[0].length });\n  return out;\n}\n\nfunction enumValue(field: keyof TicketFields, raw: string, me: string) {\n  if (field === \"owner\" && raw.toLowerCase() === \"me\") return me;\n  const def = ticketFields[field];\n  const options = (def.options ?? []) as readonly { value: string; label: string }[];\n  return options.find((o) => o.value === raw.toLowerCase() || o.label.toLowerCase() === raw.toLowerCase())?.value ?? raw;\n}\n\nfunction toToken(part: { raw: string; start: number; end: number }, me: string): Token {\n  const m = /^(-?)([\\w.]+)(:|>=|<=|>|<)(.*)$/.exec(part.raw);\n  if (!m) {\n    const text = part.raw.replace(/^\"|\"$/g, \"\");\n    return { ...part, node: { kind: \"rule\", field: \"title\", operator: \"contains\", value: text }, label: \"Title\" };\n  }\n  const [, neg, key, sign, rest] = m;\n  const field = key as keyof TicketFields;\n  const raw = rest.replace(/^\"|\"$/g, \"\");\n  const def = ticketFields[field];\n  const negate = neg === \"-\";\n  if (!def) return { ...part, node: { kind: \"rule\", field: key, operator: \"eq\", value: raw }, label: key };\n  const kind = def.kind;\n  if (sign !== \":\") {\n    const op = { \">\": \"gt\", \">=\": \"gte\", \"<\": \"lt\", \"<=\": \"lte\" }[sign] as string;\n    const parsed = parseInput(def, raw);\n    return { ...part, node: { kind: \"rule\", field, operator: op, value: parsed ?? (raw || null) }, label: fieldLabel(field, def) };\n  }\n  if (kind === \"number\" && raw.includes(\"..\")) {\n    const [a, b] = raw.split(\"..\").map((v) => parseInput(def, v) ?? (v || null));\n    return { ...part, node: { kind: \"rule\", field, operator: negate ? \"notBetween\" : \"between\", value: [a, b] }, label: fieldLabel(field, def) };\n  }\n  if (kind === \"enum\") {\n    const values = raw.split(\",\").filter(Boolean).map((v) => enumValue(field, v, me));\n    const many = values.length > 1;\n    return {\n      ...part,\n      node: {\n        kind: \"rule\",\n        field,\n        operator: many ? (negate ? \"notIn\" : \"in\") : negate ? \"neq\" : \"eq\",\n        value: many ? values : (values[0] ?? null),\n      },\n      label: fieldLabel(field, def),\n    };\n  }\n  if (kind === \"number\") {\n    return { ...part, node: { kind: \"rule\", field, operator: negate ? \"neq\" : \"eq\", value: parseInput(def, raw) ?? (raw || null) }, label: fieldLabel(field, def) };\n  }\n  return { ...part, node: { kind: \"rule\", field, operator: negate ? \"notContains\" : \"contains\", value: raw || null }, label: fieldLabel(field, def) };\n}\n\nfunction describeValue(node: QueryNode, me: string) {\n  if (node.kind !== \"rule\" || !(\"value\" in node)) return \"\";\n  const field = node.field as keyof TicketFields;\n  const def = ticketFields[field];\n  const show = (v: unknown) => {\n    if (v === null || v === undefined || v === \"\") return \"…\";\n    if (field === \"owner\" && v === me) return \"me\";\n    if (field === \"priority\" && typeof v === \"number\" && priorityNames[v - 1]) return priorityNames[v - 1];\n    const option = (def?.options as readonly { value: unknown; label: string }[] | undefined)?.find((o) => o.value === v);\n    return option?.label ?? String(v);\n  };\n  const v = node.value;\n  if (Array.isArray(v)) return node.operator.includes(\"etween\") ? `${show(v[0])}–${show(v[1])}` : v.map(show).join(\", \");\n  return show(v);\n}\n\nfunction suggestionsFor(token: string, me: string) {\n  const m = /^(-?)([\\w.]*)(:?)(.*)$/.exec(token);\n  if (!m) return [];\n  const [, neg, key, colon, rest] = m;\n  if (!colon) {\n    return fieldNames\n      .filter((name) => name.startsWith(key.toLowerCase()) && name !== \"title\")\n      .map((name) => ({ insert: `${neg}${name}:`, label: `${neg}${name}:`, hint: ticketFields[name].description ?? fieldLabel(name, ticketFields[name]) }));\n  }\n  const def = ticketFields[key as keyof TicketFields];\n  if (!def?.options) return [];\n  const done = rest.split(\",\");\n  const partial = done.pop() ?? \"\";\n  const prefix = `${neg}${key}:${done.length ? `${done.join(\",\")},` : \"\"}`;\n  const options = (def.options as readonly { value: string; label: string }[]).map((o) => ({ value: o.value, label: o.label }));\n  if (key === \"owner\") options.unshift({ value: \"me\", label: `Me (${options.find((o) => o.value === me)?.label ?? me})` });\n  return options\n    .filter((o) => o.value.startsWith(partial.toLowerCase()) && !(key === \"owner\" && o.value === me))\n    .map((o) => ({ insert: `${prefix}${o.value} `, label: `${prefix}${o.value}`, hint: o.label }));\n}\n\nexport function SmartSearch({\n  tickets,\n  me = \"riley\",\n  defaultValue = \"\",\n  examples = [],\n  onQueryChange,\n  className,\n}: SmartSearchProps) {\n  const reduce = useReducedMotion();\n  const inputId = useId();\n  const listId = useId();\n  const input = useRef<HTMLInputElement>(null);\n  const [text, setText] = useState(defaultValue);\n  const [caret, setCaret] = useState(defaultValue.length);\n  const [focused, setFocused] = useState(false);\n  const [active, setActive] = useState(0);\n  const [dismissed, setDismissed] = useState(false);\n\n  const tokens = useMemo(() => splitTokens(text).map((p) => toToken(p, me)), [text, me]);\n  const query = useMemo<Query>(() => ({ kind: \"group\", combinator: \"and\", children: tokens.map((t) => t.node) }), [tokens]);\n  const issues = useMemo(() => validateQuery(ticketFields, query), [query]);\n  const issueAt = (index: number): PathIssue | undefined =>\n    issues.find((i) => i.path[0] === index && i.severity === \"error\" && !(i.code === \"required\" && tokens[index]?.end === caret));\n\n  const results = useMemo(\n    () => tickets.filter(compile<TicketFields, Ticket>(ticketFields, query as QueryOf<TicketFields>, { invalidRules: \"skip\" })),\n    [tickets, query],\n  );\n  useEffect(() => {\n    onQueryChange?.(query);\n  }, [query, onQueryChange]);\n\n  const current = useMemo(() => {\n    const before = text.slice(0, caret);\n    const start = before.search(/\\S*$/);\n    const after = text.slice(caret).search(/\\s|$/);\n    return { start, end: caret + after, value: text.slice(start, caret + after) };\n  }, [text, caret]);\n  const suggestions = useMemo(() => suggestionsFor(current.value, me).slice(0, 6), [current.value, me]);\n  const open =\n    focused &&\n    !dismissed &&\n    suggestions.length > 0 &&\n    (current.value !== \"\" || text.trim() === \"\") && !(suggestions.length === 1 && suggestions[0].label === current.value.trim());\n\n  const commit = (next: string, nextCaret: number) => {\n    setText(next);\n    setCaret(nextCaret);\n    setActive(0);\n    setDismissed(false);\n    requestAnimationFrame(() => input.current?.setSelectionRange(nextCaret, nextCaret));\n  };\n\n  const accept = (index: number) => {\n    const s = suggestions[index];\n    if (!s) return;\n    const next = text.slice(0, current.start) + s.insert + text.slice(current.end).replace(/^\\s+/, \"\");\n    commit(next, current.start + s.insert.length);\n  };\n\n  const removeToken = (token: Token) => {\n    const next = (text.slice(0, token.start) + text.slice(token.end)).replace(/\\s{2,}/g, \" \").trimStart();\n    commit(next, Math.min(token.start, next.length));\n    input.current?.focus();\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (open && (e.key === \"ArrowDown\" || e.key === \"ArrowUp\")) {\n      e.preventDefault();\n      setActive((a) => (a + (e.key === \"ArrowDown\" ? 1 : -1) + suggestions.length) % suggestions.length);\n    } else if (open && (e.key === \"Enter\" || e.key === \"Tab\")) {\n      e.preventDefault();\n      accept(active);\n    } else if (e.key === \"Escape\") {\n      if (open) setDismissed(true);\n      else if (text) commit(\"\", 0);\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full min-w-0 max-w-[620px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\",\n        className,\n      )}\n    >\n      <div className=\"relative px-3 pt-3\">\n        <label className=\"sr-only\" htmlFor={inputId}>\n          Search tickets\n        </label>\n        <div className=\"relative flex h-10 items-center gap-2 rounded-xl border border-input bg-background px-3 shadow-xs/5 ring-ring/24 transition-shadow has-focus-visible:border-ring has-focus-visible:ring-[3px] dark:bg-input/32\">\n          <SearchIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n          <input\n            aria-activedescendant={open ? `${listId}-${active}` : undefined}\n            aria-autocomplete=\"list\"\n            aria-controls={listId}\n            aria-expanded={open}\n            autoCapitalize=\"off\"\n            autoComplete=\"off\"\n            className=\"h-full min-w-0 flex-1 bg-transparent font-mono text-base outline-none sm:text-[13px] placeholder:font-sans placeholder:text-muted-foreground/72\"\n            id={inputId}\n            onBlur={() => setFocused(false)}\n            onChange={(e) => {\n              setText(e.target.value);\n              setCaret(e.target.selectionStart ?? e.target.value.length);\n              setActive(0);\n              setDismissed(false);\n            }}\n            onFocus={() => setFocused(true)}\n            onKeyDown={onKeyDown}\n            onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)}\n            placeholder=\"status:open owner:me priority>2\"\n            ref={input}\n            role=\"combobox\"\n            spellCheck={false}\n            value={text}\n          />\n          {text ? (\n            <button\n              aria-label=\"Clear search\"\n              className=\"flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={() => commit(\"\", 0)}\n              type=\"button\"\n            >\n              <XIcon className=\"size-3.5\" />\n            </button>\n          ) : null}\n        </div>\n        <AnimatePresence>\n          {open && (\n            <motion.ul\n              animate={{ opacity: 1, scale: 1, y: 0 }}\n              className=\"absolute inset-x-3 top-full z-20 mt-1 origin-top overflow-hidden rounded-xl border bg-popover p-1 shadow-lg/5\"\n              exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.1 } }}\n              id={listId}\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.98, y: reduce ? 0 : -2 }}\n              role=\"listbox\"\n              transition={{ duration: 0.14, ease: easeOut }}\n            >\n              {suggestions.map((s, i) => (\n                <li\n                  aria-selected={i === active}\n                  className={cn(\n                    \"flex h-8 cursor-default items-center gap-3 rounded-md px-2 text-xs\",\n                    i === active && \"bg-accent text-accent-foreground\",\n                  )}\n                  id={`${listId}-${i}`}\n                  key={s.label}\n                  onMouseDown={(e) => {\n                    e.preventDefault();\n                    accept(i);\n                  }}\n                  onMouseMove={() => setActive(i)}\n                  role=\"option\"\n                >\n                  <span className=\"font-mono text-[12px]\">{s.label}</span>\n                  <span className=\"min-w-0 flex-1 truncate text-muted-foreground\">{s.hint}</span>\n                  {i === active && <CornerDownLeftIcon className=\"size-3 shrink-0 text-muted-foreground\" />}\n                </li>\n              ))}\n            </motion.ul>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"flex min-h-11 flex-wrap items-center gap-1.5 px-3 py-2\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {tokens.map((token, i) => {\n            const issue = issueAt(i);\n            const node = token.node;\n            const op = node.kind === \"rule\" ? (opWords[node.operator] ?? node.operator) : \"\";\n            return (\n              <motion.span\n                animate={{ opacity: 1, scale: 1 }}\n                className={cn(\n                  \"flex h-6 max-w-full items-center gap-1 rounded-md border ps-1.5 text-xs\",\n                  issue\n                    ? \"border-destructive/32 bg-destructive/6 text-destructive-foreground dark:bg-destructive/12\"\n                    : \"border-transparent bg-muted\",\n                )}\n                exit={{ opacity: 0, scale: 0.95, transition: { duration: reduce ? 0 : 0.1 } }}\n                initial={{ opacity: 0, scale: reduce ? 1 : 0.95 }}\n                key={token.start}\n                layout={reduce ? false : \"position\"}\n                title={issue?.message}\n                transition={reduce ? { duration: 0 } : { ...glide, opacity: { duration: 0.12 } }}\n              >\n                <span className={cn(\"font-medium\", !issue && \"text-foreground\")}>{token.label}</span>\n                <span className={issue ? \"\" : \"text-muted-foreground\"}>{op}</span>\n                <span className=\"min-w-0 truncate font-medium\">{describeValue(node, me)}</span>\n                {issue && <span className=\"truncate opacity-80\">· {issue.message}</span>}\n                <button\n                  aria-label={`Remove ${token.raw}`}\n                  className=\"flex h-full w-5 shrink-0 items-center justify-center rounded-e-md opacity-64 outline-none transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring\"\n                  onClick={() => removeToken(token)}\n                  type=\"button\"\n                >\n                  <XIcon className=\"size-3\" />\n                </button>\n              </motion.span>\n            );\n          })}\n        </AnimatePresence>\n        {tokens.length === 0 && (\n          <div className=\"flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs\">\n            <span>Try</span>\n            {examples.map((ex) => (\n              <button\n                className=\"rounded-md border border-dashed px-1.5 py-0.5 font-mono text-[11px] outline-none transition-colors hover:border-solid hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                key={ex}\n                onClick={() => {\n                  commit(`${ex} `, ex.length + 1);\n                  input.current?.focus();\n                }}\n                type=\"button\"\n              >\n                {ex}\n              </button>\n            ))}\n            <span className=\"max-sm:hidden\">\n              or press <Kbd>Tab</Kbd> to complete\n            </span>\n          </div>\n        )}\n      </div>\n\n      <div className=\"border-t\">\n        <div className=\"flex h-9 items-center justify-between gap-3 px-4 pt-1 text-muted-foreground text-xs\">\n          <span className=\"min-w-0 truncate tabular-nums\">\n            {tokens.length ? `${results.length} of ${tickets.length} tickets` : `${tickets.length} tickets`}\n          </span>\n          {tokens.length > 0 && (\n            <button\n              className=\"shrink-0 rounded-md px-1.5 py-0.5 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={() => commit(\"\", 0)}\n              type=\"button\"\n            >\n              Clear search\n            </button>\n          )}\n        </div>\n        <div\n          aria-label=\"Tickets\"\n          className=\"relative h-[264px] overflow-y-auto px-1.5 pb-1.5 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\"\n          role=\"table\"\n          tabIndex={0}\n        >\n          <div className=\"sr-only\" role=\"row\">\n            <span role=\"columnheader\">Priority</span>\n            <span role=\"columnheader\">Ticket</span>\n            <span role=\"columnheader\">Status</span>\n            <span role=\"columnheader\">Owner</span>\n          </div>\n          <AnimatePresence initial={false}>\n            {results.map((t) => (\n              <motion.div\n                animate={{ opacity: 1 }}\n                className=\"flex h-11 items-center gap-3 rounded-lg px-2.5 hover:bg-accent/50\"\n                exit={{ opacity: 0, transition: { duration: 0 } }}\n                initial={{ opacity: 0 }}\n                key={t.id}\n                role=\"row\"\n                transition={{ duration: reduce ? 0 : 0.15, ease: easeOut }}\n              >\n                <span role=\"cell\">\n                  <PriorityPill priority={t.priority} />\n                </span>\n                <span className=\"min-w-0 flex-1\" role=\"cell\">\n                  <span className=\"block truncate\">{t.title}</span>\n                  <span className=\"block truncate text-muted-foreground text-xs\">\n                    <span className=\"font-mono\">{t.id}</span> · {t.customer} · {t.updated}\n                  </span>\n                </span>\n                <span className=\"shrink-0\" role=\"cell\">\n                  <StatusBadge status={t.status} />\n                </span>\n                <span className=\"w-12 shrink-0 truncate text-end text-muted-foreground text-xs max-sm:hidden\" role=\"cell\">\n                  {t.owner === me ? \"Me\" : t.owner === \"none\" ? \"—\" : ticketFields.owner.options?.find((o) => o.value === t.owner)?.label}\n                </span>\n              </motion.div>\n            ))}\n          </AnimatePresence>\n          <AnimatePresence>\n            {results.length === 0 && (\n              <motion.div\n                animate={{ opacity: 1 }}\n                className=\"absolute inset-0 flex flex-col items-center justify-center gap-1 px-6 text-center\"\n                exit={{ opacity: 0 }}\n                initial={{ opacity: 0 }}\n                transition={{ duration: 0.18 }}\n              >\n                <SearchIcon className=\"mb-1 size-5 text-muted-foreground\" />\n                <p className=\"font-medium\">No tickets match</p>\n                <p className=\"text-muted-foreground text-xs\">Drop a token or widen a range.</p>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction PriorityPill({ priority }: { priority: number }) {\n  const tone =\n    priority >= 4\n      ? \"bg-destructive/10 text-destructive-foreground\"\n      : priority === 3\n        ? \"bg-warning/12 text-warning-foreground\"\n        : \"bg-muted text-muted-foreground\";\n  return (\n    <span className={cn(\"flex h-5 w-14 items-center justify-center rounded-md font-medium text-[11px]\", tone)}>\n      {priorityNames[priority - 1] ?? priority}\n    </span>\n  );\n}\n\nfunction StatusBadge({ status }: { status: Ticket[\"status\"] }) {\n  const styles: Record<Ticket[\"status\"], string> = {\n    open: \"bg-info/10 text-info-foreground\",\n    pending: \"bg-warning/12 text-warning-foreground\",\n    solved: \"bg-success/10 text-success-foreground\",\n    closed: \"bg-muted text-muted-foreground\",\n  };\n  return (\n    <span className={cn(\"rounded-full px-2 py-0.5 font-medium text-[11px]\", styles[status])}>\n      {ticketFields.status.options?.find((o) => o.value === status)?.label}\n    </span>\n  );\n}\n"
    }
  ]
}