@lateralus-ai/shipping-ui 2.0.0-dev.22 → 2.0.0-dev.24
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/dist/domain/Filters/FilterDropdown.d.ts +33 -7
- package/dist/domain/Filters/FilterPill.d.ts +13 -3
- package/dist/domain/Filters/index.d.ts +2 -2
- package/dist/index.cjs +23 -23
- package/dist/index.esm.js +3302 -3270
- package/dist/primitives/Switch.d.ts +4 -0
- package/package.json +1 -1
- package/src/domain/Filters/FilterDropdown.tsx +540 -438
- package/src/domain/Filters/FilterPill.tsx +74 -61
- package/src/domain/Filters/FilterPills.tsx +34 -34
- package/src/domain/Filters/FilteredPill.tsx +59 -59
- package/src/domain/Filters/FiltersBar.tsx +72 -72
- package/src/domain/Filters/formatActiveFilterChipLabel.ts +23 -23
- package/src/domain/Filters/index.ts +37 -35
- package/src/primitives/Switch.tsx +16 -6
- package/src/stories/canvases/FiltersCanvas.tsx +171 -167
|
@@ -1,438 +1,540 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useState,
|
|
3
|
-
type ComponentType,
|
|
4
|
-
type CSSProperties,
|
|
5
|
-
type ReactNode,
|
|
6
|
-
} from "react";
|
|
7
|
-
import * as Popover from "@radix-ui/react-popover";
|
|
8
|
-
import { cn } from "../../utils/cn";
|
|
9
|
-
import { ChevronIcon } from "../../icons/ChevronIcon";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
{
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
type ComponentType,
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from "react";
|
|
7
|
+
import * as Popover from "@radix-ui/react-popover";
|
|
8
|
+
import { cn } from "../../utils/cn";
|
|
9
|
+
import { ChevronIcon } from "../../icons/ChevronIcon";
|
|
10
|
+
import { Switch } from "../../primitives/Switch";
|
|
11
|
+
import { FilterPill, type FilterPillAppearance } from "./FilterPill";
|
|
12
|
+
|
|
13
|
+
/** One selectable row at a leaf list. */
|
|
14
|
+
export type FilterOption = {
|
|
15
|
+
value: string;
|
|
16
|
+
label: string;
|
|
17
|
+
icon?: ComponentType<{ className?: string }>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type FilterSelectionMode = "multi" | "single";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recursive submenu: options leaf, nested folders, or custom panel (e.g. date range).
|
|
24
|
+
*/
|
|
25
|
+
export type FilterSubmenuContent =
|
|
26
|
+
| {
|
|
27
|
+
type: "options";
|
|
28
|
+
/** Key into `selectedValuesBySelectionKey` / `onSelectOption` */
|
|
29
|
+
selectionKey: string;
|
|
30
|
+
/** `multi` (default) toggles; `single` is exclusive — still one check slot reserved. */
|
|
31
|
+
selectionMode?: FilterSelectionMode;
|
|
32
|
+
options: FilterOption[];
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
type: "nested";
|
|
36
|
+
items: FilterNestedItem[];
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
type: "custom";
|
|
40
|
+
render: () => ReactNode;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type FilterNestedItem = {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
content: FilterSubmenuContent;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Left-column row that opens a (possibly nested) submenu panel. */
|
|
50
|
+
export type FilterCategorySubmenuRow = {
|
|
51
|
+
kind?: "submenu";
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
content: FilterSubmenuContent;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Options rendered inline in the first panel (no chevron / second column).
|
|
59
|
+
* Mix with submenu rows for hybrid menus (e.g. Status submenu + Sort by flat).
|
|
60
|
+
*/
|
|
61
|
+
export type FilterCategoryInlineOptionsRow = {
|
|
62
|
+
kind: "inline-options";
|
|
63
|
+
id: string;
|
|
64
|
+
/** Small section header above the options (e.g. "Sort by"). */
|
|
65
|
+
title?: string;
|
|
66
|
+
selectionKey: string;
|
|
67
|
+
selectionMode?: FilterSelectionMode;
|
|
68
|
+
options: FilterOption[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** First-panel row: label + Switch (no right panel). */
|
|
72
|
+
export type FilterCategoryToggleRow = {
|
|
73
|
+
kind: "toggle";
|
|
74
|
+
id: string;
|
|
75
|
+
label: string;
|
|
76
|
+
checked: boolean;
|
|
77
|
+
onCheckedChange: (checked: boolean) => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type FilterCategoryRow =
|
|
81
|
+
| FilterCategorySubmenuRow
|
|
82
|
+
| FilterCategoryInlineOptionsRow
|
|
83
|
+
| FilterCategoryToggleRow;
|
|
84
|
+
|
|
85
|
+
function isToggleRow(row: FilterCategoryRow): row is FilterCategoryToggleRow {
|
|
86
|
+
return row.kind === "toggle";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isInlineOptionsRow(
|
|
90
|
+
row: FilterCategoryRow,
|
|
91
|
+
): row is FilterCategoryInlineOptionsRow {
|
|
92
|
+
return row.kind === "inline-options";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isSubmenuRow(row: FilterCategoryRow): row is FilterCategorySubmenuRow {
|
|
96
|
+
return !isToggleRow(row) && !isInlineOptionsRow(row);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type ResolvedSubmenuView =
|
|
100
|
+
| { kind: "folders"; items: FilterNestedItem[]; trail: string[] }
|
|
101
|
+
| {
|
|
102
|
+
kind: "options";
|
|
103
|
+
selectionKey: string;
|
|
104
|
+
selectionMode: FilterSelectionMode;
|
|
105
|
+
options: FilterOption[];
|
|
106
|
+
trail: string[];
|
|
107
|
+
}
|
|
108
|
+
| { kind: "custom"; render: () => ReactNode; trail: string[] };
|
|
109
|
+
|
|
110
|
+
/** Walk `path` (folder ids) from `content` to the current view. */
|
|
111
|
+
export function resolveSubmenuView(
|
|
112
|
+
content: FilterSubmenuContent,
|
|
113
|
+
path: string[],
|
|
114
|
+
): ResolvedSubmenuView {
|
|
115
|
+
if (content.type === "options") {
|
|
116
|
+
return {
|
|
117
|
+
kind: "options",
|
|
118
|
+
selectionKey: content.selectionKey,
|
|
119
|
+
selectionMode: content.selectionMode ?? "multi",
|
|
120
|
+
options: content.options,
|
|
121
|
+
trail: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (content.type === "custom") {
|
|
125
|
+
return { kind: "custom", render: content.render, trail: [] };
|
|
126
|
+
}
|
|
127
|
+
if (path.length === 0) {
|
|
128
|
+
return { kind: "folders", items: content.items, trail: [] };
|
|
129
|
+
}
|
|
130
|
+
const [head, ...tail] = path;
|
|
131
|
+
const child = content.items.find((i) => i.id === head);
|
|
132
|
+
if (!child) {
|
|
133
|
+
return { kind: "folders", items: content.items, trail: [] };
|
|
134
|
+
}
|
|
135
|
+
if (child.content.type === "options") {
|
|
136
|
+
return {
|
|
137
|
+
kind: "options",
|
|
138
|
+
selectionKey: child.content.selectionKey,
|
|
139
|
+
selectionMode: child.content.selectionMode ?? "multi",
|
|
140
|
+
options: child.content.options,
|
|
141
|
+
trail: [child.label],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (child.content.type === "custom") {
|
|
145
|
+
return {
|
|
146
|
+
kind: "custom",
|
|
147
|
+
render: child.content.render,
|
|
148
|
+
trail: [child.label],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (tail.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
kind: "folders",
|
|
154
|
+
items: child.content.items,
|
|
155
|
+
trail: [child.label],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const inner = resolveSubmenuView(child.content, tail);
|
|
159
|
+
return {
|
|
160
|
+
...inner,
|
|
161
|
+
trail: [child.label, ...inner.trail],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function CheckMark({ className }: { className?: string }) {
|
|
166
|
+
return (
|
|
167
|
+
<svg
|
|
168
|
+
viewBox="0 0 16 16"
|
|
169
|
+
fill="none"
|
|
170
|
+
aria-hidden
|
|
171
|
+
className={cn("size-4 shrink-0", className)}
|
|
172
|
+
>
|
|
173
|
+
<path
|
|
174
|
+
d="M3.5 8.5L6.5 11.5L12.5 4.5"
|
|
175
|
+
stroke="currentColor"
|
|
176
|
+
strokeWidth="1.5"
|
|
177
|
+
strokeLinecap="round"
|
|
178
|
+
strokeLinejoin="round"
|
|
179
|
+
/>
|
|
180
|
+
</svg>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Always reserves check width so the panel does not jump when selection moves. */
|
|
185
|
+
function CheckSlot({ selected }: { selected: boolean }) {
|
|
186
|
+
return (
|
|
187
|
+
<CheckMark
|
|
188
|
+
className={cn(
|
|
189
|
+
"text-blue-600",
|
|
190
|
+
selected ? "visible" : "invisible",
|
|
191
|
+
)}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type FilterDropdownProps = {
|
|
197
|
+
align?: "start" | "center" | "end";
|
|
198
|
+
sideOffset?: number;
|
|
199
|
+
/**
|
|
200
|
+
* First panel rows — mix freely:
|
|
201
|
+
* submenu (chevron + second panel), inline-options (flat list), toggle (Switch).
|
|
202
|
+
*/
|
|
203
|
+
categoryRows: FilterCategoryRow[];
|
|
204
|
+
selectedValuesBySelectionKey: Record<string, string[] | undefined>;
|
|
205
|
+
/**
|
|
206
|
+
* Fired when an option is activated. For `multi`, consumers typically toggle;
|
|
207
|
+
* for `single`, replace the selection with `[optionValue]`.
|
|
208
|
+
*/
|
|
209
|
+
onSelectOption: (selectionKey: string, optionValue: string) => void;
|
|
210
|
+
onResetAll: () => void;
|
|
211
|
+
resetAllLabel?: string;
|
|
212
|
+
/** When false, hides the Reset all footer. Default true. */
|
|
213
|
+
showResetAll?: boolean;
|
|
214
|
+
activeFilterCount: number;
|
|
215
|
+
getOptionLabelStyle?: (
|
|
216
|
+
selectionKey: string,
|
|
217
|
+
optionValue: string,
|
|
218
|
+
) => CSSProperties | undefined;
|
|
219
|
+
zIndexClass?: string;
|
|
220
|
+
/** Replace the default FilterPill trigger. */
|
|
221
|
+
trigger?: ReactNode;
|
|
222
|
+
/** Passed to the default FilterPill when `trigger` is omitted. */
|
|
223
|
+
triggerAppearance?: FilterPillAppearance;
|
|
224
|
+
/**
|
|
225
|
+
* Which side the options panel attaches to the category column.
|
|
226
|
+
* Carets stay on the right of category rows either way.
|
|
227
|
+
*/
|
|
228
|
+
submenuSide?: "left" | "right";
|
|
229
|
+
open?: boolean;
|
|
230
|
+
onOpenChange?: (open: boolean) => void;
|
|
231
|
+
className?: string;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const scrollClass = "py-2 overflow-y-auto overscroll-y-contain flex-1 min-h-0";
|
|
235
|
+
|
|
236
|
+
const rowClass =
|
|
237
|
+
"w-full flex items-center justify-between gap-4 px-4 py-2 text-left transition-colors hover:bg-grey-50";
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Filters trigger + popover. First panel supports hybrid rows: submenus,
|
|
241
|
+
* inline option lists, and Switch toggles. Submenus open a second column.
|
|
242
|
+
*/
|
|
243
|
+
export function FilterDropdown({
|
|
244
|
+
align = "start",
|
|
245
|
+
sideOffset = 4,
|
|
246
|
+
categoryRows,
|
|
247
|
+
selectedValuesBySelectionKey,
|
|
248
|
+
onSelectOption,
|
|
249
|
+
onResetAll,
|
|
250
|
+
resetAllLabel = "Reset all",
|
|
251
|
+
showResetAll = true,
|
|
252
|
+
activeFilterCount,
|
|
253
|
+
getOptionLabelStyle,
|
|
254
|
+
zIndexClass = "z-50",
|
|
255
|
+
trigger,
|
|
256
|
+
triggerAppearance = "filled",
|
|
257
|
+
submenuSide = "right",
|
|
258
|
+
open: openControlled,
|
|
259
|
+
onOpenChange,
|
|
260
|
+
className,
|
|
261
|
+
}: FilterDropdownProps) {
|
|
262
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
|
263
|
+
const open = openControlled ?? uncontrolledOpen;
|
|
264
|
+
const setOpen = onOpenChange ?? setUncontrolledOpen;
|
|
265
|
+
|
|
266
|
+
const [activeSubmenuId, setActiveSubmenuId] = useState<string | null>(null);
|
|
267
|
+
const [navPath, setNavPath] = useState<string[]>([]);
|
|
268
|
+
|
|
269
|
+
const activeSubmenuRow = categoryRows.find(
|
|
270
|
+
(r) => isSubmenuRow(r) && r.id === activeSubmenuId,
|
|
271
|
+
) as FilterCategorySubmenuRow | undefined;
|
|
272
|
+
|
|
273
|
+
const view = activeSubmenuRow
|
|
274
|
+
? resolveSubmenuView(activeSubmenuRow.content, navPath)
|
|
275
|
+
: null;
|
|
276
|
+
|
|
277
|
+
const showSubmenuPanel = Boolean(activeSubmenuRow && view);
|
|
278
|
+
const submenuOpensLeft = submenuSide === "left";
|
|
279
|
+
|
|
280
|
+
const resetNav = () => setNavPath([]);
|
|
281
|
+
|
|
282
|
+
const submenuPanelClass = cn(
|
|
283
|
+
"bg-background-primary max-h-[min(28rem,calc(100dvh-8rem))] min-w-[200px] max-w-[min(28rem,calc(100vw-2rem))] flex flex-col overflow-hidden border border-divider-primary shadow-raise1",
|
|
284
|
+
submenuOpensLeft
|
|
285
|
+
? "rounded-l-lg rounded-r-none border-r-0"
|
|
286
|
+
: "rounded-r-lg rounded-l-none border-l-0",
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const categoryPanelClass = cn(
|
|
290
|
+
"min-w-[180px] border border-divider-primary bg-background-primary shadow-raise1",
|
|
291
|
+
showSubmenuPanel
|
|
292
|
+
? submenuOpensLeft
|
|
293
|
+
? "rounded-r-lg rounded-l-none"
|
|
294
|
+
: "rounded-l-lg rounded-r-none"
|
|
295
|
+
: "rounded-lg",
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const renderOptionButtons = (
|
|
299
|
+
selectionKey: string,
|
|
300
|
+
options: FilterOption[],
|
|
301
|
+
) =>
|
|
302
|
+
options.map((option) => {
|
|
303
|
+
const selected =
|
|
304
|
+
selectedValuesBySelectionKey[selectionKey]?.includes(option.value) ??
|
|
305
|
+
false;
|
|
306
|
+
const Icon = option.icon;
|
|
307
|
+
const labelStyle = getOptionLabelStyle?.(selectionKey, option.value);
|
|
308
|
+
return (
|
|
309
|
+
<button
|
|
310
|
+
key={option.value}
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={() => onSelectOption(selectionKey, option.value)}
|
|
313
|
+
className={cn(rowClass, selected && "bg-grey-100")}
|
|
314
|
+
>
|
|
315
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
316
|
+
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
|
317
|
+
<span
|
|
318
|
+
className={cn(
|
|
319
|
+
"text-body",
|
|
320
|
+
labelStyle ? undefined : "text-display-on-light-primary",
|
|
321
|
+
)}
|
|
322
|
+
style={labelStyle}
|
|
323
|
+
>
|
|
324
|
+
{option.label}
|
|
325
|
+
</span>
|
|
326
|
+
</div>
|
|
327
|
+
<CheckSlot selected={selected} />
|
|
328
|
+
</button>
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const categoryPanel = (
|
|
333
|
+
<div className={categoryPanelClass}>
|
|
334
|
+
<div className="py-2">
|
|
335
|
+
{categoryRows.map((row, index) => {
|
|
336
|
+
const prev = index > 0 ? categoryRows[index - 1] : null;
|
|
337
|
+
const showDividerBefore =
|
|
338
|
+
index > 0 &&
|
|
339
|
+
(isToggleRow(row) ||
|
|
340
|
+
(isInlineOptionsRow(row) && prev && isSubmenuRow(prev)) ||
|
|
341
|
+
(isSubmenuRow(row) && prev && !isSubmenuRow(prev)));
|
|
342
|
+
|
|
343
|
+
if (isToggleRow(row)) {
|
|
344
|
+
return (
|
|
345
|
+
<div key={row.id}>
|
|
346
|
+
{showDividerBefore && (
|
|
347
|
+
<div className="my-2 border-t border-divider-primary" />
|
|
348
|
+
)}
|
|
349
|
+
<div className={cn(rowClass, "hover:bg-transparent")}>
|
|
350
|
+
<span className="text-body text-display-on-light-primary">
|
|
351
|
+
{row.label}
|
|
352
|
+
</span>
|
|
353
|
+
<Switch
|
|
354
|
+
checked={row.checked}
|
|
355
|
+
onChange={row.onCheckedChange}
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (isInlineOptionsRow(row)) {
|
|
363
|
+
return (
|
|
364
|
+
<div key={row.id}>
|
|
365
|
+
{showDividerBefore && (
|
|
366
|
+
<div className="my-2 border-t border-divider-primary" />
|
|
367
|
+
)}
|
|
368
|
+
{row.title ? (
|
|
369
|
+
<div className="px-4 pb-1 pt-1 text-caption-2 text-display-on-light-secondary">
|
|
370
|
+
{row.title}
|
|
371
|
+
</div>
|
|
372
|
+
) : null}
|
|
373
|
+
{row.options.length === 0 ? (
|
|
374
|
+
<p className="px-4 py-2 text-body text-grey-500">No options</p>
|
|
375
|
+
) : (
|
|
376
|
+
renderOptionButtons(row.selectionKey, row.options)
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<div key={row.id}>
|
|
384
|
+
{showDividerBefore && (
|
|
385
|
+
<div className="my-2 border-t border-divider-primary" />
|
|
386
|
+
)}
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
onClick={() => {
|
|
390
|
+
if (activeSubmenuId === row.id) {
|
|
391
|
+
setActiveSubmenuId(null);
|
|
392
|
+
resetNav();
|
|
393
|
+
} else {
|
|
394
|
+
setActiveSubmenuId(row.id);
|
|
395
|
+
resetNav();
|
|
396
|
+
}
|
|
397
|
+
}}
|
|
398
|
+
className={cn(
|
|
399
|
+
rowClass,
|
|
400
|
+
activeSubmenuId === row.id && "bg-grey-50",
|
|
401
|
+
)}
|
|
402
|
+
>
|
|
403
|
+
<span className="text-body text-display-on-light-primary">
|
|
404
|
+
{row.label}
|
|
405
|
+
</span>
|
|
406
|
+
<ChevronIcon
|
|
407
|
+
direction="right"
|
|
408
|
+
size="small"
|
|
409
|
+
className={cn(
|
|
410
|
+
"text-grey-400 transition-transform",
|
|
411
|
+
activeSubmenuId === row.id && "rotate-90",
|
|
412
|
+
)}
|
|
413
|
+
/>
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
})}
|
|
418
|
+
|
|
419
|
+
{showResetAll && (
|
|
420
|
+
<div className="mt-2 border-t border-divider-primary pt-2">
|
|
421
|
+
<button
|
|
422
|
+
type="button"
|
|
423
|
+
onClick={onResetAll}
|
|
424
|
+
className={cn(rowClass)}
|
|
425
|
+
>
|
|
426
|
+
<span className="text-body text-display-on-light-primary">
|
|
427
|
+
{resetAllLabel}
|
|
428
|
+
</span>
|
|
429
|
+
</button>
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const submenuPanel =
|
|
437
|
+
showSubmenuPanel && view ? (
|
|
438
|
+
<div className={cn(submenuPanelClass, "flex flex-col")}>
|
|
439
|
+
{navPath.length > 0 && (
|
|
440
|
+
<div className="flex-shrink-0 border-b border-grey-100 px-2 py-1.5">
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={() => setNavPath((p) => p.slice(0, -1))}
|
|
444
|
+
className="flex w-full items-center gap-1 rounded-md px-2 py-1 text-left text-caption-2-em text-display-on-light-secondary hover:bg-grey-50"
|
|
445
|
+
>
|
|
446
|
+
<ChevronIcon
|
|
447
|
+
direction="left"
|
|
448
|
+
size="small"
|
|
449
|
+
className="shrink-0"
|
|
450
|
+
/>
|
|
451
|
+
Back
|
|
452
|
+
</button>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
{view.trail.length > 0 && (
|
|
456
|
+
<div className="flex-shrink-0 truncate px-4 pb-1 pt-2 text-footnote text-grey-500">
|
|
457
|
+
{view.trail.join(" › ")}
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
<div className={scrollClass}>
|
|
461
|
+
{view.kind === "folders" ? (
|
|
462
|
+
view.items.length === 0 ? (
|
|
463
|
+
<p className="px-4 py-2 text-body text-grey-500">No options</p>
|
|
464
|
+
) : (
|
|
465
|
+
view.items.map((item) => (
|
|
466
|
+
<button
|
|
467
|
+
key={item.id}
|
|
468
|
+
type="button"
|
|
469
|
+
onClick={() => setNavPath((p) => [...p, item.id])}
|
|
470
|
+
className={rowClass}
|
|
471
|
+
>
|
|
472
|
+
<span className="text-body text-display-on-light-primary">
|
|
473
|
+
{item.label}
|
|
474
|
+
</span>
|
|
475
|
+
<ChevronIcon
|
|
476
|
+
direction="right"
|
|
477
|
+
size="small"
|
|
478
|
+
className="shrink-0 text-grey-400"
|
|
479
|
+
/>
|
|
480
|
+
</button>
|
|
481
|
+
))
|
|
482
|
+
)
|
|
483
|
+
) : view.kind === "custom" ? (
|
|
484
|
+
<div className="p-4">{view.render()}</div>
|
|
485
|
+
) : view.options.length === 0 ? (
|
|
486
|
+
<p className="px-4 py-2 text-body text-grey-500">No options</p>
|
|
487
|
+
) : (
|
|
488
|
+
renderOptionButtons(view.selectionKey, view.options)
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
) : null;
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
<Popover.Root
|
|
496
|
+
open={open}
|
|
497
|
+
onOpenChange={(next) => {
|
|
498
|
+
setOpen(next);
|
|
499
|
+
if (!next) {
|
|
500
|
+
setActiveSubmenuId(null);
|
|
501
|
+
setNavPath([]);
|
|
502
|
+
}
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
<Popover.Trigger asChild>
|
|
506
|
+
{trigger ?? (
|
|
507
|
+
<FilterPill
|
|
508
|
+
activeFilterCount={activeFilterCount}
|
|
509
|
+
appearance={triggerAppearance}
|
|
510
|
+
className={className}
|
|
511
|
+
/>
|
|
512
|
+
)}
|
|
513
|
+
</Popover.Trigger>
|
|
514
|
+
<Popover.Portal>
|
|
515
|
+
<Popover.Content
|
|
516
|
+
align={align}
|
|
517
|
+
sideOffset={sideOffset}
|
|
518
|
+
className={cn(
|
|
519
|
+
zIndexClass,
|
|
520
|
+
"w-auto border-0 bg-transparent p-0 shadow-none outline-none",
|
|
521
|
+
)}
|
|
522
|
+
>
|
|
523
|
+
<div className="flex items-start gap-0">
|
|
524
|
+
{submenuOpensLeft ? (
|
|
525
|
+
<>
|
|
526
|
+
{submenuPanel}
|
|
527
|
+
{categoryPanel}
|
|
528
|
+
</>
|
|
529
|
+
) : (
|
|
530
|
+
<>
|
|
531
|
+
{categoryPanel}
|
|
532
|
+
{submenuPanel}
|
|
533
|
+
</>
|
|
534
|
+
)}
|
|
535
|
+
</div>
|
|
536
|
+
</Popover.Content>
|
|
537
|
+
</Popover.Portal>
|
|
538
|
+
</Popover.Root>
|
|
539
|
+
);
|
|
540
|
+
}
|