@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.
- package/dist/domain/Filters/FilterDropdown.d.ts +93 -0
- package/dist/domain/Filters/FilterPill.d.ts +12 -5
- package/dist/domain/Filters/FilterPills.d.ts +18 -0
- package/dist/domain/Filters/FilteredPill.d.ts +15 -5
- package/dist/domain/Filters/formatActiveFilterChipLabel.d.ts +11 -0
- package/dist/domain/Filters/index.d.ts +8 -3
- package/dist/index.cjs +33 -33
- package/dist/index.esm.js +7300 -5990
- package/package.json +2 -1
- package/src/components/EmptyState.tsx +44 -44
- package/src/components/Modal.tsx +141 -141
- package/src/components/PageHeader.tsx +132 -132
- package/src/components/ScrollableList.tsx +61 -61
- package/src/components/Tabs.tsx +146 -146
- package/src/components/index.ts +56 -56
- package/src/domain/Filters/FilterDropdown.tsx +438 -0
- package/src/domain/Filters/FilterPill.tsx +54 -18
- package/src/domain/Filters/FilterPills.tsx +34 -0
- package/src/domain/Filters/FilteredPill.tsx +48 -28
- package/src/domain/Filters/FiltersBar.tsx +15 -19
- package/src/domain/Filters/formatActiveFilterChipLabel.ts +23 -0
- package/src/domain/Filters/index.ts +35 -3
- package/src/icons/arrow-paths.ts +8 -8
- package/src/icons/chevron-paths.ts +17 -17
- package/src/icons/icons-data.ts +656 -656
- package/src/index.ts +85 -85
- package/src/patterns/Search/ResultRow.tsx +44 -44
- package/src/patterns/Search/SearchModal.tsx +310 -310
- package/src/patterns/Search/index.ts +31 -31
- package/src/patterns/Sidebar/assets/logo-compliance-mark.png +5 -5
- package/src/patterns/Sidebar/assets/logo-compliance-mask.png +13 -13
- package/src/patterns/Sidebar/assets/logo-compliance.svg +17 -17
- package/src/patterns/Sidebar/assets/logo-technical-avatar.svg +17 -17
- package/src/patterns/Sidebar/assets/logo-technical-mark.png +5 -5
- package/src/patterns/Sidebar/assets/logo-technical.svg +16 -16
- package/src/patterns/Skeleton/Skeleton.tsx +56 -56
- package/src/primitives/button-styles.ts +92 -92
- package/src/stories/canvases/ButtonsCanvas.tsx +107 -107
- package/src/stories/canvases/ButtonsMatrixCanvas.tsx +65 -65
- package/src/stories/canvases/ContentCanvas.tsx +353 -353
- package/src/stories/canvases/FiltersCanvas.tsx +156 -30
- package/src/stories/canvases/SearchCanvas.tsx +150 -150
- package/src/stories/canvases/figma-buttons-layout.ts +83 -83
- package/src/stories/canvases/figma-widths.ts +28 -28
- package/src/stories/canvases/helpers.tsx +146 -146
- package/src/stories/components/Buttons.stories.tsx +11 -11
- package/src/stories/components/Chat.stories.tsx +11 -11
- package/src/stories/components/Content.stories.tsx +11 -11
- package/src/stories/components/Core.stories.tsx +11 -11
- package/src/stories/components/DomainForms.stories.tsx +11 -11
- package/src/stories/components/Filters.stories.tsx +11 -11
- package/src/stories/components/Forms.stories.tsx +11 -11
- package/src/stories/components/Icons.stories.tsx +11 -11
- package/src/stories/components/Illustrations.stories.tsx +11 -11
- package/src/stories/components/Library.stories.tsx +11 -11
- package/src/stories/components/Modals.stories.tsx +11 -11
- package/src/stories/components/Report.stories.tsx +11 -11
- package/src/stories/components/ReportLayout.stories.tsx +11 -11
- package/src/stories/components/Search.stories.tsx +11 -11
- package/src/stories/components/Settings.stories.tsx +11 -11
- package/src/stories/components/Ships.stories.tsx +11 -11
- package/src/stories/components/SidebarLayouts.stories.tsx +11 -11
- package/src/stories/components/Skeletons.stories.tsx +11 -11
- package/src/stories/components/Workflows.stories.tsx +11 -11
- package/src/stories/style-guide/Buttons.stories.tsx +11 -11
- package/src/stories/style-guide/ColorTokens.stories.tsx +11 -11
- package/src/stories/style-guide/Colors.stories.tsx +11 -11
- package/src/stories/style-guide/RaiseLevels.stories.tsx +11 -11
- package/src/stories/style-guide/Typography.stories.tsx +11 -11
- package/src/tailwind-theme.ts +186 -186
- 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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
label,
|
|
25
|
+
onRemove,
|
|
26
|
+
removeAriaLabel,
|
|
22
27
|
className,
|
|
28
|
+
classNames,
|
|
23
29
|
}: FilteredPillProps) => (
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
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">
|
|
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
|
-
<
|
|
65
|
-
{filterChips.map((chip) => (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
);
|