{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "query-input",
  "type": "registry:ui",
  "title": "QueryInput",
  "description": "One-line text query for the filter model (status:open created:>-7d (owner:@me OR labels:bug)) as an ARIA combobox: field, operator and value suggestions, colored tokens drawn by an overlay behind a single input, an underlined error range with an announced message, and commit on Enter or blur that never applies invalid text. Shares useFilterState with FilterBar so text and chips stay in sync, keeping chip ids for unchanged conditions.",
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/filter-core.json",
    "https://components.dethink.co.uk/r/filter-bar.json",
    "https://components.dethink.co.uk/r/input.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/filter-builder/query-input-parts.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/query-input-parts.tsx",
      "content": "import type { ReactNode } from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport type { InputControlSize } from \"../input\";\nimport type {\n  FilterQueryError,\n  FilterQuerySegment,\n  FilterQuerySegmentKind,\n  FilterQuerySuggestion,\n} from \"./filter-core\";\n\nexport interface QueryInputLabels {\n  /** Accessible name of the input. */\n  input: string;\n  placeholder: string;\n  /** Accessible name of the suggestion list. */\n  suggestions: string;\n  /** Kind shown beside a suggestion. */\n  suggestionKind: Record<FilterQuerySuggestion[\"kind\"], string>;\n  /** Error text. Defaults to the parser's English message. */\n  error: (error: FilterQueryError) => string;\n}\n\nexport const defaultQueryInputLabels: QueryInputLabels = {\n  input: \"Filter query\",\n  placeholder: \"Filter, e.g. status:open created:>-7d\",\n  suggestions: \"Suggestions\",\n  suggestionKind: { field: \"Field\", operator: \"Operator\", value: \"Value\" },\n  error: (error) => error.message,\n};\n\n/** Padding and type size shared by the input and its highlight overlay. */\nexport const queryInputSizeClasses: Record<InputControlSize, string> = {\n  sm: \"h-8 px-[var(--dt-space-2-5)] text-base sm:text-sm\",\n  md: \"h-density-control px-[var(--dt-space-3)] text-base sm:text-sm\",\n  lg: \"h-11 px-[var(--dt-space-4)] text-base\",\n};\n\n// The input's own text is transparent; the overlay draws it in color. Only\n// color and decoration change per token, never width, so glyphs line up.\nexport const queryInputFieldClasses =\n  \"font-mono text-transparent caret-foreground selection:bg-primary/25 selection:text-transparent\";\n\nexport const queryInputOverlayClasses =\n  \"pointer-events-none absolute inset-0 flex items-center overflow-hidden whitespace-pre border border-transparent font-mono text-foreground\";\n\nconst segmentClasses: Record<FilterQuerySegmentKind, string> = {\n  field: \"text-primary\",\n  operator: \"text-muted-foreground\",\n  value: \"text-foreground\",\n  keyword: \"text-info\",\n  negation: \"text-destructive\",\n  paren: \"text-muted-foreground\",\n  text: \"text-foreground\",\n};\n\nconst errorClasses =\n  \"underline decoration-destructive decoration-wavy decoration-from-font underline-offset-4\";\n\nexport const queryInputListClasses =\n  \"absolute inset-x-0 top-full z-50 mt-[var(--dt-space-1)] max-h-64 overflow-auto overscroll-contain rounded-md border border-border bg-background p-[var(--dt-space-1)] text-foreground shadow-lg\";\n\nexport const queryInputOptionClasses =\n  \"grid cursor-default grid-cols-[minmax(0,1fr)_auto] items-baseline gap-[var(--dt-space-3)] rounded-sm px-[var(--dt-space-2)] py-[var(--dt-space-1-5)] text-sm outline-none data-[active]:bg-muted motion-safe:transition-[background-color] motion-safe:duration-[var(--dt-motion-fast)] motion-safe:ease-control\";\n\nexport const queryInputErrorClasses =\n  \"mt-[var(--dt-space-1-5)] text-xs text-destructive\";\n\nexport function queryInputClassNames({\n  className,\n}: { className?: string } = {}) {\n  return cn(\"relative w-full min-w-0\", className);\n}\n\n/**\n * The query text as colored runs, split at segment and error boundaries.\n * An empty error range (e.g. a missing filter at the end) marks the last\n * character, so there is always something to underline.\n */\nexport function renderQueryHighlight(\n  text: string,\n  segments: FilterQuerySegment[],\n  error: FilterQueryError | undefined,\n): ReactNode[] {\n  const errorRange = error\n    ? error.start === error.end\n      ? { start: Math.max(0, error.start - 1), end: Math.max(1, error.end) }\n      : error\n    : undefined;\n  const bounds = new Set([0, text.length]);\n\n  for (const segment of segments) {\n    bounds.add(segment.start);\n    bounds.add(segment.end);\n  }\n\n  if (errorRange) {\n    bounds.add(Math.min(errorRange.start, text.length));\n    bounds.add(Math.min(errorRange.end, text.length));\n  }\n\n  const points = [...bounds].sort((a, b) => a - b);\n  const runs: ReactNode[] = [];\n\n  for (let index = 0; index < points.length - 1; index += 1) {\n    const start = points[index] ?? 0;\n    const end = points[index + 1] ?? 0;\n\n    if (end <= start) {\n      continue;\n    }\n\n    const segment = segments.find(\n      (candidate) => candidate.start <= start && end <= candidate.end,\n    );\n    const inError =\n      errorRange !== undefined &&\n      errorRange.start <= start &&\n      end <= errorRange.end;\n\n    runs.push(\n      <span\n        key={start}\n        data-kind={segment?.kind}\n        data-error={inError ? \"\" : undefined}\n        className={cn(\n          segment ? segmentClasses[segment.kind] : undefined,\n          inError && errorClasses,\n        )}\n      >\n        {text.slice(start, end)}\n      </span>,\n    );\n  }\n\n  return runs;\n}\n"
    },
    {
      "path": "packages/components/src/components/filter-builder/query-input.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/query-input.tsx",
      "content": "import {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type InputHTMLAttributes,\n  type KeyboardEvent,\n} from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport { inputClassNames, type InputControlSize } from \"../input\";\nimport {\n  DEFAULT_FILTER_MAX_DEPTH,\n  getFilterQuerySegments,\n  getFilterQuerySuggestions,\n  getFilterSignature,\n  normalizeFilter,\n  parseFilterQuery,\n  printFilterQuery,\n  reconcileFilterIds,\n  type FilterQueryError,\n  type FilterQueryOptions,\n  type FilterQuerySuggestion,\n} from \"./filter-core\";\nimport type { Filter, FilterFields } from \"./filter-types\";\nimport { useFilterState, type FilterState } from \"./use-filter-state\";\nimport {\n  defaultQueryInputLabels,\n  queryInputClassNames,\n  queryInputErrorClasses,\n  queryInputFieldClasses,\n  queryInputListClasses,\n  queryInputOptionClasses,\n  queryInputOverlayClasses,\n  queryInputSizeClasses,\n  renderQueryHighlight,\n  type QueryInputLabels,\n} from \"./query-input-parts\";\n\nexport interface QueryInputProps<TData = unknown> extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"size\" | \"children\"\n> {\n  fields: FilterFields<TData>;\n  value?: Filter;\n  defaultValue?: Filter;\n  onValueChange?: (filter: Filter) => void;\n  /**\n   * External state from `useFilterState`. Share it with a FilterBar to keep\n   * the text and the chips in sync.\n   */\n  state?: FilterState;\n  /** Field that plain words search. Defaults to the first text field. */\n  defaultField?: string;\n  /** Group levels allowed, counting the root as 1. Defaults to 3. */\n  maxDepth?: number;\n  labels?: Partial<QueryInputLabels>;\n  /** Locale for date suggestions. Defaults to \"en-US\". */\n  locale?: string;\n  controlSize?: InputControlSize;\n  /** Class for the input element; `className` styles the wrapper. */\n  inputClassName?: string;\n  /** Called when a commit fails, with the error that is shown. */\n  onQueryError?: (error: FilterQueryError) => void;\n}\n\n/**\n * One-line text query for a filter, e.g. `status:open created:>-7d\n * (assignee:@me OR labels:bug)`. An ARIA combobox with field, operator and\n * value suggestions, colored tokens and an underlined error range. Commits\n * on Enter or blur; invalid text never changes the filter.\n */\nexport function QueryInput<TData>({\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-label\": ariaLabel,\n  className,\n  controlSize = \"md\",\n  defaultField,\n  defaultValue,\n  disabled,\n  fields,\n  id: idProp,\n  inputClassName,\n  labels: labelOverrides,\n  locale = \"en-US\",\n  maxDepth = DEFAULT_FILTER_MAX_DEPTH,\n  onBlur,\n  onFocus,\n  onKeyDown,\n  onQueryError,\n  onValueChange,\n  placeholder,\n  readOnly,\n  state: externalState,\n  value,\n  ...props\n}: QueryInputProps<TData>) {\n  const internalState = useFilterState({ defaultValue, onValueChange, value });\n  const state = externalState ?? internalState;\n  const labels = useMemo(\n    () => ({ ...defaultQueryInputLabels, ...labelOverrides }),\n    [labelOverrides],\n  );\n  const schema = fields as FilterFields;\n  const options = useMemo<FilterQueryOptions>(\n    () => ({ defaultField, maxDepth }),\n    [defaultField, maxDepth],\n  );\n  const printed = useMemo(\n    () => printFilterQuery(state.filter, schema, options),\n    [options, schema, state.filter],\n  );\n  // null while the text follows the filter; a string while it is edited.\n  const [draft, setDraft] = useState<string | null>(null);\n  const [showError, setShowError] = useState(false);\n  const [open, setOpen] = useState(false);\n  const [activeIndex, setActiveIndex] = useState(-1);\n  const [caret, setCaret] = useState(0);\n  const [syncedFilter, setSyncedFilter] = useState(state.filter);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const overlayRef = useRef<HTMLSpanElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const pendingCaretRef = useRef<number | null>(null);\n  const baseId = useId();\n  const inputId = idProp ?? `${baseId}-input`;\n  const listboxId = `${baseId}-suggestions`;\n  const errorId = `${baseId}-error`;\n\n  // Chips, undo or a parent changed the filter: show its text again.\n  if (syncedFilter !== state.filter) {\n    setSyncedFilter(state.filter);\n    setDraft(null);\n    setShowError(false);\n  }\n\n  // Read-only and disabled inputs never change the text or the filter.\n  const editable = !disabled && !readOnly;\n  const text = draft ?? printed;\n  const parsed = useMemo(\n    () =>\n      draft === null ? undefined : parseFilterQuery(draft, schema, options),\n    [draft, options, schema],\n  );\n  const error = showError && parsed && !parsed.ok ? parsed.error : undefined;\n  const segments = useMemo(\n    () => getFilterQuerySegments(text, schema),\n    [schema, text],\n  );\n  const suggestions = useMemo<FilterQuerySuggestion[]>(\n    () =>\n      open && editable\n        ? getFilterQuerySuggestions(text, caret, schema, { ...options, locale })\n        : [],\n    [caret, editable, locale, open, options, schema, text],\n  );\n  const expanded = open && suggestions.length > 0;\n  const activeSuggestion = expanded ? suggestions[activeIndex] : undefined;\n\n  const syncScroll = () => {\n    const input = inputRef.current;\n\n    if (input && overlayRef.current) {\n      overlayRef.current.style.transform = `translateX(${-input.scrollLeft}px)`;\n    }\n  };\n\n  useLayoutEffect(() => {\n    const input = inputRef.current;\n    const next = pendingCaretRef.current;\n\n    if (input && next !== null) {\n      pendingCaretRef.current = null;\n      input.setSelectionRange(next, next);\n    }\n\n    syncScroll();\n  });\n\n  // Keep the active option visible by scrolling the list only; scrolling\n  // the option into view would also scroll the page.\n  useEffect(() => {\n    const list = listRef.current;\n    const option = list?.querySelector<HTMLElement>(\"[data-active]\");\n\n    if (!list || !option) {\n      return;\n    }\n\n    if (option.offsetTop < list.scrollTop) {\n      list.scrollTop = option.offsetTop;\n    } else if (\n      option.offsetTop + option.offsetHeight >\n      list.scrollTop + list.clientHeight\n    ) {\n      list.scrollTop =\n        option.offsetTop + option.offsetHeight - list.clientHeight;\n    }\n  }, [activeIndex, expanded]);\n\n  const updateCaret = () => {\n    setCaret(inputRef.current?.selectionStart ?? 0);\n    syncScroll();\n  };\n\n  const edit = (next: string, nextCaret: number) => {\n    if (!editable) {\n      return;\n    }\n\n    setDraft(next);\n    setCaret(nextCaret);\n    setActiveIndex(-1);\n  };\n\n  const applySuggestion = (suggestion: FilterQuerySuggestion) => {\n    if (!editable) {\n      return;\n    }\n\n    const next =\n      text.slice(0, suggestion.start) +\n      suggestion.insert +\n      text.slice(suggestion.end);\n    const nextCaret = suggestion.start + suggestion.insert.length;\n\n    pendingCaretRef.current = nextCaret;\n    edit(next, nextCaret);\n    setOpen(true);\n  };\n\n  const commit = () => {\n    if (!editable || draft === null || !parsed) {\n      return;\n    }\n\n    if (!parsed.ok) {\n      setShowError(true);\n      onQueryError?.(parsed.error);\n      return;\n    }\n\n    setShowError(false);\n\n    if (\n      getFilterSignature(parsed.filter) !==\n      getFilterSignature(normalizeFilter(state.filter))\n    ) {\n      state.setFilter(reconcileFilterIds(parsed.filter, state.filter));\n    } else {\n      setDraft(null);\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    onKeyDown?.(event);\n\n    // Keys that confirm or navigate an IME composition (Japanese, Chinese…)\n    // belong to the composition, not to the query.\n    if (\n      event.defaultPrevented ||\n      !editable ||\n      event.nativeEvent.isComposing ||\n      event.keyCode === 229\n    ) {\n      return;\n    }\n\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        event.preventDefault();\n\n        if (!expanded) {\n          setOpen(true);\n          setActiveIndex(event.key === \"ArrowDown\" ? 0 : -2);\n          return;\n        }\n\n        const step = event.key === \"ArrowDown\" ? 1 : -1;\n\n        setActiveIndex((current) =>\n          current < 0 && step < 0\n            ? suggestions.length - 1\n            : (current + step + suggestions.length) % suggestions.length,\n        );\n        return;\n      }\n      case \"Enter\":\n        event.preventDefault();\n\n        if (activeSuggestion) {\n          applySuggestion(activeSuggestion);\n          return;\n        }\n\n        setOpen(false);\n        commit();\n        return;\n      case \"Escape\":\n        if (expanded) {\n          event.preventDefault();\n          setOpen(false);\n          setActiveIndex(-1);\n          return;\n        }\n\n        if (draft !== null) {\n          event.preventDefault();\n          setDraft(null);\n          setShowError(false);\n        }\n        return;\n      default:\n        return;\n    }\n  };\n\n  // ArrowUp on a closed list opens it on the last suggestion.\n  if (activeIndex === -2 && suggestions.length > 0) {\n    setActiveIndex(suggestions.length - 1);\n  }\n\n  const describedBy =\n    [ariaDescribedBy, error ? errorId : undefined].filter(Boolean).join(\" \") ||\n    undefined;\n\n  return (\n    <div\n      data-slot=\"query-input\"\n      data-invalid={error ? \"\" : undefined}\n      className={queryInputClassNames({ className })}\n    >\n      <div className=\"relative\">\n        <input\n          {...props}\n          ref={inputRef}\n          id={inputId}\n          type=\"text\"\n          role=\"combobox\"\n          aria-label={\n            ariaLabel ?? (props[\"aria-labelledby\"] ? undefined : labels.input)\n          }\n          aria-autocomplete=\"list\"\n          aria-expanded={expanded}\n          aria-controls={listboxId}\n          aria-activedescendant={\n            activeSuggestion ? `${listboxId}-${activeIndex}` : undefined\n          }\n          aria-invalid={error ? true : undefined}\n          aria-describedby={describedBy}\n          autoComplete=\"off\"\n          autoCapitalize=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          disabled={disabled}\n          readOnly={readOnly}\n          placeholder={placeholder ?? labels.placeholder}\n          data-slot=\"query-input-field\"\n          className={inputClassNames({\n            controlSize,\n            className: cn(queryInputFieldClasses, inputClassName),\n          })}\n          value={text}\n          onChange={(event) => {\n            const input = event.currentTarget;\n\n            edit(input.value, input.selectionStart ?? input.value.length);\n            setOpen(true);\n          }}\n          onSelect={updateCaret}\n          onScroll={syncScroll}\n          onFocus={(event) => {\n            onFocus?.(event);\n            updateCaret();\n          }}\n          onBlur={(event) => {\n            onBlur?.(event);\n            setOpen(false);\n            setActiveIndex(-1);\n            commit();\n          }}\n          onKeyDown={handleKeyDown}\n        />\n        <div\n          aria-hidden=\"true\"\n          data-slot=\"query-input-highlight\"\n          className={cn(\n            queryInputOverlayClasses,\n            queryInputSizeClasses[controlSize],\n          )}\n        >\n          <span ref={overlayRef} className=\"inline-block\">\n            {renderQueryHighlight(text, segments, error)}\n          </span>\n        </div>\n      </div>\n      <div\n        ref={listRef}\n        id={listboxId}\n        role=\"listbox\"\n        aria-label={labels.suggestions}\n        data-slot=\"query-input-suggestions\"\n        hidden={!expanded}\n        className={queryInputListClasses}\n      >\n        {expanded\n          ? suggestions.map((suggestion, index) => (\n              // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus -- Combobox options stay unfocusable: the input keeps focus and handles keys through aria-activedescendant.\n              <div\n                key={suggestion.id}\n                id={`${listboxId}-${index}`}\n                role=\"option\"\n                aria-selected={index === activeIndex}\n                data-active={index === activeIndex ? \"\" : undefined}\n                data-kind={suggestion.kind}\n                className={queryInputOptionClasses}\n                onPointerDown={(event) => {\n                  // Keep focus (and the caret) in the input.\n                  event.preventDefault();\n                }}\n                onClick={() => {\n                  applySuggestion(suggestion);\n                }}\n                onPointerMove={() => {\n                  setActiveIndex(index);\n                }}\n              >\n                <span className=\"min-w-0 truncate\">\n                  {suggestion.label}\n                  <span className=\"sr-only\">\n                    , {labels.suggestionKind[suggestion.kind]}\n                  </span>\n                </span>\n                {suggestion.detail ? (\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"text-muted-foreground font-mono text-xs\"\n                  >\n                    {suggestion.detail}\n                  </span>\n                ) : null}\n              </div>\n            ))\n          : null}\n      </div>\n      {error ? (\n        <p\n          id={errorId}\n          role=\"alert\"\n          data-slot=\"query-input-error\"\n          className={queryInputErrorClasses}\n        >\n          {labels.error(error)}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
