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

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 +21 -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 +7299 -5989
  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 +41 -0
  19. package/src/domain/Filters/FilteredPill.tsx +40 -23
  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,41 @@
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
+ * Lives outside the dropdown DOM so callers can place it anywhere
20
+ * (same row, new row, sticky footer, etc.).
21
+ *
22
+ * Multi-select chip *content* (when to show, label format) is owned by the
23
+ * consumer; use `formatActiveFilterChipLabel` for audit-prep-style labels.
24
+ */
25
+ export const FilterPills = ({ chips, className }: FilterPillsProps) => {
26
+ if (chips.length === 0) return null;
27
+
28
+ return (
29
+ <div className={cn("flex flex-wrap items-start gap-2", className)}>
30
+ {chips.map((chip) => (
31
+ <FilteredPill
32
+ key={chip.key}
33
+ label={chip.label}
34
+ onRemove={chip.onRemove}
35
+ removeAriaLabel={chip.removeAriaLabel}
36
+ classNames={chip.classNames}
37
+ />
38
+ ))}
39
+ </div>
40
+ );
41
+ };
@@ -1,39 +1,56 @@
1
1
  import { cn } from "../../utils/cn";
2
2
  import { ClearIcon } from "../../icons";
3
3
 
4
- export type FilteredPillState = "idle" | "hover";
5
-
6
4
  export type FilteredPillProps = {
7
- label?: string;
8
- count?: number;
9
- state?: FilteredPillState;
5
+ label: string;
6
+ onRemove: () => void;
7
+ removeAriaLabel?: string;
10
8
  className?: string;
9
+ /** Optional tone overrides (e.g. criticality-colored chips). */
10
+ classNames?: {
11
+ root?: string;
12
+ label?: string;
13
+ remove?: string;
14
+ };
11
15
  };
12
16
 
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
-
17
+ /**
18
+ * Active filter value pill — Figma Filtered (6154:149787).
19
+ * Clear control on the left, label on the right. Partner to FilterDropdown;
20
+ * render via FilterPills outside the dropdown DOM.
21
+ */
18
22
  export const FilteredPill = ({
19
- label = "Filtered",
20
- count = 12,
21
- state = "idle",
23
+ label,
24
+ onRemove,
25
+ removeAriaLabel,
22
26
  className,
27
+ classNames,
23
28
  }: FilteredPillProps) => (
24
- <button
25
- type="button"
29
+ <div
26
30
  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",
31
+ "inline-flex min-h-9 items-center justify-center gap-2.5 rounded-full bg-background-secondary px-3 py-1",
32
+ classNames?.root,
30
33
  className,
31
34
  )}
32
35
  >
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
+ <button
37
+ type="button"
38
+ onClick={onRemove}
39
+ aria-label={removeAriaLabel ?? `Remove ${label} filter`}
40
+ className={cn(
41
+ "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",
42
+ classNames?.remove,
43
+ )}
44
+ >
45
+ <ClearIcon size="xs" />
46
+ </button>
47
+ <span
48
+ className={cn(
49
+ "text-caption-2-em text-display-on-light-primary whitespace-nowrap",
50
+ classNames?.label,
51
+ )}
52
+ >
53
+ {label}
36
54
  </span>
37
- <ClearIcon size="xs" />
38
- </button>
55
+ </div>
39
56
  );
@@ -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
  );