aplance-ui 1.3.0 → 1.5.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 (44) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/icon/icon.tsx +4 -6
  3. package/registry/components/primitives/modal/modal.module.css +5 -0
  4. package/registry/components/primitives/modal/modal.tsx +32 -34
  5. package/registry/components/primitives/popover/popover-context.ts +33 -29
  6. package/registry/components/primitives/popover/popover.module.css +4 -2
  7. package/registry/components/primitives/popover/popover.tsx +150 -37
  8. package/registry/components/ui/accordion/accordion.module.css +52 -0
  9. package/registry/components/ui/accordion/accordion.tsx +97 -25
  10. package/registry/components/ui/avatar/avatar.tsx +2 -2
  11. package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
  12. package/registry/components/ui/button/button.module.css +43 -5
  13. package/registry/components/ui/button/button.tsx +2 -1
  14. package/registry/components/ui/copy-button/copy-button.module.css +2 -0
  15. package/registry/components/ui/copy-button/copy-button.tsx +5 -0
  16. package/registry/components/ui/data-table/data-table.module.css +786 -710
  17. package/registry/components/ui/data-table/data-table.tsx +1238 -1211
  18. package/registry/components/ui/data-table/filter-modal.tsx +86 -0
  19. package/registry/components/ui/data-table/filters.tsx +21 -0
  20. package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
  21. package/registry/components/ui/data-table/types.ts +19 -1
  22. package/registry/components/ui/data-table/useDataTable.tsx +2 -2
  23. package/registry/components/ui/data-table/utils.ts +30 -4
  24. package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
  25. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
  26. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +118 -76
  27. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +552 -484
  28. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +936 -933
  29. package/registry/components/ui/input-fields/date-picker/types.ts +2 -0
  30. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +8 -0
  31. package/registry/components/ui/input-fields/input/input.tsx +348 -348
  32. package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
  33. package/registry/components/ui/input-fields/media-input/types.ts +8 -2
  34. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
  35. package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
  36. package/registry/components/ui/scroll-rail/scroll-rail.module.css +104 -0
  37. package/registry/components/ui/scroll-rail/scroll-rail.tsx +159 -0
  38. package/registry/components/ui/tabs/tabs.module.css +8 -12
  39. package/registry/components/ui/tabs/tabs.tsx +94 -50
  40. package/registry/components/ui/tooltip/tooltip.module.css +8 -3
  41. package/registry/components/ui/tooltip/tooltip.tsx +16 -3
  42. package/registry/hooks/useMediaQuery.ts +38 -0
  43. package/registry/lib/string.ts +3 -3
  44. package/registry/registry.json +54 -4
@@ -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
+ };
@@ -0,0 +1,21 @@
1
+ import { useMediaQuery } from "../../../hooks/useMediaQuery";
2
+ import { FilterBar } from "./filter-bar";
3
+ import { FilterModal } from "./filter-modal";
4
+ import type { FilterBarProps } from "./types";
5
+
6
+ // Below this width an inline filter row can't fit its fields (matches the
7
+ // `--md` custom-media token in styles/media.css): the filters collapse into a
8
+ // single "Filters" button and a modal instead of stacking full-height.
9
+ const FILTER_COMPACT_QUERY = "(max-width: 900px)";
10
+
11
+ /**
12
+ * A list's filters: the inline <FilterBar> while it fits, the <FilterModal>
13
+ * on a narrower screen. DataTable shows its filters through it; a list built
14
+ * on its own uses it the same way.
15
+ */
16
+ export const Filters = (props: FilterBarProps) =>
17
+ useMediaQuery(FILTER_COMPACT_QUERY) ? (
18
+ <FilterModal {...props} />
19
+ ) : (
20
+ <FilterBar {...props} />
21
+ );
@@ -8,6 +8,7 @@ import clsx from "clsx";
8
8
  import { Button } from "../button/button";
9
9
  import { CustomSelect } from "../input-fields/custom-select/custom-select";
10
10
  import { Loader } from "../loader/loader";
11
+ import { ScrollRail } from "../scroll-rail/scroll-rail";
11
12
  import type { PaginationBarProps } from "./types";
12
13
  import s from "./data-table.module.css";
13
14
 
@@ -28,6 +29,7 @@ export const PaginationBar = <TData,>({
28
29
  rowLabel,
29
30
  rowsSelectable,
30
31
  selectedCount,
32
+ label,
31
33
  extra,
32
34
  }: PaginationBarProps<TData>) => {
33
35
  const pageCount = table.getPageCount();
@@ -42,6 +44,7 @@ export const PaginationBar = <TData,>({
42
44
  const pageNavItems = [
43
45
  {
44
46
  key: "first",
47
+ edge: true,
45
48
  label: "First page",
46
49
  icon: <IconChevronsLeft size={14} stroke={2} />,
47
50
  onClick: () => goTo(0),
@@ -63,6 +66,7 @@ export const PaginationBar = <TData,>({
63
66
  },
64
67
  {
65
68
  key: "last",
69
+ edge: true,
66
70
  label: "Last page",
67
71
  icon: <IconChevronsRight size={14} stroke={2} />,
68
72
  onClick: () => goTo(pageCount - 1),
@@ -111,11 +115,9 @@ export const PaginationBar = <TData,>({
111
115
  );
112
116
 
113
117
  return (
114
- <div
115
- className={clsx(s.pagination_bar, isLoading && s.pagination_bar_loading)}
116
- aria-busy={isLoading || undefined}
117
- >
118
- {/* left - count + optional bulk-action extra slot */}
118
+ <div className={s.pagination_bar} aria-busy={isLoading || undefined}>
119
+ {/* left - the count (or the caller's label), then the extra slot,
120
+ which scrolls on its own while the count stays put */}
119
121
  <div
120
122
  className={clsx(
121
123
  s.pagination_left,
@@ -131,18 +133,26 @@ export const PaginationBar = <TData,>({
131
133
  ) : (
132
134
  <>
133
135
  <p className={s.pagination_count}>
134
- {rowsSelectable && selectedCount > 0 ? (
135
- <>
136
- <span>{selectedCount}</span> of <span>{total}</span>{" "}
137
- {rowLabel}(s) selected
138
- </>
139
- ) : (
140
- <>
141
- <span>{total}</span> {rowLabel}(s)
142
- </>
143
- )}
136
+ {label ??
137
+ (rowsSelectable && selectedCount > 0 ? (
138
+ <>
139
+ <span>{selectedCount}</span> of <span>{total}</span>{" "}
140
+ {rowLabel}(s) selected
141
+ </>
142
+ ) : (
143
+ <>
144
+ <span>{total}</span> {rowLabel}(s)
145
+ </>
146
+ ))}
144
147
  </p>
145
- {extra}
148
+ {extra ? (
149
+ <ScrollRail
150
+ className={s.pagination_extra}
151
+ contentClassName={s.pagination_extra_items}
152
+ >
153
+ {extra}
154
+ </ScrollRail>
155
+ ) : null}
146
156
  </>
147
157
  )}
148
158
  </div>
@@ -205,7 +215,10 @@ export const PaginationBar = <TData,>({
205
215
  <Loader
206
216
  key={`${item.key}-skeleton`}
207
217
  variant="skeleton"
208
- className={s.pagination_button_skeleton}
218
+ className={clsx(
219
+ s.pagination_button_skeleton,
220
+ item.edge && s.page_nav_edge,
221
+ )}
209
222
  />
210
223
  ))
211
224
  : pageNavItems.map((item) => (
@@ -213,6 +226,7 @@ export const PaginationBar = <TData,>({
213
226
  key={item.key}
214
227
  variant="bordered"
215
228
  layout="box"
229
+ className={item.edge ? s.page_nav_edge : undefined}
216
230
  onClick={item.onClick}
217
231
  disabled={item.disabled}
218
232
  aria-label={item.label}
@@ -159,6 +159,7 @@ export interface PaginationBarProps<TData> {
159
159
  rowLabel: string;
160
160
  rowsSelectable: boolean;
161
161
  selectedCount: number;
162
+ label: ReactNode;
162
163
  extra: ReactNode;
163
164
  }
164
165
 
@@ -298,10 +299,19 @@ export interface DataTableProps<TData extends { id: string }> {
298
299
 
299
300
  /**
300
301
  * Slot rendered inside the pagination bar next to the count text.
301
- * Perfect for bulk-action buttons. Does not shift table layout.
302
+ * Perfect for bulk-action buttons. Does not shift table layout: when it
303
+ * does not fit, it scrolls across on its own (a ScrollRail) while the count
304
+ * stays put.
302
305
  */
303
306
  paginationExtra?: ReactNode;
304
307
 
308
+ /**
309
+ * Replaces the pagination bar's count text, e.g. for a selection that
310
+ * reaches past the loaded rows ("All 120 files selected"). Numbers wrapped
311
+ * in a `<span>` are emphasised like the default count's.
312
+ */
313
+ paginationLabel?: ReactNode;
314
+
305
315
  className?: string;
306
316
  }
307
317
 
@@ -556,6 +566,8 @@ export interface PersistArgs {
556
566
  setGrouping: (next: GroupingState) => void;
557
567
  expanded: ExpandedState;
558
568
  setExpanded: (next: ExpandedState) => void;
569
+ /** Resolved default expansion - used to skip persisting it when unchanged. */
570
+ defaultExpanded: ExpandedState;
559
571
  }
560
572
 
561
573
  // ─── Snapshot / apply helpers shape ───────────────────────────────────────────
@@ -573,6 +585,12 @@ export interface SnapshotInput {
573
585
  columnSizing: ColumnSizingState;
574
586
  grouping: GroupingState;
575
587
  expanded: ExpandedState;
588
+ /**
589
+ * Resolved default expansion (the hook's `defaultExpanded ?? true`). Used to
590
+ * skip persisting `expanded` when it still matches the default - otherwise the
591
+ * default writes a noisy `?expanded=true` even when no group was touched.
592
+ */
593
+ defaultExpanded: ExpandedState;
576
594
  }
577
595
 
578
596
  /** Setters used by `applyPersistedState` to push hydrated values back into state. */
@@ -260,8 +260,7 @@ export function useDataTable<TData extends { id: string }>({
260
260
  return;
261
261
  }
262
262
  const next = [...prev];
263
- const [moved] = next.splice(draggedIdx, 1);
264
- next.splice(targetIdx, 0, moved);
263
+ next.splice(targetIdx, 0, ...next.splice(draggedIdx, 1));
265
264
  localDataRef.current = next;
266
265
  setLocalData(next);
267
266
  onDataChange?.(next);
@@ -334,6 +333,7 @@ export function useDataTable<TData extends { id: string }>({
334
333
  setGrouping: setGroupingState,
335
334
  expanded,
336
335
  setExpanded: setExpandedState,
336
+ defaultExpanded: defaultExpanded ?? true,
337
337
  });
338
338
 
339
339
  // ── 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
  }
@@ -333,9 +358,10 @@ export function writeSearchParams(
333
358
  params.delete(p(PARAM_KEYS.pageSize));
334
359
  }
335
360
 
336
- if (state.sorting && state.sorting.length > 0) {
337
- params.set(p(PARAM_KEYS.sortBy), state.sorting[0].id);
338
- params.set(p(PARAM_KEYS.sortDir), state.sorting[0].desc ? "desc" : "asc");
361
+ const sort = state.sorting?.[0];
362
+ if (sort) {
363
+ params.set(p(PARAM_KEYS.sortBy), sort.id);
364
+ params.set(p(PARAM_KEYS.sortDir), sort.desc ? "desc" : "asc");
339
365
  } else {
340
366
  params.delete(p(PARAM_KEYS.sortBy));
341
367
  params.delete(p(PARAM_KEYS.sortDir));
@@ -1,6 +1,9 @@
1
- .container {
1
+ .container {
2
2
  width: 100%;
3
- min-width: fit-content;
3
+ /* A field takes the width its layout gives it, never its content's: a long
4
+ value truncates instead of pushing the field over its neighbour in a grid
5
+ column or a flex row. */
6
+ min-width: 0;
4
7
  display: flex;
5
8
  flex-direction: column;
6
9
  gap: var(--spacing-2);
@@ -248,6 +251,9 @@
248
251
  Keep these after .field_default so they reliably override base paddings. */
249
252
  .field_full_width {
250
253
  width: 100% !important;
254
+ /* Fills the field and no more: a button is otherwise never narrower than
255
+ its label (`--button-min-width`), which would defeat `.truncate_text`. */
256
+ --button-min-width: 0;
251
257
  }
252
258
 
253
259
  .field_wrapper_fit {
@@ -10,6 +10,10 @@
10
10
 
11
11
  .picker_popover_trigger {
12
12
  width: 100%;
13
+ /* A field's trigger takes the field's width, never its value's: Popover
14
+ keeps a trigger at least as wide as its content (right for a plain
15
+ button), which would push a long value over the next field. */
16
+ --popover-trigger-min-width: 0;
13
17
  }
14
18
 
15
19
  .picker_popover_menu_base {
@@ -1,76 +1,118 @@
1
- /* uses input-field-common (.container and all)
2
- + popover related stuff
3
- */
4
-
5
- /* actual menu selector */
6
- .popover_menu {
7
- --picker-popover-menu-width: 100%;
8
- width: 100%;
9
- border-radius: calc(var(--radius-md) + var(--spacing-2));
10
-
11
- /* Search container */
12
- .search_container {
13
- padding: var(--spacing-4);
14
- border-bottom: 1px solid var(--color-border);
15
- }
16
-
17
- /* Options container */
18
- .options_container {
19
- width: 100%;
20
- max-height: 16rem;
21
- overflow-y: auto;
22
- padding: var(--spacing-2);
23
- display: flex;
24
- flex-direction: column;
25
- gap: var(--spacing-2);
26
-
27
- .no_options {
28
- padding: var(--spacing-4);
29
- text-align: center;
30
- color: var(--color-muted-foreground);
31
- font-size: var(--font-size-sm);
32
- }
33
-
34
- /* Option button */
35
- .option {
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;
40
- /* button has justify-content: space-between */
41
- justify-content: flex-start;
42
- /* modifying padding of actual button variant for consistency */
43
- padding: var(--spacing-3) var(--spacing-4);
44
- border-radius: var(--radius-md);
45
-
46
- /* > icon */
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). */
52
- .option_label {
53
- flex: 1 1 auto;
54
- min-width: 0;
55
- text-align: start;
56
- }
57
- }
58
- }
59
- }
60
-
61
- .option_label {
62
- max-width: 100% !important;
63
- }
64
-
65
- /* Trailing per-option delete affordance (option.onDelete). The growing
66
- .option_label pushes it to the row's end; muted until hovered, then
67
- error-toned. Click is stopPropagation'd in the component so it never
68
- selects the option. */
69
- .option_delete {
70
- flex-shrink: 0;
71
- --icon-color: var(--color-muted-foreground);
72
-
73
- &:hover {
74
- --icon-color: var(--color-error-foreground);
75
- }
76
- }
1
+ /* uses input-field-common (.container and all)
2
+ + popover related stuff
3
+ */
4
+
5
+ /* actual menu selector */
6
+ .popover_menu {
7
+ --picker-popover-menu-width: 100%;
8
+ width: 100%;
9
+ border-radius: calc(var(--radius-md) + var(--spacing-2));
10
+
11
+ /* Search container */
12
+ .search_container {
13
+ padding: var(--spacing-4);
14
+ border-bottom: 1px solid var(--color-border);
15
+ }
16
+
17
+ /* Options container */
18
+ .options_container {
19
+ width: 100%;
20
+ max-height: 16rem;
21
+ overflow-y: auto;
22
+ padding: var(--spacing-2);
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: var(--spacing-2);
26
+
27
+ .no_options {
28
+ padding: var(--spacing-4);
29
+ text-align: center;
30
+ color: var(--color-muted-foreground);
31
+ font-size: var(--font-size-sm);
32
+ }
33
+
34
+ /* Option button */
35
+ .option {
36
+ width: 100%;
37
+ /* The list scrolls; an option keeps its height. The button base sets a
38
+ min-height, which would otherwise let a long list squeeze each option
39
+ down to it, its text spilling over the next. */
40
+ flex-shrink: 0;
41
+ /* button base is min-width: fit-content - a long label must truncate
42
+ inside the row, never widen it past the menu */
43
+ min-width: 0;
44
+ /* button has justify-content: space-between */
45
+ justify-content: flex-start;
46
+ /* modifying padding of actual button variant for consistency */
47
+ padding: var(--spacing-3) var(--spacing-4);
48
+ border-radius: var(--radius-md);
49
+
50
+ /* > icon */
51
+
52
+ /* The label owns the row's slack space so trailing affordances
53
+ (.option_delete) always sit at the row's end - layout-driven, no
54
+ auto-margins (those tie with the Icon primitive's own margin reset
55
+ and the winner would depend on CSS bundle order). */
56
+ .option_label {
57
+ flex: 1 1 auto;
58
+ min-width: 0;
59
+ text-align: start;
60
+ }
61
+
62
+ /* With a description: two lines, so more room around them, and what
63
+ leads the row (a checkbox, an icon) sits at the label's line. */
64
+ &.option_rich {
65
+ padding-block: var(--spacing-5);
66
+ align-items: flex-start;
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ .option_label {
73
+ max-width: 100% !important;
74
+ }
75
+
76
+ /* An option with a description: its label over a line saying what it does.
77
+ The pair takes the row's slack, as a label alone does, and the line wraps:
78
+ the button base keeps its text on one line. */
79
+ .option_text {
80
+ flex: 1 1 auto;
81
+ min-width: 0;
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: var(--spacing-2);
85
+ text-align: start;
86
+ }
87
+
88
+ .option_description {
89
+ color: var(--color-muted-foreground);
90
+ font-size: var(--font-size-xs);
91
+ font-weight: var(--font-weight-normal);
92
+ line-height: var(--line-height-md);
93
+ white-space: normal;
94
+ }
95
+
96
+ /* Trailing per-option meta (option.meta). The growing .option_label pushes it
97
+ to the row's end; consumers own the slot's visual styling. */
98
+ .option_meta {
99
+ flex-shrink: 0;
100
+ display: inline-flex;
101
+ align-items: center;
102
+ gap: var(--spacing-2);
103
+ margin-inline-start: var(--spacing-3);
104
+ white-space: nowrap;
105
+ }
106
+
107
+ /* Trailing per-option delete affordance (option.onDelete). The growing
108
+ .option_label pushes it to the row's end; muted until hovered, then
109
+ error-toned. Click is stopPropagation'd in the component so it never
110
+ selects the option. */
111
+ .option_delete {
112
+ flex-shrink: 0;
113
+ --icon-color: var(--color-muted-foreground);
114
+
115
+ &:hover {
116
+ --icon-color: var(--color-error-foreground);
117
+ }
118
+ }