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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lateralus-ai/shipping-ui",
3
- "version": "2.0.0-dev.21",
3
+ "version": "2.0.0-dev.23",
4
4
  "description": "Shared UI theme and components for Lateralus shipping applications",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.esm.js",
@@ -7,7 +7,7 @@ import {
7
7
  import * as Popover from "@radix-ui/react-popover";
8
8
  import { cn } from "../../utils/cn";
9
9
  import { ChevronIcon } from "../../icons/ChevronIcon";
10
- import { FilterPill } from "./FilterPill";
10
+ import { FilterPill, type FilterPillAppearance } from "./FilterPill";
11
11
 
12
12
  /** One selectable row at a leaf list. */
13
13
  export type FilterOption = {
@@ -45,7 +45,7 @@ export type FilterNestedItem = {
45
45
  content: FilterSubmenuContent;
46
46
  };
47
47
 
48
- /** Left column row that opens a (possibly nested) submenu on the right. */
48
+ /** Left-column row that opens a (possibly nested) submenu panel. */
49
49
  export type FilterCategorySubmenuRow = {
50
50
  kind?: "submenu";
51
51
  id: string;
@@ -192,14 +192,18 @@ export type FilterDropdownProps = {
192
192
  zIndexClass?: string;
193
193
  /** Replace the default FilterPill trigger. */
194
194
  trigger?: ReactNode;
195
+ /** Passed to the default FilterPill when `trigger` is omitted. */
196
+ triggerAppearance?: FilterPillAppearance;
197
+ /**
198
+ * Which side the options panel attaches to the category column.
199
+ * Carets stay on the right of category rows either way.
200
+ */
201
+ submenuSide?: "left" | "right";
195
202
  open?: boolean;
196
203
  onOpenChange?: (open: boolean) => void;
197
204
  className?: string;
198
205
  };
199
206
 
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
207
  const scrollClass = "py-2 overflow-y-auto overscroll-y-contain flex-1 min-h-0";
204
208
 
205
209
  const rowClass =
@@ -221,6 +225,8 @@ export function FilterDropdown({
221
225
  getOptionLabelStyle,
222
226
  zIndexClass = "z-50",
223
227
  trigger,
228
+ triggerAppearance = "filled",
229
+ submenuSide = "right",
224
230
  open: openControlled,
225
231
  onOpenChange,
226
232
  className,
@@ -240,10 +246,185 @@ export function FilterDropdown({
240
246
  ? resolveSubmenuView(activeSubmenuRow.content, navPath)
241
247
  : null;
242
248
 
243
- const showRightPanel = Boolean(activeSubmenuRow && view);
249
+ const showSubmenuPanel = Boolean(activeSubmenuRow && view);
250
+ const submenuOpensLeft = submenuSide === "left";
244
251
 
245
252
  const resetNav = () => setNavPath([]);
246
253
 
254
+ const submenuPanelClass = cn(
255
+ "bg-background-primary max-h-[min(28rem,calc(100dvh-8rem))] min-w-[200px] max-w-[min(28rem,calc(100vw-2rem))] flex flex-col overflow-hidden border border-divider-primary shadow-raise1",
256
+ submenuOpensLeft
257
+ ? "rounded-l-lg rounded-r-none border-r-0"
258
+ : "rounded-r-lg rounded-l-none border-l-0",
259
+ );
260
+
261
+ const categoryPanelClass = cn(
262
+ "min-w-[180px] border border-divider-primary bg-background-primary shadow-raise1",
263
+ showSubmenuPanel
264
+ ? submenuOpensLeft
265
+ ? "rounded-r-lg rounded-l-none"
266
+ : "rounded-l-lg rounded-r-none"
267
+ : "rounded-lg",
268
+ );
269
+
270
+ const categoryPanel = (
271
+ <div className={categoryPanelClass}>
272
+ <div className="py-2">
273
+ {categoryRows.map((row) =>
274
+ isToggleRow(row) ? (
275
+ <button
276
+ key={row.id}
277
+ type="button"
278
+ onClick={() => row.onCheckedChange(!row.checked)}
279
+ className={cn(rowClass, row.checked && "bg-grey-50")}
280
+ >
281
+ <span className="text-body text-display-on-light-primary">
282
+ {row.label}
283
+ </span>
284
+ <CheckSlot selected={row.checked} />
285
+ </button>
286
+ ) : (
287
+ <button
288
+ key={row.id}
289
+ type="button"
290
+ onClick={() => {
291
+ if (activeSubmenuId === row.id) {
292
+ setActiveSubmenuId(null);
293
+ resetNav();
294
+ } else {
295
+ setActiveSubmenuId(row.id);
296
+ resetNav();
297
+ }
298
+ }}
299
+ className={cn(
300
+ rowClass,
301
+ activeSubmenuId === row.id && "bg-grey-50",
302
+ )}
303
+ >
304
+ <span className="text-body text-display-on-light-primary">
305
+ {row.label}
306
+ </span>
307
+ <ChevronIcon
308
+ direction="right"
309
+ size="small"
310
+ className={cn(
311
+ "text-grey-400 transition-transform",
312
+ activeSubmenuId === row.id && "rotate-90",
313
+ )}
314
+ />
315
+ </button>
316
+ ),
317
+ )}
318
+
319
+ <div className="mt-2 border-t border-divider-primary pt-2">
320
+ <button
321
+ type="button"
322
+ onClick={onResetAll}
323
+ className={cn(rowClass)}
324
+ >
325
+ <span className="text-body text-display-on-light-primary">
326
+ {resetAllLabel}
327
+ </span>
328
+ </button>
329
+ </div>
330
+ </div>
331
+ </div>
332
+ );
333
+
334
+ const submenuPanel =
335
+ showSubmenuPanel && view ? (
336
+ <div className={cn(submenuPanelClass, "flex flex-col")}>
337
+ {navPath.length > 0 && (
338
+ <div className="flex-shrink-0 border-b border-grey-100 px-2 py-1.5">
339
+ <button
340
+ type="button"
341
+ onClick={() => setNavPath((p) => p.slice(0, -1))}
342
+ className="flex w-full items-center gap-1 rounded-md px-2 py-1 text-left text-caption-2-em text-display-on-light-secondary hover:bg-grey-50"
343
+ >
344
+ <ChevronIcon
345
+ direction="left"
346
+ size="small"
347
+ className="shrink-0"
348
+ />
349
+ Back
350
+ </button>
351
+ </div>
352
+ )}
353
+ {view.trail.length > 0 && (
354
+ <div className="flex-shrink-0 truncate px-4 pb-1 pt-2 text-footnote text-grey-500">
355
+ {view.trail.join(" › ")}
356
+ </div>
357
+ )}
358
+ <div className={scrollClass}>
359
+ {view.kind === "folders" ? (
360
+ view.items.length === 0 ? (
361
+ <p className="px-4 py-2 text-body text-grey-500">No options</p>
362
+ ) : (
363
+ view.items.map((item) => (
364
+ <button
365
+ key={item.id}
366
+ type="button"
367
+ onClick={() => setNavPath((p) => [...p, item.id])}
368
+ className={rowClass}
369
+ >
370
+ <span className="text-body text-display-on-light-primary">
371
+ {item.label}
372
+ </span>
373
+ <ChevronIcon
374
+ direction="right"
375
+ size="small"
376
+ className="shrink-0 text-grey-400"
377
+ />
378
+ </button>
379
+ ))
380
+ )
381
+ ) : view.kind === "custom" ? (
382
+ <div className="p-4">{view.render()}</div>
383
+ ) : view.options.length === 0 ? (
384
+ <p className="px-4 py-2 text-body text-grey-500">No options</p>
385
+ ) : (
386
+ view.options.map((option) => {
387
+ const selected =
388
+ selectedValuesBySelectionKey[view.selectionKey]?.includes(
389
+ option.value,
390
+ ) ?? false;
391
+ const Icon = option.icon;
392
+ const labelStyle = getOptionLabelStyle?.(
393
+ view.selectionKey,
394
+ option.value,
395
+ );
396
+ return (
397
+ <button
398
+ key={option.value}
399
+ type="button"
400
+ onClick={() =>
401
+ onSelectOption(view.selectionKey, option.value)
402
+ }
403
+ className={cn(rowClass, selected && "bg-grey-100")}
404
+ >
405
+ <div className="flex min-w-0 items-center gap-2">
406
+ {Icon && <Icon className="h-4 w-4 shrink-0" />}
407
+ <span
408
+ className={cn(
409
+ "text-body",
410
+ labelStyle
411
+ ? undefined
412
+ : "text-display-on-light-primary",
413
+ )}
414
+ style={labelStyle}
415
+ >
416
+ {option.label}
417
+ </span>
418
+ </div>
419
+ <CheckSlot selected={selected} />
420
+ </button>
421
+ );
422
+ })
423
+ )}
424
+ </div>
425
+ </div>
426
+ ) : null;
427
+
247
428
  return (
248
429
  <Popover.Root
249
430
  open={open}
@@ -257,7 +438,11 @@ export function FilterDropdown({
257
438
  >
258
439
  <Popover.Trigger asChild>
259
440
  {trigger ?? (
260
- <FilterPill activeFilterCount={activeFilterCount} className={className} />
441
+ <FilterPill
442
+ activeFilterCount={activeFilterCount}
443
+ appearance={triggerAppearance}
444
+ className={className}
445
+ />
261
446
  )}
262
447
  </Popover.Trigger>
263
448
  <Popover.Portal>
@@ -270,165 +455,16 @@ export function FilterDropdown({
270
455
  )}
271
456
  >
272
457
  <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>
458
+ {submenuOpensLeft ? (
459
+ <>
460
+ {submenuPanel}
461
+ {categoryPanel}
462
+ </>
463
+ ) : (
464
+ <>
465
+ {categoryPanel}
466
+ {submenuPanel}
467
+ </>
432
468
  )}
433
469
  </div>
434
470
  </Popover.Content>
@@ -3,22 +3,29 @@ import { cn } from "../../utils/cn";
3
3
  import { FiltersIcon } from "../../icons/generated";
4
4
  import { Badge } from "../../primitives/Badge";
5
5
 
6
+ export type FilterPillAppearance = "filled" | "ghost";
7
+
6
8
  export type FilterPillProps = Omit<
7
9
  ButtonHTMLAttributes<HTMLButtonElement>,
8
10
  "children"
9
11
  > & {
10
- /** When > 0, trigger uses active (blue) fill and shows a count badge. */
12
+ /** When > 0, shows the orange count badge. */
11
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;
12
19
  };
13
20
 
14
21
  /**
15
22
  * Filter trigger — Figma Filter Pill (6154:149608 / 6154:149599).
16
- * Idle: grey-100. Active: accent blue-100 + orange count badge.
17
23
  */
18
24
  export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
19
25
  (
20
26
  {
21
27
  activeFilterCount = 0,
28
+ appearance = "filled",
22
29
  className,
23
30
  type = "button",
24
31
  "aria-label": ariaLabel = "Filters",
@@ -27,6 +34,7 @@ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
27
34
  ref,
28
35
  ) => {
29
36
  const active = activeFilterCount > 0;
37
+ const ghost = appearance === "ghost";
30
38
 
31
39
  return (
32
40
  <button
@@ -36,9 +44,11 @@ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
36
44
  aria-pressed={active}
37
45
  className={cn(
38
46
  "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",
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",
42
52
  className,
43
53
  )}
44
54
  {...props}
@@ -48,7 +58,10 @@ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
48
58
  <Badge
49
59
  color="orange"
50
60
  type="icon"
51
- className="absolute -right-1 -top-2 border-2 border-background-primary text-display-on-light-primary"
61
+ className={cn(
62
+ "absolute -right-1 -top-2 border-2 text-display-on-light-primary",
63
+ ghost ? "border-white" : "border-background-primary",
64
+ )}
52
65
  >
53
66
  {activeFilterCount > 99 ? "99+" : activeFilterCount}
54
67
  </Badge>
@@ -16,26 +16,19 @@ export type FilterPillsProps = {
16
16
 
17
17
  /**
18
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.
19
+ * Always renders the row wrapper (even when empty) so sibling filter
20
+ * triggers can stay pinned with `ml-auto` / flex layouts.
24
21
  */
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
- };
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,12 +1,13 @@
1
1
  import { cn } from "../../utils/cn";
2
2
  import { ClearIcon } from "../../icons";
3
+ import { Tooltip } from "../../primitives/Tooltip";
3
4
 
4
5
  export type FilteredPillProps = {
5
6
  label: string;
6
7
  onRemove: () => void;
7
8
  removeAriaLabel?: string;
8
9
  className?: string;
9
- /** Optional tone overrides (e.g. criticality-colored chips). */
10
+ /** Optional tone overrides. Prefer default beige unless a product needs a custom tone. */
10
11
  classNames?: {
11
12
  root?: string;
12
13
  label?: string;
@@ -16,8 +17,8 @@ export type FilteredPillProps = {
16
17
 
17
18
  /**
18
19
  * 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.
20
+ * Clear control on the left, label on the right. Max 300px with ellipsis;
21
+ * full label in a tooltip on hover.
21
22
  */
22
23
  export const FilteredPill = ({
23
24
  label,
@@ -26,31 +27,33 @@ export const FilteredPill = ({
26
27
  className,
27
28
  classNames,
28
29
  }: FilteredPillProps) => (
29
- <div
30
- className={cn(
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,
33
- className,
34
- )}
35
- >
36
- <button
37
- type="button"
38
- onClick={onRemove}
39
- aria-label={removeAriaLabel ?? `Remove ${label} filter`}
30
+ <Tooltip content={label} hint side="top">
31
+ <div
40
32
  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,
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,
43
36
  )}
44
37
  >
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}
54
- </span>
55
- </div>
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>
56
59
  );
@@ -10,6 +10,7 @@ export {
10
10
  } from "./FilteredPill";
11
11
  export {
12
12
  FilterPill,
13
+ type FilterPillAppearance,
13
14
  type FilterPillProps,
14
15
  } from "./FilterPill";
15
16
  export {
@@ -109,6 +109,8 @@ export const FiltersCanvas = () => {
109
109
  <FigmaGrid gap={16}>
110
110
  <FilterPill />
111
111
  <FilterPill activeFilterCount={2} />
112
+ <FilterPill appearance="ghost" />
113
+ <FilterPill appearance="ghost" activeFilterCount={2} />
112
114
  </FigmaGrid>
113
115
  </FigmaSection>
114
116
 
@@ -125,6 +127,8 @@ export const FiltersCanvas = () => {
125
127
  <div className="flex shrink-0 items-center gap-4">
126
128
  <FilterDropdown
127
129
  align="end"
130
+ submenuSide="left"
131
+ triggerAppearance="ghost"
128
132
  categoryRows={categoryRows}
129
133
  selectedValuesBySelectionKey={selectedValuesBySelectionKey}
130
134
  activeFilterCount={activeFilterCount}