@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.
@@ -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
+ }