@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,79 @@
|
|
|
1
|
+
import { MoneyInput } from "@voila.dev/ui/components/money-input";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import {
|
|
4
|
+
FilterFieldFrame,
|
|
5
|
+
FilterRangeRow,
|
|
6
|
+
} from "#/components/fields/field-frame.tsx";
|
|
7
|
+
import type {
|
|
8
|
+
FilterLabels,
|
|
9
|
+
MoneyRangeFilterDefinition,
|
|
10
|
+
MoneyRangeFilterValue,
|
|
11
|
+
} from "#/types.ts";
|
|
12
|
+
|
|
13
|
+
// Values travel as minor units (cents) — the platform's money representation —
|
|
14
|
+
// while the field edits a major-unit string, so the rounding happens once, here.
|
|
15
|
+
const toAmountInput = (minorUnits: number | undefined): string =>
|
|
16
|
+
minorUnits === undefined ? "" : String(minorUnits / 100);
|
|
17
|
+
|
|
18
|
+
const toMinorUnits = (raw: string): number | undefined =>
|
|
19
|
+
raw.trim() === "" || Number.isNaN(Number(raw))
|
|
20
|
+
? undefined
|
|
21
|
+
: Math.round(Number(raw) * 100);
|
|
22
|
+
|
|
23
|
+
/** A price floor and ceiling, either of which may be left open. */
|
|
24
|
+
export function MoneyRangeFilterField({
|
|
25
|
+
definition,
|
|
26
|
+
value,
|
|
27
|
+
onValueChange,
|
|
28
|
+
labels,
|
|
29
|
+
}: {
|
|
30
|
+
readonly definition: MoneyRangeFilterDefinition;
|
|
31
|
+
readonly value: MoneyRangeFilterValue | undefined;
|
|
32
|
+
readonly onValueChange: (value: MoneyRangeFilterValue) => void;
|
|
33
|
+
readonly labels: FilterLabels;
|
|
34
|
+
}) {
|
|
35
|
+
const controlId = useId();
|
|
36
|
+
const isEmpty = value?.min === undefined && value?.max === undefined;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<FilterFieldFrame
|
|
40
|
+
label={definition.label}
|
|
41
|
+
description={definition.description}
|
|
42
|
+
controlId={controlId}
|
|
43
|
+
labels={labels}
|
|
44
|
+
onClear={
|
|
45
|
+
isEmpty ? undefined : () => onValueChange({ kind: "moneyRange" })
|
|
46
|
+
}
|
|
47
|
+
>
|
|
48
|
+
<FilterRangeRow>
|
|
49
|
+
<MoneyInput
|
|
50
|
+
id={controlId}
|
|
51
|
+
value={toAmountInput(value?.min)}
|
|
52
|
+
placeholder={labels.min}
|
|
53
|
+
currency={definition.currency}
|
|
54
|
+
currencyLabel={definition.currency}
|
|
55
|
+
onValueChange={(next) =>
|
|
56
|
+
onValueChange({
|
|
57
|
+
kind: "moneyRange",
|
|
58
|
+
min: toMinorUnits(next),
|
|
59
|
+
max: value?.max,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
/>
|
|
63
|
+
<MoneyInput
|
|
64
|
+
value={toAmountInput(value?.max)}
|
|
65
|
+
placeholder={labels.max}
|
|
66
|
+
currency={definition.currency}
|
|
67
|
+
currencyLabel={definition.currency}
|
|
68
|
+
onValueChange={(next) =>
|
|
69
|
+
onValueChange({
|
|
70
|
+
kind: "moneyRange",
|
|
71
|
+
min: value?.min,
|
|
72
|
+
max: toMinorUnits(next),
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
/>
|
|
76
|
+
</FilterRangeRow>
|
|
77
|
+
</FilterFieldFrame>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InputGroup,
|
|
3
|
+
InputGroupAddon,
|
|
4
|
+
InputGroupInput,
|
|
5
|
+
} from "@voila.dev/ui/components/input-group";
|
|
6
|
+
import { useId } from "react";
|
|
7
|
+
import {
|
|
8
|
+
FilterFieldFrame,
|
|
9
|
+
FilterRangeRow,
|
|
10
|
+
} from "#/components/fields/field-frame.tsx";
|
|
11
|
+
import type {
|
|
12
|
+
FilterLabels,
|
|
13
|
+
NumberFilterDefinition,
|
|
14
|
+
NumberFilterValue,
|
|
15
|
+
NumberRangeFilterDefinition,
|
|
16
|
+
NumberRangeFilterValue,
|
|
17
|
+
} from "#/types.ts";
|
|
18
|
+
|
|
19
|
+
/** `""` is a bound the reader cleared, not a zero — keep the two apart. */
|
|
20
|
+
const parseBound = (raw: string): number | undefined =>
|
|
21
|
+
raw.trim() === "" || Number.isNaN(Number(raw)) ? undefined : Number(raw);
|
|
22
|
+
|
|
23
|
+
const toInputValue = (bound: number | undefined): string =>
|
|
24
|
+
bound === undefined ? "" : String(bound);
|
|
25
|
+
|
|
26
|
+
function NumberField({
|
|
27
|
+
id,
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
unit,
|
|
31
|
+
placeholder,
|
|
32
|
+
min,
|
|
33
|
+
max,
|
|
34
|
+
step,
|
|
35
|
+
}: {
|
|
36
|
+
readonly id?: string;
|
|
37
|
+
readonly value: string;
|
|
38
|
+
readonly onChange: (raw: string) => void;
|
|
39
|
+
readonly unit?: string;
|
|
40
|
+
readonly placeholder?: string;
|
|
41
|
+
readonly min?: number;
|
|
42
|
+
readonly max?: number;
|
|
43
|
+
readonly step?: number;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<InputGroup>
|
|
47
|
+
<InputGroupInput
|
|
48
|
+
id={id}
|
|
49
|
+
type="number"
|
|
50
|
+
inputMode="decimal"
|
|
51
|
+
value={value}
|
|
52
|
+
placeholder={placeholder}
|
|
53
|
+
min={min}
|
|
54
|
+
max={max}
|
|
55
|
+
step={step}
|
|
56
|
+
onChange={(event) => onChange(event.target.value)}
|
|
57
|
+
/>
|
|
58
|
+
{unit !== undefined && (
|
|
59
|
+
<InputGroupAddon align="inline-end">{unit}</InputGroupAddon>
|
|
60
|
+
)}
|
|
61
|
+
</InputGroup>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A single numeric value. */
|
|
66
|
+
export function NumberFilterField({
|
|
67
|
+
definition,
|
|
68
|
+
value,
|
|
69
|
+
onValueChange,
|
|
70
|
+
labels,
|
|
71
|
+
}: {
|
|
72
|
+
readonly definition: NumberFilterDefinition;
|
|
73
|
+
readonly value: NumberFilterValue | undefined;
|
|
74
|
+
readonly onValueChange: (value: NumberFilterValue) => void;
|
|
75
|
+
readonly labels: FilterLabels;
|
|
76
|
+
}) {
|
|
77
|
+
const controlId = useId();
|
|
78
|
+
const raw = value === undefined ? "" : String(value.number);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<FilterFieldFrame
|
|
82
|
+
label={definition.label}
|
|
83
|
+
description={definition.description}
|
|
84
|
+
controlId={controlId}
|
|
85
|
+
labels={labels}
|
|
86
|
+
onClear={
|
|
87
|
+
raw === ""
|
|
88
|
+
? undefined
|
|
89
|
+
: () => onValueChange({ kind: "number", number: Number.NaN })
|
|
90
|
+
}
|
|
91
|
+
>
|
|
92
|
+
<NumberField
|
|
93
|
+
id={controlId}
|
|
94
|
+
value={raw}
|
|
95
|
+
unit={definition.unit}
|
|
96
|
+
min={definition.min}
|
|
97
|
+
max={definition.max}
|
|
98
|
+
step={definition.step}
|
|
99
|
+
onChange={(next) =>
|
|
100
|
+
onValueChange({
|
|
101
|
+
kind: "number",
|
|
102
|
+
number: parseBound(next) ?? Number.NaN,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
/>
|
|
106
|
+
</FilterFieldFrame>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A lower and an upper bound, either of which may be left open. */
|
|
111
|
+
export function NumberRangeFilterField({
|
|
112
|
+
definition,
|
|
113
|
+
value,
|
|
114
|
+
onValueChange,
|
|
115
|
+
labels,
|
|
116
|
+
}: {
|
|
117
|
+
readonly definition: NumberRangeFilterDefinition;
|
|
118
|
+
readonly value: NumberRangeFilterValue | undefined;
|
|
119
|
+
readonly onValueChange: (value: NumberRangeFilterValue) => void;
|
|
120
|
+
readonly labels: FilterLabels;
|
|
121
|
+
}) {
|
|
122
|
+
const controlId = useId();
|
|
123
|
+
const isEmpty = value?.min === undefined && value?.max === undefined;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<FilterFieldFrame
|
|
127
|
+
label={definition.label}
|
|
128
|
+
description={definition.description}
|
|
129
|
+
controlId={controlId}
|
|
130
|
+
labels={labels}
|
|
131
|
+
onClear={
|
|
132
|
+
isEmpty ? undefined : () => onValueChange({ kind: "numberRange" })
|
|
133
|
+
}
|
|
134
|
+
>
|
|
135
|
+
<FilterRangeRow>
|
|
136
|
+
<NumberField
|
|
137
|
+
id={controlId}
|
|
138
|
+
value={toInputValue(value?.min)}
|
|
139
|
+
placeholder={labels.min}
|
|
140
|
+
unit={definition.unit}
|
|
141
|
+
min={definition.min}
|
|
142
|
+
max={definition.max}
|
|
143
|
+
step={definition.step}
|
|
144
|
+
onChange={(next) =>
|
|
145
|
+
onValueChange({
|
|
146
|
+
kind: "numberRange",
|
|
147
|
+
min: parseBound(next),
|
|
148
|
+
max: value?.max,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
/>
|
|
152
|
+
<NumberField
|
|
153
|
+
value={toInputValue(value?.max)}
|
|
154
|
+
placeholder={labels.max}
|
|
155
|
+
unit={definition.unit}
|
|
156
|
+
min={definition.min}
|
|
157
|
+
max={definition.max}
|
|
158
|
+
step={definition.step}
|
|
159
|
+
onChange={(next) =>
|
|
160
|
+
onValueChange({
|
|
161
|
+
kind: "numberRange",
|
|
162
|
+
min: value?.min,
|
|
163
|
+
max: parseBound(next),
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
/>
|
|
167
|
+
</FilterRangeRow>
|
|
168
|
+
</FilterFieldFrame>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { CheckIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { Input } from "@voila.dev/ui/components/input";
|
|
3
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
4
|
+
import { useId, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
FilterFieldFrame,
|
|
7
|
+
FilterOperatorToggle,
|
|
8
|
+
} from "#/components/fields/field-frame.tsx";
|
|
9
|
+
import type {
|
|
10
|
+
FilterLabels,
|
|
11
|
+
FilterOption,
|
|
12
|
+
SelectFilterDefinition,
|
|
13
|
+
SelectFilterValue,
|
|
14
|
+
} from "#/types.ts";
|
|
15
|
+
|
|
16
|
+
/** Past this many options the list gets its own search box. */
|
|
17
|
+
const SEARCHABLE_FROM = 8;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One option, as a toggle. Options are pressable pills rather than a dropdown:
|
|
21
|
+
* the whole choice set stays visible while you build a filter, which is what
|
|
22
|
+
* makes multi-select legible — and a pill is the same target size on a phone as
|
|
23
|
+
* a native option row, without the modal picker's round trip.
|
|
24
|
+
*/
|
|
25
|
+
function OptionToggle({
|
|
26
|
+
option,
|
|
27
|
+
selected,
|
|
28
|
+
onToggle,
|
|
29
|
+
}: {
|
|
30
|
+
readonly option: FilterOption;
|
|
31
|
+
readonly selected: boolean;
|
|
32
|
+
readonly onToggle: () => void;
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
role="option"
|
|
38
|
+
aria-selected={selected}
|
|
39
|
+
onClick={onToggle}
|
|
40
|
+
className={cn(
|
|
41
|
+
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors",
|
|
42
|
+
"focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2",
|
|
43
|
+
selected
|
|
44
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
45
|
+
: "border-input bg-background text-foreground hover:bg-accent",
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{selected && <CheckIcon weight="bold" className="size-3.5 shrink-0" />}
|
|
49
|
+
{option.icon}
|
|
50
|
+
{option.label}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toggleValue(
|
|
56
|
+
values: ReadonlyArray<string>,
|
|
57
|
+
value: string,
|
|
58
|
+
multiple: boolean,
|
|
59
|
+
): ReadonlyArray<string> {
|
|
60
|
+
if (values.includes(value)) {
|
|
61
|
+
return values.filter((current) => current !== value);
|
|
62
|
+
}
|
|
63
|
+
return multiple ? [...values, value] : [value];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Single or multiple choice among a known set, optionally inverted. */
|
|
67
|
+
export function SelectFilterField({
|
|
68
|
+
definition,
|
|
69
|
+
value,
|
|
70
|
+
onValueChange,
|
|
71
|
+
labels,
|
|
72
|
+
}: {
|
|
73
|
+
readonly definition: SelectFilterDefinition;
|
|
74
|
+
readonly value: SelectFilterValue | undefined;
|
|
75
|
+
readonly onValueChange: (value: SelectFilterValue) => void;
|
|
76
|
+
readonly labels: FilterLabels;
|
|
77
|
+
}) {
|
|
78
|
+
const searchId = useId();
|
|
79
|
+
const [query, setQuery] = useState("");
|
|
80
|
+
const selected = value?.values ?? [];
|
|
81
|
+
const excluded = value?.excluded ?? false;
|
|
82
|
+
const multiple = definition.multiple ?? false;
|
|
83
|
+
|
|
84
|
+
const searchable = definition.options.length >= SEARCHABLE_FROM;
|
|
85
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
86
|
+
const visibleOptions =
|
|
87
|
+
normalizedQuery === ""
|
|
88
|
+
? definition.options
|
|
89
|
+
: definition.options.filter((option) =>
|
|
90
|
+
option.label.toLowerCase().includes(normalizedQuery),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<FilterFieldFrame
|
|
95
|
+
label={definition.label}
|
|
96
|
+
description={definition.description}
|
|
97
|
+
controlId={searchable ? searchId : undefined}
|
|
98
|
+
labels={labels}
|
|
99
|
+
operator={
|
|
100
|
+
definition.allowExclusion === true ? (
|
|
101
|
+
<FilterOperatorToggle
|
|
102
|
+
excluded={excluded}
|
|
103
|
+
disabled={selected.length === 0}
|
|
104
|
+
onExcludedChange={(next) =>
|
|
105
|
+
onValueChange({
|
|
106
|
+
kind: "select",
|
|
107
|
+
values: selected,
|
|
108
|
+
excluded: next,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
labels={labels}
|
|
112
|
+
/>
|
|
113
|
+
) : undefined
|
|
114
|
+
}
|
|
115
|
+
onClear={
|
|
116
|
+
selected.length === 0
|
|
117
|
+
? undefined
|
|
118
|
+
: () => onValueChange({ kind: "select", values: [], excluded })
|
|
119
|
+
}
|
|
120
|
+
>
|
|
121
|
+
{searchable && (
|
|
122
|
+
<Input
|
|
123
|
+
id={searchId}
|
|
124
|
+
value={query}
|
|
125
|
+
placeholder={labels.optionSearchPlaceholder}
|
|
126
|
+
aria-label={`${definition.label} — ${labels.search}`}
|
|
127
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
<div
|
|
131
|
+
role="listbox"
|
|
132
|
+
aria-multiselectable={multiple}
|
|
133
|
+
aria-label={definition.label}
|
|
134
|
+
className="flex flex-wrap gap-2"
|
|
135
|
+
>
|
|
136
|
+
{visibleOptions.map((option) => (
|
|
137
|
+
<OptionToggle
|
|
138
|
+
key={option.value}
|
|
139
|
+
option={option}
|
|
140
|
+
selected={selected.includes(option.value)}
|
|
141
|
+
onToggle={() =>
|
|
142
|
+
onValueChange({
|
|
143
|
+
kind: "select",
|
|
144
|
+
values: toggleValue(selected, option.value, multiple),
|
|
145
|
+
excluded,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</FilterFieldFrame>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Input } from "@voila.dev/ui/components/input";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import {
|
|
4
|
+
FilterFieldFrame,
|
|
5
|
+
FilterOperatorToggle,
|
|
6
|
+
} from "#/components/fields/field-frame.tsx";
|
|
7
|
+
import type {
|
|
8
|
+
FilterLabels,
|
|
9
|
+
TextFilterDefinition,
|
|
10
|
+
TextFilterValue,
|
|
11
|
+
} from "#/types.ts";
|
|
12
|
+
|
|
13
|
+
/** Free-text contains/does-not-contain filter. */
|
|
14
|
+
export function TextFilterField({
|
|
15
|
+
definition,
|
|
16
|
+
value,
|
|
17
|
+
onValueChange,
|
|
18
|
+
labels,
|
|
19
|
+
}: {
|
|
20
|
+
readonly definition: TextFilterDefinition;
|
|
21
|
+
readonly value: TextFilterValue | undefined;
|
|
22
|
+
readonly onValueChange: (value: TextFilterValue) => void;
|
|
23
|
+
readonly labels: FilterLabels;
|
|
24
|
+
}) {
|
|
25
|
+
const controlId = useId();
|
|
26
|
+
const text = value?.text ?? "";
|
|
27
|
+
const excluded = value?.excluded ?? false;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<FilterFieldFrame
|
|
31
|
+
label={definition.label}
|
|
32
|
+
description={definition.description}
|
|
33
|
+
controlId={controlId}
|
|
34
|
+
labels={labels}
|
|
35
|
+
operator={
|
|
36
|
+
definition.allowExclusion === true ? (
|
|
37
|
+
<FilterOperatorToggle
|
|
38
|
+
excluded={excluded}
|
|
39
|
+
disabled={text === ""}
|
|
40
|
+
onExcludedChange={(next) =>
|
|
41
|
+
onValueChange({ kind: "text", text, excluded: next })
|
|
42
|
+
}
|
|
43
|
+
labels={labels}
|
|
44
|
+
/>
|
|
45
|
+
) : undefined
|
|
46
|
+
}
|
|
47
|
+
onClear={
|
|
48
|
+
text === ""
|
|
49
|
+
? undefined
|
|
50
|
+
: () => onValueChange({ kind: "text", text: "", excluded })
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
<Input
|
|
54
|
+
id={controlId}
|
|
55
|
+
value={text}
|
|
56
|
+
placeholder={definition.placeholder}
|
|
57
|
+
onChange={(event) =>
|
|
58
|
+
onValueChange({ kind: "text", text: event.target.value, excluded })
|
|
59
|
+
}
|
|
60
|
+
/>
|
|
61
|
+
</FilterFieldFrame>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { FilterChips } from "#/components/filter-chips.tsx";
|
|
3
|
+
import { FilterPanel } from "#/components/filter-panel.tsx";
|
|
4
|
+
import { FilterTrigger } from "#/components/filter-trigger.tsx";
|
|
5
|
+
import { countActiveFilters } from "#/lib/filter-values.ts";
|
|
6
|
+
import {
|
|
7
|
+
defaultFilterLabels,
|
|
8
|
+
type FilterDefinition,
|
|
9
|
+
type FilterLabels,
|
|
10
|
+
type FilterValues,
|
|
11
|
+
} from "#/types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The whole filtering surface of a list, in one component: the search-shaped
|
|
15
|
+
* trigger, the overlay editor behind it, the chips stating what is active, and
|
|
16
|
+
* the result count. Screens declare their filters and hand over the applied
|
|
17
|
+
* record — everything else (draft handling, responsiveness, copy) lives here so
|
|
18
|
+
* every list filters the same way.
|
|
19
|
+
*/
|
|
20
|
+
export function FilterBar({
|
|
21
|
+
definitions,
|
|
22
|
+
values,
|
|
23
|
+
onValuesChange,
|
|
24
|
+
searchValue,
|
|
25
|
+
onSearchChange,
|
|
26
|
+
resultCount,
|
|
27
|
+
labels: labelOverrides,
|
|
28
|
+
locale = "en-US",
|
|
29
|
+
}: {
|
|
30
|
+
readonly definitions: ReadonlyArray<FilterDefinition>;
|
|
31
|
+
readonly values: FilterValues;
|
|
32
|
+
readonly onValuesChange: (values: FilterValues) => void;
|
|
33
|
+
/** Omit both search props for a list that filters without free text. */
|
|
34
|
+
readonly searchValue?: string;
|
|
35
|
+
readonly onSearchChange?: (value: string) => void;
|
|
36
|
+
readonly resultCount?: number;
|
|
37
|
+
readonly labels?: Partial<FilterLabels>;
|
|
38
|
+
readonly locale?: string;
|
|
39
|
+
}) {
|
|
40
|
+
const [open, setOpen] = useState(false);
|
|
41
|
+
const labels: FilterLabels = { ...defaultFilterLabels, ...labelOverrides };
|
|
42
|
+
const activeCount = countActiveFilters(values);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex w-full min-w-0 flex-col gap-3" data-slot="filter-bar">
|
|
46
|
+
{/* `min-w-0` on both children: without it a flex item refuses to shrink
|
|
47
|
+
below its content, and the nowrap result count pushes the row past
|
|
48
|
+
the viewport — a phone then scrolls sideways. */}
|
|
49
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
50
|
+
<FilterTrigger
|
|
51
|
+
summary={
|
|
52
|
+
searchValue === undefined || searchValue === ""
|
|
53
|
+
? undefined
|
|
54
|
+
: searchValue
|
|
55
|
+
}
|
|
56
|
+
activeCount={activeCount}
|
|
57
|
+
labels={labels}
|
|
58
|
+
className="min-w-0 flex-1 sm:max-w-md"
|
|
59
|
+
onClick={() => setOpen(true)}
|
|
60
|
+
/>
|
|
61
|
+
{resultCount !== undefined && (
|
|
62
|
+
<span className="shrink-0 whitespace-nowrap text-muted-foreground text-sm">
|
|
63
|
+
{labels.resultCount(resultCount)}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<FilterChips
|
|
69
|
+
definitions={definitions}
|
|
70
|
+
values={values}
|
|
71
|
+
onValuesChange={onValuesChange}
|
|
72
|
+
labels={labels}
|
|
73
|
+
locale={locale}
|
|
74
|
+
onChipClick={() => setOpen(true)}
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
<FilterPanel
|
|
78
|
+
open={open}
|
|
79
|
+
onOpenChange={setOpen}
|
|
80
|
+
definitions={definitions}
|
|
81
|
+
values={values}
|
|
82
|
+
onValuesChange={onValuesChange}
|
|
83
|
+
labels={labels}
|
|
84
|
+
locale={locale}
|
|
85
|
+
searchValue={searchValue}
|
|
86
|
+
onSearchChange={onSearchChange}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { XIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { Chip, ChipRemove } from "@voila.dev/ui/components/chip";
|
|
3
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
4
|
+
import { clearFilterValue, describeFilterValue } from "#/lib/filter-values.ts";
|
|
5
|
+
import type { FilterDefinition, FilterLabels, FilterValues } from "#/types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What is currently filtered, stated in full: one removable chip per active
|
|
9
|
+
* filter. This is the "view" half of the pair — the editor is behind a trigger,
|
|
10
|
+
* so without it a filtered list would look like a short list.
|
|
11
|
+
*/
|
|
12
|
+
export function FilterChips({
|
|
13
|
+
definitions,
|
|
14
|
+
values,
|
|
15
|
+
onValuesChange,
|
|
16
|
+
labels,
|
|
17
|
+
locale,
|
|
18
|
+
onChipClick,
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
readonly definitions: ReadonlyArray<FilterDefinition>;
|
|
22
|
+
readonly values: FilterValues;
|
|
23
|
+
readonly onValuesChange: (values: FilterValues) => void;
|
|
24
|
+
readonly labels: FilterLabels;
|
|
25
|
+
readonly locale: string;
|
|
26
|
+
/** Opens the editor, usually scrolled to the clicked filter. */
|
|
27
|
+
readonly onChipClick?: (key: string) => void;
|
|
28
|
+
readonly className?: string;
|
|
29
|
+
}) {
|
|
30
|
+
const entries = Object.entries(values);
|
|
31
|
+
if (entries.length === 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
data-slot="filter-chips"
|
|
38
|
+
className={cn("flex flex-wrap items-center gap-2", className)}
|
|
39
|
+
>
|
|
40
|
+
{entries.map(([key, value]) => {
|
|
41
|
+
const description = describeFilterValue({
|
|
42
|
+
definition: definitions.find((definition) => definition.key === key),
|
|
43
|
+
value,
|
|
44
|
+
labels,
|
|
45
|
+
locale,
|
|
46
|
+
});
|
|
47
|
+
if (description === null) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return (
|
|
51
|
+
<Chip key={key} variant="outline" className="max-w-full">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className="cursor-pointer truncate text-left"
|
|
55
|
+
onClick={() => onChipClick?.(key)}
|
|
56
|
+
>
|
|
57
|
+
{description}
|
|
58
|
+
</button>
|
|
59
|
+
<ChipRemove
|
|
60
|
+
aria-label={`${labels.remove}: ${description}`}
|
|
61
|
+
onClick={() => onValuesChange(clearFilterValue(values, key))}
|
|
62
|
+
>
|
|
63
|
+
<XIcon />
|
|
64
|
+
</ChipRemove>
|
|
65
|
+
</Chip>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|