@sveltier/ui 0.1.0-alpha.0 → 0.1.0-alpha.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/dist/components/Accordion/Accordion.svelte +74 -0
- package/dist/components/Accordion/Accordion.svelte.d.ts +25 -0
- package/dist/components/Accordion/accordion.css +88 -0
- package/dist/components/Accordion/index.d.ts +2 -0
- package/dist/components/Accordion/index.js +1 -0
- package/dist/components/Accordion/types.d.ts +64 -0
- package/dist/components/Accordion/types.js +1 -0
- package/dist/components/Dialog/Dialog.svelte +36 -0
- package/dist/components/Dialog/Dialog.svelte.d.ts +4 -0
- package/dist/components/Dialog/index.d.ts +2 -0
- package/dist/components/Dialog/index.js +1 -0
- package/dist/components/Dialog/types.d.ts +50 -0
- package/dist/components/Dialog/types.js +1 -0
- package/dist/components/Menu/Menu.svelte +195 -0
- package/dist/components/Menu/Menu.svelte.d.ts +4 -0
- package/dist/components/Menu/index.d.ts +2 -0
- package/dist/components/Menu/index.js +1 -0
- package/dist/components/Menu/menu.css +103 -0
- package/dist/components/Menu/types.d.ts +90 -0
- package/dist/components/Menu/types.js +1 -0
- package/dist/components/Modal/Modal.svelte +146 -0
- package/dist/components/Modal/Modal.svelte.d.ts +4 -0
- package/dist/components/Modal/modal.css +150 -0
- package/dist/components/Modal/types.d.ts +54 -0
- package/dist/components/Modal/types.js +1 -0
- package/dist/components/Popover/Popover.svelte +115 -0
- package/dist/components/Popover/Popover.svelte.d.ts +4 -0
- package/dist/components/Popover/index.d.ts +2 -0
- package/dist/components/Popover/index.js +1 -0
- package/dist/components/Popover/popover.css +67 -0
- package/dist/components/Popover/types.d.ts +83 -0
- package/dist/components/Popover/types.js +1 -0
- package/dist/components/Sheet/Sheet.svelte +56 -0
- package/dist/components/Sheet/Sheet.svelte.d.ts +4 -0
- package/dist/components/Sheet/index.d.ts +2 -0
- package/dist/components/Sheet/index.js +2 -0
- package/dist/components/Sheet/sheet.css +61 -0
- package/dist/components/Sheet/types.d.ts +51 -0
- package/dist/components/Sheet/types.js +1 -0
- package/dist/components/Tooltip/Tooltip.svelte +161 -0
- package/dist/components/Tooltip/Tooltip.svelte.d.ts +4 -0
- package/dist/components/Tooltip/index.d.ts +2 -0
- package/dist/components/Tooltip/index.js +1 -0
- package/dist/components/Tooltip/tooltip.css +44 -0
- package/dist/components/Tooltip/types.d.ts +55 -0
- package/dist/components/Tooltip/types.js +1 -0
- package/dist/core/errors/codes.d.ts +15 -3
- package/dist/core/errors/codes.js +15 -3
- package/dist/core/locale/locale.d.ts +1 -0
- package/dist/core/locale/locale.js +2 -0
- package/dist/core/overlay/types.d.ts +33 -0
- package/dist/core/overlay/types.js +1 -0
- package/dist/core/provider/UIProvider.svelte +5 -2
- package/dist/core/theme/css.js +6 -2
- package/dist/core/toast/ToastOutlet.svelte +192 -0
- package/dist/core/toast/ToastOutlet.svelte.d.ts +11 -0
- package/dist/core/toast/ToastProvider.svelte +39 -0
- package/dist/core/toast/ToastProvider.svelte.d.ts +19 -0
- package/dist/core/toast/toast.css +134 -0
- package/dist/core/toast/toast.svelte.d.ts +117 -0
- package/dist/core/toast/toast.svelte.js +133 -0
- package/dist/dev/off.d.ts +1 -0
- package/dist/dev/off.js +1 -0
- package/dist/dev/on.d.ts +14 -2
- package/dist/dev/on.js +22 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +11 -0
- package/dist/primitives/layer/dismissable.d.ts +6 -0
- package/dist/primitives/layer/dismissable.js +11 -4
- package/dist/styles/props.css +5 -2
- package/package.json +1 -1
|
@@ -29,12 +29,13 @@
|
|
|
29
29
|
</script>
|
|
30
30
|
|
|
31
31
|
<script lang="ts">
|
|
32
|
-
// The root of an app: theme, defaults, display and
|
|
32
|
+
// The root of an app: theme, defaults, display, locale and toasts in one component.
|
|
33
33
|
import { untrack } from 'svelte';
|
|
34
34
|
import DefaultsProvider from '../defaults/DefaultsProvider.svelte';
|
|
35
35
|
import { createDisplay, setDisplayContext } from '../display/display.js';
|
|
36
36
|
import { createLocale, setLocaleContext } from '../locale/locale.js';
|
|
37
37
|
import ThemeProvider from '../theme/ThemeProvider.svelte';
|
|
38
|
+
import ToastProvider from '../toast/ToastProvider.svelte';
|
|
38
39
|
|
|
39
40
|
let {
|
|
40
41
|
themes,
|
|
@@ -55,6 +56,8 @@
|
|
|
55
56
|
|
|
56
57
|
<ThemeProvider {themes} bind:theme {light} {dark} {storageKey} {nonce}>
|
|
57
58
|
<DefaultsProvider defaults={defaults ?? {}}>
|
|
58
|
-
|
|
59
|
+
<ToastProvider>
|
|
60
|
+
{@render children?.()}
|
|
61
|
+
</ToastProvider>
|
|
59
62
|
</DefaultsProvider>
|
|
60
63
|
</ThemeProvider>
|
package/dist/core/theme/css.js
CHANGED
|
@@ -8,10 +8,14 @@ function check(ok, code, message) {
|
|
|
8
8
|
if (!ok)
|
|
9
9
|
throw sveltierError(code, message);
|
|
10
10
|
}
|
|
11
|
-
/** `{color-scheme:…;--sv-color-x:…;--sv-on-x:…;--sv-var:…}` for one theme. */
|
|
11
|
+
/** `{color-scheme:…;--sv-color-scrim:…;--sv-color-x:…;--sv-on-x:…;--sv-var:…}` for one theme. */
|
|
12
12
|
function block(theme, name, registered) {
|
|
13
13
|
check(theme, 'SV1002', `Unknown theme "${name}". Registered: ${registered}.`);
|
|
14
|
-
|
|
14
|
+
// The veil behind modal layers: the theme's ink over a light theme, its background over a
|
|
15
|
+
// dark one. Written here, where the mode is known: bundlers rewrite CSS light-dark() into
|
|
16
|
+
// variables that only their own color-scheme rules set, and these themes are set at runtime.
|
|
17
|
+
const scrim = theme.dark ? 'var(--sv-color-background) 61.8%' : 'var(--sv-color-text) 38.2%';
|
|
18
|
+
let out = `color-scheme:${theme.dark ? 'dark' : 'light'};--sv-color-scrim:color-mix(in oklch,${scrim},transparent)`;
|
|
15
19
|
const entries = [
|
|
16
20
|
...Object.entries(theme.colors).map(([k, v]) => [k.startsWith('on-') ? k : `color-${k}`, v]),
|
|
17
21
|
...Object.entries(theme.variables)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Where the toasts show: the region in the top layer and the live regions that read them
|
|
3
|
+
// out. The provider renders one at the end of the page, and the modal shell one inside each
|
|
4
|
+
// open dialog; only the topmost outlet shows the toasts.
|
|
5
|
+
import { flip } from 'svelte/animate';
|
|
6
|
+
import { on } from 'svelte/events';
|
|
7
|
+
import { easing, fade, motion, rise } from '../../primitives/motion/motion.js';
|
|
8
|
+
import '../../styles/visually-hidden.css';
|
|
9
|
+
import { useLocale } from '../locale/locale.js';
|
|
10
|
+
import { sharedClasses } from '../props/classes.js';
|
|
11
|
+
import './toast.css';
|
|
12
|
+
import type { ToastEntry, Toasts } from './toast.svelte.js';
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
toasts,
|
|
16
|
+
outlet,
|
|
17
|
+
ref = $bindable(null)
|
|
18
|
+
}: {
|
|
19
|
+
toasts: Toasts;
|
|
20
|
+
/** This outlet's token, which the toasts compare with their topmost outlet. */
|
|
21
|
+
outlet: object;
|
|
22
|
+
/** The region, which a dialog counts as inside itself. */
|
|
23
|
+
ref?: HTMLElement | null;
|
|
24
|
+
} = $props();
|
|
25
|
+
|
|
26
|
+
const locale = useLocale();
|
|
27
|
+
const top = $derived(toasts.top === outlet);
|
|
28
|
+
const style = $derived(toasts.style);
|
|
29
|
+
const notes = $derived(top ? toasts.announcements : []);
|
|
30
|
+
|
|
31
|
+
const CLOSE =
|
|
32
|
+
'M6.4 5 12 10.6 17.6 5 19 6.4 13.4 12l5.6 5.6-1.4 1.4-5.6-5.6L6.4 19 5 17.6l5.6-5.6L5 6.4z';
|
|
33
|
+
|
|
34
|
+
// The region joins the top layer when it starts showing toasts, and again each time it
|
|
35
|
+
// becomes the topmost outlet, so it sits above whatever opened before. Emptied, it stays,
|
|
36
|
+
// which lets the last toast fade out.
|
|
37
|
+
const showing = $derived(top && toasts.list.length > 0);
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (!showing || !ref) return;
|
|
40
|
+
if (ref.matches(':popover-open')) ref.hidePopover();
|
|
41
|
+
ref.showPopover();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The timers wait while the pointer or focus is on the toasts.
|
|
45
|
+
const hover = {};
|
|
46
|
+
const focus = {};
|
|
47
|
+
$effect(() => {
|
|
48
|
+
if (!top) return;
|
|
49
|
+
return () => {
|
|
50
|
+
toasts.resume(hover);
|
|
51
|
+
toasts.resume(focus);
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Where focus goes back to when the user leaves the toasts.
|
|
56
|
+
let back: HTMLElement | null = null;
|
|
57
|
+
|
|
58
|
+
// F8 moves focus to the toasts, the way to reach an action before its toast leaves; Escape
|
|
59
|
+
// in them goes back to where focus came from. Captured, so the layer stack never sees that
|
|
60
|
+
// Escape and an open dialog stays open.
|
|
61
|
+
$effect(() => {
|
|
62
|
+
if (!top) return;
|
|
63
|
+
return on(document, 'keydown', onkey, { capture: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function onkey(event: KeyboardEvent) {
|
|
67
|
+
if (!ref) return;
|
|
68
|
+
const inside = ref.contains(document.activeElement);
|
|
69
|
+
if (event.key === 'Escape') {
|
|
70
|
+
if (!inside || !back?.isConnected) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
goBack();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const plain = !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey;
|
|
76
|
+
if (event.key !== 'F8' || !plain || toasts.list.length === 0) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
if (!inside) back = focused(document.activeElement);
|
|
79
|
+
ref.focus();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const focused = (element: unknown) =>
|
|
83
|
+
element instanceof HTMLElement && element !== document.body ? element : null;
|
|
84
|
+
|
|
85
|
+
function enter(event: FocusEvent) {
|
|
86
|
+
toasts.pause(focus);
|
|
87
|
+
if (!ref?.contains(event.relatedTarget as Node | null)) {
|
|
88
|
+
back = focused(event.relatedTarget) ?? back;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function exit(event: FocusEvent) {
|
|
93
|
+
if (!(event.relatedTarget instanceof Node && ref?.contains(event.relatedTarget))) {
|
|
94
|
+
toasts.resume(focus);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function goBack() {
|
|
99
|
+
if (back?.isConnected) back.focus();
|
|
100
|
+
back = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A toast that closes with focus in it hands focus on first: to the toasts left, else back.
|
|
104
|
+
function leave() {
|
|
105
|
+
if (!ref?.contains(document.activeElement)) return;
|
|
106
|
+
if (toasts.list.length > 1) ref.focus();
|
|
107
|
+
else goBack();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function act(toast: ToastEntry) {
|
|
111
|
+
leave();
|
|
112
|
+
try {
|
|
113
|
+
toast.action?.onclick();
|
|
114
|
+
} finally {
|
|
115
|
+
toasts.dismiss(toast.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function close(toast: ToastEntry) {
|
|
120
|
+
leave();
|
|
121
|
+
toasts.dismiss(toast.id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Toasts below one that leaves move into its place with the theme's timing.
|
|
125
|
+
function move(node: Element, rects: { from: DOMRect; to: DOMRect }) {
|
|
126
|
+
const timing = motion(node);
|
|
127
|
+
return flip(node, rects, { duration: timing.normal, easing: easing(timing.ease) });
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<!-- Read out once each, then emptied: the toasts themselves are not live. -->
|
|
132
|
+
{#snippet read(assertive: boolean)}
|
|
133
|
+
{#each notes as note (note.id)}
|
|
134
|
+
{#if note.assertive === assertive}<p>{note.message}</p>
|
|
135
|
+
{#if note.action}<p>{note.action}</p>{/if}{/if}
|
|
136
|
+
{/each}
|
|
137
|
+
{/snippet}
|
|
138
|
+
<div class="sv-visually-hidden" aria-live="polite">{@render read(false)}</div>
|
|
139
|
+
<div class="sv-visually-hidden" aria-live="assertive">{@render read(true)}</div>
|
|
140
|
+
|
|
141
|
+
<section
|
|
142
|
+
bind:this={ref}
|
|
143
|
+
popover="manual"
|
|
144
|
+
tabindex="-1"
|
|
145
|
+
aria-label="{locale.t('notifications')} (F8)"
|
|
146
|
+
class="sv-toasts"
|
|
147
|
+
onfocusin={enter}
|
|
148
|
+
onfocusout={exit}
|
|
149
|
+
>
|
|
150
|
+
{#if top}
|
|
151
|
+
<ol
|
|
152
|
+
class="sv-toasts__list"
|
|
153
|
+
onpointerenter={() => toasts.pause(hover)}
|
|
154
|
+
onpointerleave={() => toasts.resume(hover)}
|
|
155
|
+
>
|
|
156
|
+
{#each toasts.list as toast (toast.id)}
|
|
157
|
+
<li
|
|
158
|
+
class={[
|
|
159
|
+
'sv-toast',
|
|
160
|
+
sharedClasses(
|
|
161
|
+
{
|
|
162
|
+
variant: 'flat',
|
|
163
|
+
color: toast.color,
|
|
164
|
+
density: style.density,
|
|
165
|
+
rounded: style.rounded
|
|
166
|
+
},
|
|
167
|
+
'Toast'
|
|
168
|
+
)
|
|
169
|
+
]}
|
|
170
|
+
in:rise
|
|
171
|
+
out:fade
|
|
172
|
+
animate:move
|
|
173
|
+
>
|
|
174
|
+
<p class="sv-toast__message">{toast.message}</p>
|
|
175
|
+
{#if toast.action}
|
|
176
|
+
<button type="button" class="sv-toast__action" onclick={() => act(toast)}>
|
|
177
|
+
{toast.action.label}
|
|
178
|
+
</button>
|
|
179
|
+
{/if}
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
class="sv-toast__close"
|
|
183
|
+
aria-label={locale.t('close')}
|
|
184
|
+
onclick={() => close(toast)}
|
|
185
|
+
>
|
|
186
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={CLOSE} /></svg>
|
|
187
|
+
</button>
|
|
188
|
+
</li>
|
|
189
|
+
{/each}
|
|
190
|
+
</ol>
|
|
191
|
+
{/if}
|
|
192
|
+
</section>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Toasts } from './toast.svelte.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
toasts: Toasts;
|
|
4
|
+
/** This outlet's token, which the toasts compare with their topmost outlet. */
|
|
5
|
+
outlet: object;
|
|
6
|
+
/** The region, which a dialog counts as inside itself. */
|
|
7
|
+
ref?: HTMLElement | null;
|
|
8
|
+
};
|
|
9
|
+
declare const ToastOutlet: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
10
|
+
type ToastOutlet = ReturnType<typeof ToastOutlet>;
|
|
11
|
+
export default ToastOutlet;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gives its subtree toasts: `useToast()` inside it shows them at the bottom of the screen.
|
|
6
|
+
* `<UIProvider>` already includes one; add it yourself only when the app uses
|
|
7
|
+
* `<ThemeProvider>` alone.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* <ThemeProvider themes={monolith}>
|
|
11
|
+
* <ToastProvider>
|
|
12
|
+
* {@render children()}
|
|
13
|
+
* </ToastProvider>
|
|
14
|
+
* </ThemeProvider>
|
|
15
|
+
*/
|
|
16
|
+
export interface ToastProviderProps {
|
|
17
|
+
children?: Snippet;
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script lang="ts">
|
|
22
|
+
// The toasts of a subtree, and the outlet that shows them while no modal dialog is open.
|
|
23
|
+
import { useDefaults } from '../defaults/context.js';
|
|
24
|
+
import { setToasts, Toasts, type ToastDefaults } from './toast.svelte.js';
|
|
25
|
+
import ToastOutlet from './ToastOutlet.svelte';
|
|
26
|
+
|
|
27
|
+
let { children }: ToastProviderProps = $props();
|
|
28
|
+
|
|
29
|
+
const resolve = useDefaults<ToastDefaults>('Toast', {
|
|
30
|
+
color: 'neutral',
|
|
31
|
+
duration: 6000,
|
|
32
|
+
density: 'default',
|
|
33
|
+
rounded: true
|
|
34
|
+
});
|
|
35
|
+
const toasts = setToasts(new Toasts(() => resolve({})));
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
{@render children?.()}
|
|
39
|
+
<ToastOutlet {toasts} outlet={toasts.base} />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Gives its subtree toasts: `useToast()` inside it shows them at the bottom of the screen.
|
|
4
|
+
* `<UIProvider>` already includes one; add it yourself only when the app uses
|
|
5
|
+
* `<ThemeProvider>` alone.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <ThemeProvider themes={monolith}>
|
|
9
|
+
* <ToastProvider>
|
|
10
|
+
* {@render children()}
|
|
11
|
+
* </ToastProvider>
|
|
12
|
+
* </ThemeProvider>
|
|
13
|
+
*/
|
|
14
|
+
export interface ToastProviderProps {
|
|
15
|
+
children?: Snippet;
|
|
16
|
+
}
|
|
17
|
+
declare const ToastProvider: import("svelte").Component<ToastProviderProps, {}, "">;
|
|
18
|
+
type ToastProvider = ReturnType<typeof ToastProvider>;
|
|
19
|
+
export default ToastProvider;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
@layer sv.tokens, sv.base, sv.components, sv.utilities;
|
|
2
|
+
|
|
3
|
+
@layer sv.components {
|
|
4
|
+
/*
|
|
5
|
+
* The region: a strip of the screen's height in the top layer, with the toasts at its
|
|
6
|
+
* bottom, across a phone and at the end from 600px. It never changes height, so a toast
|
|
7
|
+
* fading out stays where it was while the others move. Around the toasts, presses go
|
|
8
|
+
* through to the page.
|
|
9
|
+
*/
|
|
10
|
+
.sv-toasts {
|
|
11
|
+
position: fixed;
|
|
12
|
+
inset-block: 0;
|
|
13
|
+
inset-inline: var(--sv-space-8);
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
justify-content: flex-end;
|
|
18
|
+
inline-size: auto;
|
|
19
|
+
block-size: auto;
|
|
20
|
+
max-inline-size: none;
|
|
21
|
+
max-block-size: none;
|
|
22
|
+
margin: 0;
|
|
23
|
+
padding-block: 0 calc(var(--sv-space-21) + env(safe-area-inset-bottom, 0px));
|
|
24
|
+
padding-inline: 0;
|
|
25
|
+
border: 0;
|
|
26
|
+
overflow: visible;
|
|
27
|
+
background: transparent;
|
|
28
|
+
color: var(--sv-color-text);
|
|
29
|
+
font-family: var(--sv-font-ui);
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@media (width >= 600px) {
|
|
34
|
+
.sv-toasts {
|
|
35
|
+
inset-inline: auto var(--sv-space-21);
|
|
36
|
+
inline-size: 21rem;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* F8 focuses the region; the ring goes round the toasts, not the strip. */
|
|
41
|
+
.sv-toasts:focus-visible {
|
|
42
|
+
outline: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.sv-toasts:focus-visible .sv-toasts__list {
|
|
46
|
+
outline: var(--sv-focus-width) solid var(--sv-color-primary);
|
|
47
|
+
outline-offset: var(--sv-focus-offset);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.sv-toasts__list {
|
|
51
|
+
display: grid;
|
|
52
|
+
gap: var(--sv-space-8);
|
|
53
|
+
margin: 0;
|
|
54
|
+
padding: 0;
|
|
55
|
+
border-radius: var(--sv-rounded, min(var(--sv-radius-md), var(--sv-radius-lg)));
|
|
56
|
+
list-style: none;
|
|
57
|
+
pointer-events: auto;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Filled with its colour; neutral is the page inverted, so it reads on any surface. */
|
|
61
|
+
.sv-toast {
|
|
62
|
+
display: flex;
|
|
63
|
+
gap: var(--sv-space-5);
|
|
64
|
+
align-items: center;
|
|
65
|
+
box-sizing: border-box;
|
|
66
|
+
min-block-size: calc(var(--sv-control-lg) * var(--sv-density));
|
|
67
|
+
padding-block: calc(var(--sv-space-5) * var(--sv-density));
|
|
68
|
+
padding-inline: calc(var(--sv-space-13) * var(--sv-density)) var(--sv-space-5);
|
|
69
|
+
border-radius: var(--sv-rounded, min(var(--sv-radius-md), var(--sv-radius-lg)));
|
|
70
|
+
background-color: var(--sv-variant-bg);
|
|
71
|
+
box-shadow: var(--sv-shadow);
|
|
72
|
+
color: var(--sv-variant-fg);
|
|
73
|
+
font-size: var(--sv-text-sm);
|
|
74
|
+
line-height: var(--sv-leading-snug);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.sv-toast__message {
|
|
78
|
+
flex: 1;
|
|
79
|
+
min-inline-size: 0;
|
|
80
|
+
margin: 0;
|
|
81
|
+
padding-block: var(--sv-space-3);
|
|
82
|
+
overflow-wrap: break-word;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.sv-toast__action,
|
|
86
|
+
.sv-toast__close {
|
|
87
|
+
flex: none;
|
|
88
|
+
border: 0;
|
|
89
|
+
background: transparent;
|
|
90
|
+
color: inherit;
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.sv-toast__action {
|
|
95
|
+
min-block-size: var(--sv-control-sm);
|
|
96
|
+
padding-inline: var(--sv-space-8);
|
|
97
|
+
border-radius: var(--sv-radius-sm);
|
|
98
|
+
font: inherit;
|
|
99
|
+
font-weight: var(--sv-weight-semibold);
|
|
100
|
+
/* Lettered like the theme's buttons: capitals in a theme that sets them. */
|
|
101
|
+
letter-spacing: var(--sv-button-tracking);
|
|
102
|
+
text-transform: var(--sv-button-transform);
|
|
103
|
+
white-space: nowrap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.sv-toast__close {
|
|
107
|
+
display: inline-grid;
|
|
108
|
+
place-items: center;
|
|
109
|
+
inline-size: var(--sv-control-sm);
|
|
110
|
+
block-size: var(--sv-control-sm);
|
|
111
|
+
padding: 0;
|
|
112
|
+
border-radius: 50%;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.sv-toast__close svg {
|
|
116
|
+
inline-size: 1.272em;
|
|
117
|
+
block-size: 1.272em;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@media (hover: hover) {
|
|
121
|
+
:is(.sv-toast__action, .sv-toast__close):hover {
|
|
122
|
+
background-color: color-mix(in oklch, currentColor var(--sv-state-hover), transparent);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
:is(.sv-toast__action, .sv-toast__close):active {
|
|
127
|
+
background-color: color-mix(in oklch, currentColor var(--sv-state-press), transparent);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* The ring takes the toast's own text colour, which contrasts with it in every colour. */
|
|
131
|
+
.sv-toast :focus-visible {
|
|
132
|
+
outline-color: currentColor;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Color, Density, Rounded } from '../props/types.js';
|
|
2
|
+
/** The Toast props that `DefaultsProvider` and `UIProvider` can set for a subtree. */
|
|
3
|
+
export interface ToastDefaults {
|
|
4
|
+
/**
|
|
5
|
+
* The colour of a toast shown without one: `neutral` is the page inverted.
|
|
6
|
+
* @default 'neutral'
|
|
7
|
+
*/
|
|
8
|
+
color: Color;
|
|
9
|
+
/**
|
|
10
|
+
* How long a toast stays, in ms, not counting the time it is hovered or focused. `0` keeps
|
|
11
|
+
* it until it is dismissed.
|
|
12
|
+
* @default 6000
|
|
13
|
+
*/
|
|
14
|
+
duration: number;
|
|
15
|
+
/**
|
|
16
|
+
* Multiplies the padding, never the text.
|
|
17
|
+
* @default 'default'
|
|
18
|
+
*/
|
|
19
|
+
density: Density;
|
|
20
|
+
/**
|
|
21
|
+
* Corner rounding. `true` keeps the theme's radius.
|
|
22
|
+
* @default true
|
|
23
|
+
*/
|
|
24
|
+
rounded: Rounded;
|
|
25
|
+
}
|
|
26
|
+
declare module '../defaults/context.js' {
|
|
27
|
+
interface ComponentDefaults {
|
|
28
|
+
Toast: ToastDefaults;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** A button in a toast, such as Undo. The toast closes after `onclick` has run. */
|
|
32
|
+
export interface ToastAction {
|
|
33
|
+
/** The button's text, short: "Undo", "Retry", "View". */
|
|
34
|
+
label: string;
|
|
35
|
+
onclick: () => void;
|
|
36
|
+
}
|
|
37
|
+
/** How a toast looks and how long it stays. Unset options take the Toast defaults. */
|
|
38
|
+
export interface ToastOptions {
|
|
39
|
+
/** A button that acts on what the message reports, such as Undo. */
|
|
40
|
+
action?: ToastAction;
|
|
41
|
+
/** The colour. `error` is announced at once; the others wait for the screen reader to pause. */
|
|
42
|
+
color?: Color;
|
|
43
|
+
/** In ms, not counting the time it is hovered or focused. `0` keeps it until it is dismissed. */
|
|
44
|
+
duration?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Short messages at the bottom of the screen that report what just happened, with a button
|
|
48
|
+
* to act on it, such as Undo. At most three show at once, newest first; each leaves after
|
|
49
|
+
* its duration, which waits while the pointer or focus is on the toasts. F8 moves focus to
|
|
50
|
+
* them. While a Dialog or Sheet is open they show inside it, so they can still be pressed.
|
|
51
|
+
* Returned by `useToast()`.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* const toast = useToast();
|
|
55
|
+
* toast.show('Payment sent', { action: { label: 'Undo', onclick: cancelPayment } });
|
|
56
|
+
*/
|
|
57
|
+
export interface ToastInstance {
|
|
58
|
+
/** Shows `message` and returns the toast's id. */
|
|
59
|
+
show(message: string, options?: ToastOptions): string;
|
|
60
|
+
/** Closes the toast with this id, if it is still shown. */
|
|
61
|
+
dismiss(id: string): void;
|
|
62
|
+
}
|
|
63
|
+
/** A toast as the outlets render it. */
|
|
64
|
+
export interface ToastEntry {
|
|
65
|
+
id: string;
|
|
66
|
+
message: string;
|
|
67
|
+
color: Color;
|
|
68
|
+
action?: ToastAction;
|
|
69
|
+
}
|
|
70
|
+
/** What the live regions read out: the message, then the action's label. */
|
|
71
|
+
export interface Announcement {
|
|
72
|
+
id: string;
|
|
73
|
+
message: string;
|
|
74
|
+
action?: string;
|
|
75
|
+
assertive: boolean;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The toasts of an app: the queue, the timers and what to announce. Outlets render them: the
|
|
79
|
+
* provider's at the end of the page, and one inside each open modal dialog, which makes the
|
|
80
|
+
* rest of the page inert. The topmost outlet shows them all.
|
|
81
|
+
*/
|
|
82
|
+
export declare class Toasts {
|
|
83
|
+
#private;
|
|
84
|
+
/** Newest first. */
|
|
85
|
+
list: ToastEntry[];
|
|
86
|
+
announcements: Announcement[];
|
|
87
|
+
/** The provider's outlet, used while no modal dialog has one open. */
|
|
88
|
+
readonly base: {};
|
|
89
|
+
constructor(defaults: () => ToastDefaults);
|
|
90
|
+
/** The outlet that shows the toasts. */
|
|
91
|
+
get top(): object;
|
|
92
|
+
/** The density and rounding of every toast, from the provider's defaults. */
|
|
93
|
+
get style(): Pick<ToastDefaults, 'density' | 'rounded'>;
|
|
94
|
+
/** Makes `outlet` the topmost until the returned function is called. */
|
|
95
|
+
register(outlet: object): () => void;
|
|
96
|
+
show(message: string, { action, color, duration }?: ToastOptions): string;
|
|
97
|
+
dismiss(id: string): void;
|
|
98
|
+
/** Stops every timer until each source has resumed: the pointer on the toasts, focus in them. */
|
|
99
|
+
pause(source: object): void;
|
|
100
|
+
resume(source: object): void;
|
|
101
|
+
}
|
|
102
|
+
/** The nearest provider's toasts, if there is one. */
|
|
103
|
+
export declare const getToasts: () => Toasts | undefined;
|
|
104
|
+
export declare const setToasts: (toasts: Toasts) => Toasts;
|
|
105
|
+
/**
|
|
106
|
+
* Returns the toasts of the nearest `<UIProvider>` or `<ToastProvider>`. Call it while a
|
|
107
|
+
* component initialises; call `show()` whenever something happens.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* <script>
|
|
111
|
+
* import { Button, useToast } from '@sveltier/ui';
|
|
112
|
+
* const toast = useToast();
|
|
113
|
+
* </script>
|
|
114
|
+
*
|
|
115
|
+
* <Button onclick={() => toast.show('Saved', { color: 'success' })}>Save</Button>
|
|
116
|
+
*/
|
|
117
|
+
export declare function useToast(): ToastInstance;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { getContext, hasContext, setContext, untrack } from 'svelte';
|
|
2
|
+
import { sveltierError } from '../errors/errors.js';
|
|
3
|
+
// At most this many on screen; a newer toast pushes the oldest out.
|
|
4
|
+
const MAX = 3;
|
|
5
|
+
// Long enough for a screen reader to take the text, short enough that switching outlets
|
|
6
|
+
// does not read it twice.
|
|
7
|
+
const ANNOUNCED = 1000;
|
|
8
|
+
/**
|
|
9
|
+
* The toasts of an app: the queue, the timers and what to announce. Outlets render them: the
|
|
10
|
+
* provider's at the end of the page, and one inside each open modal dialog, which makes the
|
|
11
|
+
* rest of the page inert. The topmost outlet shows them all.
|
|
12
|
+
*/
|
|
13
|
+
export class Toasts {
|
|
14
|
+
/** Newest first. */
|
|
15
|
+
list = $state.raw([]);
|
|
16
|
+
announcements = $state.raw([]);
|
|
17
|
+
/** The provider's outlet, used while no modal dialog has one open. */
|
|
18
|
+
base = {};
|
|
19
|
+
#outlets = $state.raw([]);
|
|
20
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- never read reactively
|
|
21
|
+
#timers = new Map();
|
|
22
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- never read reactively
|
|
23
|
+
#pausers = new Set();
|
|
24
|
+
#count = 0;
|
|
25
|
+
#defaults;
|
|
26
|
+
constructor(defaults) {
|
|
27
|
+
this.#defaults = defaults;
|
|
28
|
+
}
|
|
29
|
+
/** The outlet that shows the toasts. */
|
|
30
|
+
get top() {
|
|
31
|
+
return this.#outlets.at(-1) ?? this.base;
|
|
32
|
+
}
|
|
33
|
+
/** The density and rounding of every toast, from the provider's defaults. */
|
|
34
|
+
get style() {
|
|
35
|
+
const { density, rounded } = this.#defaults();
|
|
36
|
+
return { density, rounded };
|
|
37
|
+
}
|
|
38
|
+
// The methods below may run inside an effect, which must not come to depend on the lists
|
|
39
|
+
// they change, so they read them untracked.
|
|
40
|
+
/** Makes `outlet` the topmost until the returned function is called. */
|
|
41
|
+
register(outlet) {
|
|
42
|
+
untrack(() => (this.#outlets = [...this.#outlets, outlet]));
|
|
43
|
+
return () => untrack(() => (this.#outlets = this.#outlets.filter((other) => other !== outlet)));
|
|
44
|
+
}
|
|
45
|
+
show(message, { action, color, duration } = {}) {
|
|
46
|
+
const id = `sv-toast-${++this.#count}`;
|
|
47
|
+
// Toasts answer what the user does, so they belong to the browser: one shown while the
|
|
48
|
+
// server renders would have nothing to hydrate into.
|
|
49
|
+
if (typeof window === 'undefined')
|
|
50
|
+
return id;
|
|
51
|
+
const defaults = untrack(this.#defaults);
|
|
52
|
+
const entry = { id, message, color: color ?? defaults.color, action };
|
|
53
|
+
untrack(() => (this.list = [entry, ...this.list]));
|
|
54
|
+
for (const old of untrack(() => this.list.slice(MAX)))
|
|
55
|
+
this.dismiss(old.id);
|
|
56
|
+
const time = duration ?? defaults.duration;
|
|
57
|
+
if (time > 0 && Number.isFinite(time)) {
|
|
58
|
+
this.#timers.set(id, { remaining: time, started: 0 });
|
|
59
|
+
if (this.#pausers.size === 0)
|
|
60
|
+
this.#start(id);
|
|
61
|
+
}
|
|
62
|
+
this.#announce(entry);
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
dismiss(id) {
|
|
66
|
+
this.#stop(id);
|
|
67
|
+
this.#timers.delete(id);
|
|
68
|
+
untrack(() => (this.list = this.list.filter((toast) => toast.id !== id)));
|
|
69
|
+
}
|
|
70
|
+
/** Stops every timer until each source has resumed: the pointer on the toasts, focus in them. */
|
|
71
|
+
pause(source) {
|
|
72
|
+
if (this.#pausers.size === 0)
|
|
73
|
+
for (const id of this.#timers.keys())
|
|
74
|
+
this.#stop(id);
|
|
75
|
+
this.#pausers.add(source);
|
|
76
|
+
}
|
|
77
|
+
resume(source) {
|
|
78
|
+
if (!this.#pausers.delete(source) || this.#pausers.size > 0)
|
|
79
|
+
return;
|
|
80
|
+
for (const id of this.#timers.keys())
|
|
81
|
+
this.#start(id);
|
|
82
|
+
}
|
|
83
|
+
#start(id) {
|
|
84
|
+
const timer = this.#timers.get(id);
|
|
85
|
+
if (!timer)
|
|
86
|
+
return;
|
|
87
|
+
timer.started = performance.now();
|
|
88
|
+
timer.handle = setTimeout(() => this.dismiss(id), timer.remaining);
|
|
89
|
+
}
|
|
90
|
+
#stop(id) {
|
|
91
|
+
const timer = this.#timers.get(id);
|
|
92
|
+
if (timer?.handle === undefined)
|
|
93
|
+
return;
|
|
94
|
+
clearTimeout(timer.handle);
|
|
95
|
+
timer.handle = undefined;
|
|
96
|
+
timer.remaining -= performance.now() - timer.started;
|
|
97
|
+
}
|
|
98
|
+
#announce({ id, message, action, color }) {
|
|
99
|
+
const note = { id, message, action: action?.label, assertive: color === 'error' };
|
|
100
|
+
untrack(() => (this.announcements = [...this.announcements, note]));
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
this.announcements = this.announcements.filter((other) => other !== note);
|
|
103
|
+
}, ANNOUNCED);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// A private symbol key instead of createContext(): its `has` function only exists from
|
|
107
|
+
// Svelte 5.57, and the supported range starts at 5.40.
|
|
108
|
+
const KEY = Symbol('sveltier.toasts');
|
|
109
|
+
/** The nearest provider's toasts, if there is one. */
|
|
110
|
+
export const getToasts = () => hasContext(KEY) ? getContext(KEY) : undefined;
|
|
111
|
+
export const setToasts = (toasts) => setContext(KEY, toasts);
|
|
112
|
+
/**
|
|
113
|
+
* Returns the toasts of the nearest `<UIProvider>` or `<ToastProvider>`. Call it while a
|
|
114
|
+
* component initialises; call `show()` whenever something happens.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* <script>
|
|
118
|
+
* import { Button, useToast } from '@sveltier/ui';
|
|
119
|
+
* const toast = useToast();
|
|
120
|
+
* </script>
|
|
121
|
+
*
|
|
122
|
+
* <Button onclick={() => toast.show('Saved', { color: 'success' })}>Save</Button>
|
|
123
|
+
*/
|
|
124
|
+
export function useToast() {
|
|
125
|
+
const toasts = getToasts();
|
|
126
|
+
if (!toasts) {
|
|
127
|
+
throw sveltierError('SV4003', 'useToast() was called outside a <UIProvider>. Call it in a component inside <UIProvider>, or inside <ToastProvider> when the app uses <ThemeProvider> alone.');
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
show: (message, options) => toasts.show(message, options),
|
|
131
|
+
dismiss: (id) => toasts.dismiss(id)
|
|
132
|
+
};
|
|
133
|
+
}
|