@lyeve-labs/ui-kit 0.17.1 → 0.19.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 (65) hide show
  1. package/README.md +27 -17
  2. package/dist/components/AccordionItem.svelte +2 -2
  3. package/dist/components/AccountMenu.svelte +1 -1
  4. package/dist/components/AppShell.svelte +22 -8
  5. package/dist/components/Autocomplete.svelte +13 -4
  6. package/dist/components/AvatarGroup.svelte +2 -2
  7. package/dist/components/Breadcrumb.svelte +1 -1
  8. package/dist/components/Button.svelte +34 -9
  9. package/dist/components/ButtonGroup.svelte +3 -3
  10. package/dist/components/Card.svelte +2 -1
  11. package/dist/components/Checkbox.svelte +1 -1
  12. package/dist/components/CheckboxGroup.svelte +1 -1
  13. package/dist/components/Collapsible.svelte +4 -4
  14. package/dist/components/CopyButton.svelte +1 -1
  15. package/dist/components/DatePicker.svelte +3 -3
  16. package/dist/components/DateTimePicker.svelte +1 -1
  17. package/dist/components/Drawer.svelte +7 -27
  18. package/dist/components/Dropdown.svelte +197 -11
  19. package/dist/components/Dropdown.svelte.d.ts +12 -0
  20. package/dist/components/Field.svelte +1 -1
  21. package/dist/components/Input.svelte +1 -1
  22. package/dist/components/Label.svelte +1 -1
  23. package/dist/components/Logo.svelte +77 -0
  24. package/dist/components/Logo.svelte.d.ts +35 -0
  25. package/dist/components/Modal.svelte +2 -15
  26. package/dist/components/MultiSelect.svelte +16 -7
  27. package/dist/components/NumberInput.svelte +3 -3
  28. package/dist/components/PageHeader.svelte +23 -1
  29. package/dist/components/Pagination.svelte +164 -65
  30. package/dist/components/Pagination.svelte.d.ts +39 -2
  31. package/dist/components/Panel.svelte +1 -1
  32. package/dist/components/PasswordInput.svelte +4 -4
  33. package/dist/components/Radio.svelte +1 -1
  34. package/dist/components/RadioGroup.svelte +1 -1
  35. package/dist/components/SearchInput.svelte +13 -4
  36. package/dist/components/SegmentedControl.svelte +3 -1
  37. package/dist/components/Select.svelte +6 -6
  38. package/dist/components/SidebarNav.svelte +20 -5
  39. package/dist/components/Table.svelte +256 -10
  40. package/dist/components/Table.svelte.d.ts +44 -0
  41. package/dist/components/Tabs.svelte +1 -1
  42. package/dist/components/Tag.svelte +1 -1
  43. package/dist/components/Textarea.svelte +1 -1
  44. package/dist/components/ThemeToggle.svelte +76 -13
  45. package/dist/components/ThemeToggle.svelte.d.ts +9 -0
  46. package/dist/components/TimePicker.svelte +2 -2
  47. package/dist/components/Toaster.svelte +3 -15
  48. package/dist/components/Toggle.svelte +10 -4
  49. package/dist/components/Tooltip.svelte +1 -1
  50. package/dist/components/TreeView.svelte +4 -4
  51. package/dist/components/dialog/Dialog.svelte +10 -5
  52. package/dist/components/dialog/dialog-manager.svelte.d.ts +20 -1
  53. package/dist/components/dialog/dialog-manager.svelte.js +13 -2
  54. package/dist/index.d.ts +5 -2
  55. package/dist/index.js +6 -2
  56. package/dist/internal/href.d.ts +13 -0
  57. package/dist/internal/href.js +27 -0
  58. package/dist/internal/layout.js +11 -2
  59. package/dist/internal/panel.d.ts +1 -1
  60. package/dist/internal/panel.js +2 -2
  61. package/dist/styles/theme.css +288 -0
  62. package/dist/utils/theme.d.ts +68 -3
  63. package/dist/utils/theme.js +118 -3
  64. package/package.json +20 -22
  65. package/src/lib/styles/theme.css +288 -0
@@ -1,3 +1,14 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * How a body cell treats content wider than its column.
4
+ *
5
+ * `wrap` breaks anywhere it has to. `truncate` keeps the cell to one line and
6
+ * ends it with an ellipsis. `auto` is the browser's own table algorithm, with
7
+ * no cap of any kind.
8
+ */
9
+ export type CellFit = 'auto' | 'wrap' | 'truncate';
10
+ </script>
11
+
1
12
  <script lang="ts">
2
13
  import type { Snippet } from 'svelte';
3
14
 
@@ -5,6 +16,42 @@
5
16
  striped?: boolean;
6
17
  hoverable?: boolean;
7
18
  fixed?: boolean;
19
+ /**
20
+ * Names the scroll box, so a screen reader announces what the region it
21
+ * just landed in holds. Without it the box is still focusable and simply
22
+ * carries no role: an unnamed region is announced as "region" and nothing
23
+ * else, which is a landmark the reader has to enter to identify.
24
+ */
25
+ label?: string;
26
+ /**
27
+ * What a body cell does with content too wide for its column.
28
+ *
29
+ * `wrap` by default, and the default is not `auto`. A table constrained
30
+ * nothing: one cell holding an unbroken token - an API key, a signed URL,
31
+ * a base64 payload - has no break opportunity in it, so the auto table
32
+ * algorithm sizes the column to the whole string and the table grows to
33
+ * whatever that comes to. One measured page took its container from no
34
+ * overflow at all to 3,086px of it, and the audit log overran its own
35
+ * container by 419px at 390px wide.
36
+ *
37
+ * `wrap` fixes exactly that and nothing else. It is `overflow-wrap:
38
+ * anywhere`, which is the one value that shrinks the column's min-content
39
+ * width, so a paragraph still breaks at its spaces and only a token with
40
+ * no spaces in it is broken mid-string. Prose in a cell renders as it
41
+ * always did.
42
+ *
43
+ * The default wraps rather than truncates because wrapping is the choice
44
+ * that cannot lose anything. Truncation hides, and a table that hides is
45
+ * worse than one that scrolls, so it is opted into.
46
+ */
47
+ cell?: CellFit;
48
+ /**
49
+ * The width a truncating cell stops at. Any CSS length.
50
+ *
51
+ * Read through a custom property, so a single column can disagree with the
52
+ * table by setting `--cell-truncate` in its own style attribute.
53
+ */
54
+ truncateAt?: string;
8
55
  class?: string;
9
56
  children: Snippet;
10
57
  }
@@ -13,26 +60,225 @@
13
60
  striped = false,
14
61
  hoverable = true,
15
62
  fixed = false,
63
+ label = undefined,
64
+ cell = 'wrap',
65
+ truncateAt = '20rem',
16
66
  class: cls = '',
17
67
  children,
18
68
  }: Props = $props();
69
+
70
+ /*
71
+ * The scroll box is a tab stop. A wide table scrolls sideways, and a mouse is
72
+ * the only thing that could reach the columns past the right edge: nothing
73
+ * inside a plain table takes focus, so the arrow keys never had an element to
74
+ * act on (SC 2.1.1).
75
+ *
76
+ * Spread rather than written on the element. The compiler reads a literal
77
+ * tabindex on any element it does not consider interactive as a defect, and
78
+ * a focusable scroll container is the one case where that is the fix rather
79
+ * than the fault. The rule cannot express it and the gate stays on for
80
+ * everything else, so the attributes are assembled here.
81
+ */
82
+ const scrollAttrs = $derived(
83
+ label === undefined ? { tabindex: 0 } : { tabindex: 0, role: 'region', 'aria-label': label },
84
+ );
85
+
86
+ /*
87
+ * Reachable is not the same as discoverable.
88
+ *
89
+ * Making the box a tab stop answered SC 2.1.1 and told nobody the columns
90
+ * were there. A desktop has a scrollbar to give it away; a phone has an
91
+ * overlay scrollbar that is painted while a finger is moving and is invisible
92
+ * the rest of the time. On the admin's plugin list at 390px the column that
93
+ * falls off the edge is ACTIONS, which is the only thing on the page a reader
94
+ * came to do, and nothing on screen says so.
95
+ *
96
+ * A fade on the edge that has more behind it. Only when the box really
97
+ * overflows, and only on the side the content continues on, so it reads as a
98
+ * property of this table rather than as decoration every table wears.
99
+ */
100
+ let scroller: HTMLElement | undefined = $state();
101
+ let moreBefore = $state(false);
102
+ let moreAfter = $state(false);
103
+
104
+ function measure() {
105
+ const el = scroller;
106
+ if (!el) return;
107
+ const slack = el.scrollWidth - el.clientWidth;
108
+ // A sub-pixel layout leaves scrollLeft a fraction short of its own maximum
109
+ // at the far end, so an exact comparison never reports the end as reached.
110
+ if (slack <= 1) {
111
+ moreBefore = false;
112
+ moreAfter = false;
113
+ return;
114
+ }
115
+ // A right-to-left box reports the offset as a negative number, and the two
116
+ // edges are the same two edges either way round.
117
+ const travelled = Math.abs(el.scrollLeft);
118
+ moreBefore = travelled > 1;
119
+ moreAfter = travelled < slack - 1;
120
+ }
121
+
122
+ $effect(() => {
123
+ const el = scroller;
124
+ if (!el) return;
125
+ measure();
126
+ // The width that matters changes without a scroll and without a resize of
127
+ // the window: a column added, a filter applied, a sidebar collapsed. Both
128
+ // boxes are watched because either one moving changes whether the other
129
+ // overflows.
130
+ if (typeof ResizeObserver === 'undefined') return;
131
+ const observer = new ResizeObserver(measure);
132
+ observer.observe(el);
133
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
134
+ return () => observer.disconnect();
135
+ });
136
+
137
+ /*
138
+ * A dark fade rather than a palette colour. The box has no background of its
139
+ * own and sits on the page in one place and inside a card in another, so no
140
+ * surface token is the colour to fade to. The scrims use black at an alpha
141
+ * for the same reason and it reads in both themes.
142
+ */
143
+ const EDGE = 'pointer-events-none absolute inset-y-px w-6';
144
+
145
+ /*
146
+ * The table-wide treatment, applied only to cells that did not ask for their
147
+ * own.
148
+ *
149
+ * `:not([data-cell])` is what keeps the two apart. Without it a column that
150
+ * opted out still matched the table's rule for the same property, and which
151
+ * of the two won came down to the order Tailwind happened to emit them in.
152
+ */
153
+ const CELL_FIT: Record<CellFit, string> = {
154
+ auto: '',
155
+ wrap: '[&_tbody_td:not([data-cell])]:[overflow-wrap:anywhere]',
156
+ truncate:
157
+ '[&_tbody_td:not([data-cell])]:max-w-[var(--cell-truncate)] [&_tbody_td:not([data-cell])]:truncate',
158
+ };
159
+
160
+ /*
161
+ * The per-cell opt-in, always present so a caller can mix treatments down one
162
+ * row: `data-cell="nowrap"` on a timestamp, `data-cell="truncate"` on a
163
+ * description, nothing at all on the rest.
164
+ */
165
+ const CELL_NAMED =
166
+ '[&_tbody_td[data-cell=wrap]]:[overflow-wrap:anywhere] ' +
167
+ '[&_tbody_td[data-cell=nowrap]]:whitespace-nowrap ' +
168
+ '[&_tbody_td[data-cell=truncate]]:max-w-[var(--cell-truncate)] ' +
169
+ '[&_tbody_td[data-cell=truncate]]:truncate';
170
+
171
+ /** Marks a title this component put there, so it can take it back again. */
172
+ const OWNED = 'data-cell-title';
173
+
174
+ /**
175
+ * Gives a clipped cell its full value back.
176
+ *
177
+ * The text is never removed from the document, so a screen reader already
178
+ * reads the whole of it and it is the sighted pointer user who loses the tail
179
+ * to the ellipsis. The title that returns it is attached when a pointer or
180
+ * the keyboard actually reaches the cell, from one listener on the scroll
181
+ * box, rather than measured for every cell as the table renders: a tenant
182
+ * with 10,000 rows would pay for 10,000 measurements and 10,000 title
183
+ * attributes to answer a question asked of about three of them.
184
+ */
185
+ function reveal(event: Event) {
186
+ const from = event.target;
187
+ if (!(from instanceof Element)) return;
188
+ const td = from.closest('td');
189
+ if (!(td instanceof HTMLElement)) return;
190
+
191
+ const named = td.dataset.cell;
192
+ const clipping = named === 'truncate' || (named === undefined && cell === 'truncate');
193
+ if (!clipping) return;
194
+
195
+ const ours = td.hasAttribute(OWNED);
196
+ // A title the caller wrote is theirs. It may well say more than the cell
197
+ // holds, and replacing it with the visible text would be a downgrade.
198
+ if (td.hasAttribute('title') && !ours) return;
199
+
200
+ const full = (td.textContent ?? '').trim();
201
+ if (full && td.scrollWidth - td.clientWidth > 1) {
202
+ td.setAttribute('title', full);
203
+ td.setAttribute(OWNED, '');
204
+ } else if (ours) {
205
+ // The column widened, or the row now holds something shorter. A tooltip
206
+ // repeating text the reader can already see is noise.
207
+ td.removeAttribute('title');
208
+ td.removeAttribute(OWNED);
209
+ }
210
+ }
19
211
  </script>
20
212
 
21
- <div class="w-full overflow-x-auto rounded-xl border border-line {cls}">
22
- <table
23
- class="w-full text-sm text-left
213
+ <div data-testid="table-frame" class="relative w-full rounded-xl border border-line {cls}">
214
+ <!--
215
+ data-print: the box exists to clip. On paper there is no viewport to clip
216
+ to, and every column past the edge would simply not be printed.
217
+ -->
218
+ <div
219
+ bind:this={scroller}
220
+ onscroll={measure}
221
+ onpointerover={reveal}
222
+ onfocusin={reveal}
223
+ data-print="unclip"
224
+ data-testid="table-scroll"
225
+ class="w-full overflow-x-auto rounded-xl"
226
+ {...scrollAttrs}
227
+ >
228
+ <!--
229
+ border-separate with no spacing, and the row separators on the cells.
230
+
231
+ The hovered row draws a ring, and a ring is a box-shadow: a browser paints
232
+ no box-shadow on a table row while the table collapses its borders, so the
233
+ same row treatment the panels use would have rendered nothing at all here.
234
+ The separate model paints it. Borders on a row are ignored in that model,
235
+ which is why the lines moved down to the cells they were already drawn
236
+ between; with border-spacing at zero the result is the same line in the
237
+ same place.
238
+ -->
239
+ <table
240
+ style="--cell-truncate: {truncateAt}"
241
+ class="w-full border-separate border-spacing-0 text-sm text-start
24
242
  {fixed ? 'table-fixed' : ''}
25
- [&_thead_tr]:border-b [&_thead_tr]:border-line [&_thead]:bg-surface-2/40
243
+ {CELL_FIT[cell]} {CELL_NAMED}
244
+ [&_thead_th]:border-b [&_thead_th]:border-line [&_thead]:bg-surface-2/40
26
245
  [&_thead_th]:px-4 [&_thead_th]:py-3 [&_thead_th]:text-xs
27
246
  [&_thead_th]:font-medium [&_thead_th]:text-faint
28
247
  [&_thead_th]:uppercase [&_thead_th]:tracking-wider [&_thead_th]:whitespace-nowrap
29
248
  [&_tbody_td]:px-4 [&_tbody_td]:py-3 [&_tbody_td]:text-fg [&_tbody_td]:align-middle
30
- [&_tbody_tr]:border-b [&_tbody_tr]:border-line [&_tbody_tr:last-child]:border-0
249
+ [&_tbody_td]:border-b [&_tbody_td]:border-line [&_tbody_tr:last-child_td]:border-0
31
250
  {striped ? '[&_tbody_tr:nth-child(even)]:bg-surface-2/40' : ''}
32
251
  {hoverable
33
- ? '[&_tbody_tr:hover]:bg-surface-2/60 [&_tbody_tr]:transition-colors [&_tbody_tr]:duration-150'
34
- : ''}"
35
- >
36
- {@render children()}
37
- </table>
252
+ ? '[&_tbody_tr:hover]:bg-surface-2/60 [&_tbody_tr:hover]:ring-1 [&_tbody_tr:hover]:ring-inset [&_tbody_tr:hover]:ring-brand [&_tbody_tr]:transition-colors [&_tbody_tr]:duration-150'
253
+ : ''}"
254
+ >
255
+ {@render children()}
256
+ </table>
257
+ </div>
258
+
259
+ <!--
260
+ aria-hidden and outside the scroll box. It is a picture of the scrollbar
261
+ the phone will not draw, it says nothing a screen reader has not already
262
+ been told by the region, and a child of the box would scroll away with the
263
+ content it is describing.
264
+
265
+ The gradient direction is logical: in a right-to-left locale the start edge
266
+ is the right one and the fade has to run the other way with it.
267
+ -->
268
+ {#if moreBefore}
269
+ <div
270
+ aria-hidden="true"
271
+ data-print="hide"
272
+ data-testid="table-more-before"
273
+ class="{EDGE} start-0 rounded-s-xl bg-linear-to-r rtl:bg-linear-to-l from-black/25 to-transparent"
274
+ ></div>
275
+ {/if}
276
+ {#if moreAfter}
277
+ <div
278
+ aria-hidden="true"
279
+ data-print="hide"
280
+ data-testid="table-more-after"
281
+ class="{EDGE} end-0 rounded-e-xl bg-linear-to-l rtl:bg-linear-to-r from-black/25 to-transparent"
282
+ ></div>
283
+ {/if}
38
284
  </div>
@@ -1,8 +1,52 @@
1
+ /**
2
+ * How a body cell treats content wider than its column.
3
+ *
4
+ * `wrap` breaks anywhere it has to. `truncate` keeps the cell to one line and
5
+ * ends it with an ellipsis. `auto` is the browser's own table algorithm, with
6
+ * no cap of any kind.
7
+ */
8
+ export type CellFit = 'auto' | 'wrap' | 'truncate';
1
9
  import type { Snippet } from 'svelte';
2
10
  interface Props {
3
11
  striped?: boolean;
4
12
  hoverable?: boolean;
5
13
  fixed?: boolean;
14
+ /**
15
+ * Names the scroll box, so a screen reader announces what the region it
16
+ * just landed in holds. Without it the box is still focusable and simply
17
+ * carries no role: an unnamed region is announced as "region" and nothing
18
+ * else, which is a landmark the reader has to enter to identify.
19
+ */
20
+ label?: string;
21
+ /**
22
+ * What a body cell does with content too wide for its column.
23
+ *
24
+ * `wrap` by default, and the default is not `auto`. A table constrained
25
+ * nothing: one cell holding an unbroken token - an API key, a signed URL,
26
+ * a base64 payload - has no break opportunity in it, so the auto table
27
+ * algorithm sizes the column to the whole string and the table grows to
28
+ * whatever that comes to. One measured page took its container from no
29
+ * overflow at all to 3,086px of it, and the audit log overran its own
30
+ * container by 419px at 390px wide.
31
+ *
32
+ * `wrap` fixes exactly that and nothing else. It is `overflow-wrap:
33
+ * anywhere`, which is the one value that shrinks the column's min-content
34
+ * width, so a paragraph still breaks at its spaces and only a token with
35
+ * no spaces in it is broken mid-string. Prose in a cell renders as it
36
+ * always did.
37
+ *
38
+ * The default wraps rather than truncates because wrapping is the choice
39
+ * that cannot lose anything. Truncation hides, and a table that hides is
40
+ * worse than one that scrolls, so it is opted into.
41
+ */
42
+ cell?: CellFit;
43
+ /**
44
+ * The width a truncating cell stops at. Any CSS length.
45
+ *
46
+ * Read through a custom property, so a single column can disagree with the
47
+ * table by setting `--cell-truncate` in its own style attribute.
48
+ */
49
+ truncateAt?: string;
6
50
  class?: string;
7
51
  children: Snippet;
8
52
  }
@@ -27,7 +27,7 @@
27
27
  {tab.label}
28
28
  {#if tab.count !== undefined}
29
29
  <span
30
- class="ml-1.5 inline-flex items-center justify-center min-w-4.5 h-4.5 px-1
30
+ class="ms-1.5 inline-flex items-center justify-center min-w-4.5 h-4.5 px-1
31
31
  rounded-full text-[10px] font-semibold
32
32
  {active === tab.id ? 'bg-brand/40 text-brand' : 'bg-surface-2 text-faint'}"
33
33
  >
@@ -37,7 +37,7 @@
37
37
  type="button"
38
38
  onclick={onremove}
39
39
  aria-label="Remove {label}"
40
- class="ml-0.5 rounded transition-opacity hover:opacity-60 leading-none"
40
+ class="ms-0.5 rounded transition-opacity hover:opacity-60 leading-none"
41
41
  >
42
42
  <svg
43
43
  width="10"
@@ -54,7 +54,7 @@
54
54
  <div class="{FIELD_WRAP} {cls}">
55
55
  {#if label}
56
56
  <label for={fieldId} class={FIELD_LABEL}>
57
- {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
57
+ {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
58
58
  </label>
59
59
  {/if}
60
60
 
@@ -1,33 +1,96 @@
1
1
  <script lang="ts">
2
- import { Moon, Sun } from '@lucide/svelte';
3
- import { getTheme, toggleTheme, type Theme } from '../utils/theme.js';
2
+ /**
3
+ * The control that picks the palette.
4
+ *
5
+ * Three states, not two. It offered `dark` and `light` and had no way to say
6
+ * "whatever the operating system is on", which is the state a reader who
7
+ * already answered that question at the OS level expects to find, and the
8
+ * reason one console shipped its own copy of this file rather than adopt it.
9
+ *
10
+ * One button rather than three radios: it sits in the header bar of every
11
+ * authed screen in three applications, next to the account menu, and a
12
+ * segmented control there costs the width of the section name beside it. The
13
+ * cost of the cycle is that the button's name has to carry the current state
14
+ * as well as the action, because with three states the action no longer
15
+ * implies the state.
16
+ */
17
+ import { Monitor, Moon, Sun } from '@lucide/svelte';
18
+ import {
19
+ THEME_PREFERENCES,
20
+ getThemePreference,
21
+ nextThemePreference,
22
+ setThemePreference,
23
+ watchSystemTheme,
24
+ type ThemePreference,
25
+ } from '../utils/theme.js';
4
26
 
5
27
  interface Props {
28
+ /**
29
+ * Which preferences to offer, in the order the button cycles through them.
30
+ *
31
+ * A surface that must not offer `system` - a screen printed to a fixed
32
+ * palette, a kiosk - passes `['light', 'dark']` and gets the two-state
33
+ * control back with no other change.
34
+ */
35
+ order?: readonly ThemePreference[];
6
36
  class?: string;
7
37
  }
8
38
 
9
- let { class: klass = '' }: Props = $props();
39
+ let { order = THEME_PREFERENCES, class: klass = '' }: Props = $props();
10
40
 
11
- let theme = $state<Theme>('dark');
41
+ /**
42
+ * `system` until the browser says otherwise, which is what an unanswered
43
+ * question resolves to. The server cannot read storage or the OS, so a
44
+ * hardcoded `dark` here would render the wrong icon on every light-OS
45
+ * reader's first paint.
46
+ */
47
+ let preference = $state<ThemePreference>('system');
12
48
 
13
49
  $effect(() => {
14
- theme = getTheme();
50
+ preference = getThemePreference();
15
51
  });
16
52
 
17
- function flip() {
18
- theme = toggleTheme();
53
+ /**
54
+ * A reader on `system` whose OS flips at dusk gets the new palette without
55
+ * touching anything. Without this the preference is honoured once, at load,
56
+ * and reads as ignored for the rest of the session.
57
+ */
58
+ $effect(() =>
59
+ watchSystemTheme(() => {
60
+ if (preference === 'system') setThemePreference('system');
61
+ }),
62
+ );
63
+
64
+ const upcoming = $derived(nextThemePreference(preference, order));
65
+
66
+ const LABEL: Record<ThemePreference, string> = {
67
+ light: 'light',
68
+ dark: 'dark',
69
+ system: 'system',
70
+ };
71
+
72
+ function advance() {
73
+ preference = upcoming;
74
+ setThemePreference(preference);
19
75
  }
20
76
  </script>
21
77
 
22
78
  <button
23
79
  type="button"
24
- onclick={flip}
25
- aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
26
- class="rounded-lg p-2 text-faint transition-colors duration-150 hover:bg-surface-2 hover:text-fg {klass}"
80
+ onclick={advance}
81
+ data-print="hide"
82
+ data-theme-preference={preference}
83
+ aria-label="Theme: {LABEL[preference]}. Switch to {LABEL[upcoming]}."
84
+ class="rounded-lg p-2 text-faint transition-colors duration-150 hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg {klass}"
27
85
  >
28
- {#if theme === 'dark'}
29
- <Sun size={16} aria-hidden="true" />
30
- {:else}
86
+ <!-- The icon shows the state the control is in, not the one it moves to. It
87
+ showed the destination while the label named the same destination, which
88
+ said the action twice and the state not at all. -->
89
+ {#if preference === 'system'}
90
+ <Monitor size={16} aria-hidden="true" />
91
+ {:else if preference === 'dark'}
31
92
  <Moon size={16} aria-hidden="true" />
93
+ {:else}
94
+ <Sun size={16} aria-hidden="true" />
32
95
  {/if}
33
96
  </button>
@@ -1,4 +1,13 @@
1
+ import { type ThemePreference } from '../utils/theme.js';
1
2
  interface Props {
3
+ /**
4
+ * Which preferences to offer, in the order the button cycles through them.
5
+ *
6
+ * A surface that must not offer `system` - a screen printed to a fixed
7
+ * palette, a kiosk - passes `['light', 'dark']` and gets the two-state
8
+ * control back with no other change.
9
+ */
10
+ order?: readonly ThemePreference[];
2
11
  class?: string;
3
12
  }
4
13
  declare const ThemeToggle: import("svelte").Component<Props, {}, "">;
@@ -401,7 +401,7 @@
401
401
  takes its name from this same element. The hour keeps its own aria-label,
402
402
  which is what a screen reader reads for it. -->
403
403
  <label id="{fieldId}-label" for="{fieldId}-hour" class={FIELD_LABEL}>
404
- {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
404
+ {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
405
405
  </label>
406
406
  {/if}
407
407
 
@@ -459,7 +459,7 @@
459
459
  aria-required={required ? 'true' : undefined}
460
460
  onchange={onMeridiemChange}
461
461
  onkeydown={onMeridiemKeydown}
462
- class="{CONTROL_SEGMENT} ml-1"
462
+ class="{CONTROL_SEGMENT} ms-1"
463
463
  >
464
464
  <option value="AM">AM</option>
465
465
  <option value="PM">PM</option>
@@ -17,7 +17,8 @@
17
17
  changes, and a region that arrives already holding its text is not a
18
18
  change. -->
19
19
  <div
20
- class="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
20
+ data-print="hide"
21
+ class="pointer-events-none fixed bottom-4 end-4 z-toast flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
21
22
  role="status"
22
23
  aria-live="polite"
23
24
  aria-atomic="false"
@@ -25,7 +26,7 @@
25
26
  {#each toast.items as t (t.id)}
26
27
  <div
27
28
  class="pointer-events-auto flex items-start gap-3 overflow-hidden rounded-lg border border-line
28
- bg-surface pl-0 pr-3 py-3 shadow-xl animate-[toast-in_140ms_ease-out]"
29
+ bg-surface ps-0 pe-3 py-3 shadow-xl animate-[toast-in_var(--duration-toast-in)_ease-out]"
29
30
  >
30
31
  <span class="w-1 self-stretch shrink-0 {styles[statusTone(t.tone)].bar}"></span>
31
32
  <span
@@ -68,16 +69,3 @@
68
69
  </div>
69
70
  {/each}
70
71
  </div>
71
-
72
- <style>
73
- @keyframes toast-in {
74
- from {
75
- opacity: 0;
76
- transform: translateX(12px);
77
- }
78
- to {
79
- opacity: 1;
80
- transform: translateX(0);
81
- }
82
- }
83
- </style>
@@ -27,13 +27,19 @@
27
27
  };
28
28
 
29
29
  const thumb: Record<'sm' | 'md', string> = {
30
- sm: 'w-3 h-3 top-0.5 left-0.5',
31
- md: 'w-4 h-4 top-0.5 left-0.5',
30
+ sm: 'w-3 h-3 top-0.5 start-0.5',
31
+ md: 'w-4 h-4 top-0.5 start-0.5',
32
32
  };
33
33
 
34
+ /*
35
+ * The travel is mirrored by hand. `start-0.5` moves the resting knob to the
36
+ * right-hand end of the track in a right-to-left page, and a positive
37
+ * translate from there would carry it straight off the track; there is no
38
+ * logical translate utility, so the sign is flipped instead.
39
+ */
34
40
  const thumbOn: Record<'sm' | 'md', string> = {
35
- sm: 'translate-x-4',
36
- md: 'translate-x-5',
41
+ sm: 'translate-x-4 rtl:-translate-x-4',
42
+ md: 'translate-x-5 rtl:-translate-x-5',
37
43
  };
38
44
 
39
45
  function handleClick() {
@@ -72,7 +72,7 @@
72
72
  {id}
73
73
  role="tooltip"
74
74
  hidden={!visible}
75
- class="absolute {pos[position]} z-50 max-w-[min(16rem,calc(100vw-2rem))]
75
+ class="absolute {pos[position]} z-tooltip max-w-[min(16rem,calc(100vw-2rem))]
76
76
  rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-xl"
77
77
  >
78
78
  {text}
@@ -270,11 +270,11 @@
270
270
  scan, so no rule is generated and every row draws flush left.
271
271
  -->
272
272
  <div
273
- class="flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-sm transition-colors duration-150 {rowPaint(
273
+ class="flex w-full items-center gap-2 rounded-md py-1.5 pe-2 text-sm transition-colors duration-150 {rowPaint(
274
274
  selected === node.id,
275
275
  !!node.disabled,
276
276
  )}"
277
- style="padding-left: calc(var(--spacing-inline) + {depth} * var(--spacing-stack))"
277
+ style="padding-inline-start: calc(var(--spacing-inline) + {depth} * var(--spacing-stack))"
278
278
  >
279
279
  {#if isBranch(node)}
280
280
  <span
@@ -283,7 +283,7 @@
283
283
  >
284
284
  <ChevronRight
285
285
  size={14}
286
- class="transition-transform duration-150 {open ? 'rotate-90' : ''}"
286
+ class="transition-transform duration-150 {open ? 'rotate-90' : 'rtl:rotate-180'}"
287
287
  />
288
288
  </span>
289
289
  {:else}
@@ -321,7 +321,7 @@
321
321
  <span class="truncate">{node.label}</span>
322
322
 
323
323
  {#if node.badge !== undefined}
324
- <span class="ml-auto shrink-0 pl-2 text-xs text-faint">{node.badge}</span>
324
+ <span class="ms-auto shrink-0 ps-2 text-xs text-faint">{node.badge}</span>
325
325
  {/if}
326
326
  </div>
327
327
 
@@ -32,9 +32,8 @@
32
32
  let visible = $state(false);
33
33
  let exiting = $state(false);
34
34
 
35
- // Stacked offset: each deeper dialog shrinks and shifts back
35
+ // Stacked offset: each deeper dialog shrinks, shifts back and rises
36
36
  let offset = $derived(entry.depth);
37
- let zIndex = $derived(50 + offset);
38
37
 
39
38
  // ──────────────────────────────────────────────────────
40
39
  // DOM refs
@@ -93,10 +92,16 @@
93
92
  <!-- z-index is an inline style, not a `z-[...]` class. Tailwind scans source text
94
93
  for complete class names, so a class built from a runtime value matches no
95
94
  candidate and no rule is ever generated: every stacked dialog rendered at
96
- `z-index: auto` and the stacking order came down to DOM order. -->
95
+ `z-index: auto` and the stacking order came down to DOM order.
96
+
97
+ The base is the modal layer rather than the literal 50 it used to be. 50
98
+ was the number every floating surface in the kit happened to hold, so a
99
+ dialog neither sat above a drawer nor below a tooltip on purpose; it sat
100
+ wherever the document put it. The offset climbs from there, and the layer
101
+ above is 100 clear of this one. -->
97
102
  <div
98
103
  class="fixed inset-0 flex items-center justify-center"
99
- style="z-index: {zIndex}"
104
+ style="z-index: calc(var(--z-index-modal) + {offset})"
100
105
  role="presentation"
101
106
  >
102
107
  <!-- Backdrop -->
@@ -148,7 +153,7 @@
148
153
 
149
154
  {#if !entry.options.persistent}
150
155
  <button
151
- class="inline-flex items-center justify-center w-8 h-8 -mr-2 rounded-lg
156
+ class="inline-flex items-center justify-center w-8 h-8 -me-2 rounded-lg
152
157
  text-muted hover:text-fg hover:bg-surface-2 transition-colors duration-150 shrink-0"
153
158
  onclick={() => handleClose()}
154
159
  aria-label="Close"