@lyeve-labs/ui-kit 0.11.2 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/Button.svelte +26 -4
- package/dist/components/Card.svelte +18 -1
- package/dist/components/Checkbox.svelte +7 -3
- package/dist/components/Drawer.svelte +15 -4
- package/dist/components/Modal.svelte +25 -8
- package/dist/components/MultiSelect.svelte +1 -1
- package/dist/components/Pagination.svelte +15 -10
- package/dist/components/Radio.svelte +6 -3
- package/dist/components/Toaster.svelte +9 -2
- package/dist/components/Toggle.svelte +5 -1
- package/dist/components/Toggle.svelte.d.ts +2 -0
- package/dist/components/Tooltip.svelte +48 -12
- package/dist/components/dialog/Dialog.svelte +15 -58
- package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
- package/dist/components/dialog/dialog-manager.svelte.js +21 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/field.js +4 -1
- package/dist/internal/overlay.d.ts +25 -0
- package/dist/internal/overlay.js +92 -0
- package/dist/styles/theme.css +48 -15
- package/package.json +1 -1
- package/src/lib/styles/theme.css +48 -15
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ src/
|
|
|
188
188
|
breaking changes bump the **minor** version; additive changes bump the **patch**.
|
|
189
189
|
Every release is logged in [`CHANGELOG.md`](CHANGELOG.md) and on the docs site.
|
|
190
190
|
|
|
191
|
-
Maintainers:
|
|
191
|
+
Maintainers: the release workflow is [`CONTRIBUTING.md`](CONTRIBUTING.md#releases).
|
|
192
192
|
|
|
193
193
|
## Contributing
|
|
194
194
|
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
aria-controls={panelId}
|
|
36
36
|
class="flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium
|
|
37
37
|
text-fg transition-colors duration-150 hover:bg-surface-2/50
|
|
38
|
-
outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand
|
|
38
|
+
outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand
|
|
39
39
|
{open ? 'bg-surface-2/40' : ''}"
|
|
40
40
|
>
|
|
41
41
|
<span>{title}</span>
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
primary: 'bg-brand text-ink hover:bg-brand-light active:bg-brand shadow-sm shadow-brand/20',
|
|
48
48
|
violet:
|
|
49
49
|
'bg-violet text-ink hover:brightness-110 active:brightness-100 shadow-sm shadow-violet/20',
|
|
50
|
-
secondary: 'bg-surface-2 text-fg border border-line hover:bg-line',
|
|
50
|
+
secondary: 'bg-surface-2 text-fg border border-line-strong hover:bg-line',
|
|
51
51
|
danger: 'bg-danger text-ink hover:brightness-110 active:brightness-100',
|
|
52
52
|
ghost: 'text-muted hover:bg-surface-2 hover:text-fg',
|
|
53
|
-
outline: 'border border-line text-fg hover:border-brand hover:text-brand',
|
|
53
|
+
outline: 'border border-line-strong text-fg hover:border-brand hover:text-brand',
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
const sizes: Record<Size, string> = {
|
|
@@ -68,15 +68,37 @@
|
|
|
68
68
|
|
|
69
69
|
// Reject javascript: and data: URIs - only allow standard schemes and relative URLs.
|
|
70
70
|
let safeHref = $derived(href && !/^(javascript|data):/i.test(href) ? href : undefined);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An anchor has no `disabled`. The prop was accepted and then dropped on this
|
|
74
|
+
* branch, so `<Button href="..." disabled>` rendered a link that looked
|
|
75
|
+
* ordinary and navigated on click, and `disabled:opacity-50` never matched
|
|
76
|
+
* because that pseudo-class does not apply to `a`. Dropping the href is what
|
|
77
|
+
* actually takes it out of the tab order and stops activation.
|
|
78
|
+
*/
|
|
79
|
+
const inert = $derived(disabled || loading);
|
|
71
80
|
</script>
|
|
72
81
|
|
|
73
82
|
{#if href}
|
|
74
|
-
<a
|
|
83
|
+
<a
|
|
84
|
+
href={inert ? undefined : safeHref}
|
|
85
|
+
class="{cls} {inert ? 'pointer-events-none opacity-50' : ''}"
|
|
86
|
+
aria-disabled={inert ? 'true' : undefined}
|
|
87
|
+
tabindex={inert ? -1 : undefined}
|
|
88
|
+
{...rest}
|
|
89
|
+
>
|
|
75
90
|
{#if loading}<Spinner size={spinnerSize} />{/if}
|
|
76
91
|
{@render children()}
|
|
77
92
|
</a>
|
|
78
93
|
{:else}
|
|
79
|
-
<button
|
|
94
|
+
<button
|
|
95
|
+
{type}
|
|
96
|
+
disabled={disabled || loading}
|
|
97
|
+
aria-busy={loading ? 'true' : undefined}
|
|
98
|
+
{onclick}
|
|
99
|
+
class={cls}
|
|
100
|
+
{...rest}
|
|
101
|
+
>
|
|
80
102
|
{#if loading}<Spinner size={spinnerSize} />{/if}
|
|
81
103
|
{@render children()}
|
|
82
104
|
</button>
|
|
@@ -29,6 +29,23 @@
|
|
|
29
29
|
children,
|
|
30
30
|
}: Props = $props();
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A clickable Card advertises itself as a button and used to do nothing when
|
|
34
|
+
* you pressed one. It took `role="button"` and `tabindex="0"` from `onclick`
|
|
35
|
+
* alone, so it entered the tab order and then ignored Enter and Space unless
|
|
36
|
+
* the page happened to pass its own `onkeydown`. Native button semantics are
|
|
37
|
+
* the contract here, so the component honours them itself and still forwards
|
|
38
|
+
* whatever the consumer supplied.
|
|
39
|
+
*/
|
|
40
|
+
function activate(e: KeyboardEvent) {
|
|
41
|
+
onkeydown?.(e);
|
|
42
|
+
if (!onclick || e.defaultPrevented) return;
|
|
43
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
44
|
+
// Space scrolls the page on a non-button element.
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
(e.currentTarget as HTMLElement).click();
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
const pads: Record<Pad, string> = {
|
|
33
50
|
none: '',
|
|
34
51
|
sm: 'p-4',
|
|
@@ -47,7 +64,7 @@
|
|
|
47
64
|
role={onclick ? 'button' : undefined}
|
|
48
65
|
tabindex={onclick ? 0 : undefined}
|
|
49
66
|
{onclick}
|
|
50
|
-
{onkeydown}
|
|
67
|
+
onkeydown={onclick ? activate : onkeydown}
|
|
51
68
|
>
|
|
52
69
|
{#if header || title}
|
|
53
70
|
<div class="px-5 py-4 border-b border-line">
|
|
@@ -47,11 +47,15 @@
|
|
|
47
47
|
onchange={handleChange}
|
|
48
48
|
class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
49
49
|
/>
|
|
50
|
+
<!-- The focus ring is stated once, outside the checked branch. It used to
|
|
51
|
+
live only on the unchecked classes, so ticking a box removed the only
|
|
52
|
+
indicator a keyboard user had; in a permissions matrix of a hundred
|
|
53
|
+
boxes that loses your place entirely. A ring rather than a border
|
|
54
|
+
colour, because when checked the border is already brand. -->
|
|
50
55
|
<span
|
|
51
56
|
class="pointer-events-none w-4 h-4 rounded border transition-colors duration-150 flex items-center justify-center
|
|
52
|
-
|
|
53
|
-
? 'bg-brand border-brand'
|
|
54
|
-
: 'bg-surface-2 border-line peer-focus-visible:border-brand'}"
|
|
57
|
+
peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-brand peer-focus-visible:outline-offset-2
|
|
58
|
+
{checked ? 'bg-brand border-brand' : 'bg-surface-2 border-line-strong'}"
|
|
55
59
|
>
|
|
56
60
|
{#if checked}
|
|
57
61
|
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { overlay } from '../internal/overlay.js';
|
|
3
4
|
|
|
4
5
|
type Side = 'left' | 'right';
|
|
5
6
|
type Size = 'sm' | 'md' | 'lg' | 'xl';
|
|
@@ -33,6 +34,9 @@
|
|
|
33
34
|
xl: 'w-[480px]',
|
|
34
35
|
};
|
|
35
36
|
|
|
37
|
+
const headingId = $props.id();
|
|
38
|
+
const descriptionId = `${headingId}-description`;
|
|
39
|
+
|
|
36
40
|
function close() {
|
|
37
41
|
open = false;
|
|
38
42
|
onclose?.();
|
|
@@ -52,25 +56,32 @@
|
|
|
52
56
|
<div class="fixed inset-0 z-50 flex {side === 'right' ? 'justify-end' : 'justify-start'}">
|
|
53
57
|
<button
|
|
54
58
|
type="button"
|
|
59
|
+
tabindex="-1"
|
|
60
|
+
aria-hidden="true"
|
|
55
61
|
class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
|
|
56
|
-
aria-label="Close"
|
|
57
62
|
onclick={close}
|
|
58
63
|
></button>
|
|
59
64
|
|
|
60
65
|
<div
|
|
61
|
-
|
|
66
|
+
use:overlay
|
|
67
|
+
class="relative flex h-full max-w-full flex-col {widths[size]} bg-surface shadow-2xl
|
|
62
68
|
{side === 'right' ? 'border-l' : 'border-r'} border-line
|
|
63
69
|
{side === 'right'
|
|
64
70
|
? 'animate-[drawer-in-right_150ms_ease-out]'
|
|
65
71
|
: 'animate-[drawer-in-left_150ms_ease-out]'}"
|
|
66
72
|
role="dialog"
|
|
67
73
|
aria-modal="true"
|
|
74
|
+
aria-labelledby={title ? headingId : undefined}
|
|
75
|
+
aria-describedby={description ? descriptionId : undefined}
|
|
76
|
+
aria-label={title ? undefined : 'Panel'}
|
|
68
77
|
>
|
|
69
78
|
{#if title}
|
|
70
79
|
<div class="flex shrink-0 items-start justify-between gap-4 border-b border-line px-5 py-4">
|
|
71
80
|
<div>
|
|
72
|
-
<h2 class="font-semibold text-fg">{title}</h2>
|
|
73
|
-
{#if description}
|
|
81
|
+
<h2 id={headingId} class="font-semibold text-fg">{title}</h2>
|
|
82
|
+
{#if description}
|
|
83
|
+
<p id={descriptionId} class="mt-0.5 text-sm text-muted">{description}</p>
|
|
84
|
+
{/if}
|
|
74
85
|
</div>
|
|
75
86
|
<button
|
|
76
87
|
type="button"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { overlay } from '../internal/overlay.js';
|
|
3
4
|
|
|
4
5
|
type Size = 'sm' | 'md' | 'lg';
|
|
5
6
|
|
|
@@ -29,6 +30,11 @@
|
|
|
29
30
|
lg: 'max-w-2xl',
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
// aria-labelledby needs an id that is unique per instance, because two modals
|
|
34
|
+
// can be mounted at once while one animates out.
|
|
35
|
+
const headingId = $props.id();
|
|
36
|
+
const descriptionId = `${headingId}-description`;
|
|
37
|
+
|
|
32
38
|
function close() {
|
|
33
39
|
open = false;
|
|
34
40
|
onclose?.();
|
|
@@ -48,27 +54,38 @@
|
|
|
48
54
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
49
55
|
<button
|
|
50
56
|
type="button"
|
|
57
|
+
tabindex="-1"
|
|
58
|
+
aria-hidden="true"
|
|
51
59
|
class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
|
|
52
|
-
aria-label="Close"
|
|
53
60
|
onclick={close}
|
|
54
61
|
></button>
|
|
55
62
|
|
|
56
63
|
<div
|
|
57
|
-
|
|
64
|
+
use:overlay
|
|
65
|
+
class="relative flex max-h-[calc(100dvh-2rem)] w-full {widths[size]} flex-col
|
|
66
|
+
overflow-hidden rounded-xl border border-line bg-surface shadow-2xl
|
|
58
67
|
animate-[modal-in_120ms_ease-out]"
|
|
59
68
|
role="dialog"
|
|
60
69
|
aria-modal="true"
|
|
70
|
+
aria-labelledby={title ? headingId : undefined}
|
|
71
|
+
aria-describedby={description ? descriptionId : undefined}
|
|
72
|
+
aria-label={title ? undefined : 'Dialog'}
|
|
61
73
|
>
|
|
62
74
|
{#if title}
|
|
63
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
class="flex shrink-0 items-start justify-between gap-4 px-5 py-4 border-b border-line"
|
|
77
|
+
>
|
|
64
78
|
<div>
|
|
65
|
-
<h2 class="font-semibold text-fg">{title}</h2>
|
|
66
|
-
{#if description}
|
|
79
|
+
<h2 id={headingId} class="font-semibold text-fg">{title}</h2>
|
|
80
|
+
{#if description}
|
|
81
|
+
<p id={descriptionId} class="text-sm text-muted mt-0.5">{description}</p>
|
|
82
|
+
{/if}
|
|
67
83
|
</div>
|
|
68
84
|
<button
|
|
69
85
|
type="button"
|
|
70
86
|
onclick={close}
|
|
71
|
-
class="shrink-0 text-faint transition-colors duration-150 hover:text-fg
|
|
87
|
+
class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg
|
|
88
|
+
outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
72
89
|
aria-label="Close"
|
|
73
90
|
>
|
|
74
91
|
<svg
|
|
@@ -85,13 +102,13 @@
|
|
|
85
102
|
</div>
|
|
86
103
|
{/if}
|
|
87
104
|
|
|
88
|
-
<div class="px-5 py-4">
|
|
105
|
+
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
89
106
|
{@render children()}
|
|
90
107
|
</div>
|
|
91
108
|
|
|
92
109
|
{#if footer}
|
|
93
110
|
<div
|
|
94
|
-
class="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface-2/40"
|
|
111
|
+
class="flex shrink-0 items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface-2/40"
|
|
95
112
|
>
|
|
96
113
|
{@render footer()}
|
|
97
114
|
</div>
|
|
@@ -197,7 +197,7 @@
|
|
|
197
197
|
onclick={() => toggle(opt)}
|
|
198
198
|
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-fg transition-colors duration-150
|
|
199
199
|
hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed
|
|
200
|
-
outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand
|
|
200
|
+
outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
201
201
|
>
|
|
202
202
|
<span
|
|
203
203
|
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors duration-150
|
|
@@ -37,20 +37,25 @@
|
|
|
37
37
|
'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors duration-150';
|
|
38
38
|
</script>
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
<!-- The summary renders whenever there is a count to state, and the page
|
|
41
|
+
buttons only when there is more than one page. The whole component used to
|
|
42
|
+
be behind `totalPages > 1`, and an empty list has one page, so the 'No
|
|
43
|
+
results' line below could never appear and a single page of results showed
|
|
44
|
+
no count at all. -->
|
|
45
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 {cls}">
|
|
46
|
+
<span class="text-xs text-faint shrink-0">
|
|
47
|
+
{safeTotal === 0 ? 'No results' : `${from} to ${to} of ${safeTotal}`}
|
|
48
|
+
</span>
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
{#if totalPages > 1}
|
|
51
|
+
<div class="flex flex-wrap items-center gap-0.5 ml-auto">
|
|
47
52
|
<button
|
|
48
53
|
type="button"
|
|
49
54
|
disabled={safePage <= 1}
|
|
50
55
|
onclick={() => onchange(safePage - 1)}
|
|
51
56
|
aria-label="Previous page"
|
|
52
57
|
class="{btnBase} text-muted hover:text-fg hover:bg-surface-2
|
|
53
|
-
|
|
58
|
+
disabled:opacity-30 disabled:cursor-not-allowed"
|
|
54
59
|
>
|
|
55
60
|
<svg
|
|
56
61
|
width="14"
|
|
@@ -76,7 +81,7 @@
|
|
|
76
81
|
onclick={() => onchange(n as number)}
|
|
77
82
|
aria-current={safePage === n ? 'page' : undefined}
|
|
78
83
|
class="{btnBase}
|
|
79
|
-
|
|
84
|
+
{safePage === n
|
|
80
85
|
? 'bg-brand text-ink'
|
|
81
86
|
: 'text-muted hover:text-fg hover:bg-surface-2'}"
|
|
82
87
|
>
|
|
@@ -108,5 +113,5 @@
|
|
|
108
113
|
</svg>
|
|
109
114
|
</button>
|
|
110
115
|
</div>
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
{/if}
|
|
117
|
+
</div>
|
|
@@ -48,11 +48,14 @@
|
|
|
48
48
|
onchange={handleChange}
|
|
49
49
|
class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
50
50
|
/>
|
|
51
|
+
<!-- The focus ring is stated once, outside the selected branch. It used to
|
|
52
|
+
sit only on the unselected classes, so choosing an option removed the
|
|
53
|
+
only indicator a keyboard user had. A ring rather than a border colour,
|
|
54
|
+
because when selected the border is already brand. -->
|
|
51
55
|
<span
|
|
52
56
|
class="pointer-events-none flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
|
|
53
|
-
|
|
54
|
-
? 'border-brand bg-surface-2'
|
|
55
|
-
: 'border-line bg-surface-2 peer-focus-visible:border-brand'}"
|
|
57
|
+
peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-brand peer-focus-visible:outline-offset-2
|
|
58
|
+
{group === value ? 'border-brand bg-surface-2' : 'border-line-strong bg-surface-2'}"
|
|
56
59
|
>
|
|
57
60
|
{#if group === value}
|
|
58
61
|
<span class="h-2 w-2 rounded-full bg-brand"></span>
|
|
@@ -11,14 +11,21 @@
|
|
|
11
11
|
};
|
|
12
12
|
</script>
|
|
13
13
|
|
|
14
|
+
<!-- The live region is this container, which is mounted for the life of the
|
|
15
|
+
app and empty most of the time. Marking each toast `role="status"` instead
|
|
16
|
+
announced nothing: assistive technology watches an existing region for
|
|
17
|
+
changes, and a region that arrives already holding its text is not a
|
|
18
|
+
change. -->
|
|
14
19
|
<div
|
|
15
20
|
class="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
|
21
|
+
role="status"
|
|
22
|
+
aria-live="polite"
|
|
23
|
+
aria-atomic="false"
|
|
16
24
|
>
|
|
17
25
|
{#each toast.items as t (t.id)}
|
|
18
26
|
<div
|
|
19
27
|
class="pointer-events-auto flex items-start gap-3 overflow-hidden rounded-lg border border-line
|
|
20
28
|
bg-surface pl-0 pr-3 py-3 shadow-xl animate-[toast-in_140ms_ease-out]"
|
|
21
|
-
role="status"
|
|
22
29
|
>
|
|
23
30
|
<span class="w-1 self-stretch shrink-0 {styles[statusTone(t.tone)].bar}"></span>
|
|
24
31
|
<span
|
|
@@ -43,7 +50,7 @@
|
|
|
43
50
|
<p class="flex-1 text-sm text-fg leading-snug">{t.message}</p>
|
|
44
51
|
<button
|
|
45
52
|
type="button"
|
|
46
|
-
class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand
|
|
53
|
+
class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
|
|
47
54
|
aria-label="Dismiss"
|
|
48
55
|
onclick={() => toast.dismiss(t.id)}
|
|
49
56
|
>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
hint,
|
|
7
7
|
disabled = false,
|
|
8
8
|
size = 'md',
|
|
9
|
+
id,
|
|
9
10
|
class: cls = '',
|
|
10
11
|
onchange,
|
|
11
12
|
}: {
|
|
@@ -14,6 +15,8 @@
|
|
|
14
15
|
hint?: string;
|
|
15
16
|
disabled?: boolean;
|
|
16
17
|
size?: 'sm' | 'md';
|
|
18
|
+
/** Lets a `<Label for=...>` outside the component target the switch. */
|
|
19
|
+
id?: string;
|
|
17
20
|
class?: string;
|
|
18
21
|
onchange?: (checked: boolean) => void;
|
|
19
22
|
} = $props();
|
|
@@ -47,13 +50,14 @@
|
|
|
47
50
|
>
|
|
48
51
|
<button
|
|
49
52
|
type="button"
|
|
53
|
+
{id}
|
|
50
54
|
role="switch"
|
|
51
55
|
aria-checked={checked}
|
|
52
56
|
aria-label={label ?? 'Toggle'}
|
|
53
57
|
{disabled}
|
|
54
58
|
onclick={handleClick}
|
|
55
59
|
class="relative shrink-0 rounded-full transition-colors duration-150 outline-none
|
|
56
|
-
focus-visible:ring-2 focus-visible:ring-brand
|
|
60
|
+
focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1
|
|
57
61
|
focus-visible:ring-offset-ink mt-0.5
|
|
58
62
|
{track[size]}
|
|
59
63
|
{checked ? 'bg-brand' : 'bg-surface-2'}"
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
let { text, position = 'top', class: cls = '', children }: Props = $props();
|
|
14
14
|
|
|
15
15
|
let visible = $state(false);
|
|
16
|
+
let wrapper = $state<HTMLElement>();
|
|
17
|
+
|
|
18
|
+
const id = $props.id();
|
|
16
19
|
|
|
17
20
|
const pos: Record<Position, string> = {
|
|
18
21
|
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
|
@@ -20,25 +23,58 @@
|
|
|
20
23
|
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
|
21
24
|
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
|
22
25
|
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `role="tooltip"` on its own is inert: assistive technology reads a tooltip
|
|
29
|
+
* only because the element it describes points at it. The trigger arrives as
|
|
30
|
+
* a snippet, so the component cannot put the attribute on it at compile time
|
|
31
|
+
* and wires it to the first focusable descendant instead. Without this the
|
|
32
|
+
* text was visible to a sighted mouse user and to nobody else.
|
|
33
|
+
*/
|
|
34
|
+
$effect(() => {
|
|
35
|
+
const trigger = wrapper?.querySelector<HTMLElement>(
|
|
36
|
+
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
37
|
+
);
|
|
38
|
+
if (!trigger) return;
|
|
39
|
+
trigger.setAttribute('aria-describedby', id);
|
|
40
|
+
return () => trigger.removeAttribute('aria-describedby');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function onkeydown(e: KeyboardEvent) {
|
|
44
|
+
// SC 1.4.13 requires content shown on hover or focus to be dismissible
|
|
45
|
+
// without moving the pointer or the focus.
|
|
46
|
+
if (e.key === 'Escape' && visible) {
|
|
47
|
+
e.stopPropagation();
|
|
48
|
+
visible = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
23
51
|
</script>
|
|
24
52
|
|
|
53
|
+
<!-- focusin and focusout, not focus and blur. focus and blur do not bubble, so
|
|
54
|
+
bound to this wrapper they never fired for the trigger inside it and the
|
|
55
|
+
tooltip was unreachable by keyboard. -->
|
|
25
56
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
26
57
|
<span
|
|
58
|
+
bind:this={wrapper}
|
|
27
59
|
class="relative inline-flex {cls}"
|
|
28
60
|
onmouseenter={() => (visible = true)}
|
|
29
61
|
onmouseleave={() => (visible = false)}
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
onfocusin={() => (visible = true)}
|
|
63
|
+
onfocusout={() => (visible = false)}
|
|
64
|
+
{onkeydown}
|
|
32
65
|
>
|
|
33
66
|
{@render children()}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
<!-- Always rendered, hidden by the `hidden` attribute rather than removed:
|
|
68
|
+
aria-describedby resolves the text of a hidden element, so the trigger
|
|
69
|
+
keeps a stable target. Left hoverable rather than pointer-events-none, so
|
|
70
|
+
moving onto it does not dismiss it, which SC 1.4.13 also requires. -->
|
|
71
|
+
<span
|
|
72
|
+
{id}
|
|
73
|
+
role="tooltip"
|
|
74
|
+
hidden={!visible}
|
|
75
|
+
class="absolute {pos[position]} z-50 max-w-[min(16rem,calc(100vw-2rem))]
|
|
76
|
+
rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-xl"
|
|
77
|
+
>
|
|
78
|
+
{text}
|
|
79
|
+
</span>
|
|
44
80
|
</span>
|
|
@@ -9,15 +9,14 @@
|
|
|
9
9
|
* Not meant to be used directly - use openDialog() from dialog-manager.
|
|
10
10
|
*/
|
|
11
11
|
import { X } from '@lucide/svelte';
|
|
12
|
-
import { onMount
|
|
12
|
+
import { onMount } from 'svelte';
|
|
13
13
|
import type { Snippet } from 'svelte';
|
|
14
14
|
import type { DialogEntry } from './types.js';
|
|
15
15
|
import { sizeClass } from './types.js';
|
|
16
|
+
import { overlay } from '../../internal/overlay.js';
|
|
16
17
|
import {
|
|
17
18
|
closeDialog,
|
|
18
19
|
dismissDialog,
|
|
19
|
-
_lockBodyScroll,
|
|
20
|
-
_unlockBodyScroll,
|
|
21
20
|
} from './dialog-manager.svelte';
|
|
22
21
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
23
22
|
|
|
@@ -45,45 +44,18 @@
|
|
|
45
44
|
// ──────────────────────────────────────────────────────
|
|
46
45
|
|
|
47
46
|
let dialogEl = $state<HTMLDivElement>();
|
|
48
|
-
let previousFocus = $state<HTMLElement | null>(null);
|
|
49
|
-
let focusableSelector =
|
|
50
|
-
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
51
47
|
|
|
52
48
|
// ──────────────────────────────────────────────────────
|
|
53
49
|
// Lifecycle
|
|
54
50
|
// ──────────────────────────────────────────────────────
|
|
55
51
|
|
|
52
|
+
// Focus entry, the Tab trap, the body scroll lock and focus restore all come
|
|
53
|
+
// from `use:overlay` on the panel below, which is the same implementation
|
|
54
|
+
// Modal and Drawer use. This is only the enter animation.
|
|
56
55
|
onMount(() => {
|
|
57
|
-
// Save previously focused element for restore on unmount
|
|
58
|
-
const el = document.activeElement;
|
|
59
|
-
previousFocus = el instanceof HTMLElement ? el : null;
|
|
60
|
-
|
|
61
|
-
// Trigger enter animation on next frame
|
|
62
56
|
requestAnimationFrame(() => {
|
|
63
57
|
visible = true;
|
|
64
58
|
});
|
|
65
|
-
|
|
66
|
-
// Focus trap: focus first focusable element, or the dialog itself
|
|
67
|
-
tick().then(() => {
|
|
68
|
-
if (dialogEl) {
|
|
69
|
-
const focusable = dialogEl.querySelectorAll(focusableSelector);
|
|
70
|
-
if (focusable.length > 0) {
|
|
71
|
-
(focusable[0] as HTMLElement).focus();
|
|
72
|
-
} else {
|
|
73
|
-
dialogEl.focus();
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// Lock body scroll (counter-based: only unlock when no dialogs remain)
|
|
79
|
-
_lockBodyScroll();
|
|
80
|
-
const unlock = () => _unlockBodyScroll();
|
|
81
|
-
|
|
82
|
-
return () => {
|
|
83
|
-
unlock();
|
|
84
|
-
// Restore focus
|
|
85
|
-
previousFocus?.focus?.();
|
|
86
|
-
};
|
|
87
59
|
});
|
|
88
60
|
|
|
89
61
|
// ──────────────────────────────────────────────────────
|
|
@@ -104,30 +76,6 @@
|
|
|
104
76
|
handleDismiss();
|
|
105
77
|
}
|
|
106
78
|
}
|
|
107
|
-
|
|
108
|
-
// Focus trap
|
|
109
|
-
if (e.key === 'Tab' && dialogEl) {
|
|
110
|
-
const focusable = dialogEl.querySelectorAll(focusableSelector);
|
|
111
|
-
if (focusable.length === 0) {
|
|
112
|
-
e.preventDefault();
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const first = focusable[0] as HTMLElement;
|
|
117
|
-
const last = focusable[focusable.length - 1] as HTMLElement;
|
|
118
|
-
|
|
119
|
-
if (e.shiftKey) {
|
|
120
|
-
if (document.activeElement === first) {
|
|
121
|
-
e.preventDefault();
|
|
122
|
-
last.focus();
|
|
123
|
-
}
|
|
124
|
-
} else {
|
|
125
|
-
if (document.activeElement === last) {
|
|
126
|
-
e.preventDefault();
|
|
127
|
-
first.focus();
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
79
|
}
|
|
132
80
|
|
|
133
81
|
async function handleDismiss(): Promise<void> {
|
|
@@ -145,7 +93,15 @@
|
|
|
145
93
|
</script>
|
|
146
94
|
|
|
147
95
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
148
|
-
|
|
96
|
+
<!-- z-index is an inline style, not a `z-[...]` class. Tailwind scans source text
|
|
97
|
+
for complete class names, so a class built from a runtime value matches no
|
|
98
|
+
candidate and no rule is ever generated: every stacked dialog rendered at
|
|
99
|
+
`z-index: auto` and the stacking order came down to DOM order. -->
|
|
100
|
+
<div
|
|
101
|
+
class="fixed inset-0 flex items-center justify-center"
|
|
102
|
+
style="z-index: {zIndex}"
|
|
103
|
+
role="presentation"
|
|
104
|
+
>
|
|
149
105
|
<!-- Backdrop -->
|
|
150
106
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
151
107
|
<div
|
|
@@ -163,6 +119,7 @@
|
|
|
163
119
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
164
120
|
<div
|
|
165
121
|
bind:this={dialogEl}
|
|
122
|
+
use:overlay
|
|
166
123
|
class="relative w-full {sizeClass(entry.options.size ?? 'md')} mx-4
|
|
167
124
|
bg-surface border border-line rounded-xl shadow-2xl
|
|
168
125
|
transition-all duration-200 ease-out
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* if (ok) { /* ... *\/ }
|
|
16
16
|
*/
|
|
17
17
|
import type { DialogOptions, DialogEntry } from './types.js';
|
|
18
|
-
|
|
19
|
-
export
|
|
18
|
+
import { lockBodyScroll, unlockBodyScroll } from '../../internal/overlay.js';
|
|
19
|
+
export { lockBodyScroll as _lockBodyScroll, unlockBodyScroll as _unlockBodyScroll };
|
|
20
20
|
/** Readonly snapshot for components */
|
|
21
21
|
export declare function getDialogStack(): readonly DialogEntry<any>[];
|
|
22
22
|
/**
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* const ok = await confirm('Delete this item?', 'This action cannot be undone.');
|
|
15
15
|
* if (ok) { /* ... *\/ }
|
|
16
16
|
*/
|
|
17
|
+
import { lockBodyScroll, unlockBodyScroll } from '../../internal/overlay.js';
|
|
17
18
|
// ──────────────────────────────────────────────────────────
|
|
18
19
|
// Module-level reactive state
|
|
19
20
|
// ──────────────────────────────────────────────────────────
|
|
@@ -23,22 +24,13 @@ let stack = $state([]);
|
|
|
23
24
|
// ──────────────────────────────────────────────────────────
|
|
24
25
|
// Body scroll lock (counter-based - handles stacked dialogs)
|
|
25
26
|
// ──────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
export function _unlockBodyScroll() {
|
|
34
|
-
bodyLockCount--;
|
|
35
|
-
if (bodyLockCount <= 0) {
|
|
36
|
-
bodyLockCount = 0;
|
|
37
|
-
if (typeof document !== 'undefined') {
|
|
38
|
-
document.body.style.overflow = '';
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
27
|
+
/*
|
|
28
|
+
* The count lives in internal/overlay.ts, which is also what Modal and Drawer
|
|
29
|
+
* lock through. Two counters meant a Dialog opened over a Drawer restored
|
|
30
|
+
* scrolling to the page as soon as either one closed, because each believed it
|
|
31
|
+
* held the only lock.
|
|
32
|
+
*/
|
|
33
|
+
export { lockBodyScroll as _lockBodyScroll, unlockBodyScroll as _unlockBodyScroll };
|
|
42
34
|
/** Readonly snapshot for components */
|
|
43
35
|
export function getDialogStack() {
|
|
44
36
|
return stack;
|
|
@@ -132,10 +124,9 @@ export function dismissDialog(id) {
|
|
|
132
124
|
export function dismissAllDialogs() {
|
|
133
125
|
const copy = [...stack];
|
|
134
126
|
stack = [];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
127
|
+
// The manager never locks. Each overlay takes one lock on mount and releases
|
|
128
|
+
// it on unmount, so clearing the stack unmounts the dialogs and the count
|
|
129
|
+
// unwinds itself. Zeroing it here released locks a Modal or Drawer still held.
|
|
139
130
|
for (const entry of copy) {
|
|
140
131
|
entry.reject(new DOMException('All dialogs dismissed', 'AbortError'));
|
|
141
132
|
}
|
|
@@ -150,7 +141,16 @@ export function confirm(title, message) {
|
|
|
150
141
|
const id = `confirm-${++idCounter}`;
|
|
151
142
|
const promise = openDialog({ id, size: 'sm', title });
|
|
152
143
|
setDialogMeta(id, { confirmTitle: title, confirmMessage: message ?? '' });
|
|
153
|
-
|
|
144
|
+
// Cancelling a dialog dismisses it, and dismissal rejects. Callers write
|
|
145
|
+
// `if (await confirm(...))`, so a rejection on Cancel is an unhandled
|
|
146
|
+
// rejection on the ordinary path rather than an error anyone meant to
|
|
147
|
+
// handle. Cancel is an answer, not a failure: it resolves false, which is
|
|
148
|
+
// what this function has always documented.
|
|
149
|
+
return promise.catch((err) => {
|
|
150
|
+
if (err instanceof DOMException && err.name === 'AbortError')
|
|
151
|
+
return false;
|
|
152
|
+
throw err;
|
|
153
|
+
});
|
|
154
154
|
}
|
|
155
155
|
/**
|
|
156
156
|
* Set metadata on a dialog entry after it's opened.
|
package/dist/index.d.ts
CHANGED
|
@@ -60,4 +60,4 @@ export { openDialog, closeDialog, dismissDialog, dismissAllDialogs, confirm, set
|
|
|
60
60
|
export type { DialogOptions, DialogEntry, DialogSize } from './components/dialog/types.js';
|
|
61
61
|
export { cn, type ClassValue } from './utils/cn.js';
|
|
62
62
|
export { getTheme, setTheme, toggleTheme, themeBootScript, type Theme } from './utils/theme.js';
|
|
63
|
-
export declare const VERSION = "0.
|
|
63
|
+
export declare const VERSION = "0.12.1";
|
package/dist/index.js
CHANGED
|
@@ -71,4 +71,4 @@ export { getTheme, setTheme, toggleTheme, themeBootScript } from './utils/theme.
|
|
|
71
71
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
72
72
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
73
73
|
// this line; the build and the test suite fail when the two disagree.
|
|
74
|
-
export const VERSION = '0.
|
|
74
|
+
export const VERSION = '0.12.1';
|
package/dist/internal/field.js
CHANGED
|
@@ -41,7 +41,10 @@ export const CONTROL_MULTILINE = 'w-full min-h-control rounded-lg bg-surface-2 b
|
|
|
41
41
|
* `border-danger/70`.
|
|
42
42
|
*/
|
|
43
43
|
export function controlBorder(error) {
|
|
44
|
-
|
|
44
|
+
// line-strong, not line: at 1.25:1 against the page the resting border was
|
|
45
|
+
// the only thing marking the control and it failed SC 1.4.11. line stays the
|
|
46
|
+
// divider colour, where there is no control to identify.
|
|
47
|
+
return error ? 'border-danger focus:border-danger' : 'border-line-strong focus:border-brand';
|
|
45
48
|
}
|
|
46
49
|
/**
|
|
47
50
|
* Wires a control to whichever of its hint or error is on screen.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour every modal surface owes a keyboard and screen reader user.
|
|
3
|
+
*
|
|
4
|
+
* Dialog carried a correct implementation and Modal and Drawer carried none:
|
|
5
|
+
* both declared `aria-modal="true"` while leaving focus behind them in the
|
|
6
|
+
* page, so a screen reader user was told a modal had opened and then went on
|
|
7
|
+
* reading the document underneath it, and a keyboard user tabbed straight out
|
|
8
|
+
* of the panel with no way back. The behaviour lives here now so a fourth
|
|
9
|
+
* overlay cannot ship without it.
|
|
10
|
+
*
|
|
11
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
12
|
+
*/
|
|
13
|
+
export declare function lockBodyScroll(): void;
|
|
14
|
+
export declare function unlockBodyScroll(): void;
|
|
15
|
+
/**
|
|
16
|
+
* Svelte action for the panel element of a modal overlay.
|
|
17
|
+
*
|
|
18
|
+
* <div use:overlay role="dialog" aria-modal="true">
|
|
19
|
+
*
|
|
20
|
+
* Moves focus in on mount, keeps Tab inside the panel, locks the page behind
|
|
21
|
+
* it, and returns focus to whatever opened it on unmount.
|
|
22
|
+
*/
|
|
23
|
+
export declare function overlay(node: HTMLElement): {
|
|
24
|
+
destroy(): void;
|
|
25
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour every modal surface owes a keyboard and screen reader user.
|
|
3
|
+
*
|
|
4
|
+
* Dialog carried a correct implementation and Modal and Drawer carried none:
|
|
5
|
+
* both declared `aria-modal="true"` while leaving focus behind them in the
|
|
6
|
+
* page, so a screen reader user was told a modal had opened and then went on
|
|
7
|
+
* reading the document underneath it, and a keyboard user tabbed straight out
|
|
8
|
+
* of the panel with no way back. The behaviour lives here now so a fourth
|
|
9
|
+
* overlay cannot ship without it.
|
|
10
|
+
*
|
|
11
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Elements that can hold focus. `[tabindex="-1"]` is excluded because it is
|
|
15
|
+
* programmatically focusable but not part of the tab sequence, which is what
|
|
16
|
+
* the trap is wrapping.
|
|
17
|
+
*/
|
|
18
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
19
|
+
/**
|
|
20
|
+
* Counted rather than boolean: a dialog opened from inside a drawer must not
|
|
21
|
+
* restore scrolling when only the inner one closes.
|
|
22
|
+
*/
|
|
23
|
+
let bodyLockCount = 0;
|
|
24
|
+
export function lockBodyScroll() {
|
|
25
|
+
bodyLockCount++;
|
|
26
|
+
if (typeof document !== 'undefined') {
|
|
27
|
+
document.body.style.overflow = 'hidden';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function unlockBodyScroll() {
|
|
31
|
+
bodyLockCount--;
|
|
32
|
+
if (bodyLockCount <= 0) {
|
|
33
|
+
bodyLockCount = 0;
|
|
34
|
+
if (typeof document !== 'undefined') {
|
|
35
|
+
document.body.style.overflow = '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function focusable(node) {
|
|
40
|
+
return [...node.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Svelte action for the panel element of a modal overlay.
|
|
44
|
+
*
|
|
45
|
+
* <div use:overlay role="dialog" aria-modal="true">
|
|
46
|
+
*
|
|
47
|
+
* Moves focus in on mount, keeps Tab inside the panel, locks the page behind
|
|
48
|
+
* it, and returns focus to whatever opened it on unmount.
|
|
49
|
+
*/
|
|
50
|
+
export function overlay(node) {
|
|
51
|
+
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
52
|
+
lockBodyScroll();
|
|
53
|
+
// A panel with nothing focusable still has to receive focus, or the screen
|
|
54
|
+
// reader stays on the element behind the overlay and reads the wrong thing.
|
|
55
|
+
const first = focusable(node)[0];
|
|
56
|
+
if (first) {
|
|
57
|
+
first.focus();
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
node.tabIndex = -1;
|
|
61
|
+
node.focus();
|
|
62
|
+
}
|
|
63
|
+
function onkeydown(e) {
|
|
64
|
+
if (e.key !== 'Tab')
|
|
65
|
+
return;
|
|
66
|
+
const items = focusable(node);
|
|
67
|
+
if (items.length === 0) {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const head = items[0];
|
|
72
|
+
const tail = items[items.length - 1];
|
|
73
|
+
if (e.shiftKey && document.activeElement === head) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
tail.focus();
|
|
76
|
+
}
|
|
77
|
+
else if (!e.shiftKey && document.activeElement === tail) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
head.focus();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
node.addEventListener('keydown', onkeydown);
|
|
83
|
+
return {
|
|
84
|
+
destroy() {
|
|
85
|
+
node.removeEventListener('keydown', onkeydown);
|
|
86
|
+
unlockBodyScroll();
|
|
87
|
+
// The opener can be gone by now, for instance a row action whose row the
|
|
88
|
+
// dialog just deleted, so this is deliberately best effort.
|
|
89
|
+
previous?.focus?.();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/styles/theme.css
CHANGED
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
--color-surface: #101a2b;
|
|
37
37
|
--color-surface-2: #162233;
|
|
38
38
|
--color-line: #1e2d40;
|
|
39
|
+
/* line reads 1.25:1 against surface. That is fine for a divider, which
|
|
40
|
+
* carries no information, and fails SC 1.4.11 for anything whose boundary
|
|
41
|
+
* is the only thing identifying it as a control. Inputs, selects,
|
|
42
|
+
* checkboxes, radios and toggles use line-strong, which clears 3:1 on
|
|
43
|
+
* every surface in the palette. */
|
|
44
|
+
--color-line-strong: #616d80;
|
|
39
45
|
--color-fg: #e6e8ee;
|
|
40
46
|
--color-muted: #9aa1ad;
|
|
41
47
|
/* #525866 failed WCAG AA against surface/ink (~2.5:1); every caption and
|
|
@@ -46,13 +52,24 @@
|
|
|
46
52
|
--color-brand: #00d4ff;
|
|
47
53
|
--color-brand-light: #5fd9f4;
|
|
48
54
|
--color-brand-deep: #0099c4;
|
|
49
|
-
|
|
55
|
+
/* #7c6aef reads 4.29:1 on surface and 3.83:1 inside its own bg-violet/10
|
|
56
|
+
* badge, both under the 4.5:1 floor. #9587f2 clears every surface the kit
|
|
57
|
+
* paints it on and the badge as well. */
|
|
58
|
+
--color-violet: #9587f2;
|
|
50
59
|
|
|
51
60
|
/* #15803d and #b45309 sit at ~3.7:1 on ink, below the 4.5:1 AA floor
|
|
52
61
|
* for the text-size status labels that use them. */
|
|
53
62
|
--color-success: #22c55e;
|
|
54
63
|
--color-warn: #f59e0b;
|
|
55
|
-
|
|
64
|
+
/* #b91c1c reads 2.85:1 on ink and 2.61:1 inside bg-danger/10, so every
|
|
65
|
+
* form error message and every validation hint failed AA. A dark red
|
|
66
|
+
* cannot work as a foreground on a dark surface.
|
|
67
|
+
*
|
|
68
|
+
* One token serves both roles because it inverts with `ink`. As text on a
|
|
69
|
+
* dark surface #f87171 reads 6.30:1; as the fill behind a `text-ink` label
|
|
70
|
+
* on a filled destructive button it reads 6.67:1. The light palette does
|
|
71
|
+
* the same in reverse with a dark red and a near-white ink. */
|
|
72
|
+
--color-danger: #f87171;
|
|
56
73
|
|
|
57
74
|
/* ── Radius ─────────────────────────────────────────────────── */
|
|
58
75
|
--radius-xs: 4px;
|
|
@@ -125,20 +142,30 @@ html[data-theme='light'] {
|
|
|
125
142
|
--color-surface: #ffffff;
|
|
126
143
|
--color-surface-2: #e6e8ee;
|
|
127
144
|
--color-line: #d6d9e0;
|
|
145
|
+
--color-line-strong: #767d8e;
|
|
128
146
|
--color-fg: #111827;
|
|
129
147
|
--color-muted: #525866;
|
|
130
|
-
/*
|
|
131
|
-
*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
--color-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
/* Every value below is the lightest colour on its own hue that clears
|
|
149
|
+
* 4.5:1 against the worst background the kit actually paints it on. That
|
|
150
|
+
* worst case is rarely the bare canvas: status text and brand chips sit
|
|
151
|
+
* inside a tint of their own token (bg-warn/10 text-warn), and the tint
|
|
152
|
+
* drags the background toward the text. Values chosen against white alone
|
|
153
|
+
* fail inside the tint and on surface-2. */
|
|
154
|
+
--color-faint: #616774;
|
|
155
|
+
|
|
156
|
+
--color-brand: #005a77;
|
|
157
|
+
/* brand-light is the hover step for brand links and for the filled brand
|
|
158
|
+
* button. In a light theme emphasis reads as deeper, not lighter, so it
|
|
159
|
+
* sits below brand here and brand-deep below that. The names describe
|
|
160
|
+
* their place in the default dark ramp. #00b4dd put near-white text at
|
|
161
|
+
* 2.45:1 on the primary button's hover state. */
|
|
162
|
+
--color-brand-light: #004f69;
|
|
163
|
+
--color-brand-deep: #00455c;
|
|
164
|
+
--color-violet: #5544da;
|
|
165
|
+
|
|
166
|
+
--color-success: #126c34;
|
|
167
|
+
--color-warn: #904207;
|
|
168
|
+
--color-danger: #ab1a1a;
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
@layer base {
|
|
@@ -163,8 +190,14 @@ html[data-theme='light'] {
|
|
|
163
190
|
background-color: color-mix(in srgb, var(--color-brand) 30%, transparent);
|
|
164
191
|
}
|
|
165
192
|
|
|
193
|
+
/*
|
|
194
|
+
* SC 1.4.11 puts a 3:1 floor on the focus indicator. Mixing brand down to
|
|
195
|
+
* 60% alpha left it at 2.39:1 on the light palette, so the one affordance a
|
|
196
|
+
* keyboard user has for locating themselves was the element that failed.
|
|
197
|
+
* Solid brand reads 9.85:1 on dark and 7.70:1 on light.
|
|
198
|
+
*/
|
|
166
199
|
:focus-visible {
|
|
167
|
-
outline: 2px solid
|
|
200
|
+
outline: 2px solid var(--color-brand);
|
|
168
201
|
outline-offset: 2px;
|
|
169
202
|
}
|
|
170
203
|
|
package/package.json
CHANGED
package/src/lib/styles/theme.css
CHANGED
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
--color-surface: #101a2b;
|
|
37
37
|
--color-surface-2: #162233;
|
|
38
38
|
--color-line: #1e2d40;
|
|
39
|
+
/* line reads 1.25:1 against surface. That is fine for a divider, which
|
|
40
|
+
* carries no information, and fails SC 1.4.11 for anything whose boundary
|
|
41
|
+
* is the only thing identifying it as a control. Inputs, selects,
|
|
42
|
+
* checkboxes, radios and toggles use line-strong, which clears 3:1 on
|
|
43
|
+
* every surface in the palette. */
|
|
44
|
+
--color-line-strong: #616d80;
|
|
39
45
|
--color-fg: #e6e8ee;
|
|
40
46
|
--color-muted: #9aa1ad;
|
|
41
47
|
/* #525866 failed WCAG AA against surface/ink (~2.5:1); every caption and
|
|
@@ -46,13 +52,24 @@
|
|
|
46
52
|
--color-brand: #00d4ff;
|
|
47
53
|
--color-brand-light: #5fd9f4;
|
|
48
54
|
--color-brand-deep: #0099c4;
|
|
49
|
-
|
|
55
|
+
/* #7c6aef reads 4.29:1 on surface and 3.83:1 inside its own bg-violet/10
|
|
56
|
+
* badge, both under the 4.5:1 floor. #9587f2 clears every surface the kit
|
|
57
|
+
* paints it on and the badge as well. */
|
|
58
|
+
--color-violet: #9587f2;
|
|
50
59
|
|
|
51
60
|
/* #15803d and #b45309 sit at ~3.7:1 on ink, below the 4.5:1 AA floor
|
|
52
61
|
* for the text-size status labels that use them. */
|
|
53
62
|
--color-success: #22c55e;
|
|
54
63
|
--color-warn: #f59e0b;
|
|
55
|
-
|
|
64
|
+
/* #b91c1c reads 2.85:1 on ink and 2.61:1 inside bg-danger/10, so every
|
|
65
|
+
* form error message and every validation hint failed AA. A dark red
|
|
66
|
+
* cannot work as a foreground on a dark surface.
|
|
67
|
+
*
|
|
68
|
+
* One token serves both roles because it inverts with `ink`. As text on a
|
|
69
|
+
* dark surface #f87171 reads 6.30:1; as the fill behind a `text-ink` label
|
|
70
|
+
* on a filled destructive button it reads 6.67:1. The light palette does
|
|
71
|
+
* the same in reverse with a dark red and a near-white ink. */
|
|
72
|
+
--color-danger: #f87171;
|
|
56
73
|
|
|
57
74
|
/* ── Radius ─────────────────────────────────────────────────── */
|
|
58
75
|
--radius-xs: 4px;
|
|
@@ -125,20 +142,30 @@ html[data-theme='light'] {
|
|
|
125
142
|
--color-surface: #ffffff;
|
|
126
143
|
--color-surface-2: #e6e8ee;
|
|
127
144
|
--color-line: #d6d9e0;
|
|
145
|
+
--color-line-strong: #767d8e;
|
|
128
146
|
--color-fg: #111827;
|
|
129
147
|
--color-muted: #525866;
|
|
130
|
-
/*
|
|
131
|
-
*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
--color-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
/* Every value below is the lightest colour on its own hue that clears
|
|
149
|
+
* 4.5:1 against the worst background the kit actually paints it on. That
|
|
150
|
+
* worst case is rarely the bare canvas: status text and brand chips sit
|
|
151
|
+
* inside a tint of their own token (bg-warn/10 text-warn), and the tint
|
|
152
|
+
* drags the background toward the text. Values chosen against white alone
|
|
153
|
+
* fail inside the tint and on surface-2. */
|
|
154
|
+
--color-faint: #616774;
|
|
155
|
+
|
|
156
|
+
--color-brand: #005a77;
|
|
157
|
+
/* brand-light is the hover step for brand links and for the filled brand
|
|
158
|
+
* button. In a light theme emphasis reads as deeper, not lighter, so it
|
|
159
|
+
* sits below brand here and brand-deep below that. The names describe
|
|
160
|
+
* their place in the default dark ramp. #00b4dd put near-white text at
|
|
161
|
+
* 2.45:1 on the primary button's hover state. */
|
|
162
|
+
--color-brand-light: #004f69;
|
|
163
|
+
--color-brand-deep: #00455c;
|
|
164
|
+
--color-violet: #5544da;
|
|
165
|
+
|
|
166
|
+
--color-success: #126c34;
|
|
167
|
+
--color-warn: #904207;
|
|
168
|
+
--color-danger: #ab1a1a;
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
@layer base {
|
|
@@ -163,8 +190,14 @@ html[data-theme='light'] {
|
|
|
163
190
|
background-color: color-mix(in srgb, var(--color-brand) 30%, transparent);
|
|
164
191
|
}
|
|
165
192
|
|
|
193
|
+
/*
|
|
194
|
+
* SC 1.4.11 puts a 3:1 floor on the focus indicator. Mixing brand down to
|
|
195
|
+
* 60% alpha left it at 2.39:1 on the light palette, so the one affordance a
|
|
196
|
+
* keyboard user has for locating themselves was the element that failed.
|
|
197
|
+
* Solid brand reads 9.85:1 on dark and 7.70:1 on light.
|
|
198
|
+
*/
|
|
166
199
|
:focus-visible {
|
|
167
|
-
outline: 2px solid
|
|
200
|
+
outline: 2px solid var(--color-brand);
|
|
168
201
|
outline-offset: 2px;
|
|
169
202
|
}
|
|
170
203
|
|