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.
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.