{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scoped-search",
  "type": "registry:ui",
  "title": "Scoped Search",
  "description": "A search field where from: and in: become tokens with value suggestions.",
  "categories": [
    "command"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/highlight-match.json"
  ],
  "files": [
    {
      "path": "components/ui/scoped-search.tsx",
      "content": "\"use client\";\nimport { Autocomplete } from \"@base-ui/react/autocomplete\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useMemo, useRef, useState } from \"react\";\nimport { HighlightMatch, findMatches, fold } from \"@/components/ui/highlight-match\";\nimport { cn } from \"@/lib/cn\";\nimport { CornerDownLeft, Search, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ScopeValue = { value: string; label: string; icon?: React.ReactNode; description?: string };\n\nexport type SearchScope = {\n  /** What people type before the colon: \"from\" for from:. Letters, digits and dashes. */\n  key: string;\n  /** What it narrows by, in the suggestion list: \"Person\". */\n  label: string;\n  description?: string;\n  icon?: React.ReactNode;\n  /** Suggestions for the value. A function receives the typed query and can return a fresh list. */\n  values: ScopeValue[] | ((query: string) => ScopeValue[]);\n  /** Accept a typed value that isn't in the list (dates, numbers, ids). */\n  freeform?: boolean;\n  /** Placeholder while the value is being typed: \"a person\". */\n  placeholder?: string;\n};\n\nexport type ScopeToken = { scope: string; value: string; label: string };\nexport type ScopedQuery = { tokens: ScopeToken[]; text: string };\n\nexport type ScopedSearchProps = {\n  scopes: SearchScope[];\n  value?: ScopedQuery;\n  defaultValue?: ScopedQuery;\n  onValueChange?: (query: ScopedQuery) => void;\n  /** Enter with nothing highlighted. */\n  onSubmit?: (query: ScopedQuery) => void;\n  placeholder?: string;\n  \"aria-label\"?: string;\n  /** Portal target for the suggestions. Defaults to document.body. */\n  container?: HTMLElement | null;\n  className?: string;\n};\n\ntype Draft = { scope: string; query: string };\ntype Item = { kind: \"scope\"; scope: SearchScope } | { kind: \"value\"; scope: SearchScope; option: ScopeValue } | { kind: \"free\"; scope: SearchScope; text: string };\n\nconst EMPTY: ScopedQuery = { tokens: [], text: \"\" };\n\nexport function ScopedSearch({\n  scopes,\n  value,\n  defaultValue = EMPTY,\n  onValueChange,\n  onSubmit,\n  placeholder = \"Search\",\n  \"aria-label\": ariaLabel = \"Search\",\n  container,\n  className,\n}: ScopedSearchProps) {\n  const reduce = !!useReducedMotion();\n  const [query, setQuery] = useControllableState({ value, defaultValue, onChange: onValueChange });\n  const [draft, setDraft] = useState<Draft | null>(null);\n  const [focused, setFocused] = useState(false);\n  const [highlighted, setHighlighted] = useState(false);\n  const [dismissed, setDismissed] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const hintId = useId();\n\n  const scopeOf = (key: string) => scopes.find((s) => s.key.toLowerCase() === key.toLowerCase());\n  const draftScope = draft ? scopeOf(draft.scope) : undefined;\n  const input = draft ? draft.query : query.text;\n\n  // The word being typed at the end of the free text, which may be the start of a scope key.\n  const tail = /(?:^|\\s)([\\w-]*)$/.exec(query.text)?.[1] ?? \"\";\n\n  const items = useMemo<Item[]>(() => {\n    if (draftScope) {\n      const q = draft!.query.trim();\n      const source = typeof draftScope.values === \"function\" ? draftScope.values(q) : draftScope.values;\n      // Best matches first: a hit at the start of a name beats one in the middle.\n      const hits = q\n        ? source\n            .map((o) => ({ o, m: findMatches(o.label, q) ?? findMatches(o.value, q) }))\n            .filter((r) => r.m)\n            .sort((a, b) => b.m!.score - a.m!.score)\n            .map((r) => r.o)\n        : source;\n      const list: Item[] = hits.slice(0, 8).map((option) => ({ kind: \"value\", scope: draftScope, option }));\n      const exact = hits.some((o) => fold(o.label) === fold(q) || fold(o.value) === fold(q));\n      if (draftScope.freeform && q && !exact) list.push({ kind: \"free\", scope: draftScope, text: q });\n      return list;\n    }\n    // Scopes are offered on an empty field, or when the last word could be the start of one.\n    if (query.text.trim() === \"\" || tail) {\n      const t = fold(tail);\n      return scopes.filter((s) => !t || fold(s.key).startsWith(t) || fold(s.label).startsWith(t)).map((scope) => ({ kind: \"scope\", scope }));\n    }\n    return [];\n  }, [draftScope, draft, scopes, query.text, tail]);\n\n  const open = focused && !dismissed && (items.length > 0 || !!draftScope);\n\n  const commit = (token: ScopeToken) => {\n    setQuery({ ...query, tokens: [...query.tokens, token] });\n    setDraft(null);\n    const scope = scopeOf(token.scope);\n    setAnnounce(`Added ${scope?.label ?? token.scope}: ${token.label}`);\n  };\n\n  const startDraft = (scope: SearchScope, withText = query.text) => {\n    // The key the person typed becomes a chip; whatever came before it stays as free text.\n    const text = withText.replace(/(?:^|\\s)[\\w-]*:?$/, (m) => (m.startsWith(\" \") ? \" \" : \"\")).replace(/\\s+$/, \" \");\n    setQuery({ ...query, text: text.trim() ? text : \"\" });\n    setDraft({ scope: scope.key, query: \"\" });\n    setDismissed(false);\n    setAnnounce(`${scope.label} scope. Type ${scope.placeholder ?? \"a value\"}.`);\n  };\n\n  const pick = (item: Item) => {\n    if (item.kind === \"scope\") startDraft(item.scope);\n    else if (item.kind === \"value\") commit({ scope: item.scope.key, value: item.option.value, label: item.option.label });\n    else commit({ scope: item.scope.key, value: item.text, label: item.text });\n    inputRef.current?.focus();\n  };\n\n  const onInput = (next: string) => {\n    setDismissed(false);\n    if (draft) {\n      setDraft({ ...draft, query: next });\n      return;\n    }\n    // Typing a known key and its colon turns it into a scope on the same keystroke.\n    const m = /(?:^|\\s)([\\w-]+):$/.exec(next);\n    const scope = m ? scopeOf(m[1]) : undefined;\n    if (scope) return startDraft(scope, next);\n    setQuery({ ...query, text: next });\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const el = e.currentTarget;\n    const atStart = el.selectionStart === 0 && el.selectionEnd === 0;\n    if (e.key === \"Backspace\" && atStart) {\n      if (draft && draft.query === \"\") {\n        // Backspacing into a bare key hands it back as text, colon removed, like deleting a character.\n        e.preventDefault();\n        const text = `${query.text}${query.text && !query.text.endsWith(\" \") ? \" \" : \"\"}${draft.scope}`;\n        setDraft(null);\n        setQuery({ ...query, text });\n        requestAnimationFrame(() => inputRef.current?.setSelectionRange(text.length, text.length));\n        return;\n      }\n      if (!draft && query.tokens.length) {\n        // Backspacing into a finished token reopens it for editing, value and all.\n        e.preventDefault();\n        const last = query.tokens[query.tokens.length - 1];\n        setQuery({ tokens: query.tokens.slice(0, -1), text: query.text });\n        setDraft({ scope: last.scope, query: last.label });\n        setDismissed(false);\n        setAnnounce(`Editing ${scopeOf(last.scope)?.label ?? last.scope}: ${last.label}`);\n        return;\n      }\n    }\n    // Space after an exact value finishes the scope. Handled here because the list would otherwise\n    // take the space while one of its items is highlighted.\n    if (e.key === \" \" && draft && draftScope && atEnd(el)) {\n      const q = draft.query.trim();\n      const source = typeof draftScope.values === \"function\" ? draftScope.values(q) : draftScope.values;\n      const exact = q && source.find((o) => fold(o.label) === fold(q) || fold(o.value) === fold(q));\n      if (exact || (draftScope.freeform && q && !q.includes(\" \"))) {\n        e.preventDefault();\n        return commit(exact ? { scope: draftScope.key, value: exact.value, label: exact.label } : { scope: draftScope.key, value: q, label: q });\n      }\n    }\n    // Tab completes a half-typed key (\"fr\" → from:) without taking a hand to the arrows.\n    if (e.key === \"Tab\" && !e.shiftKey && !draft && tail && items[0]?.kind === \"scope\") {\n      e.preventDefault();\n      return pick(items[0]);\n    }\n    if (e.key === \"Enter\" && !highlighted) {\n      e.preventDefault();\n      // Mid-scope, Enter takes the best suggestion rather than searching with half a query.\n      if (draft && items.length) return pick(items[0]);\n      if (draftScope?.freeform && draft!.query.trim()) return commit({ scope: draftScope.key, value: draft!.query.trim(), label: draft!.query.trim() });\n      onSubmit?.(query);\n      setDismissed(true);\n    }\n    if (e.key === \"Escape\" && draft && !open) setDraft(null);\n  };\n\n  const removeToken = (index: number) => {\n    const token = query.tokens[index];\n    setQuery({ ...query, tokens: query.tokens.filter((_, i) => i !== index) });\n    setAnnounce(`Removed ${scopeOf(token.scope)?.label ?? token.scope}: ${token.label}`);\n    inputRef.current?.focus();\n  };\n\n  const editToken = (index: number) => {\n    const token = query.tokens[index];\n    setQuery({ ...query, tokens: query.tokens.filter((_, i) => i !== index) });\n    setDraft({ scope: token.scope, query: token.label });\n    setDismissed(false);\n    inputRef.current?.focus();\n  };\n\n  const clearAll = () => {\n    setQuery(EMPTY);\n    setDraft(null);\n    setAnnounce(\"Search cleared\");\n    inputRef.current?.focus();\n  };\n\n  const chip = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\" },\n    animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\", transition: { duration: 0.12, ease: ease.out } },\n    transition: reduce ? { duration: 0.12 } : { ...spring.pop, filter: { duration: 0.18, ease: ease.out } }, // blur on a tween: a spring can overshoot below zero\n  };\n  const empty = !query.tokens.length && !draft && !query.text;\n\n  return (\n    <Autocomplete.Root\n      items={items}\n      filteredItems={items}\n      filter={null}\n      value={input}\n      onValueChange={(next, details) => {\n        // Picking a suggestion is handled by the item itself; don't let it overwrite the text.\n        if (details.reason === \"item-press\") return;\n        onInput(next);\n      }}\n      open={open}\n      onOpenChange={(next, details) => {\n        if (!next && (details.reason === \"escape-key\" || details.reason === \"outside-press\")) setDismissed(true);\n        if (next) setDismissed(false);\n      }}\n      // Only mid-scope does Enter take a suggestion; with free text it always searches, even for \"h\".\n      autoHighlight={!!draft}\n      onItemHighlighted={(item) => setHighlighted(!!item)}\n      itemToStringValue={(item: Item) => (item.kind === \"scope\" ? `${item.scope.key}:` : item.kind === \"value\" ? item.option.label : item.text)}\n    >\n      <Autocomplete.InputGroup\n        ref={fieldRef}\n        onPointerDown={(e) => {\n          // Clicking the field's padding or gaps puts the caret in the input, as in a native field.\n          if (!(e.target as HTMLElement).closest(\"button, input\")) {\n            e.preventDefault();\n            inputRef.current?.focus();\n          }\n        }}\n        className={cn(\n          \"group/field relative flex min-h-9 w-full min-w-0 cursor-text items-start gap-1 rounded-lg border border-line-2 bg-raised py-1 pr-1.5 pl-2.5 text-fg shadow-[var(--shadow)]\",\n          \"transition-[border-color,box-shadow] duration-150 ease-out hover:border-fg-4 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/10\",\n          className,\n        )}\n      >\n        <Search size={16} className=\"pointer-events-none mt-1.5 mr-1 shrink-0 text-fg-3 transition-colors group-focus-within/field:text-fg-2\" />\n        <div className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1\">\n          <ul aria-label=\"Search scopes\" className=\"contents\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {query.tokens.map((t, i) => (\n                <motion.li key={`t${i}`} layout={reduce ? false : \"position\"} {...chip} className=\"flex min-w-0\">\n                  <Token\n                    scope={scopeOf(t.scope)}\n                    keyText={t.scope}\n                    label={t.label}\n                    onEdit={() => editToken(i)}\n                    onRemove={() => removeToken(i)}\n                  />\n                </motion.li>\n              ))}\n              {draft && query.text.trim() && (\n                // Words typed before the scope wait beside it until the value is picked, then return to the input.\n                <motion.li key=\"free\" layout={reduce ? false : \"position\"} {...chip} className=\"flex min-w-0\">\n                  <span className=\"truncate px-0.5 text-[13px] text-fg\">{query.text.trim()}</span>\n                </motion.li>\n              )}\n              {draft && (\n                // The draft takes the key its token will have, so committing grows this chip in place.\n                <motion.li key={`t${query.tokens.length}`} layout={reduce ? false : \"position\"} {...chip} className=\"flex\">\n                  <span className=\"flex h-6 items-center gap-1 rounded-md bg-line px-1.5 font-mono text-[12px] text-fg-2\">\n                    {draft.scope}:\n                  </span>\n                </motion.li>\n              )}\n            </AnimatePresence>\n          </ul>\n          <motion.div layout={!reduce ? \"position\" : false} transition={spring.snappy} className=\"flex min-w-[7rem] flex-1\">\n            <Autocomplete.Input\n              ref={inputRef}\n              aria-label={ariaLabel}\n              aria-describedby={hintId}\n              placeholder={draftScope ? `Type ${draftScope.placeholder ?? \"a value\"}…` : empty ? placeholder : \"\"}\n              onFocus={() => setFocused(true)}\n              onBlur={() => setFocused(false)}\n              onKeyDown={onKeyDown}\n              autoComplete=\"off\"\n              spellCheck={false}\n              enterKeyHint=\"search\"\n              className=\"h-7 w-full min-w-0 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n            />\n          </motion.div>\n        </div>\n        <AnimatePresence initial={false}>\n          {!empty && (\n            <motion.button\n              type=\"button\"\n              aria-label=\"Clear search\"\n              onClick={clearAll}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}\n              transition={spring.pop}\n              className={cn(\n                \"relative grid size-6 shrink-0 place-items-center self-start rounded-md text-fg-3 hover:bg-hover hover:text-fg\",\n                \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                \"mt-0.5 transition-colors active:scale-[0.9] after:absolute after:-inset-2.5\",\n              )}\n            >\n              <X size={14} />\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </Autocomplete.InputGroup>\n      <span id={hintId} className=\"sr-only\">\n        Type a scope such as {scopes.map((s) => `${s.key}:`).join(\", \")} to narrow the search. Backspace edits the last scope.\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n\n      <Autocomplete.Portal container={container}>\n        <Autocomplete.Positioner anchor={fieldRef} align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <Autocomplete.Popup\n            className={cn(\n              \"flex w-(--anchor-width) max-w-(--available-width) min-w-64 origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out\",\n              \"data-starting-style:scale-[0.97] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <div className=\"flex h-8 shrink-0 items-center px-3 font-mono text-2xs tracking-[0.08em] text-fg-4 uppercase\">\n              {draftScope ? draftScope.label : \"Narrow your search\"}\n            </div>\n            <Autocomplete.Empty className=\"empty:hidden\">\n              {draftScope && (\n                <p className=\"truncate px-3 pb-3 text-[12.5px] text-fg-3\">\n                  {draft!.query.trim() ? (\n                    <>\n                      Nothing matches <span className=\"text-fg-2\">“{draft!.query.trim()}”</span>\n                    </>\n                  ) : (\n                    <>Type {draftScope.placeholder ?? \"a value\"}</>\n                  )}\n                </p>\n              )}\n            </Autocomplete.Empty>\n            <Autocomplete.List className=\"max-h-[min(var(--available-height),17rem)] scroll-py-1 overflow-y-auto overscroll-contain px-1 pb-1 outline-none empty:hidden\">\n              {(item: Item) => (\n                <Autocomplete.Item key={itemKey(item)} value={item} onClick={() => pick(item)} className={itemClass}>\n                  {item.kind === \"scope\" ? (\n                    <>\n                      <span className=\"flex w-4 shrink-0 justify-center text-fg-3 [&_svg]:size-3.5\">{item.scope.icon}</span>\n                      <span className=\"shrink-0 font-mono text-[12px] text-fg\">{item.scope.key}:</span>\n                      <span className=\"min-w-0 flex-1 truncate text-fg-2\">{item.scope.label}</span>\n                      {item.scope.description && <span className=\"hidden min-w-0 truncate text-[12px] text-fg-4 sm:block\">{item.scope.description}</span>}\n                    </>\n                  ) : item.kind === \"value\" ? (\n                    <>\n                      {item.option.icon && <span className=\"flex w-4 shrink-0 justify-center text-fg-3 [&_svg]:size-3.5\">{item.option.icon}</span>}\n                      <HighlightMatch text={item.option.label} query={draft?.query ?? \"\"} className=\"min-w-0 truncate text-fg\" />\n                      {item.option.description && <span className=\"min-w-0 flex-1 truncate text-[12px] text-fg-3\">{item.option.description}</span>}\n                    </>\n                  ) : (\n                    <>\n                      <CornerDownLeft size={14} className=\"shrink-0 text-fg-3\" />\n                      <span className=\"min-w-0 truncate text-fg-2\">\n                        Use <span className=\"text-fg\">“{item.text}”</span>\n                      </span>\n                    </>\n                  )}\n                </Autocomplete.Item>\n              )}\n            </Autocomplete.List>\n            <div className=\"flex h-8 shrink-0 items-center gap-3 border-t border-line px-3 text-[11.5px] text-fg-4\">\n              <span><Key>↑↓</Key> move</span>\n              <span><Key>↵</Key> pick</span>\n              {!draft && tail && items[0]?.kind === \"scope\" && (\n                <span>\n                  <Key>⇥</Key> complete\n                </span>\n              )}\n              {(draft || query.tokens.length > 0) && (\n                <span>\n                  <Key>⌫</Key> {draft ? \"back\" : \"edit last\"}\n                </span>\n              )}\n            </div>\n          </Autocomplete.Popup>\n        </Autocomplete.Positioner>\n      </Autocomplete.Portal>\n    </Autocomplete.Root>\n  );\n}\n\nconst atEnd = (el: HTMLInputElement) => el.selectionStart === el.value.length && el.selectionEnd === el.value.length;\n\nconst itemKey = (item: Item) => (item.kind === \"scope\" ? `s:${item.scope.key}` : item.kind === \"value\" ? `v:${item.option.value}` : `f:${item.text}`);\n\nconst itemClass = cn(\n  \"group/item relative flex h-8 cursor-default scroll-my-1 items-center gap-2.5 rounded-lg px-2 text-[13px] outline-none select-none pointer-coarse:h-10\",\n  \"transition-[background-color] duration-75 data-highlighted:bg-line data-disabled:text-fg-4\",\n);\n\nfunction Key({ children }: { children: React.ReactNode }) {\n  return <kbd className=\"mr-1 rounded border border-line-2 px-1 font-mono text-[10.5px] text-fg-3\">{children}</kbd>;\n}\n\ntype TokenProps = { scope?: SearchScope; keyText: string; label: string; onEdit: () => void; onRemove: () => void };\n\nfunction Token({ scope, keyText, label, onEdit, onRemove }: TokenProps) {\n  return (\n    <span className=\"group/token flex h-6 max-w-[16rem] min-w-0 items-center rounded-md bg-line text-[12.5px] transition-colors hover:bg-line-2\">\n      <button\n        type=\"button\"\n        tabIndex={-1}\n        onClick={onEdit}\n        title={`Edit ${scope?.label ?? keyText}`}\n        className=\"flex h-full min-w-0 items-center gap-1 pr-1 pl-1.5 outline-none\"\n      >\n        <span className=\"shrink-0 font-mono text-[12px] text-fg-3\">{keyText}:</span>\n        <span className=\"min-w-0 truncate text-fg\">{label}</span>\n      </button>\n      <button\n        type=\"button\"\n        tabIndex={-1}\n        aria-label={`Remove ${scope?.label ?? keyText}: ${label}`}\n        onClick={onRemove}\n        className=\"relative mr-0.5 grid size-5 shrink-0 place-items-center rounded text-fg-3 outline-none transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.85] pointer-coarse:after:absolute pointer-coarse:after:-inset-2\"\n      >\n        <X size={12} />\n      </button>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/scoped-search"
}
