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