aplance-ui 1.2.2 → 1.4.0

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 (26) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/modal/modal.tsx +16 -10
  3. package/registry/components/primitives/popover/popover-context.ts +33 -28
  4. package/registry/components/primitives/popover/popover.tsx +145 -26
  5. package/registry/components/primitives/slider/slider.module.css +62 -5
  6. package/registry/components/primitives/slider/slider.tsx +192 -34
  7. package/registry/components/primitives/slider/types.ts +56 -3
  8. package/registry/components/primitives/slider/utils.ts +9 -0
  9. package/registry/components/ui/copy-button/copy-button.tsx +5 -0
  10. package/registry/components/ui/data-table/data-table.module.css +695 -613
  11. package/registry/components/ui/data-table/data-table.tsx +136 -112
  12. package/registry/components/ui/data-table/filter-modal.tsx +86 -0
  13. package/registry/components/ui/data-table/types.ts +8 -0
  14. package/registry/components/ui/data-table/useDataTable.tsx +1 -0
  15. package/registry/components/ui/data-table/utils.ts +26 -1
  16. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +32 -1
  17. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +500 -469
  18. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +940 -933
  19. package/registry/components/ui/input-fields/date-picker/types.ts +2 -0
  20. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +8 -0
  21. package/registry/components/ui/input-fields/input/input.tsx +80 -69
  22. package/registry/components/ui/tabs/tabs.module.css +22 -8
  23. package/registry/components/ui/tabs/tabs.tsx +29 -12
  24. package/registry/hooks/useHasMounted.ts +22 -0
  25. package/registry/hooks/useMediaQuery.ts +38 -0
  26. package/registry/registry.json +41 -4
@@ -24,6 +24,7 @@ import {
24
24
  } from "react";
25
25
  import clsx from "clsx";
26
26
  import { FilterBar } from "./filter-bar";
27
+ import { FilterModal } from "./filter-modal";
27
28
  import { PaginationBar } from "./pagination-bar";
28
29
  import { Loader } from "../loader/loader";
29
30
  import type {
@@ -60,6 +61,12 @@ import {
60
61
  } from "@tabler/icons-react";
61
62
  import { getScrollbarWidth } from "../../../lib/dom";
62
63
  import { Button } from "../button/button";
64
+ import { useMediaQuery } from "../../../hooks/useMediaQuery";
65
+
66
+ // Below this width the inline filter row can't fit its fields (matches the
67
+ // `--md` custom-media token in styles/media.css); the table collapses filters
68
+ // into a single "Filters" button + modal instead of stacking them full-height.
69
+ const FILTER_COMPACT_QUERY = "(max-width: 900px)";
63
70
 
64
71
  const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50];
65
72
  const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 10 };
@@ -143,6 +150,9 @@ export const DataTable = <TData extends { id: string }>({
143
150
  paginationExtra,
144
151
  className,
145
152
  }: DataTableProps<TData>) => {
153
+ // Collapse the filter row into a button + modal once it no longer fits inline.
154
+ const filtersCompact = useMediaQuery(FILTER_COMPACT_QUERY);
155
+
146
156
  // ── Table meta ──────────────────────────────────────────────────────────────
147
157
  const meta: DataTableMeta<TData> = useMemo(
148
158
  () => ({ editingRowId, updateData: onUpdateData }),
@@ -345,11 +355,11 @@ export const DataTable = <TData extends { id: string }>({
345
355
  // ── Sync header/body scrolling ──────────────────────────────────────────────
346
356
  // The header lives in its own `overflow:hidden` container; we drive its
347
357
  // scrollLeft from the body's scrollLeft on every scroll event so pinned
348
- // columns stay perfectly aligned with their body counterparts.
349
- const headerRef = useRef<HTMLDivElement>(null);
350
- const bodyRef = useRef<HTMLDivElement>(null);
351
- const bodyTableRef = useRef<HTMLTableElement>(null);
352
- const skeletonRowRef = useRef<HTMLTableRowElement>(null);
358
+ // columns stay perfectly aligned with their body counterparts.
359
+ const headerRef = useRef<HTMLDivElement>(null);
360
+ const bodyRef = useRef<HTMLDivElement>(null);
361
+ const bodyTableRef = useRef<HTMLTableElement>(null);
362
+ const skeletonRowRef = useRef<HTMLTableRowElement>(null);
353
363
  const rows = table.getRowModel().rows;
354
364
  const headerCount = table.getFlatHeaders().length;
355
365
  const totalWidth = table.getTotalSize();
@@ -402,6 +412,9 @@ export const DataTable = <TData extends { id: string }>({
402
412
  paddingRight,
403
413
  );
404
414
  header.parentElement?.setAttribute("data-has-scroll", hasScroll.toString());
415
+ // Mirror onto the body wrap so CSS can release the reserved scrollbar gutter
416
+ // when there's no vertical overflow (no blank strip on a few-row table).
417
+ body.setAttribute("data-has-scroll", hasScroll.toString());
405
418
 
406
419
  // Re-sync horizontal scroll position after width changes so pinned
407
420
  // columns stay perfectly aligned with their body counterparts.
@@ -465,7 +478,7 @@ export const DataTable = <TData extends { id: string }>({
465
478
  }, []);
466
479
 
467
480
  // ── Derived UI state ────────────────────────────────────────────────────────
468
- // Defer the empty state by a tick after a loading-to-idle transition so the
481
+ // Defer the empty state by a tick after a loading-to-idle transition so the
469
482
  // loader doesn't flicker into "no results" for one paint before rows land.
470
483
  // Genuinely empty results (no in-flight load) commit immediately on mount.
471
484
  const isRowsEmpty = rows.length === 0;
@@ -474,6 +487,9 @@ export const DataTable = <TData extends { id: string }>({
474
487
  );
475
488
  const wasLoadingRef = useRef(isLoading);
476
489
 
490
+ // wasLoadingRef is intentionally omitted from the dep array: it's a ref used
491
+ // to track the previous value of isLoading across renders without triggering
492
+ // re-runs. React guarantees ref objects are stable across the component lifetime.
477
493
  useEffect(() => {
478
494
  const wasLoading = wasLoadingRef.current;
479
495
  wasLoadingRef.current = isLoading;
@@ -482,18 +498,19 @@ export const DataTable = <TData extends { id: string }>({
482
498
  setShowEmptyState(false);
483
499
  return;
484
500
  }
485
- if (wasLoading) {
486
- const id = window.setTimeout(
487
- () => setShowEmptyState(true),
488
- EMPTY_STATE_GRACE_MS,
489
- );
490
- return () => window.clearTimeout(id);
501
+ if (!wasLoading) {
502
+ setShowEmptyState(true);
503
+ return;
491
504
  }
492
- setShowEmptyState(true);
505
+ const id = window.setTimeout(
506
+ () => setShowEmptyState(true),
507
+ EMPTY_STATE_GRACE_MS,
508
+ );
509
+ return () => window.clearTimeout(id);
493
510
  }, [isLoading, isRowsEmpty]);
494
511
 
495
512
  // Single tab-like discriminator drives the body - loader, empty, or content.
496
- // While loading OR while bridging the loading-to-empty grace window, the
513
+ // While loading OR while bridging the loading-to-empty grace window, the
497
514
  // loader stays visible to avoid the brief "no results" flash.
498
515
  const view: "loader" | "empty" | "content" =
499
516
  isLoading || (isRowsEmpty && !showEmptyState)
@@ -525,20 +542,20 @@ export const DataTable = <TData extends { id: string }>({
525
542
  const selectedCount = rowsSelectable
526
543
  ? table.getSelectedRowModel().flatRows.length
527
544
  : 0;
528
- const headerGroups = table.getHeaderGroups();
529
- const visibleLeafColumns = table.getVisibleLeafColumns();
530
-
531
- const skeletonRowCount =
532
- bodyViewportHeight > 0 && skeletonRowHeight > 0
533
- ? Math.max(
545
+ const headerGroups = table.getHeaderGroups();
546
+ const visibleLeafColumns = table.getVisibleLeafColumns();
547
+
548
+ const skeletonRowCount =
549
+ bodyViewportHeight > 0 && skeletonRowHeight > 0
550
+ ? Math.max(
534
551
  MIN_SKELETON_ROWS,
535
552
  Math.floor(bodyViewportHeight / skeletonRowHeight),
536
553
  )
537
554
  : MIN_SKELETON_ROWS;
538
555
 
539
- const columnExtraWidths = useMemo<Record<string, number>>(() => {
540
- const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
541
- if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
556
+ const columnExtraWidths = useMemo<Record<string, number>>(() => {
557
+ const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
558
+ if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
542
559
 
543
560
  // Never add responsive width onto pinned columns because tanstack computes
544
561
  // sticky offsets from base sizes; changing pinned visual width would desync
@@ -561,27 +578,27 @@ export const DataTable = <TData extends { id: string }>({
561
578
  remainder -= 1;
562
579
  }
563
580
  }
564
-
565
- return next;
566
- }, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
567
-
568
- const tableWidth = Math.max(totalWidth, bodyViewportWidth);
569
-
570
- // Match the table element width to the measured column widths. This keeps
571
- // header, body, and skeleton rows on the same sizing contract during resize.
572
- const tableSizingStyle: CSSProperties = useMemo(
573
- () => ({ width: `${tableWidth}px`, minWidth: `${totalWidth}px` }),
574
- [tableWidth, totalWidth],
575
- );
576
-
577
- const bodySkeletonStyle: CSSProperties = {
578
- height: "var(--spacing-9)",
579
- };
580
-
581
- const selectSkeletonStyle: CSSProperties = {
582
- width: "var(--data-table-selection-control-size, var(--spacing-9))",
583
- height: "var(--data-table-selection-control-size, var(--spacing-9))",
584
- };
581
+
582
+ return next;
583
+ }, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
584
+
585
+ const tableWidth = Math.max(totalWidth, bodyViewportWidth);
586
+
587
+ // Match the table element width to the measured column widths. This keeps
588
+ // header, body, and skeleton rows on the same sizing contract during resize.
589
+ const tableSizingStyle: CSSProperties = useMemo(
590
+ () => ({ width: `${tableWidth}px`, minWidth: `${totalWidth}px` }),
591
+ [tableWidth, totalWidth],
592
+ );
593
+
594
+ const bodySkeletonStyle: CSSProperties = {
595
+ height: "var(--spacing-9)",
596
+ };
597
+
598
+ const selectSkeletonStyle: CSSProperties = {
599
+ width: "var(--data-table-selection-control-size, var(--spacing-9))",
600
+ height: "var(--data-table-selection-control-size, var(--spacing-9))",
601
+ };
585
602
 
586
603
  const tableClass = clsx(
587
604
  s.table,
@@ -590,14 +607,22 @@ export const DataTable = <TData extends { id: string }>({
590
607
 
591
608
  return (
592
609
  <div className={clsx(s.root, className)}>
593
- {/* Filter bar - fixed height, never scrolls */}
594
- {filters.length > 0 && (
595
- <FilterBar
596
- fields={filters}
597
- values={filterValues}
598
- onChange={onFilterChange}
599
- />
600
- )}
610
+ {/* Filters - inline row on desktop; a "Filters" button + modal once the
611
+ row no longer fits (so phones aren't buried under stacked fields). */}
612
+ {filters.length > 0 &&
613
+ (filtersCompact ? (
614
+ <FilterModal
615
+ fields={filters}
616
+ values={filterValues}
617
+ onChange={onFilterChange}
618
+ />
619
+ ) : (
620
+ <FilterBar
621
+ fields={filters}
622
+ values={filterValues}
623
+ onChange={onFilterChange}
624
+ />
625
+ ))}
601
626
 
602
627
  {/* Content wrap - grows to fill remaining height.
603
628
  table_shell fills it; pagination pins to the bottom via margin-top:auto */}
@@ -644,66 +669,65 @@ export const DataTable = <TData extends { id: string }>({
644
669
  {/* Scrollable body - only this element scrolls. The body is a
645
670
  single switch over `view`: at most one branch ever renders, the
646
671
  other two are unmounted entirely. */}
647
- <div
648
- className={s.table_body_wrap}
649
- style={{ pointerEvents: isLoading ? "none" : undefined }}
650
- ref={bodyRef}
651
- >
652
- {view === "loader" && (
653
- <table className={tableClass} style={tableSizingStyle}>
654
- <tbody>
655
- {Array.from({ length: skeletonRowCount }).map(
656
- (_, rowIndex) => (
657
- <tr
658
- key={`skeleton-${rowIndex}`}
659
- ref={rowIndex === 0 ? skeletonRowRef : undefined}
660
- >
661
- {visibleLeafColumns.map((column) => {
662
- const isPinned = column.getIsPinned();
663
- const isLastPinnedCol =
664
- isPinned && isLastPinned(column);
665
- const isSelectColumn =
666
- column.id === SELECT_COLUMN_ID;
667
-
668
- return (
669
- <td
670
- key={column.id}
671
- className={clsx(
672
- isSelectColumn && s.col_select,
673
- isPinned === "left" && s.col_pinned_left,
674
- isPinned === "right" && s.col_pinned_right,
675
- isLastPinnedCol && s.col_pinned_last,
676
- )}
677
- style={getColumnStyle(
678
- column,
679
- column.getSize(),
680
- columnExtraWidths[column.id] ?? 0,
681
- )}
682
- >
683
- <div className={s.td_inner}>
684
- <Loader
685
- variant="skeleton"
686
- className={
687
- isSelectColumn
688
- ? s.loading_select_skeleton
689
- : undefined
690
- }
691
- style={
692
- isSelectColumn
693
- ? selectSkeletonStyle
694
- : bodySkeletonStyle
695
- }
696
- />
697
- </div>
698
- </td>
699
- );
700
- })}
701
- </tr>
702
- ),
703
- )}
704
- </tbody>
705
- </table>
706
- )}
672
+ <div
673
+ className={s.table_body_wrap}
674
+ style={{ pointerEvents: isLoading ? "none" : undefined }}
675
+ ref={bodyRef}
676
+ >
677
+ {view === "loader" && (
678
+ <table className={tableClass} style={tableSizingStyle}>
679
+ <tbody>
680
+ {Array.from({ length: skeletonRowCount }).map(
681
+ (_, rowIndex) => (
682
+ <tr
683
+ key={`skeleton-${rowIndex}`}
684
+ ref={rowIndex === 0 ? skeletonRowRef : undefined}
685
+ >
686
+ {visibleLeafColumns.map((column) => {
687
+ const isPinned = column.getIsPinned();
688
+ const isLastPinnedCol =
689
+ isPinned && isLastPinned(column);
690
+ const isSelectColumn = column.id === SELECT_COLUMN_ID;
691
+
692
+ return (
693
+ <td
694
+ key={column.id}
695
+ className={clsx(
696
+ isSelectColumn && s.col_select,
697
+ isPinned === "left" && s.col_pinned_left,
698
+ isPinned === "right" && s.col_pinned_right,
699
+ isLastPinnedCol && s.col_pinned_last,
700
+ )}
701
+ style={getColumnStyle(
702
+ column,
703
+ column.getSize(),
704
+ columnExtraWidths[column.id] ?? 0,
705
+ )}
706
+ >
707
+ <div className={s.td_inner}>
708
+ <Loader
709
+ variant="skeleton"
710
+ className={
711
+ isSelectColumn
712
+ ? s.loading_select_skeleton
713
+ : undefined
714
+ }
715
+ style={
716
+ isSelectColumn
717
+ ? selectSkeletonStyle
718
+ : bodySkeletonStyle
719
+ }
720
+ />
721
+ </div>
722
+ </td>
723
+ );
724
+ })}
725
+ </tr>
726
+ ),
727
+ )}
728
+ </tbody>
729
+ </table>
730
+ )}
707
731
  {view === "empty" && (
708
732
  <div className={s.state_overlay}>
709
733
  <p className={s.empty_msg}>{emptyMessage}</p>
@@ -0,0 +1,86 @@
1
+ import { useState } from "react";
2
+ import { IconFilter } from "@tabler/icons-react";
3
+ import {
4
+ Modal,
5
+ ModalBody,
6
+ ModalFooter,
7
+ ModalHeader,
8
+ ModalTitle,
9
+ } from "../../primitives/modal/modal";
10
+ import { Icon } from "../../primitives/icon/icon";
11
+ import { Button } from "../button/button";
12
+ import type { FilterBarProps } from "./types";
13
+ import s from "./data-table.module.css";
14
+
15
+ /**
16
+ * Small-screen counterpart to <FilterBar>. It keeps filtering live while moving
17
+ * the same field contract into a Modal bottom sheet.
18
+ */
19
+ export const FilterModal = ({ fields, values, onChange }: FilterBarProps) => {
20
+ const [isOpen, setIsOpen] = useState(false);
21
+
22
+ // Action slots (buttons/menus) render alongside the inputs; only input fields
23
+ // carry a value, so only they count toward the active badge + Clear-all reset.
24
+ const inputFields = fields.filter((field) => field.type === "field");
25
+ const activeCount = inputFields.filter(
26
+ (field) => (values[field.key] ?? "") !== "",
27
+ ).length;
28
+ const clearAll = () => {
29
+ inputFields.forEach((field) => {
30
+ if ((values[field.key] ?? "") !== "") onChange(field.key, "");
31
+ });
32
+ };
33
+
34
+ return (
35
+ <div className={s.filter_modal_bar}>
36
+ <Button
37
+ type="button"
38
+ variant="bordered"
39
+ className={s.filter_trigger}
40
+ onClick={() => setIsOpen(true)}
41
+ >
42
+ <Icon>
43
+ <IconFilter />
44
+ </Icon>
45
+ Filters
46
+ {activeCount > 0 && <span className={s.filter_count}>{activeCount}</span>}
47
+ </Button>
48
+
49
+ <Modal isOpen={isOpen} onOpenChange={setIsOpen} placement="bottom" size="md">
50
+ <ModalHeader>
51
+ <ModalTitle>Filters</ModalTitle>
52
+ </ModalHeader>
53
+ <ModalBody>
54
+ <div className={s.filter_modal_fields}>
55
+ {fields.map((field, i) => (
56
+ <div
57
+ className={s.filter_modal_field}
58
+ key={field.type === "action" ? `act:${i}` : `k:${field.key}`}
59
+ >
60
+ {field.type === "action"
61
+ ? field.render()
62
+ : field.render({
63
+ value: values[field.key] ?? "",
64
+ onChange: (v) => onChange(field.key, v),
65
+ })}
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </ModalBody>
70
+ <ModalFooter>
71
+ <Button
72
+ type="button"
73
+ variant="ghost"
74
+ onClick={clearAll}
75
+ disabled={activeCount === 0}
76
+ >
77
+ Clear all
78
+ </Button>
79
+ <Button type="button" variant="primary" onClick={() => setIsOpen(false)}>
80
+ Done
81
+ </Button>
82
+ </ModalFooter>
83
+ </Modal>
84
+ </div>
85
+ );
86
+ };
@@ -556,6 +556,8 @@ export interface PersistArgs {
556
556
  setGrouping: (next: GroupingState) => void;
557
557
  expanded: ExpandedState;
558
558
  setExpanded: (next: ExpandedState) => void;
559
+ /** Resolved default expansion - used to skip persisting it when unchanged. */
560
+ defaultExpanded: ExpandedState;
559
561
  }
560
562
 
561
563
  // ─── Snapshot / apply helpers shape ───────────────────────────────────────────
@@ -573,6 +575,12 @@ export interface SnapshotInput {
573
575
  columnSizing: ColumnSizingState;
574
576
  grouping: GroupingState;
575
577
  expanded: ExpandedState;
578
+ /**
579
+ * Resolved default expansion (the hook's `defaultExpanded ?? true`). Used to
580
+ * skip persisting `expanded` when it still matches the default - otherwise the
581
+ * default writes a noisy `?expanded=true` even when no group was touched.
582
+ */
583
+ defaultExpanded: ExpandedState;
576
584
  }
577
585
 
578
586
  /** Setters used by `applyPersistedState` to push hydrated values back into state. */
@@ -334,6 +334,7 @@ export function useDataTable<TData extends { id: string }>({
334
334
  setGrouping: setGroupingState,
335
335
  expanded,
336
336
  setExpanded: setExpandedState,
337
+ defaultExpanded: defaultExpanded ?? true,
337
338
  });
338
339
 
339
340
  // ── Derived data ────────────────────────────────────────────────────────────
@@ -147,6 +147,23 @@ export const RESERVED_PARAM_KEYS = new Set<string>(Object.values(PARAM_KEYS));
147
147
  const isNonEmptyExpanded = (e: ExpandedState | undefined): boolean =>
148
148
  e !== undefined && (e === true || Object.keys(e).length > 0);
149
149
 
150
+ /**
151
+ * Structural equality for two `ExpandedState`s (each a boolean or an
152
+ * id -> boolean record). Lets the snapshot skip persisting `expanded` while it
153
+ * still matches the table's default.
154
+ */
155
+ const expandedEquals = (
156
+ a: ExpandedState | undefined,
157
+ b: ExpandedState | undefined,
158
+ ): boolean => {
159
+ if (a === b) return true;
160
+ if (typeof a === "boolean" || typeof b === "boolean") return false;
161
+ if (!a || !b) return false;
162
+ const aKeys = Object.keys(a);
163
+ if (aKeys.length !== Object.keys(b).length) return false;
164
+ return aKeys.every((k) => a[k] === b[k]);
165
+ };
166
+
150
167
  /**
151
168
  * Build a `PersistedState` snapshot from current state, omitting empty/default
152
169
  * slices so the persisted blob (URL or storage) stays minimal.
@@ -183,7 +200,15 @@ export function snapshotPersistedState(input: SnapshotInput): PersistedState {
183
200
  out.columnSizing = input.columnSizing;
184
201
  }
185
202
  if (input.grouping.length > 0) out.grouping = input.grouping;
186
- if (isNonEmptyExpanded(input.expanded)) out.expanded = input.expanded;
203
+ // Persist expansion only when it deviates from the default - otherwise the
204
+ // default (`true` = all groups expanded) writes a noisy `?expanded=true` even
205
+ // when the user never touched a group.
206
+ if (
207
+ isNonEmptyExpanded(input.expanded) &&
208
+ !expandedEquals(input.expanded, input.defaultExpanded)
209
+ ) {
210
+ out.expanded = input.expanded;
211
+ }
187
212
 
188
213
  return out;
189
214
  }
@@ -34,6 +34,9 @@
34
34
  /* Option button */
35
35
  .option {
36
36
  width: 100%;
37
+ /* button base is min-width: fit-content - a long label must truncate
38
+ inside the row, never widen it past the menu */
39
+ min-width: 0;
37
40
  /* button has justify-content: space-between */
38
41
  justify-content: flex-start;
39
42
  /* modifying padding of actual button variant for consistency */
@@ -42,8 +45,14 @@
42
45
 
43
46
  /* > icon */
44
47
 
48
+ /* The label owns the row's slack space so trailing affordances
49
+ (.option_delete) always sit at the row's end - layout-driven, no
50
+ auto-margins (those tie with the Icon primitive's own margin reset
51
+ and the winner would depend on CSS bundle order). */
45
52
  .option_label {
46
- max-width: 100% !important;
53
+ flex: 1 1 auto;
54
+ min-width: 0;
55
+ text-align: start;
47
56
  }
48
57
  }
49
58
  }
@@ -53,4 +62,26 @@
53
62
  max-width: 100% !important;
54
63
  }
55
64
 
65
+ /* Trailing per-option meta (option.meta). The growing .option_label pushes it
66
+ to the row's end; consumers own the slot's visual styling. */
67
+ .option_meta {
68
+ flex-shrink: 0;
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: var(--spacing-2);
72
+ margin-inline-start: var(--spacing-3);
73
+ white-space: nowrap;
74
+ }
75
+
76
+ /* Trailing per-option delete affordance (option.onDelete). The growing
77
+ .option_label pushes it to the row's end; muted until hovered, then
78
+ error-toned. Click is stopPropagation'd in the component so it never
79
+ selects the option. */
80
+ .option_delete {
81
+ flex-shrink: 0;
82
+ --icon-color: var(--color-muted-foreground);
56
83
 
84
+ &:hover {
85
+ --icon-color: var(--color-error-foreground);
86
+ }
87
+ }