@lyeve-labs/ui-kit 0.43.0 → 0.45.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/dist/components/AccountMenu.svelte +3 -0
- package/dist/components/Alert.svelte +44 -8
- package/dist/components/Alert.svelte.d.ts +8 -0
- package/dist/components/AppShell.svelte +33 -5
- package/dist/components/AppShell.svelte.d.ts +8 -0
- package/dist/components/Banner.svelte +41 -6
- package/dist/components/Banner.svelte.d.ts +8 -0
- package/dist/components/CopyButton.svelte +8 -2
- package/dist/components/CopyField.svelte +4 -4
- package/dist/components/FormMessage.svelte +11 -5
- package/dist/components/Logo.svelte +1 -1
- package/dist/components/Pagination.svelte +9 -22
- package/dist/components/PluginCart.svelte +11 -3
- package/dist/components/PluginCart.svelte.d.ts +8 -0
- package/dist/components/SortableList.svelte +14 -5
- package/dist/components/Table.svelte +1 -0
- package/dist/components/Tabs.svelte +82 -13
- package/dist/components/Toaster.svelte +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/scroll-edges.d.ts +9 -8
- package/dist/internal/scroll-edges.js +9 -8
- package/package.json +1 -1
|
@@ -94,7 +94,10 @@
|
|
|
94
94
|
</script>
|
|
95
95
|
|
|
96
96
|
<details bind:this={root} class="group relative {klass}">
|
|
97
|
+
<!-- The name beside the avatar is hidden below sm, and the avatar is paint,
|
|
98
|
+
so the trigger carries its name itself. -->
|
|
97
99
|
<summary
|
|
100
|
+
aria-label="{name}, account menu"
|
|
98
101
|
class="{TOUCH_GROW} flex cursor-pointer list-none items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
99
102
|
>
|
|
100
103
|
<span
|
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
* being true after five seconds.
|
|
34
34
|
*/
|
|
35
35
|
autoDismiss?: number | boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Announces the message when it appears, and every change to it after,
|
|
38
|
+
* without clearing it. For a result that stays on screen, such as a
|
|
39
|
+
* confirmation that replaces a form or a status that a poll updates.
|
|
40
|
+
* A message without this or `autoDismiss` states a standing condition
|
|
41
|
+
* and is read out by nobody, so a page load says nothing.
|
|
42
|
+
*/
|
|
43
|
+
announce?: boolean;
|
|
36
44
|
ondismiss?: () => void;
|
|
37
45
|
class?: string;
|
|
38
46
|
children?: Snippet;
|
|
@@ -43,6 +51,7 @@
|
|
|
43
51
|
title = undefined,
|
|
44
52
|
dismissible = false,
|
|
45
53
|
autoDismiss = false,
|
|
54
|
+
announce = false,
|
|
46
55
|
ondismiss = undefined,
|
|
47
56
|
class: klass = '',
|
|
48
57
|
children,
|
|
@@ -64,6 +73,8 @@
|
|
|
64
73
|
* announced every tone assertively, so a green confirmation cut across
|
|
65
74
|
* whatever was being read. An alert that also clears itself must not be
|
|
66
75
|
* assertive: it would interrupt to say something and then take it away.
|
|
76
|
+
* The polite branch takes no role: browsers expose role="status" as a status
|
|
77
|
+
* bar, and Orca speaks nothing inserted into one.
|
|
67
78
|
*/
|
|
68
79
|
const delay = $derived(autoDismiss === true ? DISMISS_AFTER : Number(autoDismiss) || 0);
|
|
69
80
|
|
|
@@ -72,6 +83,28 @@
|
|
|
72
83
|
/** An alert that can close itself can also be closed by hand. */
|
|
73
84
|
const closable = $derived(dismissible || delay > 0);
|
|
74
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A live region reports a change to what it holds, and an outcome alert
|
|
88
|
+
* arrives inside `{#if form}` already holding its text, so nothing would be
|
|
89
|
+
* read out. An alert that reports an outcome, one that clears itself or
|
|
90
|
+
* takes `announce`, mounts without its text and takes it two frames later,
|
|
91
|
+
* as FormMessage does. It is atomic, as role="status" made it, so a change
|
|
92
|
+
* reads the whole message. One that states a standing condition is no live
|
|
93
|
+
* region at all: it must not be read out because the page loaded, and
|
|
94
|
+
* Chrome reads a polite region that a client-side navigation inserts. A
|
|
95
|
+
* failure or a warning that stays is the alert branch and is never deferred.
|
|
96
|
+
*/
|
|
97
|
+
const outcome = $derived(!live && (delay > 0 || announce));
|
|
98
|
+
let filled = $state(typeof window === 'undefined');
|
|
99
|
+
|
|
100
|
+
$effect(() => {
|
|
101
|
+
if (!outcome) return;
|
|
102
|
+
let frame = requestAnimationFrame(() => {
|
|
103
|
+
frame = requestAnimationFrame(() => (filled = true));
|
|
104
|
+
});
|
|
105
|
+
return () => cancelAnimationFrame(frame);
|
|
106
|
+
});
|
|
107
|
+
|
|
75
108
|
let closed = $state(false);
|
|
76
109
|
/** The pointer is over the alert, or focus is inside it. */
|
|
77
110
|
let held = $state(false);
|
|
@@ -101,8 +134,9 @@
|
|
|
101
134
|
<div
|
|
102
135
|
transition:motion.toast|global
|
|
103
136
|
class="flex items-start gap-3 rounded-lg border px-4 py-3 {tones[t].wrap} {klass}"
|
|
104
|
-
role={live ? 'alert' :
|
|
105
|
-
aria-live={live ? 'assertive' : 'polite'}
|
|
137
|
+
role={live ? 'alert' : undefined}
|
|
138
|
+
aria-live={live ? 'assertive' : outcome ? 'polite' : undefined}
|
|
139
|
+
aria-atomic={outcome ? 'true' : undefined}
|
|
106
140
|
onmouseenter={closable ? () => (held = true) : undefined}
|
|
107
141
|
onmouseleave={closable ? () => (held = false) : undefined}
|
|
108
142
|
onfocusin={closable ? () => (held = true) : undefined}
|
|
@@ -127,12 +161,14 @@
|
|
|
127
161
|
<path d={TONE_GLYPH[t]} />
|
|
128
162
|
</svg>
|
|
129
163
|
</span>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
164
|
+
{#if filled || !outcome}
|
|
165
|
+
<div class="flex-1 min-w-0">
|
|
166
|
+
{#if title}<p class="text-sm font-semibold text-fg">{title}</p>{/if}
|
|
167
|
+
{#if children}<div class="text-sm text-muted {title ? 'mt-0.5' : ''}">
|
|
168
|
+
{@render children()}
|
|
169
|
+
</div>{/if}
|
|
170
|
+
</div>
|
|
171
|
+
{/if}
|
|
136
172
|
{#if closable}
|
|
137
173
|
<button
|
|
138
174
|
type="button"
|
|
@@ -14,6 +14,14 @@ interface Props {
|
|
|
14
14
|
* being true after five seconds.
|
|
15
15
|
*/
|
|
16
16
|
autoDismiss?: number | boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Announces the message when it appears, and every change to it after,
|
|
19
|
+
* without clearing it. For a result that stays on screen, such as a
|
|
20
|
+
* confirmation that replaces a form or a status that a poll updates.
|
|
21
|
+
* A message without this or `autoDismiss` states a standing condition
|
|
22
|
+
* and is read out by nobody, so a page load says nothing.
|
|
23
|
+
*/
|
|
24
|
+
announce?: boolean;
|
|
17
25
|
ondismiss?: () => void;
|
|
18
26
|
class?: string;
|
|
19
27
|
children?: Snippet;
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
export interface SidebarState {
|
|
10
10
|
rail: boolean;
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long a pointer has to rest over the rail before it opens. This waits
|
|
15
|
+
* for the reader, not for an animation. A pointer crossing the 56px rail on
|
|
16
|
+
* its way to the page is still moving and never rests there, so the column
|
|
17
|
+
* does not open over the control it is heading for. One that stops on the
|
|
18
|
+
* rail means to read it.
|
|
19
|
+
*/
|
|
20
|
+
export const RAIL_REST_MS = 100;
|
|
12
21
|
</script>
|
|
13
22
|
|
|
14
23
|
<script lang="ts">
|
|
@@ -161,15 +170,31 @@
|
|
|
161
170
|
* The rail: the icon column, and the labels on demand.
|
|
162
171
|
*
|
|
163
172
|
* While the sidebar is the rail it is 56px wide and the nav is told so. A
|
|
164
|
-
* pointer over it, or focus inside it, opens it to the full column
|
|
165
|
-
* page rather than beside it, so the page keeps its width and the
|
|
166
|
-
* still gets every label. It closes when the pointer or the focus
|
|
173
|
+
* pointer at rest over it, or focus inside it, opens it to the full column
|
|
174
|
+
* over the page rather than beside it, so the page keeps its width and the
|
|
175
|
+
* reader still gets every label. It closes when the pointer or the focus
|
|
176
|
+
* leaves. A pointer that only passes over it leaves it the rail: the open
|
|
177
|
+
* column covers the start of the page, and opening under a pointer bound
|
|
178
|
+
* for a control there held the column open over that control.
|
|
167
179
|
* State rather than a hover rule, because the nav snippet has to be told
|
|
168
180
|
* the labels are wanted, and a stylesheet cannot tell it.
|
|
169
181
|
*/
|
|
170
182
|
const railMode = $derived(!isMobile && !railHidden && (rail || isNarrow));
|
|
171
183
|
let railOpen = $state(false);
|
|
172
184
|
const railIcons = $derived(railMode && !railOpen);
|
|
185
|
+
let railRest: ReturnType<typeof setTimeout> | undefined;
|
|
186
|
+
|
|
187
|
+
/** Every move starts the rest again, so only a pointer that stops opens the column. */
|
|
188
|
+
function onRailPointerMove() {
|
|
189
|
+
if (railOpen) return;
|
|
190
|
+
clearTimeout(railRest);
|
|
191
|
+
railRest = setTimeout(() => (railOpen = true), RAIL_REST_MS);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function onRailPointerLeave() {
|
|
195
|
+
clearTimeout(railRest);
|
|
196
|
+
railOpen = false;
|
|
197
|
+
}
|
|
173
198
|
|
|
174
199
|
$effect(() => {
|
|
175
200
|
const mobile = window.matchMedia('(max-width: 767px)');
|
|
@@ -178,12 +203,14 @@
|
|
|
178
203
|
isMobile = mobile.matches;
|
|
179
204
|
isNarrow = narrow.matches && !mobile.matches;
|
|
180
205
|
if (!mobile.matches) navOpen = false;
|
|
206
|
+
clearTimeout(railRest);
|
|
181
207
|
railOpen = false;
|
|
182
208
|
};
|
|
183
209
|
sync();
|
|
184
210
|
mobile.addEventListener('change', sync);
|
|
185
211
|
narrow.addEventListener('change', sync);
|
|
186
212
|
return () => {
|
|
213
|
+
clearTimeout(railRest);
|
|
187
214
|
mobile.removeEventListener('change', sync);
|
|
188
215
|
narrow.removeEventListener('change', sync);
|
|
189
216
|
};
|
|
@@ -269,8 +296,9 @@
|
|
|
269
296
|
data-testid="app-rail"
|
|
270
297
|
role="presentation"
|
|
271
298
|
class="relative hidden {APP_SIDEBAR_RAIL} shrink-0 md:block"
|
|
272
|
-
onpointerenter={
|
|
273
|
-
|
|
299
|
+
onpointerenter={onRailPointerMove}
|
|
300
|
+
onpointermove={onRailPointerMove}
|
|
301
|
+
onpointerleave={onRailPointerLeave}
|
|
274
302
|
onfocusin={() => (railOpen = true)}
|
|
275
303
|
onfocusout={onRailFocusOut}
|
|
276
304
|
>
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
export interface SidebarState {
|
|
9
9
|
rail: boolean;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* How long a pointer has to rest over the rail before it opens. This waits
|
|
13
|
+
* for the reader, not for an animation. A pointer crossing the 56px rail on
|
|
14
|
+
* its way to the page is still moving and never rests there, so the column
|
|
15
|
+
* does not open over the control it is heading for. One that stops on the
|
|
16
|
+
* rail means to read it.
|
|
17
|
+
*/
|
|
18
|
+
export declare const RAIL_REST_MS = 100;
|
|
11
19
|
/**
|
|
12
20
|
* The authed application frame: the sidebar, the header bar and the content
|
|
13
21
|
* column, owned once so three apps cannot each invent their own.
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
* condition, which does not stop being true after five seconds.
|
|
24
24
|
*/
|
|
25
25
|
autoDismiss?: number | boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Announces the message when it appears, and every change to it after,
|
|
28
|
+
* without clearing it. For a result that stays on screen, such as a
|
|
29
|
+
* confirmation that replaces a form or a status that a poll updates.
|
|
30
|
+
* A message without this or `autoDismiss` states a standing condition
|
|
31
|
+
* and is read out by nobody, so a page load says nothing.
|
|
32
|
+
*/
|
|
33
|
+
announce?: boolean;
|
|
26
34
|
ondismiss?: () => void;
|
|
27
35
|
class?: string;
|
|
28
36
|
children: Snippet;
|
|
@@ -33,6 +41,7 @@
|
|
|
33
41
|
tone = 'brand',
|
|
34
42
|
dismissible = false,
|
|
35
43
|
autoDismiss = false,
|
|
44
|
+
announce = false,
|
|
36
45
|
ondismiss = undefined,
|
|
37
46
|
class: cls = '',
|
|
38
47
|
children,
|
|
@@ -73,9 +82,32 @@
|
|
|
73
82
|
* A failure and a warning interrupt, because the reader has to act, as
|
|
74
83
|
* Alert does. Anything else waits for a pause. A banner that clears itself
|
|
75
84
|
* waits too: interrupting to say something and then taking it away leaves
|
|
76
|
-
* the reader with nothing to act on.
|
|
85
|
+
* the reader with nothing to act on. The polite branch takes no role, as
|
|
86
|
+
* Alert's does, because Orca speaks nothing inserted into a status role.
|
|
77
87
|
*/
|
|
78
88
|
const live = $derived((t === 'danger' || t === 'warn') && delay <= 0);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A live region reports a change to what it holds, and an outcome banner
|
|
92
|
+
* arrives inside `{#if form}` already holding its text, so nothing would be
|
|
93
|
+
* read out. A banner that reports an outcome, one that clears itself or
|
|
94
|
+
* takes `announce`, mounts without its text and takes it two frames later,
|
|
95
|
+
* as FormMessage does. It is atomic, as role="status" made it, so a change
|
|
96
|
+
* reads the whole message. One that states a standing condition is no live
|
|
97
|
+
* region at all: it must not be read out because the page loaded, and
|
|
98
|
+
* Chrome reads a polite region that a client-side navigation inserts. A
|
|
99
|
+
* failure or a warning that stays is the alert branch and is never deferred.
|
|
100
|
+
*/
|
|
101
|
+
const outcome = $derived(!live && (delay > 0 || announce));
|
|
102
|
+
let filled = $state(typeof window === 'undefined');
|
|
103
|
+
|
|
104
|
+
$effect(() => {
|
|
105
|
+
if (!outcome) return;
|
|
106
|
+
let frame = requestAnimationFrame(() => {
|
|
107
|
+
frame = requestAnimationFrame(() => (filled = true));
|
|
108
|
+
});
|
|
109
|
+
return () => cancelAnimationFrame(frame);
|
|
110
|
+
});
|
|
79
111
|
</script>
|
|
80
112
|
|
|
81
113
|
{#if visible}
|
|
@@ -85,17 +117,20 @@
|
|
|
85
117
|
rather than vanishing in one frame. -->
|
|
86
118
|
<div
|
|
87
119
|
transition:motion.toast|global
|
|
88
|
-
role={live ? 'alert' :
|
|
89
|
-
aria-live={live ? 'assertive' : 'polite'}
|
|
120
|
+
role={live ? 'alert' : undefined}
|
|
121
|
+
aria-live={live ? 'assertive' : outcome ? 'polite' : undefined}
|
|
122
|
+
aria-atomic={outcome ? 'true' : undefined}
|
|
90
123
|
class="relative flex items-center gap-3 border-b px-4 py-3 text-sm {tones[t]} {cls}"
|
|
91
124
|
onmouseenter={closable ? () => (held = true) : undefined}
|
|
92
125
|
onmouseleave={closable ? () => (held = false) : undefined}
|
|
93
126
|
onfocusin={closable ? () => (held = true) : undefined}
|
|
94
127
|
onfocusout={closable ? () => (held = false) : undefined}
|
|
95
128
|
>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
129
|
+
{#if filled || !outcome}
|
|
130
|
+
<div class="flex flex-1 items-center justify-center gap-3 text-fg">
|
|
131
|
+
{@render children()}
|
|
132
|
+
</div>
|
|
133
|
+
{/if}
|
|
99
134
|
|
|
100
135
|
{#if action}
|
|
101
136
|
<div class="shrink-0">{@render action()}</div>
|
|
@@ -12,6 +12,14 @@ interface Props {
|
|
|
12
12
|
* condition, which does not stop being true after five seconds.
|
|
13
13
|
*/
|
|
14
14
|
autoDismiss?: number | boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Announces the message when it appears, and every change to it after,
|
|
17
|
+
* without clearing it. For a result that stays on screen, such as a
|
|
18
|
+
* confirmation that replaces a form or a status that a poll updates.
|
|
19
|
+
* A message without this or `autoDismiss` states a standing condition
|
|
20
|
+
* and is read out by nobody, so a page load says nothing.
|
|
21
|
+
*/
|
|
22
|
+
announce?: boolean;
|
|
15
23
|
ondismiss?: () => void;
|
|
16
24
|
class?: string;
|
|
17
25
|
children: Snippet;
|
|
@@ -73,7 +73,13 @@
|
|
|
73
73
|
The live region is mounted for the life of the button and empty at rest.
|
|
74
74
|
Rendering it only after a copy announces nothing: assistive technology watches
|
|
75
75
|
an existing region for a change, and a region that arrives already holding its
|
|
76
|
-
text is not a change. Toaster carries the same rule for the same reason.
|
|
76
|
+
text is not a change. Toaster carries the same rule for the same reason. It
|
|
77
|
+
stays a span, so a CopyButton inside a paragraph keeps that paragraph whole.
|
|
78
|
+
It is never role="status", which browsers expose as a status bar and Orca
|
|
79
|
+
never speaks. It is role="generic": Firefox exposes a bare span as static
|
|
80
|
+
text, created only once it holds the message, and Orca drops a sentence
|
|
81
|
+
that comes within a tenth of a second of that arrival unless its region is
|
|
82
|
+
a section, which generic is.
|
|
77
83
|
|
|
78
84
|
The gap exists only while there is a message, so an idle button is the width
|
|
79
85
|
of its icon and a row of them lines up.
|
|
@@ -86,7 +92,7 @@
|
|
|
86
92
|
{/if}
|
|
87
93
|
|
|
88
94
|
<span
|
|
89
|
-
role="
|
|
95
|
+
role="generic"
|
|
90
96
|
aria-live="polite"
|
|
91
97
|
aria-atomic="true"
|
|
92
98
|
class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
|
|
@@ -180,16 +180,16 @@
|
|
|
180
180
|
|
|
181
181
|
<!-- Mounted empty for the life of the field, so a copy is a change a screen
|
|
182
182
|
reader announces rather than a region that arrives already full. The
|
|
183
|
-
check is the sighted confirmation. Only a failure is shown in words.
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
check is the sighted confirmation. Only a failure is shown in words. A
|
|
184
|
+
div with no role, as Toaster's is, so Orca speaks it. -->
|
|
185
|
+
<div
|
|
186
186
|
aria-live="polite"
|
|
187
187
|
aria-atomic="true"
|
|
188
188
|
id={failed ? `${fieldId}-error` : undefined}
|
|
189
189
|
class={failed ? FIELD_ERROR : 'sr-only'}
|
|
190
190
|
>
|
|
191
191
|
{copier.status === 'copied' ? copiedLabel : failed ? COPY_FAILED_MESSAGE : ''}
|
|
192
|
-
</
|
|
192
|
+
</div>
|
|
193
193
|
{#if hint && !failed}
|
|
194
194
|
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
195
195
|
{/if}
|
|
@@ -34,8 +34,10 @@
|
|
|
34
34
|
*
|
|
35
35
|
* A submit outcome has no such permanent host, because the page mounts this
|
|
36
36
|
* component at the moment the submit resolves. So the region goes in empty
|
|
37
|
-
* and the message is placed inside it
|
|
38
|
-
* shape of change a toast makes.
|
|
37
|
+
* and the message is placed inside it two frames later, which is the same
|
|
38
|
+
* shape of change a toast makes. One frame is not enough: Firefox builds its
|
|
39
|
+
* accessibility tree after the frame's callbacks run, so a region filled on
|
|
40
|
+
* the first frame reaches it already full and Orca hears nothing.
|
|
39
41
|
*
|
|
40
42
|
* The server runs no effect and has no frames, so it renders the message
|
|
41
43
|
* straight away: a page delivered without its client bundle still carries
|
|
@@ -44,7 +46,9 @@
|
|
|
44
46
|
let filled = $state(typeof window === 'undefined');
|
|
45
47
|
|
|
46
48
|
$effect(() => {
|
|
47
|
-
|
|
49
|
+
let frame = requestAnimationFrame(() => {
|
|
50
|
+
frame = requestAnimationFrame(() => (filled = true));
|
|
51
|
+
});
|
|
48
52
|
return () => cancelAnimationFrame(frame);
|
|
49
53
|
});
|
|
50
54
|
</script>
|
|
@@ -54,12 +58,14 @@
|
|
|
54
58
|
interrupts whatever the reader is doing, because the submit did not go
|
|
55
59
|
through and the user has to act on it. A confirmation takes aria-live
|
|
56
60
|
"polite" and waits for a pause, because the work is done and nothing is being
|
|
57
|
-
asked of anyone.
|
|
61
|
+
asked of anyone. It takes no role: browsers expose role="status" as a status
|
|
62
|
+
bar, and Orca speaks nothing inserted into one.
|
|
58
63
|
-->
|
|
59
64
|
<div
|
|
60
65
|
class="flex items-start gap-2 text-sm {tones[tone]} {klass}"
|
|
61
|
-
role={tone === 'danger' ? 'alert' :
|
|
66
|
+
role={tone === 'danger' ? 'alert' : undefined}
|
|
62
67
|
aria-live={tone === 'danger' ? 'assertive' : 'polite'}
|
|
68
|
+
aria-atomic={tone === 'danger' ? undefined : 'true'}
|
|
63
69
|
>
|
|
64
70
|
{#if filled}
|
|
65
71
|
<!-- Stroked SVG, not a literal cross or tick: a font glyph lands at
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
xmlns="http://www.w3.org/2000/svg"
|
|
59
59
|
aria-hidden="true"
|
|
60
60
|
>
|
|
61
|
-
<g transform="translate(6
|
|
61
|
+
<g transform="translate(6 3)">
|
|
62
62
|
<polygon class="fill-mark-ink" points="0,14 14,7 14,40 0,47" />
|
|
63
63
|
<polygon class="fill-mark-foot" points="0,47 14,40 14,54 0,61" />
|
|
64
64
|
<polygon class="fill-mark-step" points="14,40 28,33 28,47 14,54" />
|
|
@@ -220,9 +220,9 @@
|
|
|
220
220
|
return label ? `${label} ${range}` : range;
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
/** The target of a control, sanitized, or nothing when the
|
|
224
|
-
function linkTo(target: number
|
|
225
|
-
return href
|
|
223
|
+
/** The target of a control, sanitized, or nothing when the builder's URL is refused. */
|
|
224
|
+
function linkTo(target: number): string | undefined {
|
|
225
|
+
return href ? safeHref(href(target)) : undefined;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
/**
|
|
@@ -250,16 +250,6 @@
|
|
|
250
250
|
const btnRest = 'text-muted hover:text-fg hover:bg-surface-2 active:bg-line active:text-fg';
|
|
251
251
|
|
|
252
252
|
const btnCurrent = 'bg-brand text-ink';
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* A link with nowhere to go.
|
|
256
|
-
*
|
|
257
|
-
* An anchor has no `disabled`, and `disabled:` never matches one, so the two
|
|
258
|
-
* states have to be painted rather than declared. Dropping the href is what
|
|
259
|
-
* takes it out of the tab order and stops activation. The class is only the
|
|
260
|
-
* part a reader can see, at the same 30% the disabled buttons use.
|
|
261
|
-
*/
|
|
262
|
-
const linkInert = 'pointer-events-none opacity-30';
|
|
263
253
|
</script>
|
|
264
254
|
|
|
265
255
|
{#snippet arrow(d: string)}
|
|
@@ -281,15 +271,12 @@
|
|
|
281
271
|
|
|
282
272
|
<!-- Previous and next, as a link when the caller builds hrefs and as a button
|
|
283
273
|
when it hands over a callback. The two branches carry the same classes and
|
|
284
|
-
the same label, so the pager reads and sounds the same either way.
|
|
274
|
+
the same label, so the pager reads and sounds the same either way. A step
|
|
275
|
+
with nowhere to go is a disabled button in both: an anchor without an href
|
|
276
|
+
is a plain element, and the accessibility tree refuses a label on one. -->
|
|
285
277
|
{#snippet step(target: number, label: string, enabled: boolean, d: string)}
|
|
286
|
-
{#if href}
|
|
287
|
-
<a
|
|
288
|
-
href={linkTo(target, enabled)}
|
|
289
|
-
aria-label={label}
|
|
290
|
-
aria-disabled={enabled ? undefined : 'true'}
|
|
291
|
-
class="{btnBase} {btnRest} {enabled ? '' : linkInert}"
|
|
292
|
-
>
|
|
278
|
+
{#if href && enabled}
|
|
279
|
+
<a href={linkTo(target)} aria-label={label} class="{btnBase} {btnRest}">
|
|
293
280
|
{@render arrow(d)}
|
|
294
281
|
</a>
|
|
295
282
|
{:else}
|
|
@@ -342,7 +329,7 @@
|
|
|
342
329
|
</span>
|
|
343
330
|
{:else if href}
|
|
344
331
|
<a
|
|
345
|
-
href={linkTo(n
|
|
332
|
+
href={linkTo(n)}
|
|
346
333
|
aria-current={safePage === n ? 'page' : undefined}
|
|
347
334
|
class="{btnBase} {slot.only ? 'max-sm:coarse:hidden' : ''} {safePage === n
|
|
348
335
|
? btnCurrent
|
|
@@ -60,6 +60,14 @@
|
|
|
60
60
|
* `'once'` and the total reads "$6.00 once" rather than "$6.00/mo".
|
|
61
61
|
*/
|
|
62
62
|
per?: BillingPeriod | 'once';
|
|
63
|
+
/**
|
|
64
|
+
* What the order charges, in cents, when the host prices more than the
|
|
65
|
+
* selection, such as a discount code. It stands in for the quote's total
|
|
66
|
+
* in the bar and the default summary, and is what `summary` and `actions`
|
|
67
|
+
* are handed. It counts only while `quote` describes the selection, so it
|
|
68
|
+
* never outlives the cart it was priced for.
|
|
69
|
+
*/
|
|
70
|
+
charged?: number | null;
|
|
63
71
|
/** True while a fresh price is in flight. */
|
|
64
72
|
quoting?: boolean;
|
|
65
73
|
/** The count the bill will carry, when the host knows better than the cart. */
|
|
@@ -124,6 +132,7 @@
|
|
|
124
132
|
quotePeriod = null,
|
|
125
133
|
period = 'monthly',
|
|
126
134
|
per,
|
|
135
|
+
charged = null,
|
|
127
136
|
quoting = false,
|
|
128
137
|
billedSkus = null,
|
|
129
138
|
currency = 'USD',
|
|
@@ -187,9 +196,8 @@
|
|
|
187
196
|
|
|
188
197
|
const total = $derived(
|
|
189
198
|
quoted
|
|
190
|
-
?
|
|
191
|
-
|
|
192
|
-
: quoted.total_cents
|
|
199
|
+
? (charged ??
|
|
200
|
+
(period === 'annual' ? (quoted.annual_total_cents ?? null) : quoted.total_cents))
|
|
193
201
|
: null,
|
|
194
202
|
);
|
|
195
203
|
|
|
@@ -26,6 +26,14 @@ interface Props {
|
|
|
26
26
|
* `'once'` and the total reads "$6.00 once" rather than "$6.00/mo".
|
|
27
27
|
*/
|
|
28
28
|
per?: BillingPeriod | 'once';
|
|
29
|
+
/**
|
|
30
|
+
* What the order charges, in cents, when the host prices more than the
|
|
31
|
+
* selection, such as a discount code. It stands in for the quote's total
|
|
32
|
+
* in the bar and the default summary, and is what `summary` and `actions`
|
|
33
|
+
* are handed. It counts only while `quote` describes the selection, so it
|
|
34
|
+
* never outlives the cart it was priced for.
|
|
35
|
+
*/
|
|
36
|
+
charged?: number | null;
|
|
29
37
|
/** True while a fresh price is in flight. */
|
|
30
38
|
quoting?: boolean;
|
|
31
39
|
/** The count the bill will carry, when the host knows better than the cart. */
|
|
@@ -140,9 +140,9 @@
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/** Lands a move, or says why it cannot. True when the order changed. */
|
|
143
|
-
function land(from: number, to: number): boolean {
|
|
143
|
+
function land(from: number, to: number, announce = say): boolean {
|
|
144
144
|
const result = attempt(items, from, to, canMove);
|
|
145
|
-
|
|
145
|
+
announce(result.text);
|
|
146
146
|
if (!result.moved) return false;
|
|
147
147
|
const item = items[from];
|
|
148
148
|
items = result.items;
|
|
@@ -157,9 +157,15 @@
|
|
|
157
157
|
*/
|
|
158
158
|
async function move(from: number, to: number) {
|
|
159
159
|
const focused = document.activeElement;
|
|
160
|
-
|
|
160
|
+
let text = '';
|
|
161
|
+
if (!land(from, to, (t) => (text = t))) return say(text);
|
|
161
162
|
await tick();
|
|
162
163
|
if (focused instanceof HTMLElement && document.activeElement !== focused) focused.focus();
|
|
164
|
+
// Firefox reports the restored focus as a new one, and Orca drops a polite
|
|
165
|
+
// sentence still waiting when focus arrives. The browser builds its
|
|
166
|
+
// accessibility tree after a frame's callbacks run, so the second frame is
|
|
167
|
+
// the first that reaches it after the focus.
|
|
168
|
+
requestAnimationFrame(() => requestAnimationFrame(() => say(text)));
|
|
163
169
|
}
|
|
164
170
|
|
|
165
171
|
function typing(target: EventTarget | null): boolean {
|
|
@@ -505,7 +511,10 @@
|
|
|
505
511
|
<p id="{uid}-keys" class="sr-only">
|
|
506
512
|
Press Alt with the up or down arrow to move a row, or Enter for a menu.
|
|
507
513
|
</p>
|
|
508
|
-
|
|
514
|
+
<!-- A div with no role, because Orca speaks nothing inserted into a status
|
|
515
|
+
role, and in Firefox drops a paragraph's text that follows the list's
|
|
516
|
+
own events too closely. -->
|
|
517
|
+
<div aria-live="polite" class="sr-only">
|
|
509
518
|
{#key spoken.n}<span>{spoken.text}</span>{/key}
|
|
510
|
-
</
|
|
519
|
+
</div>
|
|
511
520
|
</div>
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
'[&_tbody_tr:hover>td:last-child]:shadow-[inset_-1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
|
|
78
78
|
'[&_tbody_tr:hover>td:first-child:last-child]:shadow-[inset_1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
|
|
79
79
|
'[&_tbody_tr]:transition-colors',
|
|
80
|
+
'[&_tbody_tr>td]:transition-shadow',
|
|
80
81
|
].join(' ');
|
|
81
82
|
|
|
82
83
|
let {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
*
|
|
30
30
|
* The scrollbar is not drawn. A strip of tabs with a bar under it reads as
|
|
31
31
|
* a second rule under the real one. The fades carry the same message, the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* arrow keys reach every tab, and the active tab is scrolled into view when
|
|
33
|
+
* it changes.
|
|
34
34
|
*/
|
|
35
35
|
let scroller: HTMLElement | undefined = $state();
|
|
36
36
|
let moreBefore = $state(false);
|
|
@@ -57,21 +57,86 @@
|
|
|
57
57
|
/*
|
|
58
58
|
* The tab for the active id is brought into view when the id changes, so a
|
|
59
59
|
* page that opens on its fifth tab does not open on a strip that hides it.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* Only the strip's own offset moves. scrollIntoView moves every scroll box
|
|
61
|
+
* above the tab, the page among them, so a strip below the fold pulled the
|
|
62
|
+
* page down to it as soon as it mounted. A test DOM lays nothing out, so
|
|
63
|
+
* every box is empty and nothing moves.
|
|
62
64
|
*/
|
|
63
|
-
|
|
65
|
+
function reveal(tab: HTMLElement) {
|
|
64
66
|
const el = scroller;
|
|
65
|
-
const id = active;
|
|
66
67
|
if (!el) return;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
const box = el.getBoundingClientRect();
|
|
69
|
+
const at = tab.getBoundingClientRect();
|
|
70
|
+
if (at.left < box.left) el.scrollLeft -= box.left - at.left;
|
|
71
|
+
else if (at.right > box.right) el.scrollLeft += at.right - box.right;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tabAt(index: number): HTMLElement | undefined {
|
|
75
|
+
return scroller?.querySelectorAll<HTMLElement>('[role="tab"]')[index];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const activeIndex = $derived(items.findIndex((t) => t.id === active));
|
|
79
|
+
|
|
80
|
+
$effect(() => {
|
|
81
|
+
const tab = activeIndex === -1 ? undefined : tabAt(activeIndex);
|
|
82
|
+
if (tab) reveal(tab);
|
|
73
83
|
});
|
|
74
84
|
|
|
85
|
+
/*
|
|
86
|
+
* The strip is one stop in the tab order. A strip of five tabs cost five
|
|
87
|
+
* presses of Tab to step over, and the arrows did nothing.
|
|
88
|
+
*
|
|
89
|
+
* Activation is manual: Left, Right, Home and End move focus and the stop
|
|
90
|
+
* with it, and Enter, Space or a click chooses. A caller may navigate to
|
|
91
|
+
* another page when a tab is chosen, and a strip that chose on every arrow
|
|
92
|
+
* would then load a page per key press and take the reader off the strip.
|
|
93
|
+
*
|
|
94
|
+
* While focus is inside the strip the stop follows it, by key or by
|
|
95
|
+
* pointer. When focus leaves, the stop goes back to the selected tab. A
|
|
96
|
+
* selection matching no item would leave every tab at -1 and drop the
|
|
97
|
+
* strip out of the tab order, so the first tab holds it then.
|
|
98
|
+
*/
|
|
99
|
+
let roving: number | null = $state(null);
|
|
100
|
+
const tabStop = $derived(roving ?? (activeIndex === -1 ? 0 : activeIndex));
|
|
101
|
+
|
|
102
|
+
function moveTo(index: number) {
|
|
103
|
+
const tab = tabAt(index);
|
|
104
|
+
if (!tab) return;
|
|
105
|
+
// The strip is in view when a key reaches it, and focus() would scroll
|
|
106
|
+
// every box above the tab, the page among them.
|
|
107
|
+
tab.focus({ preventScroll: true });
|
|
108
|
+
reveal(tab);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onTabKeydown(e: KeyboardEvent, index: number) {
|
|
112
|
+
const last = items.length - 1;
|
|
113
|
+
let next: number;
|
|
114
|
+
switch (e.key) {
|
|
115
|
+
case 'ArrowRight':
|
|
116
|
+
next = index === last ? 0 : index + 1;
|
|
117
|
+
break;
|
|
118
|
+
case 'ArrowLeft':
|
|
119
|
+
next = index === 0 ? last : index - 1;
|
|
120
|
+
break;
|
|
121
|
+
case 'Home':
|
|
122
|
+
next = 0;
|
|
123
|
+
break;
|
|
124
|
+
case 'End':
|
|
125
|
+
next = last;
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// The arrows scroll the strip and the page, and Home and End jump the page.
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
moveTo(next);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function onStripFocusOut(e: FocusEvent) {
|
|
136
|
+
if (scroller && e.relatedTarget instanceof Node && scroller.contains(e.relatedTarget)) return;
|
|
137
|
+
roving = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
75
140
|
/*
|
|
76
141
|
* The focus ring is inset: the tabs sit in a box that clips to scroll, and
|
|
77
142
|
* the global outline sits 2px outside its element, so on the strip's top
|
|
@@ -88,17 +153,21 @@
|
|
|
88
153
|
<div
|
|
89
154
|
bind:this={scroller}
|
|
90
155
|
onscroll={measure}
|
|
156
|
+
onfocusout={onStripFocusOut}
|
|
91
157
|
role="tablist"
|
|
92
158
|
data-testid="tabs-scroll"
|
|
93
159
|
class="flex gap-0.5 overflow-x-auto border-b border-line [scrollbar-width:none]"
|
|
94
160
|
>
|
|
95
|
-
{#each items as tab (tab.id)}
|
|
161
|
+
{#each items as tab, i (tab.id)}
|
|
96
162
|
<button
|
|
97
163
|
type="button"
|
|
98
164
|
role="tab"
|
|
99
165
|
data-tab={tab.id}
|
|
100
166
|
aria-selected={active === tab.id}
|
|
167
|
+
tabindex={i === tabStop ? 0 : -1}
|
|
101
168
|
onclick={() => onchange(tab.id)}
|
|
169
|
+
onfocus={() => (roving = i)}
|
|
170
|
+
onkeydown={(e) => onTabKeydown(e, i)}
|
|
102
171
|
class="{TAB}
|
|
103
172
|
{active === tab.id
|
|
104
173
|
? 'text-fg after:absolute after:bottom-0 after:inset-x-0 after:h-0.5 after:bg-brand after:rounded-t'
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
app and empty most of the time. Marking each toast `role="status"` instead
|
|
18
18
|
announced nothing: assistive technology watches an existing region for
|
|
19
19
|
changes, and a region that arrives already holding its text is not a
|
|
20
|
-
change.
|
|
20
|
+
change. It carries `aria-live` and no `role="status"`: browsers expose a
|
|
21
|
+
status role as a status bar, and Orca speaks nothing inserted into one. -->
|
|
21
22
|
<div
|
|
22
23
|
data-print="hide"
|
|
23
24
|
class="pointer-events-none fixed bottom-4 end-4 z-toast flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
|
24
|
-
role="status"
|
|
25
25
|
aria-live="polite"
|
|
26
26
|
aria-atomic="false"
|
|
27
27
|
>
|
package/dist/index.d.ts
CHANGED
|
@@ -113,4 +113,4 @@ export { cn, type ClassValue } from './utils/cn.js';
|
|
|
113
113
|
export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
|
|
114
114
|
export * as motion from './motion.js';
|
|
115
115
|
export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
|
|
116
|
-
export declare const VERSION = "0.
|
|
116
|
+
export declare const VERSION = "0.45.0";
|
package/dist/index.js
CHANGED
|
@@ -122,4 +122,4 @@ export * as motion from './motion.js';
|
|
|
122
122
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
123
123
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
124
124
|
// this line. The build and the test suite fail when the two disagree.
|
|
125
|
-
export const VERSION = '0.
|
|
125
|
+
export const VERSION = '0.45.0';
|
|
@@ -20,14 +20,15 @@ export interface ScrollMetrics {
|
|
|
20
20
|
}
|
|
21
21
|
export declare function scrollEdges(el: ScrollMetrics): ScrollEdges;
|
|
22
22
|
/**
|
|
23
|
-
* The fade itself: a
|
|
24
|
-
* does not scroll away with the content it describes.
|
|
25
|
-
*
|
|
26
|
-
* a
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* The fade itself: a wash of the foreground color on the edge, outside the
|
|
24
|
+
* scroll box so it does not scroll away with the content it describes. The
|
|
25
|
+
* foreground token inverts with the theme, so the fade darkens a light card
|
|
26
|
+
* and lightens a dark one. A black fade had nothing to darken on a dark card
|
|
27
|
+
* and read as no fade at all. No surface token is the color to fade to,
|
|
28
|
+
* because the box sits on the page in one place and inside a card in
|
|
29
|
+
* another. The direction is logical, so a right-to-left page fades the other
|
|
29
30
|
* way.
|
|
30
31
|
*/
|
|
31
32
|
export declare const SCROLL_EDGE = "pointer-events-none absolute inset-y-px w-6";
|
|
32
|
-
export declare const SCROLL_EDGE_START = "start-0 bg-linear-to-r rtl:bg-linear-to-l from-
|
|
33
|
-
export declare const SCROLL_EDGE_END = "end-0 bg-linear-to-l rtl:bg-linear-to-r from-
|
|
33
|
+
export declare const SCROLL_EDGE_START = "start-0 bg-linear-to-r rtl:bg-linear-to-l from-fg/25 to-transparent";
|
|
34
|
+
export declare const SCROLL_EDGE_END = "end-0 bg-linear-to-l rtl:bg-linear-to-r from-fg/25 to-transparent";
|
|
@@ -20,14 +20,15 @@ export function scrollEdges(el) {
|
|
|
20
20
|
return { before: traveled > 1, after: traveled < slack - 1 };
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
* The fade itself: a
|
|
24
|
-
* does not scroll away with the content it describes.
|
|
25
|
-
*
|
|
26
|
-
* a
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* The fade itself: a wash of the foreground color on the edge, outside the
|
|
24
|
+
* scroll box so it does not scroll away with the content it describes. The
|
|
25
|
+
* foreground token inverts with the theme, so the fade darkens a light card
|
|
26
|
+
* and lightens a dark one. A black fade had nothing to darken on a dark card
|
|
27
|
+
* and read as no fade at all. No surface token is the color to fade to,
|
|
28
|
+
* because the box sits on the page in one place and inside a card in
|
|
29
|
+
* another. The direction is logical, so a right-to-left page fades the other
|
|
29
30
|
* way.
|
|
30
31
|
*/
|
|
31
32
|
export const SCROLL_EDGE = 'pointer-events-none absolute inset-y-px w-6';
|
|
32
|
-
export const SCROLL_EDGE_START = 'start-0 bg-linear-to-r rtl:bg-linear-to-l from-
|
|
33
|
-
export const SCROLL_EDGE_END = 'end-0 bg-linear-to-l rtl:bg-linear-to-r from-
|
|
33
|
+
export const SCROLL_EDGE_START = 'start-0 bg-linear-to-r rtl:bg-linear-to-l from-fg/25 to-transparent';
|
|
34
|
+
export const SCROLL_EDGE_END = 'end-0 bg-linear-to-l rtl:bg-linear-to-r from-fg/25 to-transparent';
|
package/package.json
CHANGED