@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.
- package/README.md +27 -17
- package/dist/components/AccordionItem.svelte +2 -2
- package/dist/components/AccountMenu.svelte +1 -1
- package/dist/components/AppShell.svelte +22 -8
- package/dist/components/Autocomplete.svelte +13 -4
- package/dist/components/AvatarGroup.svelte +2 -2
- package/dist/components/Breadcrumb.svelte +1 -1
- package/dist/components/Button.svelte +34 -9
- package/dist/components/ButtonGroup.svelte +3 -3
- package/dist/components/Card.svelte +2 -1
- package/dist/components/Checkbox.svelte +1 -1
- package/dist/components/CheckboxGroup.svelte +1 -1
- package/dist/components/Collapsible.svelte +4 -4
- package/dist/components/CopyButton.svelte +1 -1
- package/dist/components/DatePicker.svelte +3 -3
- package/dist/components/DateTimePicker.svelte +1 -1
- package/dist/components/Drawer.svelte +7 -27
- package/dist/components/Dropdown.svelte +197 -11
- package/dist/components/Dropdown.svelte.d.ts +12 -0
- package/dist/components/Field.svelte +1 -1
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +1 -1
- package/dist/components/Logo.svelte +77 -0
- package/dist/components/Logo.svelte.d.ts +35 -0
- package/dist/components/Modal.svelte +2 -15
- package/dist/components/MultiSelect.svelte +16 -7
- package/dist/components/NumberInput.svelte +3 -3
- package/dist/components/PageHeader.svelte +23 -1
- package/dist/components/Pagination.svelte +164 -65
- package/dist/components/Pagination.svelte.d.ts +39 -2
- package/dist/components/Panel.svelte +1 -1
- package/dist/components/PasswordInput.svelte +4 -4
- package/dist/components/Radio.svelte +1 -1
- package/dist/components/RadioGroup.svelte +1 -1
- package/dist/components/SearchInput.svelte +13 -4
- package/dist/components/SegmentedControl.svelte +3 -1
- package/dist/components/Select.svelte +6 -6
- package/dist/components/SidebarNav.svelte +20 -5
- package/dist/components/Table.svelte +256 -10
- package/dist/components/Table.svelte.d.ts +44 -0
- package/dist/components/Tabs.svelte +1 -1
- package/dist/components/Tag.svelte +1 -1
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/ThemeToggle.svelte +76 -13
- package/dist/components/ThemeToggle.svelte.d.ts +9 -0
- package/dist/components/TimePicker.svelte +2 -2
- package/dist/components/Toaster.svelte +3 -15
- package/dist/components/Toggle.svelte +10 -4
- package/dist/components/Tooltip.svelte +1 -1
- package/dist/components/TreeView.svelte +4 -4
- package/dist/components/dialog/Dialog.svelte +10 -5
- package/dist/components/dialog/dialog-manager.svelte.d.ts +20 -1
- package/dist/components/dialog/dialog-manager.svelte.js +13 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +6 -2
- package/dist/internal/href.d.ts +13 -0
- package/dist/internal/href.js +27 -0
- package/dist/internal/layout.js +11 -2
- package/dist/internal/panel.d.ts +1 -1
- package/dist/internal/panel.js +2 -2
- package/dist/styles/theme.css +288 -0
- package/dist/utils/theme.d.ts +68 -3
- package/dist/utils/theme.js +118 -3
- package/package.json +20 -22
- 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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
[
|
|
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
|
-
[&
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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="
|
|
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
|
>
|
|
@@ -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
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
preference = getThemePreference();
|
|
15
51
|
});
|
|
16
52
|
|
|
17
|
-
|
|
18
|
-
|
|
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={
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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="
|
|
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}
|
|
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
|
-
|
|
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
|
|
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
|
|
31
|
-
md: 'w-4 h-4 top-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-
|
|
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
|
|
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-
|
|
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="
|
|
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
|
|
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: {
|
|
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 -
|
|
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"
|