@lateralus-ai/shipping-ui 2.0.0-dev.20 → 2.0.0-dev.22

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 (71) hide show
  1. package/dist/domain/Filters/FilterDropdown.d.ts +93 -0
  2. package/dist/domain/Filters/FilterPill.d.ts +12 -5
  3. package/dist/domain/Filters/FilterPills.d.ts +18 -0
  4. package/dist/domain/Filters/FilteredPill.d.ts +15 -5
  5. package/dist/domain/Filters/formatActiveFilterChipLabel.d.ts +11 -0
  6. package/dist/domain/Filters/index.d.ts +8 -3
  7. package/dist/index.cjs +33 -33
  8. package/dist/index.esm.js +7300 -5990
  9. package/package.json +2 -1
  10. package/src/components/EmptyState.tsx +44 -44
  11. package/src/components/Modal.tsx +141 -141
  12. package/src/components/PageHeader.tsx +132 -132
  13. package/src/components/ScrollableList.tsx +61 -61
  14. package/src/components/Tabs.tsx +146 -146
  15. package/src/components/index.ts +56 -56
  16. package/src/domain/Filters/FilterDropdown.tsx +438 -0
  17. package/src/domain/Filters/FilterPill.tsx +54 -18
  18. package/src/domain/Filters/FilterPills.tsx +34 -0
  19. package/src/domain/Filters/FilteredPill.tsx +48 -28
  20. package/src/domain/Filters/FiltersBar.tsx +15 -19
  21. package/src/domain/Filters/formatActiveFilterChipLabel.ts +23 -0
  22. package/src/domain/Filters/index.ts +35 -3
  23. package/src/icons/arrow-paths.ts +8 -8
  24. package/src/icons/chevron-paths.ts +17 -17
  25. package/src/icons/icons-data.ts +656 -656
  26. package/src/index.ts +85 -85
  27. package/src/patterns/Search/ResultRow.tsx +44 -44
  28. package/src/patterns/Search/SearchModal.tsx +310 -310
  29. package/src/patterns/Search/index.ts +31 -31
  30. package/src/patterns/Sidebar/assets/logo-compliance-mark.png +5 -5
  31. package/src/patterns/Sidebar/assets/logo-compliance-mask.png +13 -13
  32. package/src/patterns/Sidebar/assets/logo-compliance.svg +17 -17
  33. package/src/patterns/Sidebar/assets/logo-technical-avatar.svg +17 -17
  34. package/src/patterns/Sidebar/assets/logo-technical-mark.png +5 -5
  35. package/src/patterns/Sidebar/assets/logo-technical.svg +16 -16
  36. package/src/patterns/Skeleton/Skeleton.tsx +56 -56
  37. package/src/primitives/button-styles.ts +92 -92
  38. package/src/stories/canvases/ButtonsCanvas.tsx +107 -107
  39. package/src/stories/canvases/ButtonsMatrixCanvas.tsx +65 -65
  40. package/src/stories/canvases/ContentCanvas.tsx +353 -353
  41. package/src/stories/canvases/FiltersCanvas.tsx +156 -30
  42. package/src/stories/canvases/SearchCanvas.tsx +150 -150
  43. package/src/stories/canvases/figma-buttons-layout.ts +83 -83
  44. package/src/stories/canvases/figma-widths.ts +28 -28
  45. package/src/stories/canvases/helpers.tsx +146 -146
  46. package/src/stories/components/Buttons.stories.tsx +11 -11
  47. package/src/stories/components/Chat.stories.tsx +11 -11
  48. package/src/stories/components/Content.stories.tsx +11 -11
  49. package/src/stories/components/Core.stories.tsx +11 -11
  50. package/src/stories/components/DomainForms.stories.tsx +11 -11
  51. package/src/stories/components/Filters.stories.tsx +11 -11
  52. package/src/stories/components/Forms.stories.tsx +11 -11
  53. package/src/stories/components/Icons.stories.tsx +11 -11
  54. package/src/stories/components/Illustrations.stories.tsx +11 -11
  55. package/src/stories/components/Library.stories.tsx +11 -11
  56. package/src/stories/components/Modals.stories.tsx +11 -11
  57. package/src/stories/components/Report.stories.tsx +11 -11
  58. package/src/stories/components/ReportLayout.stories.tsx +11 -11
  59. package/src/stories/components/Search.stories.tsx +11 -11
  60. package/src/stories/components/Settings.stories.tsx +11 -11
  61. package/src/stories/components/Ships.stories.tsx +11 -11
  62. package/src/stories/components/SidebarLayouts.stories.tsx +11 -11
  63. package/src/stories/components/Skeletons.stories.tsx +11 -11
  64. package/src/stories/components/Workflows.stories.tsx +11 -11
  65. package/src/stories/style-guide/Buttons.stories.tsx +11 -11
  66. package/src/stories/style-guide/ColorTokens.stories.tsx +11 -11
  67. package/src/stories/style-guide/Colors.stories.tsx +11 -11
  68. package/src/stories/style-guide/RaiseLevels.stories.tsx +11 -11
  69. package/src/stories/style-guide/Typography.stories.tsx +11 -11
  70. package/src/tailwind-theme.ts +186 -186
  71. package/src/theme-entry.ts +2 -2
@@ -0,0 +1,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 { FilterPill } 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 on the right. */
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
+ open?: boolean;
196
+ onOpenChange?: (open: boolean) => void;
197
+ className?: string;
198
+ };
199
+
200
+ const submenuPanelClass =
201
+ "bg-background-primary rounded-lg shadow-raise1 border border-divider-primary border-l-0 rounded-l-none min-w-[200px] max-w-[min(28rem,calc(100vw-2rem))] max-h-[min(28rem,calc(100dvh-8rem))] flex flex-col overflow-hidden";
202
+
203
+ const scrollClass = "py-2 overflow-y-auto overscroll-y-contain flex-1 min-h-0";
204
+
205
+ const rowClass =
206
+ "w-full flex items-center justify-between gap-4 px-4 py-2 text-left transition-colors hover:bg-grey-50";
207
+
208
+ /**
209
+ * Filters trigger + two-column popover (audit-prep pattern).
210
+ * Supports multi/single option leaves, nested folders, custom panels, and toggles.
211
+ */
212
+ export function FilterDropdown({
213
+ align = "start",
214
+ sideOffset = 4,
215
+ categoryRows,
216
+ selectedValuesBySelectionKey,
217
+ onSelectOption,
218
+ onResetAll,
219
+ resetAllLabel = "Reset all",
220
+ activeFilterCount,
221
+ getOptionLabelStyle,
222
+ zIndexClass = "z-50",
223
+ trigger,
224
+ open: openControlled,
225
+ onOpenChange,
226
+ className,
227
+ }: FilterDropdownProps) {
228
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
229
+ const open = openControlled ?? uncontrolledOpen;
230
+ const setOpen = onOpenChange ?? setUncontrolledOpen;
231
+
232
+ const [activeSubmenuId, setActiveSubmenuId] = useState<string | null>(null);
233
+ const [navPath, setNavPath] = useState<string[]>([]);
234
+
235
+ const activeSubmenuRow = categoryRows.find(
236
+ (r) => isSubmenuRow(r) && r.id === activeSubmenuId,
237
+ ) as FilterCategorySubmenuRow | undefined;
238
+
239
+ const view = activeSubmenuRow
240
+ ? resolveSubmenuView(activeSubmenuRow.content, navPath)
241
+ : null;
242
+
243
+ const showRightPanel = Boolean(activeSubmenuRow && view);
244
+
245
+ const resetNav = () => setNavPath([]);
246
+
247
+ return (
248
+ <Popover.Root
249
+ open={open}
250
+ onOpenChange={(next) => {
251
+ setOpen(next);
252
+ if (!next) {
253
+ setActiveSubmenuId(null);
254
+ setNavPath([]);
255
+ }
256
+ }}
257
+ >
258
+ <Popover.Trigger asChild>
259
+ {trigger ?? (
260
+ <FilterPill activeFilterCount={activeFilterCount} className={className} />
261
+ )}
262
+ </Popover.Trigger>
263
+ <Popover.Portal>
264
+ <Popover.Content
265
+ align={align}
266
+ sideOffset={sideOffset}
267
+ className={cn(
268
+ zIndexClass,
269
+ "w-auto border-0 bg-transparent p-0 shadow-none outline-none",
270
+ )}
271
+ >
272
+ <div className="flex items-start gap-0">
273
+ <div className="min-w-[180px] rounded-lg border border-divider-primary bg-background-primary shadow-raise1">
274
+ <div className="py-2">
275
+ {categoryRows.map((row) =>
276
+ isToggleRow(row) ? (
277
+ <button
278
+ key={row.id}
279
+ type="button"
280
+ onClick={() => row.onCheckedChange(!row.checked)}
281
+ className={cn(rowClass, row.checked && "bg-grey-50")}
282
+ >
283
+ <span className="text-body text-display-on-light-primary">
284
+ {row.label}
285
+ </span>
286
+ <CheckSlot selected={row.checked} />
287
+ </button>
288
+ ) : (
289
+ <button
290
+ key={row.id}
291
+ type="button"
292
+ onClick={() => {
293
+ if (activeSubmenuId === row.id) {
294
+ setActiveSubmenuId(null);
295
+ resetNav();
296
+ } else {
297
+ setActiveSubmenuId(row.id);
298
+ resetNav();
299
+ }
300
+ }}
301
+ className={cn(
302
+ rowClass,
303
+ activeSubmenuId === row.id && "bg-grey-50",
304
+ )}
305
+ >
306
+ <span className="text-body text-display-on-light-primary">
307
+ {row.label}
308
+ </span>
309
+ <ChevronIcon
310
+ direction="right"
311
+ size="small"
312
+ className={cn(
313
+ "text-grey-400 transition-transform",
314
+ activeSubmenuId === row.id && "rotate-90",
315
+ )}
316
+ />
317
+ </button>
318
+ ),
319
+ )}
320
+
321
+ <div className="mt-2 border-t border-divider-primary pt-2">
322
+ <button
323
+ type="button"
324
+ onClick={onResetAll}
325
+ className={cn(rowClass)}
326
+ >
327
+ <span className="text-body text-display-on-light-primary">
328
+ {resetAllLabel}
329
+ </span>
330
+ </button>
331
+ </div>
332
+ </div>
333
+ </div>
334
+
335
+ {showRightPanel && 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">
362
+ No options
363
+ </p>
364
+ ) : (
365
+ view.items.map((item) => (
366
+ <button
367
+ key={item.id}
368
+ type="button"
369
+ onClick={() => setNavPath((p) => [...p, item.id])}
370
+ className={rowClass}
371
+ >
372
+ <span className="text-body text-display-on-light-primary">
373
+ {item.label}
374
+ </span>
375
+ <ChevronIcon
376
+ direction="right"
377
+ size="small"
378
+ className="shrink-0 text-grey-400"
379
+ />
380
+ </button>
381
+ ))
382
+ )
383
+ ) : view.kind === "custom" ? (
384
+ <div className="p-4">{view.render()}</div>
385
+ ) : view.options.length === 0 ? (
386
+ <p className="px-4 py-2 text-body text-grey-500">
387
+ No options
388
+ </p>
389
+ ) : (
390
+ view.options.map((option) => {
391
+ const selected =
392
+ selectedValuesBySelectionKey[
393
+ view.selectionKey
394
+ ]?.includes(option.value) ?? false;
395
+ const Icon = option.icon;
396
+ const labelStyle = getOptionLabelStyle?.(
397
+ view.selectionKey,
398
+ option.value,
399
+ );
400
+ return (
401
+ <button
402
+ key={option.value}
403
+ type="button"
404
+ onClick={() =>
405
+ onSelectOption(view.selectionKey, option.value)
406
+ }
407
+ className={cn(rowClass, selected && "bg-grey-100")}
408
+ >
409
+ <div className="flex min-w-0 items-center gap-2">
410
+ {Icon && (
411
+ <Icon className="h-4 w-4 shrink-0" />
412
+ )}
413
+ <span
414
+ className={cn(
415
+ "text-body",
416
+ labelStyle
417
+ ? undefined
418
+ : "text-display-on-light-primary",
419
+ )}
420
+ style={labelStyle}
421
+ >
422
+ {option.label}
423
+ </span>
424
+ </div>
425
+ <CheckSlot selected={selected} />
426
+ </button>
427
+ );
428
+ })
429
+ )}
430
+ </div>
431
+ </div>
432
+ )}
433
+ </div>
434
+ </Popover.Content>
435
+ </Popover.Portal>
436
+ </Popover.Root>
437
+ );
438
+ }
@@ -1,25 +1,61 @@
1
+ import { forwardRef, type ButtonHTMLAttributes } from "react";
1
2
  import { cn } from "../../utils/cn";
2
3
  import { FiltersIcon } from "../../icons/generated";
4
+ import { Badge } from "../../primitives/Badge";
3
5
 
4
- export type FilterPillIndicator = "on" | "off";
5
-
6
- export type FilterPillProps = {
7
- indicator?: FilterPillIndicator;
8
- className?: string;
6
+ export type FilterPillProps = Omit<
7
+ ButtonHTMLAttributes<HTMLButtonElement>,
8
+ "children"
9
+ > & {
10
+ /** When > 0, trigger uses active (blue) fill and shows a count badge. */
11
+ activeFilterCount?: number;
9
12
  };
10
13
 
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",
14
+ /**
15
+ * Filter trigger — Figma Filter Pill (6154:149608 / 6154:149599).
16
+ * Idle: grey-100. Active: accent blue-100 + orange count badge.
17
+ */
18
+ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
19
+ (
20
+ {
21
+ activeFilterCount = 0,
17
22
  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>
23
+ type = "button",
24
+ "aria-label": ariaLabel = "Filters",
25
+ ...props
26
+ },
27
+ ref,
28
+ ) => {
29
+ const active = activeFilterCount > 0;
30
+
31
+ return (
32
+ <button
33
+ ref={ref}
34
+ type={type}
35
+ aria-label={ariaLabel}
36
+ aria-pressed={active}
37
+ className={cn(
38
+ "relative flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
39
+ active
40
+ ? "bg-accent-bg-light text-display-on-light-secondary hover:text-display-on-light-primary"
41
+ : "bg-background-secondary text-display-on-light-secondary hover:text-display-on-light-primary",
42
+ className,
43
+ )}
44
+ {...props}
45
+ >
46
+ <FiltersIcon size="small" />
47
+ {active && (
48
+ <Badge
49
+ color="orange"
50
+ type="icon"
51
+ className="absolute -right-1 -top-2 border-2 border-background-primary text-display-on-light-primary"
52
+ >
53
+ {activeFilterCount > 99 ? "99+" : activeFilterCount}
54
+ </Badge>
55
+ )}
56
+ </button>
57
+ );
58
+ },
25
59
  );
60
+
61
+ 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
+ );
@@ -1,39 +1,59 @@
1
1
  import { cn } from "../../utils/cn";
2
2
  import { ClearIcon } from "../../icons";
3
-
4
- export type FilteredPillState = "idle" | "hover";
3
+ import { Tooltip } from "../../primitives/Tooltip";
5
4
 
6
5
  export type FilteredPillProps = {
7
- label?: string;
8
- count?: number;
9
- state?: FilteredPillState;
6
+ label: string;
7
+ onRemove: () => void;
8
+ removeAriaLabel?: string;
10
9
  className?: string;
10
+ /** Optional tone overrides. Prefer default beige unless a product needs a custom tone. */
11
+ classNames?: {
12
+ root?: string;
13
+ label?: string;
14
+ remove?: string;
15
+ };
11
16
  };
12
17
 
13
- const stateStyles: Record<FilteredPillState, string> = {
14
- idle: "bg-background-secondary text-display-on-light-tertiary",
15
- hover: "bg-grey-200 text-display-on-light-primary",
16
- };
17
-
18
+ /**
19
+ * Active filter value pill — Figma Filtered (6154:149787).
20
+ * Clear control on the left, label on the right. Max 300px with ellipsis;
21
+ * full label in a tooltip on hover.
22
+ */
18
23
  export const FilteredPill = ({
19
- label = "Filtered",
20
- count = 12,
21
- state = "idle",
24
+ label,
25
+ onRemove,
26
+ removeAriaLabel,
22
27
  className,
28
+ classNames,
23
29
  }: FilteredPillProps) => (
24
- <button
25
- type="button"
26
- className={cn(
27
- "inline-flex min-h-9 items-center gap-2 rounded-full px-3 py-1 transition-colors",
28
- stateStyles[state],
29
- state === "idle" && "hover:bg-grey-200 hover:text-display-on-light-primary",
30
- className,
31
- )}
32
- >
33
- <span className="text-caption-2-em">{label}</span>
34
- <span className="flex size-6 items-center justify-center rounded-full bg-background-primary text-footnote-em">
35
- {count}
36
- </span>
37
- <ClearIcon size="xs" />
38
- </button>
30
+ <Tooltip content={label} hint side="top">
31
+ <div
32
+ className={cn(
33
+ "inline-flex max-w-[300px] min-h-9 items-center justify-center gap-2.5 rounded-full bg-background-secondary px-3 py-1",
34
+ classNames?.root,
35
+ className,
36
+ )}
37
+ >
38
+ <button
39
+ type="button"
40
+ onClick={onRemove}
41
+ aria-label={removeAriaLabel ?? `Remove ${label} filter`}
42
+ className={cn(
43
+ "inline-flex size-4 shrink-0 items-center justify-center rounded-control text-display-on-light-secondary transition-colors hover:text-display-on-light-primary",
44
+ classNames?.remove,
45
+ )}
46
+ >
47
+ <ClearIcon size="xs" />
48
+ </button>
49
+ <span
50
+ className={cn(
51
+ "min-w-0 truncate text-caption-2-em text-display-on-light-primary",
52
+ classNames?.label,
53
+ )}
54
+ >
55
+ {label}
56
+ </span>
57
+ </div>
58
+ </Tooltip>
39
59
  );
@@ -1,6 +1,7 @@
1
1
  import { cn } from "../../utils/cn";
2
- import { FiltersIcon } from "../../icons/generated";
3
2
  import { SearchIcon } from "../../icons/generated";
3
+ import { FilterPill } from "./FilterPill";
4
+ import { FilterPills } from "./FilterPills";
4
5
 
5
6
  export type FiltersBarTabs = "on" | "off";
6
7
  export type FiltersBarFilters = "on" | "off";
@@ -47,30 +48,25 @@ export const FiltersBar = ({
47
48
  </div>
48
49
  )}
49
50
  <div className="flex shrink-0 items-center gap-4">
50
- <button
51
- type="button"
52
- aria-label="Open filters"
53
- className="flex size-9 items-center justify-center rounded-full bg-background-secondary text-display-on-light-secondary hover:text-display-on-light-primary"
54
- >
55
- <FiltersIcon size="small" />
56
- </button>
51
+ <FilterPill
52
+ activeFilterCount={filters === "on" ? filterChips.length : 0}
53
+ />
57
54
  <div className="flex w-[250px] items-center gap-2 rounded-md border border-divider-primary px-3 py-2">
58
- <span className="flex-1 text-body text-display-on-light-secondary">{searchPlaceholder}</span>
55
+ <span className="flex-1 text-body text-display-on-light-secondary">
56
+ {searchPlaceholder}
57
+ </span>
59
58
  <SearchIcon size="small" className="text-display-on-light-tertiary" />
60
59
  </div>
61
60
  </div>
62
61
  </div>
63
62
  {filters === "on" && (
64
- <div className="flex flex-wrap gap-2">
65
- {filterChips.map((chip) => (
66
- <span
67
- key={chip}
68
- className="rounded-full bg-background-secondary px-3 py-1.5 text-caption-2-em text-display-on-light-tertiary"
69
- >
70
- {chip}
71
- </span>
72
- ))}
73
- </div>
63
+ <FilterPills
64
+ chips={filterChips.map((chip) => ({
65
+ key: chip,
66
+ label: chip,
67
+ onRemove: () => undefined,
68
+ }))}
69
+ />
74
70
  )}
75
71
  </div>
76
72
  );