@lyeve-labs/ui-kit 0.24.0 → 0.25.1

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 (60) hide show
  1. package/README.md +43 -0
  2. package/dist/components/AccordionItem.svelte +3 -5
  3. package/dist/components/AccountMenu.svelte +4 -3
  4. package/dist/components/Alert.svelte +2 -1
  5. package/dist/components/AppShell.svelte +166 -54
  6. package/dist/components/AppShell.svelte.d.ts +41 -6
  7. package/dist/components/Autocomplete.svelte +3 -2
  8. package/dist/components/Badge.svelte +10 -5
  9. package/dist/components/Banner.svelte +2 -1
  10. package/dist/components/Breadcrumb.svelte +7 -2
  11. package/dist/components/Button.svelte +15 -2
  12. package/dist/components/Card.svelte +1 -1
  13. package/dist/components/Collapsible.svelte +5 -4
  14. package/dist/components/CopyButton.svelte +2 -1
  15. package/dist/components/DatePicker.svelte +77 -62
  16. package/dist/components/Drawer.svelte +6 -5
  17. package/dist/components/Dropdown.svelte +5 -2
  18. package/dist/components/FileInput.svelte +1 -1
  19. package/dist/components/Modal.svelte +6 -3
  20. package/dist/components/MultiSelect.svelte +8 -6
  21. package/dist/components/NumberInput.svelte +2 -2
  22. package/dist/components/PageHeader.svelte +13 -1
  23. package/dist/components/PageShell.svelte +37 -19
  24. package/dist/components/PageShell.svelte.d.ts +12 -0
  25. package/dist/components/Pagination.svelte +7 -2
  26. package/dist/components/PasswordInput.svelte +2 -1
  27. package/dist/components/Progress.svelte +1 -1
  28. package/dist/components/SearchInput.svelte +3 -2
  29. package/dist/components/SectionHeading.svelte +10 -1
  30. package/dist/components/SegmentedControl.svelte +101 -27
  31. package/dist/components/SegmentedControl.svelte.d.ts +9 -0
  32. package/dist/components/Select.svelte +4 -3
  33. package/dist/components/SidebarNav.svelte +26 -17
  34. package/dist/components/StepIndicator.svelte +7 -9
  35. package/dist/components/Table.svelte +39 -29
  36. package/dist/components/Tabs.svelte +128 -24
  37. package/dist/components/Tag.svelte +2 -1
  38. package/dist/components/ThemeToggle.svelte +2 -1
  39. package/dist/components/Toaster.svelte +6 -2
  40. package/dist/components/Toggle.svelte +3 -2
  41. package/dist/components/Tooltip.svelte +10 -5
  42. package/dist/components/TreeView.svelte +2 -2
  43. package/dist/components/dialog/Dialog.svelte +15 -32
  44. package/dist/index.d.ts +4 -1
  45. package/dist/index.js +7 -1
  46. package/dist/internal/choice.js +10 -3
  47. package/dist/internal/field.js +3 -3
  48. package/dist/internal/layout.d.ts +10 -1
  49. package/dist/internal/layout.js +10 -1
  50. package/dist/internal/panel.d.ts +13 -3
  51. package/dist/internal/panel.js +16 -5
  52. package/dist/internal/scroll-edges.d.ts +33 -0
  53. package/dist/internal/scroll-edges.js +33 -0
  54. package/dist/internal/touch.d.ts +28 -0
  55. package/dist/internal/touch.js +28 -0
  56. package/dist/motion.d.ts +63 -0
  57. package/dist/motion.js +170 -0
  58. package/dist/styles/theme.css +131 -63
  59. package/package.json +1 -1
  60. package/src/lib/styles/theme.css +131 -63
@@ -9,11 +9,20 @@
9
9
  label: string;
10
10
  /** A lucide component. The control sizes it, so the caller does not guess. */
11
11
  icon?: Component<{ size?: number; class?: string }>;
12
+ /**
13
+ * Renders the segment as a link to this URL instead of a radio. Every
14
+ * option carries one or none does; the modes cannot be mixed in one
15
+ * control. The chosen segment is still the one whose value matches
16
+ * `value`, and it carries aria-current="page", so a page can keep its
17
+ * choice in the URL and the control works before any script has loaded.
18
+ */
19
+ href?: string;
12
20
  }
13
21
  </script>
14
22
 
15
23
  <script lang="ts" generics="T extends string">
16
24
  import { FIELD_LABEL, FIELD_WRAP } from '../internal/field.js';
25
+ import { safeHref } from '../internal/href.js';
17
26
 
18
27
  type Size = 'sm' | 'md';
19
28
 
@@ -34,6 +43,7 @@
34
43
  size?: Size;
35
44
  disabled?: boolean;
36
45
  class?: string;
46
+ /** Value mode only. A link segment reports nothing; the page it opens does. */
37
47
  onchange?: (value: T) => void;
38
48
  }
39
49
 
@@ -62,8 +72,32 @@
62
72
 
63
73
  let rootEl: HTMLDivElement | undefined = $state();
64
74
 
75
+ /*
76
+ * Link mode when every option carries an href. A row that mixes the two is
77
+ * refused outright rather than rendered as best it can: a radio beside two
78
+ * links would report a choice that the URL never learns about, and the
79
+ * caller has no way to see that from the screen.
80
+ */
81
+ const linked = $derived.by(() => {
82
+ const count = options.filter((o) => o.href !== undefined).length;
83
+ if (count !== 0 && count !== options.length) {
84
+ throw new Error(
85
+ `SegmentedControl: every option carries href or none does, ${count} of ${options.length} did`,
86
+ );
87
+ }
88
+ return count !== 0;
89
+ });
90
+
65
91
  const selectedIndex = $derived(options.findIndex((o) => o.value === value));
66
92
 
93
+ /*
94
+ * In link mode the arrows move focus without moving the choice, since the
95
+ * choice is the URL and only following a link changes it. The tab stop
96
+ * travels with focus while it is inside the group, and returns to the chosen
97
+ * segment when focus leaves, so Tab re-enters on the one that is current.
98
+ */
99
+ let roving: number | null = $state(null);
100
+
67
101
  /*
68
102
  * The group holds ONE tab stop, which is the whole reason this is not a row
69
103
  * of buttons: a picker of five themes cost five presses of Tab to step over.
@@ -72,10 +106,15 @@
72
106
  * drop the control out of the tab order entirely, so the first segment holds
73
107
  * the stop until something is chosen.
74
108
  */
75
- const tabStop = $derived(selectedIndex === -1 ? 0 : selectedIndex);
109
+ const tabStop = $derived(roving ?? (selectedIndex === -1 ? 0 : selectedIndex));
76
110
 
77
111
  function segmentAt(index: number): HTMLElement | undefined {
78
- return rootEl?.querySelectorAll<HTMLElement>('[role="radio"]')[index];
112
+ return rootEl?.querySelectorAll<HTMLElement>('[data-segment]')[index];
113
+ }
114
+
115
+ function onGroupFocusOut(e: FocusEvent) {
116
+ if (rootEl && e.relatedTarget instanceof Node && rootEl.contains(e.relatedTarget)) return;
117
+ roving = null;
79
118
  }
80
119
 
81
120
  function choose(index: number) {
@@ -123,7 +162,13 @@
123
162
  }
124
163
  // The arrows scroll the page, and Home and End jump it to the ends.
125
164
  e.preventDefault();
126
- choose(next);
165
+ if (linked) {
166
+ if (disabled) return;
167
+ roving = next;
168
+ segmentAt(next)?.focus();
169
+ } else {
170
+ choose(next);
171
+ }
127
172
  }
128
173
  </script>
129
174
 
@@ -135,10 +180,14 @@
135
180
  <span class={FIELD_LABEL}>{label}</span>
136
181
  {/if}
137
182
 
183
+ <!-- A set of links is a group, not a radiogroup: a link is not a radio and
184
+ carries no aria-checked, and what marks the current one is aria-current,
185
+ the attribute a reader already knows from navigation. -->
138
186
  <div
139
187
  bind:this={rootEl}
140
- role="radiogroup"
188
+ role={linked ? 'group' : 'radiogroup'}
141
189
  aria-label={label}
190
+ onfocusout={linked ? onGroupFocusOut : undefined}
142
191
  class="inline-flex items-stretch rounded-lg border border-line-strong bg-surface-2 p-0.5 {SIZES[
143
192
  size
144
193
  ].row} {disabled ? 'opacity-50' : ''}"
@@ -146,28 +195,13 @@
146
195
  {#each options as option, i (option.value)}
147
196
  {@const selected = option.value === value}
148
197
  {@const Icon = option.icon}
149
- <!--
150
- Each segment is a radio, and aria-checked is what a screen reader reads.
151
- The theme picker this replaces was a row of plain buttons whose choice
152
- was carried by a background colour alone, so a screen reader user was
153
- told nothing and a colour-blind user saw nothing. The selected segment
154
- also sits at a heavier weight, so the state survives a palette a reader
155
- cannot separate.
156
- -->
157
- <button
158
- type="button"
159
- role="radio"
160
- aria-checked={selected}
161
- tabindex={i === tabStop ? 0 : -1}
162
- {disabled}
163
- onclick={() => choose(i)}
164
- onkeydown={(e) => onSegmentKeydown(e, i)}
165
- class="inline-flex items-center justify-center rounded-md outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed {SIZES[
166
- size
167
- ].segment} {selected
168
- ? 'bg-surface text-fg shadow-sm'
169
- : 'text-muted hover:text-fg active:bg-surface active:text-fg'}"
170
- >
198
+ {@const segment =
199
+ 'inline-flex items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ' +
200
+ SIZES[size].segment +
201
+ (selected
202
+ ? ' bg-surface text-fg shadow-sm'
203
+ : ' text-muted hover:text-fg active:bg-surface active:text-fg')}
204
+ {#snippet content()}
171
205
  {#if Icon}
172
206
  <Icon size={SIZES[size].icon} class="shrink-0" />
173
207
  {/if}
@@ -183,7 +217,47 @@
183
217
  >{option.label}</span
184
218
  >
185
219
  </span>
186
- </button>
220
+ {/snippet}
221
+ {#if linked}
222
+ <!--
223
+ A plain anchor with no click handler, so the choice is a navigation
224
+ the browser performs on its own. A disabled row drops the hrefs: an
225
+ anchor with no href is not a link, and aria-disabled says why.
226
+ -->
227
+ <a
228
+ data-segment
229
+ href={disabled ? undefined : safeHref(option.href)}
230
+ aria-current={selected ? 'page' : undefined}
231
+ aria-disabled={disabled ? 'true' : undefined}
232
+ tabindex={disabled ? -1 : i === tabStop ? 0 : -1}
233
+ onkeydown={(e) => onSegmentKeydown(e, i)}
234
+ class="{segment} {disabled ? 'cursor-not-allowed' : ''}"
235
+ >
236
+ {@render content()}
237
+ </a>
238
+ {:else}
239
+ <!--
240
+ Each segment is a radio, and aria-checked is what a screen reader
241
+ reads. The theme picker this replaces was a row of plain buttons whose
242
+ choice was carried by a background colour alone, so a screen reader
243
+ user was told nothing and a colour-blind user saw nothing. The
244
+ selected segment also sits at a heavier weight, so the state survives
245
+ a palette a reader cannot separate.
246
+ -->
247
+ <button
248
+ data-segment
249
+ type="button"
250
+ role="radio"
251
+ aria-checked={selected}
252
+ tabindex={i === tabStop ? 0 : -1}
253
+ {disabled}
254
+ onclick={() => choose(i)}
255
+ onkeydown={(e) => onSegmentKeydown(e, i)}
256
+ class={segment}
257
+ >
258
+ {@render content()}
259
+ </button>
260
+ {/if}
187
261
  {/each}
188
262
  </div>
189
263
 
@@ -10,6 +10,14 @@ export interface SegmentOption<T extends string = string> {
10
10
  size?: number;
11
11
  class?: string;
12
12
  }>;
13
+ /**
14
+ * Renders the segment as a link to this URL instead of a radio. Every
15
+ * option carries one or none does; the modes cannot be mixed in one
16
+ * control. The chosen segment is still the one whose value matches
17
+ * `value`, and it carries aria-current="page", so a page can keep its
18
+ * choice in the URL and the control works before any script has loaded.
19
+ */
20
+ href?: string;
13
21
  }
14
22
  declare function $$render<T extends string>(): {
15
23
  props: {
@@ -29,6 +37,7 @@ declare function $$render<T extends string>(): {
29
37
  size?: "sm" | "md";
30
38
  disabled?: boolean;
31
39
  class?: string;
40
+ /** Value mode only. A link segment reports nothing; the page it opens does. */
32
41
  onchange?: (value: T) => void;
33
42
  };
34
43
  exports: {};
@@ -84,6 +84,7 @@
84
84
  panelOption,
85
85
  placePanel,
86
86
  } from '../internal/panel.js';
87
+ import * as motion from '../motion.js';
87
88
 
88
89
  interface Props {
89
90
  /** The selected value. Bindable, so a listbox pick reaches the caller without a callback. */
@@ -283,7 +284,7 @@
283
284
  height="12"
284
285
  viewBox="0 0 12 12"
285
286
  fill="none"
286
- class="transition-transform duration-150 {open ? 'rotate-180' : ''}"
287
+ class="transition-transform duration-base {open ? 'rotate-180' : ''}"
287
288
  >
288
289
  <path
289
290
  d="M2 4l4 4 4-4"
@@ -408,7 +409,7 @@
408
409
  <input type="hidden" {name} {disabled} value={value ?? ''} />
409
410
 
410
411
  {#if box.open}
411
- <div use:placePanel class="{PANEL_SURFACE} w-full">
412
+ <div use:placePanel transition:motion.popover|global class="{PANEL_SURFACE} w-full">
412
413
  {#if searchable}
413
414
  <div class="border-b border-line p-2">
414
415
  <!--
@@ -432,7 +433,7 @@
432
433
  aria-label={label ? `Search ${label}` : 'Search options'}
433
434
  {...box.triggerAttrs}
434
435
  class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
435
- text-sm text-fg outline-none transition-colors duration-150
436
+ text-sm text-fg outline-none transition-colors
436
437
  placeholder:text-faint focus:border-brand"
437
438
  />
438
439
  </div>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { activeTrail, type NavNode, type NavTree } from '../internal/nav-tree.js';
3
+ import { TOUCH_GROW } from '../internal/touch.js';
3
4
  import { createNavExpansion } from '../internal/nav-expansion.svelte.js';
4
5
  import type { AccentTone } from '../internal/tone.js';
5
6
 
@@ -65,8 +66,8 @@
65
66
  * stray line or as nothing at all.
66
67
  */
67
68
  const ROW =
68
- 'flex w-full items-center gap-2.5 rounded-lg border-s-2 py-2 pe-2 text-sm text-start ' +
69
- 'transition-colors duration-150 outline-none ' +
69
+ `${TOUCH_GROW} flex w-full items-center gap-2.5 rounded-lg border-s-2 py-2 pe-2 text-sm text-start ` +
70
+ 'transition-colors outline-none ' +
70
71
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
71
72
 
72
73
  /**
@@ -99,8 +100,8 @@
99
100
  * it wrong half the time.
100
101
  */
101
102
  const DISCLOSURE =
102
- 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted ' +
103
- 'transition-colors duration-150 outline-none hover:bg-surface-2 hover:text-fg ' +
103
+ `${TOUCH_GROW} flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted ` +
104
+ 'transition-colors outline-none hover:bg-surface-2 hover:text-fg ' +
104
105
  'active:bg-line active:text-fg ' +
105
106
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
106
107
 
@@ -219,11 +220,13 @@
219
220
 
220
221
  {#if branch}
221
222
  <!--
222
- Rendered whether or not it is open, and hidden with a display utility.
223
- aria-controls has to name an element that exists; pointing it at markup
224
- that appears only once the group is open leaves the reference dangling
225
- in the one state where a reader needs it to tell them what the button
226
- will reveal.
223
+ Rendered whether or not it is open, and shut by collapsing its grid row
224
+ to nothing, the same move AccordionItem makes. aria-controls has to name
225
+ an element that exists; pointing it at markup that appears only once the
226
+ group is open leaves the reference dangling in the one state where a
227
+ reader needs it to tell them what the button will reveal. inert keeps a
228
+ shut group out of the tab order and the accessibility tree, which is
229
+ what the display utility used to do.
227
230
 
228
231
  It keeps the implicit list role. role="group" here overrode it, and a
229
232
  listitem whose parent is not a list is an ARIA context error, so every
@@ -231,14 +234,20 @@
231
234
  semantics belong to a tree, where the parent is a treeitem; this is a
232
235
  nav of plain links and the disclosure already says what it controls.
233
236
  -->
234
- <ul
235
- id={listId(node.id)}
236
- class="{open && !node.disabled ? 'flex' : 'hidden'} mt-0.5 flex-col gap-0.5"
237
+ {@const shown = open && !node.disabled}
238
+ <div
239
+ class="grid transition-[grid-template-rows] duration-base {shown
240
+ ? 'grid-rows-[1fr]'
241
+ : 'grid-rows-[0fr]'}"
237
242
  >
238
- {#each children as child (child.id)}
239
- {@render row(child, depth + 1)}
240
- {/each}
241
- </ul>
243
+ <div class="overflow-hidden">
244
+ <ul id={listId(node.id)} inert={!shown} class="mt-0.5 flex flex-col gap-0.5">
245
+ {#each children as child (child.id)}
246
+ {@render row(child, depth + 1)}
247
+ {/each}
248
+ </ul>
249
+ </div>
250
+ </div>
242
251
  {/if}
243
252
  </li>
244
253
  {/snippet}
@@ -272,7 +281,7 @@
272
281
  stroke-linecap="round"
273
282
  stroke-linejoin="round"
274
283
  aria-hidden="true"
275
- class="shrink-0 transition-transform duration-150 {open ? 'rotate-90' : 'rtl:rotate-180'}"
284
+ class="shrink-0 transition-transform duration-base {open ? 'rotate-90' : 'rtl:rotate-180'}"
276
285
  >
277
286
  <path d="M9 18l6-6-6-6" />
278
287
  </svg>
@@ -29,13 +29,13 @@
29
29
  ? ''
30
30
  : done || active
31
31
  ? 'bg-brand'
32
- : 'bg-line'} h-px transition-colors duration-150"
32
+ : 'bg-line'} h-px transition-colors"
33
33
  ></div>
34
34
 
35
35
  <!-- Step circle -->
36
36
  <div
37
37
  class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2
38
- font-bold text-sm transition-all
38
+ font-bold text-sm transition-colors
39
39
  {done
40
40
  ? 'border-brand bg-brand text-ink'
41
41
  : active
@@ -62,7 +62,7 @@
62
62
  ? ''
63
63
  : done
64
64
  ? 'bg-brand'
65
- : 'bg-line'} h-px transition-colors duration-150"
65
+ : 'bg-line'} h-px transition-colors"
66
66
  ></div>
67
67
  </div>
68
68
 
@@ -72,7 +72,7 @@
72
72
  ? 'text-fg'
73
73
  : done
74
74
  ? 'text-muted'
75
- : 'text-faint'} transition-colors duration-150"
75
+ : 'text-faint'} transition-colors"
76
76
  >
77
77
  {step.label}
78
78
  </p>
@@ -95,7 +95,7 @@
95
95
  <div class="flex flex-col items-center">
96
96
  <div
97
97
  class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2
98
- font-bold text-sm transition-all
98
+ font-bold text-sm transition-colors
99
99
  {done
100
100
  ? 'border-brand bg-brand text-ink'
101
101
  : active
@@ -117,9 +117,7 @@
117
117
  </div>
118
118
  {#if i < steps.length - 1}
119
119
  <div
120
- class="my-1 w-px flex-1 {done
121
- ? 'bg-brand/40'
122
- : 'bg-line'} transition-colors duration-150"
120
+ class="my-1 w-px flex-1 {done ? 'bg-brand/40' : 'bg-line'} transition-colors"
123
121
  ></div>
124
122
  {/if}
125
123
  </div>
@@ -129,7 +127,7 @@
129
127
  ? 'text-fg'
130
128
  : done
131
129
  ? 'text-muted'
132
- : 'text-faint'} transition-colors duration-150"
130
+ : 'text-faint'} transition-colors"
133
131
  >
134
132
  {step.label}
135
133
  </p>
@@ -11,6 +11,12 @@
11
11
 
12
12
  <script lang="ts">
13
13
  import type { Snippet } from 'svelte';
14
+ import {
15
+ SCROLL_EDGE,
16
+ SCROLL_EDGE_END,
17
+ SCROLL_EDGE_START,
18
+ scrollEdges,
19
+ } from '../internal/scroll-edges.js';
14
20
 
15
21
  interface Props {
16
22
  striped?: boolean;
@@ -70,7 +76,7 @@
70
76
  '[&_tbody_tr:hover>td:first-child]:shadow-[inset_1px_1px_var(--color-brand),inset_1px_-1px_var(--color-brand)]',
71
77
  '[&_tbody_tr:hover>td:last-child]:shadow-[inset_-1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
72
78
  '[&_tbody_tr:hover>td:first-child:last-child]:shadow-[inset_1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
73
- '[&_tbody_tr]:transition-colors [&_tbody_tr]:duration-150',
79
+ '[&_tbody_tr]:transition-colors',
74
80
  ].join(' ');
75
81
 
76
82
  let {
@@ -121,19 +127,9 @@
121
127
  function measure() {
122
128
  const el = scroller;
123
129
  if (!el) return;
124
- const slack = el.scrollWidth - el.clientWidth;
125
- // A sub-pixel layout leaves scrollLeft a fraction short of its own maximum
126
- // at the far end, so an exact comparison never reports the end as reached.
127
- if (slack <= 1) {
128
- moreBefore = false;
129
- moreAfter = false;
130
- return;
131
- }
132
- // A right-to-left box reports the offset as a negative number, and the two
133
- // edges are the same two edges either way round.
134
- const travelled = Math.abs(el.scrollLeft);
135
- moreBefore = travelled > 1;
136
- moreAfter = travelled < slack - 1;
130
+ const edges = scrollEdges(el);
131
+ moreBefore = edges.before;
132
+ moreAfter = edges.after;
137
133
  }
138
134
 
139
135
  $effect(() => {
@@ -151,14 +147,6 @@
151
147
  return () => observer.disconnect();
152
148
  });
153
149
 
154
- /*
155
- * A dark fade rather than a palette colour. The box has no background of its
156
- * own and sits on the page in one place and inside a card in another, so no
157
- * surface token is the colour to fade to. The scrims use black at an alpha
158
- * for the same reason and it reads in both themes.
159
- */
160
- const EDGE = 'pointer-events-none absolute inset-y-px w-6';
161
-
162
150
  /*
163
151
  * The table-wide treatment, applied only to cells that did not ask for their
164
152
  * own.
@@ -185,6 +173,28 @@
185
173
  '[&_tbody_td[data-cell=truncate]]:max-w-[var(--cell-truncate)] ' +
186
174
  '[&_tbody_td[data-cell=truncate]]:truncate';
187
175
 
176
+ /*
177
+ * Column alignment. Head cells align start: the table already said
178
+ * `text-start`, and the browser's own `th { text-align: center }` beat the
179
+ * inherited value, so every heading floated over a left-aligned column
180
+ * until the page wrote `text-left` on each one. A numeric column is the one
181
+ * kind that reads better ranged right, and its digits line up only in a
182
+ * tabular figure, so a caller marks its cells `data-col="numeric"` and the
183
+ * heading and the cells take both together.
184
+ */
185
+ const COLUMN_ALIGN =
186
+ '[&_thead_th]:text-start ' +
187
+ '[&_th[data-col=numeric]]:text-end [&_td[data-col=numeric]]:text-end ' +
188
+ '[&_td[data-col=numeric]]:tabular-nums';
189
+
190
+ /*
191
+ * A sort control in a heading is the caller's button, and the head row is
192
+ * 40px. The hit box every small kit control grows under a finger is given
193
+ * to it here, so a table sorted from its headings needs nothing from the
194
+ * page to meet the finger.
195
+ */
196
+ const HEAD_CONTROLS = '[&_thead_th_button]:relative [&_thead_th_button]:hit-area';
197
+
188
198
  /** Marks a title this component put there, so it can take it back again. */
189
199
  const OWNED = 'data-cell-title';
190
200
 
@@ -230,7 +240,10 @@
230
240
  <!-- overflow-hidden on the frame, not only on the scroller: the hovered row's
231
241
  ring is square and the frame is not, and the last row's corners drew past
232
242
  the frame's rounded ones. -->
233
- <div data-testid="table-frame" class="relative w-full overflow-hidden rounded-xl border border-line {cls}">
243
+ <div
244
+ data-testid="table-frame"
245
+ class="relative w-full overflow-hidden rounded-xl border border-line {cls}"
246
+ >
234
247
  <!--
235
248
  data-print: the box exists to clip. On paper there is no viewport to clip
236
249
  to, and every column past the edge would simply not be printed.
@@ -265,7 +278,7 @@
265
278
  style="--cell-truncate: {truncateAt}"
266
279
  class="w-full border-separate border-spacing-0 text-sm text-start
267
280
  {fixed ? 'table-fixed' : ''}
268
- {CELL_FIT[cell]} {CELL_NAMED}
281
+ {CELL_FIT[cell]} {CELL_NAMED} {COLUMN_ALIGN} {HEAD_CONTROLS}
269
282
  [&_thead_th]:border-b [&_thead_th]:border-line [&_thead]:bg-surface-2/40
270
283
  [&_thead_th]:px-4 [&_thead_th]:py-3 [&_thead_th]:text-xs
271
284
  [&_thead_th]:font-medium [&_thead_th]:text-faint
@@ -286,16 +299,13 @@
286
299
  the phone will not draw, it says nothing a screen reader has not already
287
300
  been told by the region, and a child of the box would scroll away with the
288
301
  content it is describing.
289
-
290
- The gradient direction is logical: in a right-to-left locale the start edge
291
- is the right one and the fade has to run the other way with it.
292
302
  -->
293
303
  {#if moreBefore}
294
304
  <div
295
305
  aria-hidden="true"
296
306
  data-print="hide"
297
307
  data-testid="table-more-before"
298
- class="{EDGE} start-0 rounded-s-xl bg-linear-to-r rtl:bg-linear-to-l from-black/25 to-transparent"
308
+ class="{SCROLL_EDGE} {SCROLL_EDGE_START} rounded-s-xl"
299
309
  ></div>
300
310
  {/if}
301
311
  {#if moreAfter}
@@ -303,7 +313,7 @@
303
313
  aria-hidden="true"
304
314
  data-print="hide"
305
315
  data-testid="table-more-after"
306
- class="{EDGE} end-0 rounded-e-xl bg-linear-to-l rtl:bg-linear-to-r from-black/25 to-transparent"
316
+ class="{SCROLL_EDGE} {SCROLL_EDGE_END} rounded-e-xl"
307
317
  ></div>
308
318
  {/if}
309
319
  </div>