@lateralus-ai/shipping-ui 2.0.0-dev.3 → 2.0.0-dev.30

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.
Files changed (98) hide show
  1. package/dist/components/Entry.d.ts +27 -7
  2. package/dist/components/PageHeader.d.ts +41 -0
  3. package/dist/components/ScrollableList.d.ts +22 -0
  4. package/dist/components/Tabs.d.ts +5 -5
  5. package/dist/components/index.d.ts +4 -2
  6. package/dist/domain/Filters/FilterDropdown.d.ts +119 -0
  7. package/dist/domain/Filters/FilterPill.d.ts +22 -5
  8. package/dist/domain/Filters/FilterPills.d.ts +18 -0
  9. package/dist/domain/Filters/FilteredPill.d.ts +15 -5
  10. package/dist/domain/Filters/formatActiveFilterChipLabel.d.ts +11 -0
  11. package/dist/domain/Filters/index.d.ts +8 -3
  12. package/dist/index.cjs +33 -33
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.esm.js +6383 -4792
  15. package/dist/patterns/Search/ResultRow.d.ts +11 -5
  16. package/dist/patterns/Sidebar/sidebar-styles.d.ts +2 -1
  17. package/dist/primitives/Badge.d.ts +7 -4
  18. package/dist/primitives/Button.d.ts +3 -1
  19. package/dist/primitives/Switch.d.ts +5 -0
  20. package/dist/primitives/index.d.ts +1 -1
  21. package/dist/tailwind-theme.d.ts +8 -4
  22. package/dist/theme-entry-Dwr2mV7_.js +1 -0
  23. package/dist/{theme-entry-CxDa1D0_.mjs → theme-entry-tLBc6zGT.mjs} +12 -8
  24. package/dist/theme.cjs +1 -1
  25. package/dist/theme.esm.js +1 -1
  26. package/package.json +3 -2
  27. package/src/components/EmptyState.tsx +44 -44
  28. package/src/components/Entry.tsx +119 -45
  29. package/src/components/Modal.tsx +141 -141
  30. package/src/components/PageHeader.tsx +132 -0
  31. package/src/components/ScrollableList.tsx +61 -0
  32. package/src/components/Tabs.tsx +33 -26
  33. package/src/components/index.ts +15 -1
  34. package/src/domain/Filters/FilterDropdown.tsx +540 -0
  35. package/src/domain/Filters/FilterPill.tsx +74 -25
  36. package/src/domain/Filters/FilterPills.tsx +34 -0
  37. package/src/domain/Filters/FilteredPill.tsx +59 -39
  38. package/src/domain/Filters/FiltersBar.tsx +72 -76
  39. package/src/domain/Filters/formatActiveFilterChipLabel.ts +23 -0
  40. package/src/domain/Filters/index.ts +37 -3
  41. package/src/icons/arrow-paths.ts +8 -8
  42. package/src/icons/chevron-paths.ts +17 -17
  43. package/src/icons/icons-data.ts +656 -656
  44. package/src/index.ts +11 -0
  45. package/src/patterns/Search/ResultRow.tsx +24 -38
  46. package/src/patterns/Search/SearchModal.tsx +26 -8
  47. package/src/patterns/Sidebar/CollapsibleNavGroup.tsx +1 -1
  48. package/src/patterns/Sidebar/SidebarAction.tsx +26 -17
  49. package/src/patterns/Sidebar/SidebarEntry.tsx +54 -39
  50. package/src/patterns/Sidebar/SidebarLink.tsx +30 -20
  51. package/src/patterns/Sidebar/assets/logo-compliance-mark.png +5 -5
  52. package/src/patterns/Sidebar/assets/logo-compliance-mask.png +13 -13
  53. package/src/patterns/Sidebar/assets/logo-compliance.svg +17 -17
  54. package/src/patterns/Sidebar/assets/logo-technical-avatar.svg +17 -17
  55. package/src/patterns/Sidebar/assets/logo-technical-mark.png +5 -5
  56. package/src/patterns/Sidebar/assets/logo-technical.svg +16 -16
  57. package/src/patterns/Sidebar/sidebar-styles.ts +2 -1
  58. package/src/primitives/Badge.tsx +20 -10
  59. package/src/primitives/Button.tsx +21 -11
  60. package/src/primitives/Logo.tsx +20 -8
  61. package/src/primitives/Switch.tsx +27 -7
  62. package/src/primitives/button-styles.ts +92 -92
  63. package/src/primitives/index.ts +1 -1
  64. package/src/stories/canvases/ButtonsCanvas.tsx +107 -107
  65. package/src/stories/canvases/ButtonsMatrixCanvas.tsx +65 -65
  66. package/src/stories/canvases/ContentCanvas.tsx +253 -14
  67. package/src/stories/canvases/FiltersCanvas.tsx +171 -41
  68. package/src/stories/canvases/figma-buttons-layout.ts +83 -83
  69. package/src/stories/canvases/figma-widths.ts +28 -28
  70. package/src/stories/canvases/helpers.tsx +146 -146
  71. package/src/stories/components/Buttons.stories.tsx +11 -11
  72. package/src/stories/components/Chat.stories.tsx +11 -11
  73. package/src/stories/components/Content.stories.tsx +11 -11
  74. package/src/stories/components/Core.stories.tsx +11 -11
  75. package/src/stories/components/DomainForms.stories.tsx +11 -11
  76. package/src/stories/components/Filters.stories.tsx +11 -11
  77. package/src/stories/components/Forms.stories.tsx +11 -11
  78. package/src/stories/components/Icons.stories.tsx +11 -11
  79. package/src/stories/components/Illustrations.stories.tsx +11 -11
  80. package/src/stories/components/Library.stories.tsx +11 -11
  81. package/src/stories/components/Modals.stories.tsx +11 -11
  82. package/src/stories/components/Report.stories.tsx +11 -11
  83. package/src/stories/components/ReportLayout.stories.tsx +11 -11
  84. package/src/stories/components/Search.stories.tsx +11 -11
  85. package/src/stories/components/Settings.stories.tsx +11 -11
  86. package/src/stories/components/Ships.stories.tsx +11 -11
  87. package/src/stories/components/SidebarLayouts.stories.tsx +11 -11
  88. package/src/stories/components/Skeletons.stories.tsx +11 -11
  89. package/src/stories/components/Workflows.stories.tsx +11 -11
  90. package/src/stories/style-guide/Buttons.stories.tsx +11 -11
  91. package/src/stories/style-guide/ColorTokens.stories.tsx +11 -11
  92. package/src/stories/style-guide/Colors.stories.tsx +11 -11
  93. package/src/stories/style-guide/RaiseLevels.stories.tsx +11 -11
  94. package/src/stories/style-guide/Typography.stories.tsx +11 -11
  95. package/src/tailwind-theme.ts +8 -4
  96. package/src/theme-entry.ts +2 -2
  97. package/src/utils/cn.ts +28 -1
  98. package/dist/theme-entry-D2X3Ptjf.js +0 -1
@@ -0,0 +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 { 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
+ }
@@ -1,25 +1,74 @@
1
- import { cn } from "../../utils/cn";
2
- import { FiltersIcon } from "../../icons/generated";
3
-
4
- export type FilterPillIndicator = "on" | "off";
5
-
6
- export type FilterPillProps = {
7
- indicator?: FilterPillIndicator;
8
- className?: string;
9
- };
10
-
11
- export const FilterPill = ({ indicator = "off", className }: FilterPillProps) => (
12
- <button
13
- type="button"
14
- aria-label="Filters"
15
- className={cn(
16
- "relative flex size-9 items-center justify-center rounded-full bg-background-secondary text-display-on-light-secondary transition-colors hover:text-display-on-light-primary",
17
- className,
18
- )}
19
- >
20
- <FiltersIcon size="small" />
21
- {indicator === "on" && (
22
- <span className="absolute right-1 top-1 size-2 rounded-full bg-blue-600" aria-hidden />
23
- )}
24
- </button>
25
- );
1
+ import { forwardRef, type ButtonHTMLAttributes } from "react";
2
+ import { cn } from "../../utils/cn";
3
+ import { FiltersIcon } from "../../icons/generated";
4
+ import { Badge } from "../../primitives/Badge";
5
+
6
+ export type FilterPillAppearance = "filled" | "ghost";
7
+
8
+ export type FilterPillProps = Omit<
9
+ ButtonHTMLAttributes<HTMLButtonElement>,
10
+ "children"
11
+ > & {
12
+ /** When > 0, shows the orange count badge. */
13
+ activeFilterCount?: number;
14
+ /**
15
+ * `filled` — grey idle / blue when active (Figma default).
16
+ * `ghost` white idle and active; only hover changes surface color.
17
+ */
18
+ appearance?: FilterPillAppearance;
19
+ };
20
+
21
+ /**
22
+ * Filter trigger Figma Filter Pill (6154:149608 / 6154:149599).
23
+ */
24
+ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
25
+ (
26
+ {
27
+ activeFilterCount = 0,
28
+ appearance = "filled",
29
+ className,
30
+ type = "button",
31
+ "aria-label": ariaLabel = "Filters",
32
+ ...props
33
+ },
34
+ ref,
35
+ ) => {
36
+ const active = activeFilterCount > 0;
37
+ const ghost = appearance === "ghost";
38
+
39
+ return (
40
+ <button
41
+ ref={ref}
42
+ type={type}
43
+ aria-label={ariaLabel}
44
+ aria-pressed={active}
45
+ className={cn(
46
+ "relative flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
47
+ ghost
48
+ ? "bg-white text-display-on-light-secondary hover:bg-grey-50 hover:text-display-on-light-primary"
49
+ : active
50
+ ? "bg-accent-bg-light text-display-on-light-secondary hover:text-display-on-light-primary"
51
+ : "bg-background-secondary text-display-on-light-secondary hover:text-display-on-light-primary",
52
+ className,
53
+ )}
54
+ {...props}
55
+ >
56
+ <FiltersIcon size="small" />
57
+ {active && (
58
+ <Badge
59
+ color="orange"
60
+ type="icon"
61
+ className={cn(
62
+ "absolute -right-1 -top-2 border-2 text-display-on-light-primary",
63
+ ghost ? "border-white" : "border-background-primary",
64
+ )}
65
+ >
66
+ {activeFilterCount > 99 ? "99+" : activeFilterCount}
67
+ </Badge>
68
+ )}
69
+ </button>
70
+ );
71
+ },
72
+ );
73
+
74
+ FilterPill.displayName = "FilterPill";
@@ -0,0 +1,34 @@
1
+ import { cn } from "../../utils/cn";
2
+ import { FilteredPill, type FilteredPillProps } from "./FilteredPill";
3
+
4
+ export type FilterPillsItem = {
5
+ key: string;
6
+ label: string;
7
+ onRemove: () => void;
8
+ removeAriaLabel?: string;
9
+ classNames?: FilteredPillProps["classNames"];
10
+ };
11
+
12
+ export type FilterPillsProps = {
13
+ chips: FilterPillsItem[];
14
+ className?: string;
15
+ };
16
+
17
+ /**
18
+ * Partner row for FilterDropdown — active filter pills.
19
+ * Always renders the row wrapper (even when empty) so sibling filter
20
+ * triggers can stay pinned with `ml-auto` / flex layouts.
21
+ */
22
+ export const FilterPills = ({ chips, className }: FilterPillsProps) => (
23
+ <div className={cn("flex min-w-0 flex-wrap items-start gap-2", className)}>
24
+ {chips.map((chip) => (
25
+ <FilteredPill
26
+ key={chip.key}
27
+ label={chip.label}
28
+ onRemove={chip.onRemove}
29
+ removeAriaLabel={chip.removeAriaLabel}
30
+ classNames={chip.classNames}
31
+ />
32
+ ))}
33
+ </div>
34
+ );