@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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { BooleanFilterField } from "#/components/fields/boolean-filter-field.tsx";
|
|
2
|
+
import { DateRangeFilterField } from "#/components/fields/date-range-filter-field.tsx";
|
|
3
|
+
import { GeoRadiusFilterField } from "#/components/fields/geo-radius-filter-field.tsx";
|
|
4
|
+
import { MoneyRangeFilterField } from "#/components/fields/money-range-filter-field.tsx";
|
|
5
|
+
import {
|
|
6
|
+
NumberFilterField,
|
|
7
|
+
NumberRangeFilterField,
|
|
8
|
+
} from "#/components/fields/number-filter-field.tsx";
|
|
9
|
+
import { SelectFilterField } from "#/components/fields/select-filter-field.tsx";
|
|
10
|
+
import { TextFilterField } from "#/components/fields/text-filter-field.tsx";
|
|
11
|
+
import type { FilterDefinition, FilterLabels, FilterValue } from "#/types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders the editor a definition asks for. The value union is discriminated by
|
|
15
|
+
* the same `kind` as the definition, so each branch narrows both at once — a new
|
|
16
|
+
* filter kind is a compile error here until it has an editor.
|
|
17
|
+
*/
|
|
18
|
+
export function FilterField({
|
|
19
|
+
definition,
|
|
20
|
+
value,
|
|
21
|
+
onValueChange,
|
|
22
|
+
labels,
|
|
23
|
+
locale,
|
|
24
|
+
}: {
|
|
25
|
+
readonly definition: FilterDefinition;
|
|
26
|
+
readonly value: FilterValue | undefined;
|
|
27
|
+
readonly onValueChange: (value: FilterValue | undefined) => void;
|
|
28
|
+
readonly labels: FilterLabels;
|
|
29
|
+
readonly locale: string;
|
|
30
|
+
}) {
|
|
31
|
+
const valueOfKind = <Kind extends FilterValue["kind"]>(kind: Kind) =>
|
|
32
|
+
value?.kind === kind
|
|
33
|
+
? (value as Extract<FilterValue, { kind: Kind }>)
|
|
34
|
+
: undefined;
|
|
35
|
+
|
|
36
|
+
switch (definition.kind) {
|
|
37
|
+
case "text":
|
|
38
|
+
return (
|
|
39
|
+
<TextFilterField
|
|
40
|
+
definition={definition}
|
|
41
|
+
value={valueOfKind("text")}
|
|
42
|
+
onValueChange={onValueChange}
|
|
43
|
+
labels={labels}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
case "number":
|
|
47
|
+
return (
|
|
48
|
+
<NumberFilterField
|
|
49
|
+
definition={definition}
|
|
50
|
+
value={valueOfKind("number")}
|
|
51
|
+
onValueChange={onValueChange}
|
|
52
|
+
labels={labels}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
case "numberRange":
|
|
56
|
+
return (
|
|
57
|
+
<NumberRangeFilterField
|
|
58
|
+
definition={definition}
|
|
59
|
+
value={valueOfKind("numberRange")}
|
|
60
|
+
onValueChange={onValueChange}
|
|
61
|
+
labels={labels}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
case "moneyRange":
|
|
65
|
+
return (
|
|
66
|
+
<MoneyRangeFilterField
|
|
67
|
+
definition={definition}
|
|
68
|
+
value={valueOfKind("moneyRange")}
|
|
69
|
+
onValueChange={onValueChange}
|
|
70
|
+
labels={labels}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
case "select":
|
|
74
|
+
return (
|
|
75
|
+
<SelectFilterField
|
|
76
|
+
definition={definition}
|
|
77
|
+
value={valueOfKind("select")}
|
|
78
|
+
onValueChange={onValueChange}
|
|
79
|
+
labels={labels}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
case "dateRange":
|
|
83
|
+
return (
|
|
84
|
+
<DateRangeFilterField
|
|
85
|
+
definition={definition}
|
|
86
|
+
value={valueOfKind("dateRange")}
|
|
87
|
+
onValueChange={onValueChange}
|
|
88
|
+
labels={labels}
|
|
89
|
+
locale={locale}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
case "geoRadius":
|
|
93
|
+
return (
|
|
94
|
+
<GeoRadiusFilterField
|
|
95
|
+
definition={definition}
|
|
96
|
+
value={valueOfKind("geoRadius")}
|
|
97
|
+
onValueChange={onValueChange}
|
|
98
|
+
labels={labels}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
case "boolean":
|
|
102
|
+
return (
|
|
103
|
+
<BooleanFilterField
|
|
104
|
+
definition={definition}
|
|
105
|
+
value={valueOfKind("boolean")}
|
|
106
|
+
onValueChange={onValueChange}
|
|
107
|
+
labels={labels}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Separator } from "@voila.dev/ui/components/separator";
|
|
2
|
+
import { Fragment } from "react";
|
|
3
|
+
import { FilterField } from "#/components/filter-field.tsx";
|
|
4
|
+
import { setFilterValue } from "#/lib/filter-values.ts";
|
|
5
|
+
import type { FilterDefinition, FilterLabels, FilterValues } from "#/types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The editor: every declared filter, in declaration order, separated so a long
|
|
9
|
+
* panel stays scannable. It edits a whole `FilterValues` record — clearing a
|
|
10
|
+
* field removes its key, so the record never carries empty filters.
|
|
11
|
+
*/
|
|
12
|
+
export function FilterForm({
|
|
13
|
+
definitions,
|
|
14
|
+
values,
|
|
15
|
+
onValuesChange,
|
|
16
|
+
labels,
|
|
17
|
+
locale,
|
|
18
|
+
}: {
|
|
19
|
+
readonly definitions: ReadonlyArray<FilterDefinition>;
|
|
20
|
+
readonly values: FilterValues;
|
|
21
|
+
readonly onValuesChange: (values: FilterValues) => void;
|
|
22
|
+
readonly labels: FilterLabels;
|
|
23
|
+
readonly locale: string;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex flex-col gap-5" data-slot="filter-form">
|
|
27
|
+
{definitions.map((definition, index) => (
|
|
28
|
+
<Fragment key={definition.key}>
|
|
29
|
+
{index > 0 && <Separator />}
|
|
30
|
+
<FilterField
|
|
31
|
+
definition={definition}
|
|
32
|
+
value={values[definition.key]}
|
|
33
|
+
locale={locale}
|
|
34
|
+
labels={labels}
|
|
35
|
+
onValueChange={(value) =>
|
|
36
|
+
onValuesChange(setFilterValue(values, definition.key, value))
|
|
37
|
+
}
|
|
38
|
+
/>
|
|
39
|
+
</Fragment>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Button } from "@voila.dev/ui/components/button";
|
|
2
|
+
import { Input } from "@voila.dev/ui/components/input";
|
|
3
|
+
import {
|
|
4
|
+
ResponsiveDialog,
|
|
5
|
+
ResponsiveDialogBody,
|
|
6
|
+
ResponsiveDialogContent,
|
|
7
|
+
ResponsiveDialogDescription,
|
|
8
|
+
ResponsiveDialogFooter,
|
|
9
|
+
ResponsiveDialogHeader,
|
|
10
|
+
ResponsiveDialogTitle,
|
|
11
|
+
} from "@voila.dev/ui/components/responsive-dialog";
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
import { FilterForm } from "#/components/filter-form.tsx";
|
|
14
|
+
import { countActiveFilters } from "#/lib/filter-values.ts";
|
|
15
|
+
import type { FilterDefinition, FilterLabels, FilterValues } from "#/types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The editor as an overlay: a centered dialog on desktop, a bottom drawer on
|
|
19
|
+
* mobile. Edits are a draft committed on "Apply" — a filtered list refetches,
|
|
20
|
+
* and refetching on every keystroke of a half-typed filter is both slower and
|
|
21
|
+
* harder to reason about than one deliberate commit. Dismissing discards.
|
|
22
|
+
*/
|
|
23
|
+
export function FilterPanel({
|
|
24
|
+
open,
|
|
25
|
+
onOpenChange,
|
|
26
|
+
definitions,
|
|
27
|
+
values,
|
|
28
|
+
onValuesChange,
|
|
29
|
+
labels,
|
|
30
|
+
locale,
|
|
31
|
+
searchValue,
|
|
32
|
+
onSearchChange,
|
|
33
|
+
}: {
|
|
34
|
+
readonly open: boolean;
|
|
35
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
36
|
+
readonly definitions: ReadonlyArray<FilterDefinition>;
|
|
37
|
+
readonly values: FilterValues;
|
|
38
|
+
/** Called once, on apply, with the committed record. */
|
|
39
|
+
readonly onValuesChange: (values: FilterValues) => void;
|
|
40
|
+
readonly labels: FilterLabels;
|
|
41
|
+
readonly locale: string;
|
|
42
|
+
/** Free-text search, kept beside the filters when the list has one. */
|
|
43
|
+
readonly searchValue?: string;
|
|
44
|
+
readonly onSearchChange?: (value: string) => void;
|
|
45
|
+
}) {
|
|
46
|
+
const [draft, setDraft] = useState<FilterValues>(values);
|
|
47
|
+
const [draftSearch, setDraftSearch] = useState(searchValue ?? "");
|
|
48
|
+
|
|
49
|
+
// Reopening starts from what is applied — including changes made meanwhile
|
|
50
|
+
// by removing a chip outside the panel.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (open) {
|
|
53
|
+
setDraft(values);
|
|
54
|
+
setDraftSearch(searchValue ?? "");
|
|
55
|
+
}
|
|
56
|
+
}, [open, values, searchValue]);
|
|
57
|
+
|
|
58
|
+
const apply = () => {
|
|
59
|
+
onValuesChange(draft);
|
|
60
|
+
onSearchChange?.(draftSearch);
|
|
61
|
+
onOpenChange(false);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const draftCount = countActiveFilters(draft);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
|
|
68
|
+
{/* No autofocus: the first field is the search box, and focusing it on
|
|
69
|
+
open throws the phone keyboard over the filters you came to read. */}
|
|
70
|
+
{/* `overflow-hidden` on the shell leaves exactly one scroll container —
|
|
71
|
+
the body below. The dialog half scrolls itself by default, which
|
|
72
|
+
would stack a second scrollbar next to it. */}
|
|
73
|
+
<ResponsiveDialogContent
|
|
74
|
+
size="lg"
|
|
75
|
+
className="overflow-hidden"
|
|
76
|
+
closeButtonLabel={labels.close}
|
|
77
|
+
autoFocus={false}
|
|
78
|
+
>
|
|
79
|
+
<ResponsiveDialogHeader>
|
|
80
|
+
<ResponsiveDialogTitle>{labels.title}</ResponsiveDialogTitle>
|
|
81
|
+
{labels.description !== undefined && (
|
|
82
|
+
<ResponsiveDialogDescription>
|
|
83
|
+
{labels.description}
|
|
84
|
+
</ResponsiveDialogDescription>
|
|
85
|
+
)}
|
|
86
|
+
</ResponsiveDialogHeader>
|
|
87
|
+
|
|
88
|
+
{/* The scroll container would clip the focus ring of a field sitting
|
|
89
|
+
against its edge (the search box does). The inset gutter gives the
|
|
90
|
+
ring its room back without narrowing the fields — the mobile half
|
|
91
|
+
already has `px-4` for that. */}
|
|
92
|
+
<ResponsiveDialogBody className="no-scrollbar flex max-h-[60vh] min-w-0 flex-col gap-5 overflow-x-hidden overflow-y-auto py-1 md:-mx-1 md:px-1">
|
|
93
|
+
{onSearchChange !== undefined && (
|
|
94
|
+
<Input
|
|
95
|
+
type="search"
|
|
96
|
+
aria-label={labels.search}
|
|
97
|
+
placeholder={labels.searchPlaceholder}
|
|
98
|
+
value={draftSearch}
|
|
99
|
+
onChange={(event) => setDraftSearch(event.target.value)}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
<FilterForm
|
|
103
|
+
definitions={definitions}
|
|
104
|
+
values={draft}
|
|
105
|
+
onValuesChange={setDraft}
|
|
106
|
+
labels={labels}
|
|
107
|
+
locale={locale}
|
|
108
|
+
/>
|
|
109
|
+
</ResponsiveDialogBody>
|
|
110
|
+
|
|
111
|
+
<ResponsiveDialogFooter>
|
|
112
|
+
<Button
|
|
113
|
+
type="button"
|
|
114
|
+
variant="ghost"
|
|
115
|
+
disabled={draftCount === 0 && draftSearch === ""}
|
|
116
|
+
onClick={() => {
|
|
117
|
+
setDraft({});
|
|
118
|
+
setDraftSearch("");
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{labels.clearAll}
|
|
122
|
+
</Button>
|
|
123
|
+
<Button type="button" onClick={apply}>
|
|
124
|
+
{labels.apply}
|
|
125
|
+
</Button>
|
|
126
|
+
</ResponsiveDialogFooter>
|
|
127
|
+
</ResponsiveDialogContent>
|
|
128
|
+
</ResponsiveDialog>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MagnifyingGlassIcon,
|
|
3
|
+
SlidersHorizontalIcon,
|
|
4
|
+
} from "@phosphor-icons/react";
|
|
5
|
+
import { Badge } from "@voila.dev/ui/components/badge";
|
|
6
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
7
|
+
import type { FilterLabels } from "#/types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one control a filtered list shows at rest: a search-field-shaped button.
|
|
11
|
+
* It reads as a search box because searching is what people come to a list to
|
|
12
|
+
* do, and it carries the active-filter count so a filtered list never looks
|
|
13
|
+
* like a short one. Tapping it opens the editor.
|
|
14
|
+
*/
|
|
15
|
+
export function FilterTrigger({
|
|
16
|
+
summary,
|
|
17
|
+
activeCount,
|
|
18
|
+
labels,
|
|
19
|
+
className,
|
|
20
|
+
...props
|
|
21
|
+
}: React.ComponentProps<"button"> & {
|
|
22
|
+
/** Current search text, or the placeholder when nothing is searched yet. */
|
|
23
|
+
readonly summary?: string;
|
|
24
|
+
readonly activeCount: number;
|
|
25
|
+
readonly labels: FilterLabels;
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
data-slot="filter-trigger"
|
|
31
|
+
aria-label={labels.trigger}
|
|
32
|
+
className={cn(
|
|
33
|
+
"flex h-9 w-full cursor-pointer items-center gap-2 rounded-lg border border-input bg-background px-3 text-left text-sm transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none dark:bg-input/30",
|
|
34
|
+
className,
|
|
35
|
+
)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
<MagnifyingGlassIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
39
|
+
<span
|
|
40
|
+
className={cn(
|
|
41
|
+
"truncate",
|
|
42
|
+
summary === undefined && "text-muted-foreground",
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{summary ?? labels.searchPlaceholder}
|
|
46
|
+
</span>
|
|
47
|
+
<span className="ml-auto flex shrink-0 items-center gap-2">
|
|
48
|
+
{activeCount > 0 && (
|
|
49
|
+
<Badge variant="default" size="sm">
|
|
50
|
+
{activeCount}
|
|
51
|
+
</Badge>
|
|
52
|
+
)}
|
|
53
|
+
<SlidersHorizontalIcon className="size-4 text-muted-foreground" />
|
|
54
|
+
</span>
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BooleanFilterValue,
|
|
3
|
+
DateRangeFilterValue,
|
|
4
|
+
FilterDefinition,
|
|
5
|
+
FilterLabels,
|
|
6
|
+
FilterValue,
|
|
7
|
+
FilterValues,
|
|
8
|
+
MoneyRangeFilterValue,
|
|
9
|
+
NumberRangeFilterValue,
|
|
10
|
+
SelectFilterValue,
|
|
11
|
+
} from "#/types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Is this value worth keeping? An "empty" value (blank text, no selection, both
|
|
15
|
+
* range bounds missing) is indistinguishable from no filter at all, so the
|
|
16
|
+
* record drops it — that keeps the active count, the chips and the query in
|
|
17
|
+
* agreement without every caller re-deriving emptiness.
|
|
18
|
+
*/
|
|
19
|
+
export function isFilterValueEmpty(value: FilterValue): boolean {
|
|
20
|
+
switch (value.kind) {
|
|
21
|
+
case "text":
|
|
22
|
+
return value.text.trim() === "";
|
|
23
|
+
case "number":
|
|
24
|
+
return Number.isNaN(value.number);
|
|
25
|
+
case "numberRange":
|
|
26
|
+
case "moneyRange":
|
|
27
|
+
return value.min === undefined && value.max === undefined;
|
|
28
|
+
case "select":
|
|
29
|
+
return value.values.length === 0;
|
|
30
|
+
case "dateRange":
|
|
31
|
+
return value.from === undefined && value.to === undefined;
|
|
32
|
+
case "geoRadius":
|
|
33
|
+
return false;
|
|
34
|
+
case "boolean":
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Set (or clear, when empty) one filter, returning a new record. */
|
|
40
|
+
export function setFilterValue(
|
|
41
|
+
values: FilterValues,
|
|
42
|
+
key: string,
|
|
43
|
+
value: FilterValue | undefined,
|
|
44
|
+
): FilterValues {
|
|
45
|
+
const next = { ...values };
|
|
46
|
+
if (value === undefined || isFilterValueEmpty(value)) {
|
|
47
|
+
delete next[key];
|
|
48
|
+
} else {
|
|
49
|
+
next[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop one filter, returning a new record. */
|
|
55
|
+
export function clearFilterValue(
|
|
56
|
+
values: FilterValues,
|
|
57
|
+
key: string,
|
|
58
|
+
): FilterValues {
|
|
59
|
+
return setFilterValue(values, key, undefined);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How many filters are set — the badge on the trigger. */
|
|
63
|
+
export function countActiveFilters(values: FilterValues): number {
|
|
64
|
+
return Object.keys(values).length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const formatNumber = (value: number, locale: string): string =>
|
|
68
|
+
new Intl.NumberFormat(locale).format(value);
|
|
69
|
+
|
|
70
|
+
const formatMoney = (
|
|
71
|
+
minorUnits: number,
|
|
72
|
+
currency: string,
|
|
73
|
+
locale: string,
|
|
74
|
+
): string =>
|
|
75
|
+
new Intl.NumberFormat(locale, {
|
|
76
|
+
style: "currency",
|
|
77
|
+
currency,
|
|
78
|
+
maximumFractionDigits: minorUnits % 100 === 0 ? 0 : 2,
|
|
79
|
+
}).format(minorUnits / 100);
|
|
80
|
+
|
|
81
|
+
const formatDate = (isoDate: string, locale: string): string => {
|
|
82
|
+
const parsed = new Date(`${isoDate}T00:00:00`);
|
|
83
|
+
return Number.isNaN(parsed.getTime())
|
|
84
|
+
? isoDate
|
|
85
|
+
: new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(parsed);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** `10 – 20`, `≥ 10`, `≤ 20` — the shape every range chip reads in. */
|
|
89
|
+
function describeBounds(
|
|
90
|
+
min: string | undefined,
|
|
91
|
+
max: string | undefined,
|
|
92
|
+
): string {
|
|
93
|
+
if (min !== undefined && max !== undefined) return `${min} – ${max}`;
|
|
94
|
+
if (min !== undefined) return `≥ ${min}`;
|
|
95
|
+
if (max !== undefined) return `≤ ${max}`;
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What every describer needs beyond the value itself. */
|
|
100
|
+
type DescribeContext = {
|
|
101
|
+
readonly definition: FilterDefinition;
|
|
102
|
+
readonly labels: FilterLabels;
|
|
103
|
+
readonly locale: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const operatorOf = (
|
|
107
|
+
excluded: boolean | undefined,
|
|
108
|
+
{ labels }: DescribeContext,
|
|
109
|
+
): string => (excluded === true ? labels.isNot : labels.is);
|
|
110
|
+
|
|
111
|
+
/** A value kind can outlive the definition it was written against (a stale URL). */
|
|
112
|
+
const unitOf = ({ definition }: DescribeContext): string =>
|
|
113
|
+
(definition.kind === "number" || definition.kind === "numberRange") &&
|
|
114
|
+
definition.unit !== undefined
|
|
115
|
+
? ` ${definition.unit}`
|
|
116
|
+
: "";
|
|
117
|
+
|
|
118
|
+
function describeNumberRange(
|
|
119
|
+
value: NumberRangeFilterValue,
|
|
120
|
+
context: DescribeContext,
|
|
121
|
+
): string {
|
|
122
|
+
const unit = unitOf(context);
|
|
123
|
+
const format = (bound: number | undefined) =>
|
|
124
|
+
bound === undefined
|
|
125
|
+
? undefined
|
|
126
|
+
: `${formatNumber(bound, context.locale)}${unit}`;
|
|
127
|
+
return describeBounds(format(value.min), format(value.max));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function describeMoneyRange(
|
|
131
|
+
value: MoneyRangeFilterValue,
|
|
132
|
+
{ definition, locale }: DescribeContext,
|
|
133
|
+
): string {
|
|
134
|
+
const currency =
|
|
135
|
+
definition.kind === "moneyRange" ? definition.currency : "EUR";
|
|
136
|
+
const format = (bound: number | undefined) =>
|
|
137
|
+
bound === undefined ? undefined : formatMoney(bound, currency, locale);
|
|
138
|
+
return describeBounds(format(value.min), format(value.max));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function describeSelect(
|
|
142
|
+
value: SelectFilterValue,
|
|
143
|
+
context: DescribeContext,
|
|
144
|
+
): string {
|
|
145
|
+
const { definition, labels } = context;
|
|
146
|
+
const options = definition.kind === "select" ? definition.options : [];
|
|
147
|
+
const labelOf = (optionValue: string) =>
|
|
148
|
+
options.find((option) => option.value === optionValue)?.label ??
|
|
149
|
+
optionValue;
|
|
150
|
+
// Beyond two selections the chip states the count: a chip is a summary, not
|
|
151
|
+
// a list, and the panel is one tap away for the detail.
|
|
152
|
+
const rendered =
|
|
153
|
+
value.values.length > 2
|
|
154
|
+
? labels.selectedCount(value.values.length)
|
|
155
|
+
: value.values.map(labelOf).join(", ");
|
|
156
|
+
return `${definition.label} ${operatorOf(value.excluded, context)} ${rendered}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function describeBoolean(
|
|
160
|
+
value: BooleanFilterValue,
|
|
161
|
+
{ definition }: DescribeContext,
|
|
162
|
+
): string {
|
|
163
|
+
if (definition.kind !== "boolean") return String(value.value);
|
|
164
|
+
return value.value ? definition.trueLabel : definition.falseLabel;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function describeDateRange(
|
|
168
|
+
value: DateRangeFilterValue,
|
|
169
|
+
{ locale }: DescribeContext,
|
|
170
|
+
): string {
|
|
171
|
+
const format = (bound: string | undefined) =>
|
|
172
|
+
bound === undefined ? undefined : formatDate(bound, locale);
|
|
173
|
+
return describeBounds(format(value.from), format(value.to));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The human sentence for one active filter, as shown on its chip: the label,
|
|
178
|
+
* the operator when the filter is inverted, and the value in the reader's
|
|
179
|
+
* locale. Returns `null` for a value that has no definition anymore (a filter
|
|
180
|
+
* removed from the screen but still in a bookmarked URL).
|
|
181
|
+
*/
|
|
182
|
+
export function describeFilterValue({
|
|
183
|
+
definition,
|
|
184
|
+
value,
|
|
185
|
+
labels,
|
|
186
|
+
locale,
|
|
187
|
+
}: {
|
|
188
|
+
definition: FilterDefinition | undefined;
|
|
189
|
+
value: FilterValue;
|
|
190
|
+
labels: FilterLabels;
|
|
191
|
+
locale: string;
|
|
192
|
+
}): string | null {
|
|
193
|
+
if (definition === undefined) return null;
|
|
194
|
+
const context: DescribeContext = { definition, labels, locale };
|
|
195
|
+
const labelled = (rendered: string) => `${definition.label}: ${rendered}`;
|
|
196
|
+
|
|
197
|
+
switch (value.kind) {
|
|
198
|
+
case "text":
|
|
199
|
+
return `${definition.label} ${operatorOf(value.excluded, context)} ${value.text}`;
|
|
200
|
+
case "number":
|
|
201
|
+
return labelled(
|
|
202
|
+
`${formatNumber(value.number, locale)}${unitOf(context)}`,
|
|
203
|
+
);
|
|
204
|
+
case "numberRange":
|
|
205
|
+
return labelled(describeNumberRange(value, context));
|
|
206
|
+
case "moneyRange":
|
|
207
|
+
return labelled(describeMoneyRange(value, context));
|
|
208
|
+
case "select":
|
|
209
|
+
return describeSelect(value, context);
|
|
210
|
+
case "dateRange":
|
|
211
|
+
return labelled(describeDateRange(value, context));
|
|
212
|
+
case "geoRadius":
|
|
213
|
+
return labelled(labels.around(value.place.label, value.radiusKm));
|
|
214
|
+
case "boolean":
|
|
215
|
+
return labelled(describeBoolean(value, context));
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind must scan this package or none of its classes are generated — the
|
|
3
|
+
* components ship as .tsx source, not as compiled CSS.
|
|
4
|
+
*
|
|
5
|
+
* `@source` resolves relative to the file that declares it, so this works
|
|
6
|
+
* wherever the package ends up: node_modules in an app, a symlink in a
|
|
7
|
+
* workspace. Consumers import this file and add nothing of their own.
|
|
8
|
+
*/
|
|
9
|
+
@source "./";
|
|
10
|
+
@source not "./**/*.test.*";
|