@lyeve-labs/ui-kit 0.43.0 → 0.44.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/Alert.svelte +44 -8
- package/dist/components/Alert.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/SortableList.svelte +14 -5
- package/dist/components/Tabs.svelte +9 -5
- package/dist/components/Toaster.svelte +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -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" />
|
|
@@ -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>
|
|
@@ -57,8 +57,10 @@
|
|
|
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
|
$effect(() => {
|
|
64
66
|
const el = scroller;
|
|
@@ -67,9 +69,11 @@
|
|
|
67
69
|
const tab = [...el.querySelectorAll<HTMLElement>('[role="tab"]')].find(
|
|
68
70
|
(t) => t.dataset.tab === id,
|
|
69
71
|
);
|
|
70
|
-
if (tab
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
if (!tab) return;
|
|
73
|
+
const box = el.getBoundingClientRect();
|
|
74
|
+
const at = tab.getBoundingClientRect();
|
|
75
|
+
if (at.left < box.left) el.scrollLeft -= box.left - at.left;
|
|
76
|
+
else if (at.right > box.right) el.scrollLeft += at.right - box.right;
|
|
73
77
|
});
|
|
74
78
|
|
|
75
79
|
/*
|
|
@@ -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.44.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.44.0';
|
package/package.json
CHANGED