A real listing page

Filters, a data table and a map, all driven by one FilterValues record.

The page every marketplace ships: a search box, a few filters, a table of results, and a map once the user asks "near me". This recipe wires @voila.dev/ui/filter, @voila.dev/ui/data-table and @voila.dev/ui/map-view together with one FilterValues record as the single source of truth. Pick a role, set a rate, then add an area filter around Nantes to see the map appear. This is live.

6 results
Reference
Client
Role
City
Day rate
PRJ-001
Riverside Studio
Designer
Nantes
$180
PRJ-002
Northgate Labs
Developer
Saint-Herblain
$240
PRJ-003
Harbour Media
Copywriter
Paris
$150
PRJ-004
Eastfield Group
Designer
Boulogne-Billancourt
$210
PRJ-005
Southbank Digital
Developer
Lyon
$320
PRJ-006
Westgate Ventures
Designer
Villeurbanne
$170

The declaration

Three fields, one of them geo. The geo field geocodes nothing itself; the screen supplies searchPlaces, here a static list, in production your address provider.

const ROLE = {
	kind: "select",
	key: "role",
	label: "Role",
	multiple: true,
	options: [
		{ value: "designer", label: "Designer" },
		{ value: "developer", label: "Developer" },
		{ value: "copywriter", label: "Copywriter" },
	],
} as const satisfies FilterDefinition;
 
const RATE = {
	kind: "moneyRange",
	key: "rate",
	label: "Day rate",
	currency: "EUR",
} as const satisfies FilterDefinition;
 
const AREA = {
	kind: "geoRadius",
	key: "area",
	label: "Area",
	searchPlaces,
	defaultKm: 30,
} as const satisfies FilterDefinition;
 
const definitions = [ROLE, RATE, AREA];

One record, three views

The screen holds a FilterValues record and a search string. Everything else derives from them: the filtered rows, the result count in the bar, and the map's centre and radius. In a real product the same record becomes your query string or API payload; here the rows are filtered in memory to keep the example self-contained.

const [values, setValues] = useState<FilterValues>({});
const [search, setSearch] = useState("");
const projects = useMemo(
	() => PROJECTS.filter((project) => matchesProject(project, values, search)),
	[values, search],
);
const area = values.area;
 
<Filter.Root
	definitions={definitions}
	values={values}
	onValuesChange={setValues}
	searchValue={search}
	onSearchChange={setSearch}
	resultCount={projects.length}
/>
<DataTable.Root columns={columns} data={projects} />
{area?.kind === "geoRadius" ? (
	<RadiusMap
		center={{
			latitude: area.place.latitude,
			longitude: area.place.longitude,
		}}
		radiusKm={area.radiusKm}
	/>
) : null}

The narrowing on area?.kind === "geoRadius" is the whole map wiring: a GeoRadiusFilterValue carries the chosen place and the radius in kilometres, which is exactly what RadiusMap takes. Clearing the chip removes the key from the record and the map unmounts with it.

Because FilterValues never holds an empty filter, there is no cleanup pass before serialising: the record you hold is the record you send.

Own it

The three packages meet only in your screen. The filter source is @voila.dev/ui/src/filter/components/filter-bar.tsx, the table @voila.dev/ui/src/data-table/components/data-table.tsx, the map @voila.dev/ui/src/radius-map/components/radius-map.tsx. To change how a filter kind matches your rows, the matchesProject function above is yours to begin with.

Filter.Root · Geo radius · RadiusMap