@voila.dev/ui-filter 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/package.json +60 -0
- package/src/components/fields/boolean-filter-field.tsx +67 -0
- package/src/components/fields/date-range-filter-field.tsx +135 -0
- package/src/components/fields/field-frame.tsx +130 -0
- package/src/components/fields/geo-radius-filter-field.tsx +215 -0
- package/src/components/fields/money-range-filter-field.tsx +79 -0
- package/src/components/fields/number-filter-field.tsx +170 -0
- package/src/components/fields/select-filter-field.tsx +153 -0
- package/src/components/fields/text-filter-field.tsx +63 -0
- package/src/components/filter-bar.tsx +90 -0
- package/src/components/filter-chips.tsx +70 -0
- package/src/components/filter-field.tsx +111 -0
- package/src/components/filter-form.tsx +43 -0
- package/src/components/filter-panel.tsx +130 -0
- package/src/components/filter-trigger.tsx +57 -0
- package/src/css-modules.d.ts +4 -0
- package/src/lib/filter-values.ts +217 -0
- package/src/styles.css +10 -0
- package/src/types.ts +249 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @voila.dev/ui-filter
|
|
2
|
+
|
|
3
|
+
Composable, responsive list filters: a screen declares which fields are
|
|
4
|
+
filterable, and this package renders the editor, the active-filter chips and the
|
|
5
|
+
search-shaped trigger that opens them — so every admin list filters the same way.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { FilterBar } from "@voila.dev/ui-filter/components/filter-bar";
|
|
11
|
+
import type { FilterDefinition, FilterValues } from "@voila.dev/ui-filter/types";
|
|
12
|
+
|
|
13
|
+
const definitions: ReadonlyArray<FilterDefinition> = [
|
|
14
|
+
{ kind: "text", key: "recipient", label: "Recipient", allowExclusion: true },
|
|
15
|
+
{
|
|
16
|
+
kind: "select",
|
|
17
|
+
key: "status",
|
|
18
|
+
label: "Status",
|
|
19
|
+
multiple: true,
|
|
20
|
+
options: [
|
|
21
|
+
{ value: "sent", label: "Sent" },
|
|
22
|
+
{ value: "failed", label: "Failed" },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{ kind: "dateRange", key: "sentAt", label: "Sent" },
|
|
26
|
+
{ kind: "moneyRange", key: "price", label: "Price", currency: "EUR" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
<FilterBar
|
|
30
|
+
definitions={definitions}
|
|
31
|
+
values={values}
|
|
32
|
+
onValuesChange={setValues}
|
|
33
|
+
searchValue={search}
|
|
34
|
+
onSearchChange={setSearch}
|
|
35
|
+
resultCount={total}
|
|
36
|
+
labels={{ trigger: m.filters_trigger(), /* … */ }}
|
|
37
|
+
locale="fr-FR"
|
|
38
|
+
/>;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Kinds
|
|
42
|
+
|
|
43
|
+
| kind | value | exclusion |
|
|
44
|
+
| ------------- | --------------------------- | --------- |
|
|
45
|
+
| `text` | `{ text, excluded? }` | yes |
|
|
46
|
+
| `number` | `{ number }` | no |
|
|
47
|
+
| `numberRange` | `{ min?, max? }` | no |
|
|
48
|
+
| `moneyRange` | `{ min?, max? }` (cents) | no |
|
|
49
|
+
| `select` | `{ values[], excluded? }` | yes |
|
|
50
|
+
| `dateRange` | `{ from?, to? }` (ISO days) | no |
|
|
51
|
+
| `boolean` | `{ value }` | n/a |
|
|
52
|
+
|
|
53
|
+
`FilterValues` never holds an empty filter: clearing a field removes its key, so
|
|
54
|
+
`Object.keys(values).length` is the active count and the record round-trips to a
|
|
55
|
+
query string unchanged.
|
|
56
|
+
|
|
57
|
+
## Conventions
|
|
58
|
+
|
|
59
|
+
- **Responsive by construction.** The editor is a centered dialog on desktop and
|
|
60
|
+
a bottom drawer under 768px; date bounds are the calendar popover on desktop
|
|
61
|
+
and the OS picker on mobile.
|
|
62
|
+
- **Draft, then apply.** Edits inside the panel are committed on "Apply" — a
|
|
63
|
+
filtered list refetches, and refetching per keystroke is neither fast nor
|
|
64
|
+
legible. Chips still remove a filter in one tap, outside the panel.
|
|
65
|
+
- **No translations inside.** Every string comes from the `labels` prop
|
|
66
|
+
(`defaultFilterLabels` is English); apps pass their Paraglide messages.
|
|
67
|
+
|
|
68
|
+
## Composition
|
|
69
|
+
|
|
70
|
+
`FilterBar` is the assembled surface. Its parts are exported for screens that
|
|
71
|
+
need a different arrangement: `FilterTrigger`, `FilterPanel`, `FilterForm`,
|
|
72
|
+
`FilterField` and `FilterChips`.
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voila.dev/ui-filter",
|
|
3
|
+
"version": "1.1.9",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Composable filters that survive real product requirements — including geo.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://ui.voila.dev/components/filter-bar",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/voila-voila-dev/ui.git",
|
|
11
|
+
"directory": "packages/ui-filter"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"react",
|
|
15
|
+
"filters",
|
|
16
|
+
"list",
|
|
17
|
+
"faceted-search",
|
|
18
|
+
"tailwindcss"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"!src/**/*.test.tsx",
|
|
27
|
+
"!src/**/*.stories.ts",
|
|
28
|
+
"!src/**/*.stories.tsx"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": [
|
|
31
|
+
"**/*.css"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
"./styles.css": "./src/styles.css",
|
|
35
|
+
"./types": "./src/types.ts",
|
|
36
|
+
"./lib/*": "./src/lib/*.ts",
|
|
37
|
+
"./components/*": "./src/components/*.tsx",
|
|
38
|
+
"./components/fields/*": "./src/components/fields/*.tsx"
|
|
39
|
+
},
|
|
40
|
+
"imports": {
|
|
41
|
+
"#/*": "./src/*"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"check": "biome check",
|
|
45
|
+
"check-types": "tsc --noEmit",
|
|
46
|
+
"format": "biome format",
|
|
47
|
+
"lint": "biome lint",
|
|
48
|
+
"test": "vitest run"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@phosphor-icons/react": "2.1.10",
|
|
52
|
+
"@voila.dev/ui": "0.0.0",
|
|
53
|
+
"@voila.dev/ui-map": "0.0.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"react": "^19.0.0",
|
|
57
|
+
"react-dom": "^19.0.0",
|
|
58
|
+
"tailwindcss": "^4.0.0"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
import { FilterFieldFrame } from "#/components/fields/field-frame.tsx";
|
|
3
|
+
import type {
|
|
4
|
+
BooleanFilterDefinition,
|
|
5
|
+
BooleanFilterValue,
|
|
6
|
+
FilterLabels,
|
|
7
|
+
} from "#/types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Three states, not two: yes, no, and "any" (the filter unset). A switch can
|
|
11
|
+
* only say yes/no, which silently turns "I don't care" into "must be false" —
|
|
12
|
+
* so the third state gets its own segment.
|
|
13
|
+
*/
|
|
14
|
+
export function BooleanFilterField({
|
|
15
|
+
definition,
|
|
16
|
+
value,
|
|
17
|
+
onValueChange,
|
|
18
|
+
labels,
|
|
19
|
+
}: {
|
|
20
|
+
readonly definition: BooleanFilterDefinition;
|
|
21
|
+
readonly value: BooleanFilterValue | undefined;
|
|
22
|
+
readonly onValueChange: (value: BooleanFilterValue | undefined) => void;
|
|
23
|
+
readonly labels: FilterLabels;
|
|
24
|
+
}) {
|
|
25
|
+
const states: ReadonlyArray<{ key: string; label: string; set?: boolean }> = [
|
|
26
|
+
{ key: "any", label: labels.any },
|
|
27
|
+
{ key: "true", label: definition.trueLabel, set: true },
|
|
28
|
+
{ key: "false", label: definition.falseLabel, set: false },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<FilterFieldFrame
|
|
33
|
+
label={definition.label}
|
|
34
|
+
description={definition.description}
|
|
35
|
+
labels={labels}
|
|
36
|
+
>
|
|
37
|
+
<fieldset className="inline-flex w-full items-center gap-1 rounded-full bg-muted p-1 sm:w-auto">
|
|
38
|
+
<legend className="sr-only">{definition.label}</legend>
|
|
39
|
+
{states.map((state) => {
|
|
40
|
+
const active = value?.value === state.set;
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
key={state.key}
|
|
44
|
+
type="button"
|
|
45
|
+
aria-pressed={active}
|
|
46
|
+
onClick={() =>
|
|
47
|
+
onValueChange(
|
|
48
|
+
state.set === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: { kind: "boolean", value: state.set },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
className={cn(
|
|
54
|
+
"flex-1 cursor-pointer rounded-full px-3 py-1 text-sm transition-colors sm:flex-none",
|
|
55
|
+
active
|
|
56
|
+
? "bg-background font-medium text-foreground shadow-xs"
|
|
57
|
+
: "text-muted-foreground hover:text-foreground",
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{state.label}
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</fieldset>
|
|
65
|
+
</FilterFieldFrame>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { DatePicker } from "@voila.dev/ui/components/date-picker";
|
|
2
|
+
import { NativeDatePicker } from "@voila.dev/ui/components/native-date-picker";
|
|
3
|
+
import { useIsMobile } from "@voila.dev/ui/hooks/use-mobile";
|
|
4
|
+
import { useId } from "react";
|
|
5
|
+
import {
|
|
6
|
+
FilterFieldFrame,
|
|
7
|
+
FilterRangeRow,
|
|
8
|
+
} from "#/components/fields/field-frame.tsx";
|
|
9
|
+
import type {
|
|
10
|
+
DateRangeFilterDefinition,
|
|
11
|
+
DateRangeFilterValue,
|
|
12
|
+
FilterLabels,
|
|
13
|
+
} from "#/types.ts";
|
|
14
|
+
|
|
15
|
+
// Bounds are `YYYY-MM-DD` strings: that is what a native date input reads and
|
|
16
|
+
// writes, what a query string carries, and what survives a time zone unchanged.
|
|
17
|
+
const toIsoDay = (date: Date): string => {
|
|
18
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
19
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
20
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const fromIsoDay = (isoDate: string | undefined): Date | null => {
|
|
24
|
+
if (isoDate === undefined) return null;
|
|
25
|
+
const parsed = new Date(`${isoDate}T00:00:00`);
|
|
26
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One bound, on the surface that suits the device: the calendar popover on
|
|
31
|
+
* desktop, the OS date picker under the mobile breakpoint.
|
|
32
|
+
*/
|
|
33
|
+
function DateBoundField({
|
|
34
|
+
id,
|
|
35
|
+
value,
|
|
36
|
+
onValueChange,
|
|
37
|
+
placeholder,
|
|
38
|
+
min,
|
|
39
|
+
max,
|
|
40
|
+
locale,
|
|
41
|
+
}: {
|
|
42
|
+
readonly id?: string;
|
|
43
|
+
readonly value: string | undefined;
|
|
44
|
+
readonly onValueChange: (isoDate: string | undefined) => void;
|
|
45
|
+
readonly placeholder: string;
|
|
46
|
+
readonly min?: string;
|
|
47
|
+
readonly max?: string;
|
|
48
|
+
readonly locale: string;
|
|
49
|
+
}) {
|
|
50
|
+
const isMobile = useIsMobile();
|
|
51
|
+
|
|
52
|
+
if (isMobile) {
|
|
53
|
+
return (
|
|
54
|
+
<NativeDatePicker
|
|
55
|
+
id={id}
|
|
56
|
+
wrapperClassName="w-full"
|
|
57
|
+
aria-label={placeholder}
|
|
58
|
+
value={value ?? ""}
|
|
59
|
+
min={min}
|
|
60
|
+
max={max}
|
|
61
|
+
onChange={(event) =>
|
|
62
|
+
onValueChange(
|
|
63
|
+
event.target.value === "" ? undefined : event.target.value,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<DatePicker
|
|
72
|
+
id={id}
|
|
73
|
+
className="w-full"
|
|
74
|
+
locale={locale}
|
|
75
|
+
placeholder={placeholder}
|
|
76
|
+
aria-label={placeholder}
|
|
77
|
+
value={fromIsoDay(value)}
|
|
78
|
+
onValueChange={(date) =>
|
|
79
|
+
onValueChange(date === null ? undefined : toIsoDay(date))
|
|
80
|
+
}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A start and an end date, either of which may be left open. */
|
|
86
|
+
export function DateRangeFilterField({
|
|
87
|
+
definition,
|
|
88
|
+
value,
|
|
89
|
+
onValueChange,
|
|
90
|
+
labels,
|
|
91
|
+
locale,
|
|
92
|
+
}: {
|
|
93
|
+
readonly definition: DateRangeFilterDefinition;
|
|
94
|
+
readonly value: DateRangeFilterValue | undefined;
|
|
95
|
+
readonly onValueChange: (value: DateRangeFilterValue) => void;
|
|
96
|
+
readonly labels: FilterLabels;
|
|
97
|
+
readonly locale: string;
|
|
98
|
+
}) {
|
|
99
|
+
const controlId = useId();
|
|
100
|
+
const isEmpty = value?.from === undefined && value?.to === undefined;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<FilterFieldFrame
|
|
104
|
+
label={definition.label}
|
|
105
|
+
description={definition.description}
|
|
106
|
+
controlId={controlId}
|
|
107
|
+
labels={labels}
|
|
108
|
+
onClear={isEmpty ? undefined : () => onValueChange({ kind: "dateRange" })}
|
|
109
|
+
>
|
|
110
|
+
<FilterRangeRow>
|
|
111
|
+
<DateBoundField
|
|
112
|
+
id={controlId}
|
|
113
|
+
value={value?.from}
|
|
114
|
+
placeholder={labels.from}
|
|
115
|
+
min={definition.min}
|
|
116
|
+
max={value?.to ?? definition.max}
|
|
117
|
+
locale={locale}
|
|
118
|
+
onValueChange={(from) =>
|
|
119
|
+
onValueChange({ kind: "dateRange", from, to: value?.to })
|
|
120
|
+
}
|
|
121
|
+
/>
|
|
122
|
+
<DateBoundField
|
|
123
|
+
value={value?.to}
|
|
124
|
+
placeholder={labels.to}
|
|
125
|
+
min={value?.from ?? definition.min}
|
|
126
|
+
max={definition.max}
|
|
127
|
+
locale={locale}
|
|
128
|
+
onValueChange={(to) =>
|
|
129
|
+
onValueChange({ kind: "dateRange", from: value?.from, to })
|
|
130
|
+
}
|
|
131
|
+
/>
|
|
132
|
+
</FilterRangeRow>
|
|
133
|
+
</FilterFieldFrame>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { XIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { Button } from "@voila.dev/ui/components/button";
|
|
3
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import type { FilterLabels } from "#/types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The chrome every filter field shares: a label row that also hosts the
|
|
9
|
+
* "is / is not" switch and the clear action, then the control itself. One frame
|
|
10
|
+
* for all kinds is what makes a screenful of mixed filters read as one thing.
|
|
11
|
+
*/
|
|
12
|
+
export function FilterFieldFrame({
|
|
13
|
+
label,
|
|
14
|
+
description,
|
|
15
|
+
controlId,
|
|
16
|
+
operator,
|
|
17
|
+
onClear,
|
|
18
|
+
labels,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
readonly label: string;
|
|
22
|
+
readonly description?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Ties the visible label to the control it names. Omitted by the group
|
|
25
|
+
* fields (a `<label>` would otherwise rename their first button), which name
|
|
26
|
+
* themselves through their fieldset legend.
|
|
27
|
+
*/
|
|
28
|
+
readonly controlId?: string;
|
|
29
|
+
/** The "is / is not" switch, when the field supports exclusion. */
|
|
30
|
+
readonly operator?: ReactNode;
|
|
31
|
+
/** Omitted while the field is empty — nothing to clear. */
|
|
32
|
+
readonly onClear?: () => void;
|
|
33
|
+
readonly labels: FilterLabels;
|
|
34
|
+
readonly children: ReactNode;
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex min-w-0 flex-col gap-2" data-slot="filter-field">
|
|
38
|
+
{/* `min-h-9` is the clear button's height: the row reserves it whether or
|
|
39
|
+
not the button is there, so filling a field doesn't shove the one
|
|
40
|
+
below it down. */}
|
|
41
|
+
<div className="flex min-h-9 flex-wrap items-center gap-x-3 gap-y-1">
|
|
42
|
+
<label
|
|
43
|
+
htmlFor={controlId}
|
|
44
|
+
className="font-medium text-foreground text-sm"
|
|
45
|
+
>
|
|
46
|
+
{label}
|
|
47
|
+
</label>
|
|
48
|
+
{operator}
|
|
49
|
+
{onClear !== undefined && (
|
|
50
|
+
<Button
|
|
51
|
+
type="button"
|
|
52
|
+
variant="ghost"
|
|
53
|
+
size="sm"
|
|
54
|
+
className="ml-auto text-muted-foreground"
|
|
55
|
+
onClick={onClear}
|
|
56
|
+
>
|
|
57
|
+
<XIcon />
|
|
58
|
+
{labels.clear}
|
|
59
|
+
</Button>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
{description !== undefined && (
|
|
63
|
+
<p className="text-muted-foreground text-xs">{description}</p>
|
|
64
|
+
)}
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The inversion switch: two segments, "is" and "is not". A checkbox reads as
|
|
72
|
+
* an option you might miss; two segments state the operator outright, which is
|
|
73
|
+
* what an exclusion filter has to do to be trusted.
|
|
74
|
+
*/
|
|
75
|
+
export function FilterOperatorToggle({
|
|
76
|
+
excluded,
|
|
77
|
+
onExcludedChange,
|
|
78
|
+
labels,
|
|
79
|
+
disabled = false,
|
|
80
|
+
}: {
|
|
81
|
+
readonly excluded: boolean;
|
|
82
|
+
readonly onExcludedChange: (excluded: boolean) => void;
|
|
83
|
+
readonly labels: FilterLabels;
|
|
84
|
+
/**
|
|
85
|
+
* An empty field has nothing to invert — "is not <nothing>" filters nothing —
|
|
86
|
+
* so the switch stays inert until the field holds a value.
|
|
87
|
+
*/
|
|
88
|
+
readonly disabled?: boolean;
|
|
89
|
+
}) {
|
|
90
|
+
return (
|
|
91
|
+
<fieldset
|
|
92
|
+
data-slot="filter-operator"
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
className={cn(
|
|
95
|
+
"inline-flex items-center gap-0.5 rounded-full bg-muted p-1",
|
|
96
|
+
disabled && "opacity-50",
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<legend className="sr-only">{`${labels.is} / ${labels.isNot}`}</legend>
|
|
100
|
+
{[false, true].map((isExcluded) => (
|
|
101
|
+
<button
|
|
102
|
+
key={String(isExcluded)}
|
|
103
|
+
type="button"
|
|
104
|
+
// Also on the button, not only the fieldset: assistive tech and
|
|
105
|
+
// tests read the property, which a fieldset does not propagate.
|
|
106
|
+
disabled={disabled}
|
|
107
|
+
aria-pressed={excluded === isExcluded}
|
|
108
|
+
onClick={() => onExcludedChange(isExcluded)}
|
|
109
|
+
// Segments are thumb-sized (min 44px wide, 28px tall inside a 36px
|
|
110
|
+
// row): the earlier text-xs pills were a mouse-only target.
|
|
111
|
+
className={cn(
|
|
112
|
+
"min-h-7 min-w-11 cursor-pointer rounded-full px-3 text-sm transition-colors disabled:cursor-not-allowed",
|
|
113
|
+
excluded === isExcluded
|
|
114
|
+
? "bg-background font-medium text-foreground shadow-xs"
|
|
115
|
+
: "text-muted-foreground hover:text-foreground",
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
{isExcluded ? labels.isNot : labels.is}
|
|
119
|
+
</button>
|
|
120
|
+
))}
|
|
121
|
+
</fieldset>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Two bounds side by side — the layout shared by every range field. */
|
|
126
|
+
export function FilterRangeRow({ children }: { readonly children: ReactNode }) {
|
|
127
|
+
return (
|
|
128
|
+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">{children}</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { MapPinIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { Button } from "@voila.dev/ui/components/button";
|
|
3
|
+
import { Input } from "@voila.dev/ui/components/input";
|
|
4
|
+
import { Slider } from "@voila.dev/ui/components/slider";
|
|
5
|
+
import { RadiusMap } from "@voila.dev/ui-map/components/radius-map";
|
|
6
|
+
import { useEffect, useId, useState } from "react";
|
|
7
|
+
import { FilterFieldFrame } from "#/components/fields/field-frame.tsx";
|
|
8
|
+
import type {
|
|
9
|
+
FilterLabels,
|
|
10
|
+
GeoRadiusFilterDefinition,
|
|
11
|
+
GeoRadiusFilterValue,
|
|
12
|
+
PlaceSuggestion,
|
|
13
|
+
} from "#/types.ts";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MIN_KM = 5;
|
|
16
|
+
const DEFAULT_MAX_KM = 200;
|
|
17
|
+
const DEFAULT_STEP_KM = 5;
|
|
18
|
+
const DEFAULT_RADIUS_KM = 30;
|
|
19
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
20
|
+
|
|
21
|
+
/** Debounced place lookup: a keystroke is not a query. */
|
|
22
|
+
function usePlaceSearch(
|
|
23
|
+
query: string,
|
|
24
|
+
searchPlaces: GeoRadiusFilterDefinition["searchPlaces"],
|
|
25
|
+
): {
|
|
26
|
+
readonly results: ReadonlyArray<PlaceSuggestion>;
|
|
27
|
+
readonly busy: boolean;
|
|
28
|
+
} {
|
|
29
|
+
const [results, setResults] = useState<ReadonlyArray<PlaceSuggestion>>([]);
|
|
30
|
+
const [busy, setBusy] = useState(false);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const trimmed = query.trim();
|
|
34
|
+
if (trimmed.length < 2) {
|
|
35
|
+
setResults([]);
|
|
36
|
+
setBusy(false);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setBusy(true);
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
const handle = setTimeout(() => {
|
|
42
|
+
searchPlaces(trimmed)
|
|
43
|
+
.then((found) => {
|
|
44
|
+
if (!cancelled) {
|
|
45
|
+
setResults(found);
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {
|
|
49
|
+
if (!cancelled) {
|
|
50
|
+
setResults([]);
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
.finally(() => {
|
|
54
|
+
if (!cancelled) {
|
|
55
|
+
setBusy(false);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
59
|
+
return () => {
|
|
60
|
+
cancelled = true;
|
|
61
|
+
clearTimeout(handle);
|
|
62
|
+
};
|
|
63
|
+
}, [query, searchPlaces]);
|
|
64
|
+
|
|
65
|
+
return { results, busy };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function PlaceResults({
|
|
69
|
+
results,
|
|
70
|
+
busy,
|
|
71
|
+
query,
|
|
72
|
+
labels,
|
|
73
|
+
onPick,
|
|
74
|
+
}: {
|
|
75
|
+
readonly results: ReadonlyArray<PlaceSuggestion>;
|
|
76
|
+
readonly busy: boolean;
|
|
77
|
+
readonly query: string;
|
|
78
|
+
readonly labels: FilterLabels;
|
|
79
|
+
readonly onPick: (place: PlaceSuggestion) => void;
|
|
80
|
+
}) {
|
|
81
|
+
if (busy) {
|
|
82
|
+
return (
|
|
83
|
+
<p className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
84
|
+
<SpinnerGapIcon className="size-4 animate-spin" />
|
|
85
|
+
{labels.search}
|
|
86
|
+
</p>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (query.trim().length >= 2 && results.length === 0) {
|
|
90
|
+
return (
|
|
91
|
+
<p className="text-muted-foreground text-sm">{labels.placeNoResults}</p>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return (
|
|
95
|
+
<ul className="flex flex-col gap-1">
|
|
96
|
+
{results.map((place) => (
|
|
97
|
+
<li key={place.id}>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => onPick(place)}
|
|
101
|
+
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"
|
|
102
|
+
>
|
|
103
|
+
<MapPinIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
104
|
+
<span className="truncate">{place.label}</span>
|
|
105
|
+
</button>
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* "Within N km of somewhere": pick a place, then size the circle. The map is
|
|
114
|
+
* the answer to "is that the area I meant?" — a lat/lon pair and a number are
|
|
115
|
+
* not something anyone can picture.
|
|
116
|
+
*/
|
|
117
|
+
export function GeoRadiusFilterField({
|
|
118
|
+
definition,
|
|
119
|
+
value,
|
|
120
|
+
onValueChange,
|
|
121
|
+
labels,
|
|
122
|
+
}: {
|
|
123
|
+
readonly definition: GeoRadiusFilterDefinition;
|
|
124
|
+
readonly value: GeoRadiusFilterValue | undefined;
|
|
125
|
+
readonly onValueChange: (value: GeoRadiusFilterValue | undefined) => void;
|
|
126
|
+
readonly labels: FilterLabels;
|
|
127
|
+
}) {
|
|
128
|
+
const controlId = useId();
|
|
129
|
+
const [query, setQuery] = useState("");
|
|
130
|
+
const { results, busy } = usePlaceSearch(query, definition.searchPlaces);
|
|
131
|
+
|
|
132
|
+
const minKm = definition.minKm ?? DEFAULT_MIN_KM;
|
|
133
|
+
const maxKm = definition.maxKm ?? DEFAULT_MAX_KM;
|
|
134
|
+
|
|
135
|
+
if (value === undefined) {
|
|
136
|
+
return (
|
|
137
|
+
<FilterFieldFrame
|
|
138
|
+
label={definition.label}
|
|
139
|
+
description={definition.description}
|
|
140
|
+
controlId={controlId}
|
|
141
|
+
labels={labels}
|
|
142
|
+
>
|
|
143
|
+
<Input
|
|
144
|
+
id={controlId}
|
|
145
|
+
value={query}
|
|
146
|
+
placeholder={labels.placePlaceholder}
|
|
147
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
148
|
+
/>
|
|
149
|
+
<PlaceResults
|
|
150
|
+
results={results}
|
|
151
|
+
busy={busy}
|
|
152
|
+
query={query}
|
|
153
|
+
labels={labels}
|
|
154
|
+
onPick={(place) => {
|
|
155
|
+
setQuery("");
|
|
156
|
+
onValueChange({
|
|
157
|
+
kind: "geoRadius",
|
|
158
|
+
place,
|
|
159
|
+
radiusKm: definition.defaultKm ?? DEFAULT_RADIUS_KM,
|
|
160
|
+
});
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
</FilterFieldFrame>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<FilterFieldFrame
|
|
169
|
+
label={definition.label}
|
|
170
|
+
description={definition.description}
|
|
171
|
+
labels={labels}
|
|
172
|
+
onClear={() => onValueChange(undefined)}
|
|
173
|
+
>
|
|
174
|
+
<div className="flex items-center gap-2">
|
|
175
|
+
<MapPinIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
176
|
+
<span className="min-w-0 flex-1 truncate text-sm">
|
|
177
|
+
{value.place.label}
|
|
178
|
+
</span>
|
|
179
|
+
<Button
|
|
180
|
+
type="button"
|
|
181
|
+
variant="ghost"
|
|
182
|
+
size="sm"
|
|
183
|
+
onClick={() => onValueChange(undefined)}
|
|
184
|
+
>
|
|
185
|
+
{labels.changePlace}
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<RadiusMap center={value.place} radiusKm={value.radiusKm} />
|
|
190
|
+
|
|
191
|
+
<div className="flex items-center gap-3">
|
|
192
|
+
<span className="shrink-0 text-muted-foreground text-sm">
|
|
193
|
+
{labels.radius}
|
|
194
|
+
</span>
|
|
195
|
+
<Slider
|
|
196
|
+
aria-label={labels.radius}
|
|
197
|
+
className="flex-1"
|
|
198
|
+
min={minKm}
|
|
199
|
+
max={maxKm}
|
|
200
|
+
step={definition.stepKm ?? DEFAULT_STEP_KM}
|
|
201
|
+
value={value.radiusKm}
|
|
202
|
+
onValueChange={(next) =>
|
|
203
|
+
onValueChange({
|
|
204
|
+
...value,
|
|
205
|
+
radiusKm: Array.isArray(next) ? (next[0] ?? minKm) : next,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
/>
|
|
209
|
+
<span className="w-16 shrink-0 text-right text-sm tabular-nums">
|
|
210
|
+
{`${value.radiusKm} km`}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
</FilterFieldFrame>
|
|
214
|
+
);
|
|
215
|
+
}
|