Skip to main content

Filter from a value

A list already shows the values people want to filter by. Let them select one. Select shared in a row, and the field gets is:shared. Select it again, and the filter goes away. The field shows every filter that runs, so the person can read it and edit it like typed text.

ProjectState
billing-api
marketing-site
design-tokens
staging-api
webhook-tests
import { Button } from "@ngrok/mantle/button";import { DataTable, createColumnHelper, tableFeatures, useTable } from "@ngrok/mantle/data-table";import { Empty } from "@ngrok/mantle/empty";import {	filterItemsByQuery,	parseFilterQuery,	queryUsesQualifiers,	toggleFacet,} from "@ngrok/mantle/query-filter";import { Query } from "@ngrok/mantle/query";import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";import { useDeferredValue, useState } from "react";import { type Project, projectFilterGrammar, projects } from "~/features/query-demos"; type ProjectState = "shared" | "private" | "archived"; /** The states a project row shows. */function projectStates(project: Project): ProjectState[] {	const visibility = project.shared ? "shared" : "private";	return project.archived ? [visibility, "archived"] : [visibility];} /** Whether the query already keeps only the projects in this state. */function hasStateFilter(query: string, state: ProjectState) {	return parseFilterQuery(projectFilterGrammar, query).some(		(token) =>			token.kind === "facet" &&			token.key === "is" &&			!token.negated &&			token.members.length === 1 &&			token.members[0]?.value === state,	);} const features = tableFeatures({});const columnHelper = createColumnHelper<typeof features, Project>(); export function FilterFromAValueDemo() {	const [query, setQuery] = useState("");	const deferredQuery = useDeferredValue(query);	const visibleProjects = filterItemsByQuery({		grammar: projectFilterGrammar,		items: projects,		query: deferredQuery,	}); 	function toggleState(state: ProjectState) {		setQuery((current) =>			toggleFacet({				grammar: projectFilterGrammar,				query: current,				facet: { key: "is", value: state, negated: false },			}),		);	} 	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.display({			id: "state",			header: () => <DataTable.Header>State</DataTable.Header>,			cell: (props) => (				<DataTable.Cell>					<span className="flex flex-wrap gap-1">						{projectStates(props.row.original).map((state) => (							<Button								key={state}								type="button"								appearance="outlined"								intent="neutral"								size="xs"								className="aria-pressed:border-accent-600 aria-pressed:bg-accent-500/10 aria-pressed:text-accent-700"								aria-pressed={hasStateFilter(query, state)}								onClick={() => toggleState(state)}							>								{state}							</Button>						))}					</span>				</DataTable.Cell>			),		}),	]);	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 select a state in the table"				/>				<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.Description>									{queryUsesQualifiers(projectFilterGrammar, deferredQuery)										? "Remove a filter to see more projects."										: "Check the spelling, or try a filter like is:shared."}								</Empty.Description>								<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>	);}

How it works

toggleFacet from @ngrok/mantle/query-filter does the edit. It is a pure function of the grammar, the query, and one qualifier:

  • When the query doesn't have the qualifier, it adds it at the end, quoted when the value needs quotes.
  • When the query has it, it takes it out. Aliases count, so is:personal matches is:private.
  • The opposite qualifier gives way: -is:shared becomes is:shared.
  • An OR or AND beside a removed qualifier goes with it, so no operator dangles.
  • A comma list such as is:shared,private stays as written, because the person wrote it on purpose.

A few rules keep the table honest:

  • Each value is a real <button> with aria-pressed, so a screen reader hears whether the filter is on.
  • Pass a function to setQuery, so two quick selections never lose an edit.
  • When no row matches, queryUsesQualifiers picks the advice: a filtered query needs a filter removed, and a text query needs a spelling check.

To filter a list on another page, build the link with formatFacet. It writes the canonical spelling and quotes a value that has spaces:

import { formatFacet } from "@ngrok/mantle/query-filter";import { Link } from "react-router"; const q = formatFacet({ key: "name", value: project.name, negated: false }); <Link to={`/projects?${new URLSearchParams({ q })}`}>View in projects</Link>;

Read the param on the other page with the Query from the URL hook.