Skip to main content

Query

A single-line filter field for a list. Free text and key:value qualifiers mix in one query: is:shared -staging api. The field colors each token in place. A suggestion popover completes keys and values while the caret stays in the field.

A grammar from @ngrok/mantle/query-filter sets the vocabulary. The same grammar filters your items with filterItemsByQuery, so the field and the list always agree.

Type is: to see the values and their live match counts.

ProjectDescriptionVisibility
billing-apiInvoices and paymentsShared
marketing-sitePublic websiteShared
design-tokensOld color scalesShared
staging-apiStaging APIPrivate
webhook-testsLocal webhook testsPrivate
import { Button } from "@ngrok/mantle/button";import { DataTable, createColumnHelper, tableFeatures, useTable } from "@ngrok/mantle/data-table";import { Empty } from "@ngrok/mantle/empty";import { type FilterGrammar, filterItemsByQuery } from "@ngrok/mantle/query-filter";import { Query } from "@ngrok/mantle/query";import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";import { useDeferredValue, useState } from "react"; type Project = {	id: string;	name: string;	description: string;	shared: boolean;	archived: boolean;}; const projects: Project[] = [	{		id: "p_1",		name: "billing-api",		description: "Invoices and payments",		shared: true,		archived: false,	},	{		id: "p_2",		name: "marketing-site",		description: "Public website",		shared: true,		archived: false,	},	{		id: "p_3",		name: "design-tokens",		description: "Old color scales",		shared: true,		archived: true,	},	{		id: "p_4",		name: "staging-api",		description: "Staging API",		shared: false,		archived: false,	},	{		id: "p_5",		name: "webhook-tests",		description: "Local webhook tests",		shared: false,		archived: false,	},]; const projectFilterGrammar: FilterGrammar<Project> = {	keys: [		{			key: "name",			aliases: ["title"],			description: "Text in the project name",			matches: (project, value) => project.name.toLowerCase().includes(value.toLowerCase()),		},		{			key: "description",			aliases: ["desc"],			description: "Text in the description",			prose: true,			matches: (project, value) => project.description.toLowerCase().includes(value.toLowerCase()),		},		{			key: "is",			description: "The project state",			values: [				{ value: "shared", description: "Your team can see it" },				{ value: "private", description: "Only you can see it", aliases: ["personal"] },				{ value: "archived", description: "Kept for reference" },			],			matches: (project, value) => {				switch (value) {					case "shared":						return project.shared;					case "private":						return !project.shared;					case "archived":						return project.archived;					default:						return false;				}			},		},	],	getSearchText: (project) => [project.name, project.description, project.id].join("\n"),}; const features = tableFeatures({});const columnHelper = createColumnHelper<typeof features, Project>();const columns = columnHelper.columns([	columnHelper.accessor("name", {		id: "name",		header: () => <DataTable.Header>Project</DataTable.Header>,		cell: (props) => (			<DataTable.Cell className="text-strong" translate="no">				{props.getValue()}			</DataTable.Cell>		),	}),	columnHelper.accessor("description", {		id: "description",		header: () => <DataTable.Header>Description</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.accessor((project) => (project.shared ? "Shared" : "Private"), {		id: "visibility",		header: () => <DataTable.Header>Visibility</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),]); export function QueryExample() {	const [query, setQuery] = useState("");	const deferredQuery = useDeferredValue(query);	const visibleProjects = filterItemsByQuery({		grammar: projectFilterGrammar,		items: projects,		query: deferredQuery,	});	const table = useTable({ features, data: visibleProjects, columns });	const rows = table.getRowModel().rows; 	return (		<div className="w-full space-y-3">			<Query.Root				grammar={projectFilterGrammar}				items={projects}				value={query}				onValueChange={setQuery}			>				<Query.Input					aria-label="Filter projects"					placeholder="Filter projects, or type a filter like is:shared"				/>				<Query.Content>					<Query.List>{(suggestion) => <Query.Item suggestion={suggestion} />}</Query.List>					<Query.Empty />					<Query.Footer>						<Query.Legend />						<Query.KeyboardHints />					</Query.Footer>				</Query.Content>			</Query.Root>			<DataTable.Root table={table}>				<DataTable.Head />				<DataTable.Body>					{rows.length > 0 ? (						rows.map((row) => <DataTable.Row key={row.id} row={row} />)					) : (						<DataTable.EmptyRow>							<Empty.Root>								<Empty.Icon svg={<MagnifyingGlassIcon />} />								<Empty.Title>No projects match this filter</Empty.Title>								<Empty.Actions>									<Button										type="button"										appearance="outlined"										intent="neutral"										onClick={() => setQuery("")}									>										Clear filter									</Button>								</Empty.Actions>							</Empty.Root>						</DataTable.EmptyRow>					)}				</DataTable.Body>			</DataTable.Root>		</div>	);}

The query is the only state, and you own it through value and onValueChange. There's no uncontrolled form, because every list that hosts this field already keeps the query somewhere: in component state, in form state, or in the URL. To keep it in ?q=, follow the Query from the URL recipe.

Composition

Query.Root├── Query.Input└── Query.Content    ├── Query.List    │   └── Query.Item    ├── Query.Empty    └── Query.Footer        ├── Query.Legend        └── Query.KeyboardHints

Root lays out its children in one row, so put your own buttons beside the field there. Input is the field. Content is the suggestion popover; without it, the field is a highlighted input with no suggestions.

You compose the popover from its parts:

  • List is the listbox. Its child is a function that gets each suggestion and returns a Query.Item for it. Pass children to Item to show your own row content.
  • Empty shows when no suggestion matches. By default, it says what the field accepts at the caret. Pass children to show your own message.
  • Footer stays under the rows while they scroll. Put Legend, KeyboardHints, and your own content, such as a link to your filter docs, in it.

Leave out a part to remove it. Wrap your own text in Footer in an element, such as a <p>, so it is not bare text beside the legend.

Polymorphism

Query.Root takes asChild. Use it to render the row as your own element, such as a <section> with its own label, while Root keeps the state and the layout classes.

Query.Footer takes asChild. Use it to render the footer as your own element, while the footer keeps its id and the input keeps it as its description.

The other parts don't take asChild:

  • Input renders the field's shell, its painted copy, and the <input> together.
  • Content wraps Popover.Content, whose element Radix positions.
  • List renders the section groups and their headings.
  • Item must stay the role="option" element that the input names through aria-activedescendant.
  • Empty, Legend, and KeyboardHints render their own content.

While the query has text, Query.Input shows a clear button at its right edge. The button empties the query and moves focus back to the field. Set clearLabel to rename it. Set onClear for work that only a clear does, such as applying the empty query at once. Other children of Query.Input render before the clear button. Pass ghost xs icon buttons.

Query.KeyboardHints shows the keys the field answers to. When Enter submits a form, set enterHint to name that action, such as "select, or search". Put your own footer content in Query.Footer, after the hints.

import { IconButton } from "@ngrok/mantle/button";import { Query } from "@ngrok/mantle/query";import { CopyIcon } from "@phosphor-icons/react/Copy";import { useState } from "react"; export function InlineActionsQueryExample() {	const [query, setQuery] = useState("is:shared "); 	return (		<Query.Root			className="max-w-xl"			grammar={projectFilterGrammar}			items={projects}			value={query}			onValueChange={setQuery}		>			<Query.Input				aria-label="Filter projects"				placeholder="Filter projects, or type a filter like is:shared"			>				<IconButton					type="button"					appearance="ghost"					intent="neutral"					size="xs"					label="Copy filter"					icon={<CopyIcon />}					onClick={() => void navigator.clipboard.writeText(query)}				/>			</Query.Input>			<Query.Content>				<Query.List>{(suggestion) => <Query.Item suggestion={suggestion} />}</Query.List>				<Query.Empty />				<Query.Footer>					<Query.Legend />					<Query.KeyboardHints />					<p>Filters apply as you type.</p>				</Query.Footer>			</Query.Content>		</Query.Root>	);}

projectFilterGrammar and projects are the ones from the first example.

Search on the server

When a server runs the query, declare its vocabulary with defineRemoteFilterGrammar and leave out items. The suggestions then carry no counts. The grammar's syntax keeps the highlighting to what the server reads: "qualifiers" (the default) treats only bare key:value tokens as structure, and "conjunction" reads one AND group of exclusions, comma lists, and quotes.

Inside a <form>, Enter submits the form, unless the keyboard made a suggestion active. Then Enter accepts that suggestion.

Press Enter to search.

import { Button } from "@ngrok/mantle/button";import { defineRemoteFilterGrammar } from "@ngrok/mantle/query-filter";import { Query } from "@ngrok/mantle/query";import { useState } from "react"; const accountSearchGrammar = defineRemoteFilterGrammar({	keys: [		{			key: "is",			description: "The account state",			values: [{ value: "active" }, { value: "suspended" }, { value: "past_due" }],		},		{ key: "email", description: "The account email" },	],}); export function RemoteQueryExample() {	const [query, setQuery] = useState("");	const [submittedQuery, setSubmittedQuery] = useState<string | null>(null); 	return (		<form			className="w-full max-w-xl space-y-3"			onSubmit={(event) => {				event.preventDefault();				setSubmittedQuery(query);			}}		>			<Query.Root grammar={accountSearchGrammar} value={query} onValueChange={setQuery}>				<Query.Input					aria-label="Search accounts"					placeholder="Search accounts, or type a filter like is:suspended"				/>				<Button type="submit" appearance="outlined" intent="neutral">					Search				</Button>				<Query.Content>					<Query.List>{(suggestion) => <Query.Item suggestion={suggestion} />}</Query.List>					<Query.Empty />					<Query.Footer>						<Query.Legend />						<Query.KeyboardHints />					</Query.Footer>				</Query.Content>			</Query.Root>			<p className="text-muted text-sm">				{submittedQuery == null ? "Press Enter to search." : `The server runs: ${submittedQuery}`}			</p>		</form>	);}

Large lists

The match counts and the live filter each run one pass over items per keystroke. For thousands of rows, or a list that streams in, apply the query on Enter: wrap the field in a <form>, leave out items, and set enterHint on Query.KeyboardHints. Enter with no active suggestion submits the form. The Query from the URL recipe has the hook and a demo.

Filter from a value

To let a person select a value in a table row and get its filter, call toggleFacet in the click handler. The Filter from a value recipe has the demo.

Token colors

Keys, values, and OR take the colors that Code Block gives attributes, strings, and operators, so a query reads like the code on the same page. Set the three CSS variables on the field or any ancestor to change them. This calmer set colors only the keys:

import { Query } from "@ngrok/mantle/query";import { useState } from "react"; export function TokenColorsQueryExample() {	const [query, setQuery] = useState("is:shared OR name:api"); 	return (		<Query.Root			grammar={projectFilterGrammar}			items={projects}			value={query}			onValueChange={setQuery}			className="max-w-xl [--query-operator-color:var(--shiki-token-keyword)] [--query-value-color:var(--text-color-strong)]"		>			<Query.Input				aria-label="Filter projects"				placeholder="Filter projects, or type a filter like is:shared"			/>			<Query.Content>				<Query.List>{(suggestion) => <Query.Item suggestion={suggestion} />}</Query.List>				<Query.Empty />				<Query.Footer>					<Query.Legend />					<Query.KeyboardHints />				</Query.Footer>			</Query.Content>		</Query.Root>	);}

Accessibility

The field is a real <input role="combobox"> with aria-autocomplete="list". The text you edit is the input's own value, so the caret, selection, IME composition, paste, and undo all stay native. A painted copy of the text sits over the input and is hidden from assistive tech.

  • Focus never leaves the input. The active suggestion is announced through aria-activedescendant.
  • No suggestion is active until an arrow key moves. Up and Down wrap around the list. A pointer over a row highlights it but doesn't arm Enter.
  • Enter accepts the active suggestion. With no active suggestion, Enter closes the list and lets the event reach your form. Escape closes the list.
  • A caret move retires the active suggestion, so Enter can't accept a row from a list you didn't see.
  • While an IME composes, every key goes to the IME.
  • While the popover is open, Query.Footer joins the input's accessible description, after any description you set. While no suggestion matches, Query.Empty joins it too.

Name the field with aria-label, or put it in a Field and label it with Field.Label. Inside Field.Control, the field takes its id, name, aria-describedby, aria-errormessage, and aria-invalid from the Field.Item.

The section headings ("Filter by", "Exclude", "Values"), the legend, the keyboard hints, and the default empty message are English copy that mantle sets. The listbox name defaults to "Filter suggestions"; set aria-label on Query.List to change it.

API Reference

Query.Root

The row and the state owner. It renders a div that lays out its children in a row, and accepts every div prop.

PropTypeDefaultDescription
grammarFilterGrammar<Item>noneThe grammar of this list's filter language. Declare it as a module constant: the engine caches its tables per object.
itemsItem[]noneThe unfiltered items, for the live match counts. Leave it out when a server runs the query; the suggestions show no counts.
valuestringnoneThe query, exactly as typed. Required.
onValueChange(value: string) => voidnoneCalled with the whole query on every edit, typed or inserted. Required.
asChildbooleanfalseRender the row as the child element and merge the props onto it.
Data AttributeValueDescription
data-slot"query"On the row element.

Query.Input

The query field. className and style go to the field's outer element, as on Input. All other props go to the <input>, such as ref, id, name, disabled, required, readOnly, validation, the aria-* props, and the event handlers. A disabled or read-only field shows no suggestions and no clear button.

PropTypeDefaultDescription
placeholderstringnonePlaceholder copy. Name one example filter the grammar accepts. Required.
aria-labelstringnoneThe accessible name. Leave it out when Field.Label names the field.
suggestOnFocusbooleantrueWhether focus alone opens the suggestions. Set false when you move focus into the field for the user, as on a dialog open.
childrenReactNodenoneInline actions inside the field at its right edge, before the clear button.
clearLabelstring"Clear filter"The accessible name and tooltip of the clear button.
onClear() => voidnoneCalled after the clear button empties the query.
refRef<HTMLInputElement>noneThe <input> element.
validation"error" | "success" | "warning" | false | () => "error" | "success" | "warning" | falsenoneThe validation state. It sets the border, and "error" also sets aria-invalid. Inside Field.Control, the Field.Item sets it.
aria-describedbystringnoneThe field's own description. Query.Footer joins it while the popover is open.
CSS VariableDefaultDescription
--query-key-colorvar(--shiki-token-attribute)The color of a qualifier key.
--query-value-colorvar(--shiki-token-string)The color of a qualifier value.
--query-operator-colorvar(--shiki-token-operator)The color of OR and AND.
Data AttributeValueDescription
data-slot"query-input"On the field's outer element.
data-slot"query-input-actions"On the span that wraps the children. The clear button follows it. It has display: contents, so each child lays out in the row.
data-slot"input"On the field's chrome, inside "query-input", as on Input.
data-disabledpresentOn the "input" chrome while the field is disabled.
data-validation"error" | "success" | "warning"On the "input" chrome. Omitted when unset.

Query.Content

The suggestion popover. It holds the parts you compose into it and keeps focus in the field. It accepts the Popover.Content props except the ones it owns: align, role, preferredWidth, the auto-focus handlers, and onInteractOutside.

PropTypeDefaultDescription
childrenReactNodenoneThe parts: Query.List, Query.Empty, and Query.Footer. Required.
classNamestringnoneClasses for the popover.
refRef<HTMLDivElement>noneThe popover element.
side, sideOffset, id, style, aria-*, data-*noneGo to the popover, as on Popover.Content.
Data AttributeValueDescription
data-slot"query-content"On the popover, beside "popover-content".
data-state"open" | "closed"On the popover, from Radix.
data-side"top" | "bottom"On the popover: the side it opened on.
data-align"start"On the popover.

Query.List

The suggestion listbox for the token under the caret. It groups the rows under "Filter by", "Exclude", and "Values", and it scrolls when the rows don't fit. It accepts every div prop except id and role.

PropTypeDefaultDescription
children(suggestion: FilterSuggestion) => ReactNodenoneRenders one suggestion. Return a Query.Item for every suggestion, because the arrow keys move through every one. Required.
aria-labelstring"Filter suggestions"The accessible name of the listbox.
Data AttributeValueDescription
data-slot"query-list"On the listbox.

Query.Item

One suggestion row: a role="option" that inserts its suggestion on a click. The arrow keys in the field make a row active, and Enter inserts it. It accepts every div prop except id, role, tabIndex, and aria-selected. Your event handlers run first; call preventDefault() in onClick to skip the insert.

PropTypeDefaultDescription
suggestionFilterSuggestionnoneThe suggestion this row inserts. Pass the one Query.List hands its function. Required.
childrenReactNodenoneThe row's content. When omitted, the row shows the label, the description, and the match count.
Data AttributeValueDescription
data-slot"query-item"On the option.
data-highlightedpresentWhile the row is active, or under the pointer with no active row.

Query.Empty

The message that shows in place of the rows when no suggestion matches. It accepts every div prop except id.

PropTypeDefaultDescription
childrenReactNodenoneThe message. When omitted, it says what the field accepts at the caret.
Data AttributeValueDescription
data-slot"query-empty"On the message.

Query.Footer

The area under the rows, pinned while the rows scroll. It accepts every div prop except id. The footer sets its own id, so the field can name it as its description. With asChild, that id replaces the id of the child.

PropTypeDefaultDescription
childrenReactNodenoneQuery.Legend, Query.KeyboardHints, and your own content.
asChildbooleanfalseRender the footer as the child element and merge the props onto it.
Data AttributeValueDescription
data-slot"query-footer"On the footer.

Query.Legend

The syntax reminder. It shows only the syntax the grammar honors. It accepts every p prop except children.

Data AttributeValueDescription
data-slot"query-legend"On the legend.

Query.KeyboardHints

The keys the field answers to: the arrows, Enter, and Escape. It accepts every p prop except children.

PropTypeDefaultDescription
enterHintstring"select"What Enter does. Name your action when Enter submits a form.
Data AttributeValueDescription
data-slot"query-keyboard-hints"On the hints.