@mystravil/hardpoint 0.2.0 → 0.3.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/CHANGELOG.md +51 -38
- package/LICENSE +25 -25
- package/README.md +67 -64
- package/dist/base.css +625 -625
- package/dist/components/AppShell.svelte +698 -698
- package/dist/components/Button.svelte +221 -174
- package/dist/components/Button.svelte.d.ts +18 -2
- package/dist/components/Checkbox.svelte +235 -235
- package/dist/components/CheckboxGroup.svelte +207 -207
- package/dist/components/Combobox.svelte +901 -901
- package/dist/components/Dialog.svelte +359 -359
- package/dist/components/Disclosure.svelte +201 -201
- package/dist/components/EmptyState.svelte +88 -88
- package/dist/components/ErrorState.svelte +135 -135
- package/dist/components/ImageViewer.svelte +764 -764
- package/dist/components/KeyValueList.svelte +114 -114
- package/dist/components/ListDetail.svelte +218 -218
- package/dist/components/LoadRegion.svelte +219 -219
- package/dist/components/MediaFrame.svelte +110 -110
- package/dist/components/MediaImage.svelte +256 -256
- package/dist/components/Menu.svelte +275 -258
- package/dist/components/MenuCheckboxItem.svelte +119 -119
- package/dist/components/MenuGroup.svelte +50 -50
- package/dist/components/MenuItem.svelte +94 -94
- package/dist/components/MenuRadioGroup.svelte +62 -62
- package/dist/components/MenuRadioItem.svelte +110 -110
- package/dist/components/MenuSeparator.svelte +28 -28
- package/dist/components/Meter.svelte +283 -283
- package/dist/components/NativeSelect.svelte +135 -135
- package/dist/components/Notice.svelte +262 -262
- package/dist/components/Popover.svelte +222 -222
- package/dist/components/Progress.svelte +157 -157
- package/dist/components/RadioGroup.svelte +440 -440
- package/dist/components/Select.svelte +471 -471
- package/dist/components/Skeleton.svelte +149 -149
- package/dist/components/Spinner.svelte +118 -118
- package/dist/components/Stat.svelte +253 -253
- package/dist/components/Steps.svelte +189 -189
- package/dist/components/Switch.svelte +222 -222
- package/dist/components/Table.svelte +504 -504
- package/dist/components/Tabs.svelte +246 -246
- package/dist/components/Tag.svelte +40 -40
- package/dist/components/TextArea.svelte +272 -272
- package/dist/components/TextField.svelte +396 -256
- package/dist/components/TextField.svelte.d.ts +16 -1
- package/dist/components/Timeline.svelte +156 -156
- package/dist/components/Toaster.svelte +411 -411
- package/dist/components/Tooltip.svelte +200 -200
- package/dist/components/TooltipGroup.svelte +46 -46
- package/dist/components/internal/Scrim.svelte +52 -52
- package/dist/components/internal/Sheet.svelte +263 -263
- package/dist/fonts.css +71 -71
- package/dist/tokens.css +283 -283
- package/package.json +81 -76
|
@@ -1,411 +1,411 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
-
|
|
4
|
-
/** Attributes and `class` go to the toast box while one shows. */
|
|
5
|
-
export type ToasterProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
|
|
6
|
-
/** The toast region's name, for landmark navigation (default "Notifications"). */
|
|
7
|
-
label?: string;
|
|
8
|
-
/** The close button's accessible name (default "Dismiss"). */
|
|
9
|
-
closeLabel?: string;
|
|
10
|
-
};
|
|
11
|
-
</script>
|
|
12
|
-
|
|
13
|
-
<script lang="ts">
|
|
14
|
-
import { Portal } from 'bits-ui';
|
|
15
|
-
import { onMount, untrack } from 'svelte';
|
|
16
|
-
import { prefersReducedMotion } from 'svelte/motion';
|
|
17
|
-
import type { TransitionConfig } from 'svelte/transition';
|
|
18
|
-
import { curve, duration } from '../motion/index.js';
|
|
19
|
-
import Button from './Button.svelte';
|
|
20
|
-
import { swipeDismiss, type SwipeDirection } from './internal/swipe-dismiss.js';
|
|
21
|
-
import { followVisualViewport } from './internal/visual-viewport.svelte.js';
|
|
22
|
-
import { toastSlot as slot, type ShownToast } from './toast.svelte.js';
|
|
23
|
-
|
|
24
|
-
let { label = 'Notifications', closeLabel = 'Dismiss', class: className, ...rest }: ToasterProps = $props();
|
|
25
|
-
|
|
26
|
-
/** How far the box rises as it enters and drops as it leaves (px). */
|
|
27
|
-
const RISE = 16;
|
|
28
|
-
/** How far below rest a replacement's short entrance starts (px). */
|
|
29
|
-
const REPLAY_RISE = 8;
|
|
30
|
-
|
|
31
|
-
// The first Toaster mounted renders the slot; another one renders
|
|
32
|
-
// nothing until the first unmounts.
|
|
33
|
-
const self = {};
|
|
34
|
-
onMount(() => slot.register(self));
|
|
35
|
-
const active = $derived(slot.renders(self));
|
|
36
|
-
const shown = $derived(active ? slot.current : null);
|
|
37
|
-
|
|
38
|
-
let box = $state<HTMLElement | null>(null);
|
|
39
|
-
// Where focus was before it entered the toast, to give it back.
|
|
40
|
-
let origin: HTMLElement | null = null;
|
|
41
|
-
// The way a swipe sent the box off, until it is gone or back at rest.
|
|
42
|
-
let swiped: SwipeDirection | null = null;
|
|
43
|
-
// Svelte's transition on the box, from its start event to its end.
|
|
44
|
-
let transit: 'in' | 'out' | null = null;
|
|
45
|
-
// The id shown before the last change, and the replacement's entrance.
|
|
46
|
-
let lastId: number | null = null;
|
|
47
|
-
let replay: Animation | null = null;
|
|
48
|
-
|
|
49
|
-
// Above the on-screen keyboard (internal/visual-viewport.svelte.ts): while
|
|
50
|
-
// it shortens the visual viewport, the toast stands on that edge instead.
|
|
51
|
-
const viewport = followVisualViewport(
|
|
52
|
-
() => shown !== null,
|
|
53
|
-
() => null
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
// A hidden page holds the timer, so a toast is never used up unseen.
|
|
57
|
-
$effect(() => {
|
|
58
|
-
if (!active) return;
|
|
59
|
-
const sync = () => (document.hidden ? slot.hold('hidden') : slot.release('hidden'));
|
|
60
|
-
untrack(sync);
|
|
61
|
-
document.addEventListener('visibilitychange', sync);
|
|
62
|
-
return () => {
|
|
63
|
-
document.removeEventListener('visibilitychange', sync);
|
|
64
|
-
untrack(() => {
|
|
65
|
-
slot.release('hidden');
|
|
66
|
-
slot.release('pointer');
|
|
67
|
-
slot.release('focus');
|
|
68
|
-
});
|
|
69
|
-
};
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// Before the box changes: focus on a part that goes away (the whole box,
|
|
73
|
-
// or the action when the next toast has none) returns to where it came
|
|
74
|
-
// from. A box that goes away under a resting pointer or with focus gets
|
|
75
|
-
// no leave event, so its holds end here.
|
|
76
|
-
$effect.pre(() => {
|
|
77
|
-
const next = shown;
|
|
78
|
-
untrack(() => {
|
|
79
|
-
const focused = document.activeElement;
|
|
80
|
-
if (
|
|
81
|
-
box &&
|
|
82
|
-
focused &&
|
|
83
|
-
box.contains(focused) &&
|
|
84
|
-
(!next || (focused.matches('.hp-toaster-action') && !next.action))
|
|
85
|
-
) {
|
|
86
|
-
giveFocusBack();
|
|
87
|
-
}
|
|
88
|
-
if (!next && box) {
|
|
89
|
-
slot.release('pointer');
|
|
90
|
-
slot.release('focus');
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// After the box changed: a toast that replaces the one on screen replays
|
|
96
|
-
// a short entrance. While Svelte's transition moves the box (entering,
|
|
97
|
-
// or leaving and reversed by this toast), the new text rides it.
|
|
98
|
-
$effect(() => {
|
|
99
|
-
const id = shown?.id ?? null;
|
|
100
|
-
untrack(() => {
|
|
101
|
-
const replaced = id !== null && lastId !== null && id !== lastId;
|
|
102
|
-
lastId = id;
|
|
103
|
-
if (replaced && box && !transit) replayEntrance(box);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The replacement's entrance: from 8px down and transparent to rest over
|
|
109
|
-
* --dur-pop on --settle, both read from the box. One that arrives during
|
|
110
|
-
* it starts again from where the box is. Reduced motion: opacity only.
|
|
111
|
-
*/
|
|
112
|
-
function replayEntrance(node: HTMLElement) {
|
|
113
|
-
const ms = duration(node, '--dur-pop');
|
|
114
|
-
if (ms <= 0) return;
|
|
115
|
-
const moves = !prefersReducedMotion.current;
|
|
116
|
-
const now = getComputedStyle(node);
|
|
117
|
-
const running = replay?.playState === 'running';
|
|
118
|
-
const from: Keyframe = { opacity: running ? now.opacity : 0 };
|
|
119
|
-
if (moves) from.transform = running ? now.transform : `translateY(${REPLAY_RISE}px)`;
|
|
120
|
-
const settle = now.getPropertyValue('--settle').trim();
|
|
121
|
-
replay?.cancel();
|
|
122
|
-
replay = node.animate([from, moves ? { opacity: 1, transform: 'none' } : { opacity: 1 }], {
|
|
123
|
-
duration: ms,
|
|
124
|
-
easing: CSS.supports('transition-timing-function', settle) ? settle : 'ease-out'
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Showing and dismissing, at the sheet timing read from the box
|
|
130
|
-
* (--dur-sheet in, --dur-sheet-exit out, on --settle): a 16px rise with
|
|
131
|
-
* a fade, and a 16px drop with a fade. After a swipe the box leaves the
|
|
132
|
-
* way the finger went, by its own size, from where the finger let go; a
|
|
133
|
-
* toast sent during that exit brings it back the same way. Reduced
|
|
134
|
-
* motion: a fade in place.
|
|
135
|
-
*/
|
|
136
|
-
function motion(node: Element) {
|
|
137
|
-
return ({ direction }: { direction?: 'in' | 'out' } = {}): TransitionConfig => {
|
|
138
|
-
const way = swiped;
|
|
139
|
-
const path = (u: number) =>
|
|
140
|
-
way === 'left' || way === 'right'
|
|
141
|
-
? `translateX(${(way === 'left' ? -u : u) * 100}%)`
|
|
142
|
-
: way === 'down'
|
|
143
|
-
? `translateY(${u * 100}%)`
|
|
144
|
-
: `translateY(${u * RISE}px)`;
|
|
145
|
-
return {
|
|
146
|
-
duration: duration(node, direction === 'out' ? '--dur-sheet-exit' : '--dur-sheet'),
|
|
147
|
-
easing: curve(node),
|
|
148
|
-
css: prefersReducedMotion.current
|
|
149
|
-
? (t) => `opacity: ${t}`
|
|
150
|
-
: (t, u) => `opacity: ${t}; transform: ${path(u)}`
|
|
151
|
-
};
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function giveFocusBack() {
|
|
156
|
-
const focused = document.activeElement;
|
|
157
|
-
if (!box || !(focused instanceof HTMLElement) || !box.contains(focused)) return;
|
|
158
|
-
if (origin?.isConnected) origin.focus({ preventScroll: true });
|
|
159
|
-
else focused.blur();
|
|
160
|
-
origin = null;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// F8 moves focus to the action, or to the close button when there is none.
|
|
164
|
-
function jump(e: KeyboardEvent) {
|
|
165
|
-
if (e.key !== 'F8' || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || !box) return;
|
|
166
|
-
e.preventDefault();
|
|
167
|
-
box.querySelector<HTMLElement>('.hp-toaster-action, .hp-toaster-close')?.focus();
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// The close button, Escape, a swipe and the action: focus inside goes
|
|
171
|
-
// back to where it came from, and the box leaves.
|
|
172
|
-
function dismiss(toast: ShownToast) {
|
|
173
|
-
giveFocusBack();
|
|
174
|
-
slot.dismiss(toast.id);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function run(toast: ShownToast) {
|
|
178
|
-
dismiss(toast);
|
|
179
|
-
toast.action?.onclick();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function swipeAway(way: SwipeDirection) {
|
|
183
|
-
if (!shown) return;
|
|
184
|
-
swiped = way;
|
|
185
|
-
dismiss(shown);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function escape(e: KeyboardEvent) {
|
|
189
|
-
if (e.key !== 'Escape' || !shown) return;
|
|
190
|
-
// Escape here belongs to the toast, not to a dialog or menu under it.
|
|
191
|
-
e.stopPropagation();
|
|
192
|
-
dismiss(shown);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function focusIn(e: FocusEvent) {
|
|
196
|
-
// An open dialog's focus trap may already have pulled focus back.
|
|
197
|
-
if (!box?.contains(document.activeElement)) return;
|
|
198
|
-
const from = e.relatedTarget;
|
|
199
|
-
if (!(from instanceof Node) || !box.contains(from)) origin = from instanceof HTMLElement ? from : null;
|
|
200
|
-
slot.hold('focus');
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function focusOut(e: FocusEvent) {
|
|
204
|
-
const to = e.relatedTarget;
|
|
205
|
-
if (to instanceof Node && box?.contains(to)) return;
|
|
206
|
-
slot.release('focus');
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// A press keeps focus where it is, so a field keeps the on-screen
|
|
210
|
-
// keyboard up, and it is not a press outside an open dialog, popover or
|
|
211
|
-
// menu, which would close it. The action's and close button's clicks
|
|
212
|
-
// still run.
|
|
213
|
-
function press(e: PointerEvent) {
|
|
214
|
-
e.preventDefault();
|
|
215
|
-
e.stopPropagation();
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// A pointer over the box holds the timer. On touch the finger is over it
|
|
219
|
-
// from touchdown to lift, so a drag holds it too.
|
|
220
|
-
const enter = () => slot.hold('pointer');
|
|
221
|
-
const leave = () => slot.release('pointer');
|
|
222
|
-
|
|
223
|
-
function introStart() {
|
|
224
|
-
transit = 'in';
|
|
225
|
-
// A toast sent during a swipe's exit brings the box back on the
|
|
226
|
-
// swipe's path; the finger's offset springs back with it.
|
|
227
|
-
if (swiped && box) {
|
|
228
|
-
box.style.transition = prefersReducedMotion.current ? '' : 'translate var(--dur-sheet) var(--settle)';
|
|
229
|
-
box.style.translate = '';
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
const outroStart = () => (transit = 'out');
|
|
233
|
-
function atRest() {
|
|
234
|
-
transit = null;
|
|
235
|
-
swiped = null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// On the box itself, not delegated, so a stopped press or Escape never
|
|
239
|
-
// reaches Bits UI's listeners on the document.
|
|
240
|
-
function listen(node: HTMLElement) {
|
|
241
|
-
// A new box carries nothing over from one that left mid-transition.
|
|
242
|
-
atRest();
|
|
243
|
-
replay = null;
|
|
244
|
-
node.addEventListener('pointerdown', press);
|
|
245
|
-
node.addEventListener('pointerenter', enter);
|
|
246
|
-
node.addEventListener('pointerleave', leave);
|
|
247
|
-
node.addEventListener('focusin', focusIn);
|
|
248
|
-
node.addEventListener('focusout', focusOut);
|
|
249
|
-
node.addEventListener('keydown', escape);
|
|
250
|
-
node.addEventListener('introstart', introStart);
|
|
251
|
-
node.addEventListener('introend', atRest);
|
|
252
|
-
node.addEventListener('outrostart', outroStart);
|
|
253
|
-
node.addEventListener('outroend', atRest);
|
|
254
|
-
return () => {
|
|
255
|
-
node.removeEventListener('pointerdown', press);
|
|
256
|
-
node.removeEventListener('pointerenter', enter);
|
|
257
|
-
node.removeEventListener('pointerleave', leave);
|
|
258
|
-
node.removeEventListener('focusin', focusIn);
|
|
259
|
-
node.removeEventListener('focusout', focusOut);
|
|
260
|
-
node.removeEventListener('keydown', escape);
|
|
261
|
-
node.removeEventListener('introstart', introStart);
|
|
262
|
-
node.removeEventListener('introend', atRest);
|
|
263
|
-
node.removeEventListener('outrostart', outroStart);
|
|
264
|
-
node.removeEventListener('outroend', atRest);
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
</script>
|
|
268
|
-
|
|
269
|
-
<!--
|
|
270
|
-
The one toast, at the bottom centre over everything but tooltips. It
|
|
271
|
-
rises 16px with a fade over --dur-sheet and drops 16px with a fade over
|
|
272
|
-
--dur-sheet-exit (reduced motion fades only). A new toast replaces the
|
|
273
|
-
text in place: the box keeps its place, changes size at once and replays
|
|
274
|
-
a short entrance (8px over --dur-pop); a toast sent while the box leaves
|
|
275
|
-
takes it back instead.
|
|
276
|
-
|
|
277
|
-
It dismisses on its timer, the close button at its end, Escape (with
|
|
278
|
-
focus inside), running the action, and on touch a swipe left, right or
|
|
279
|
-
down (internal/swipe-dismiss.ts). After a swipe it leaves the way the
|
|
280
|
-
finger went.
|
|
281
|
-
|
|
282
|
-
Screen readers hear each message from a visually hidden role="status"
|
|
283
|
-
line that stays mounted. Each toast is a new node in it, so the same
|
|
284
|
-
message twice is announced twice. The box is not a live region and never
|
|
285
|
-
takes focus by itself: it is a region landmark named by `label`, last in
|
|
286
|
-
the page. F8 moves focus to the action, or to the close button when
|
|
287
|
-
there is none; Escape, the close button and the action dismiss it and
|
|
288
|
-
give focus back. The timer waits while a pointer or finger is on the
|
|
289
|
-
box, while focus is inside it and while the page is hidden.
|
|
290
|
-
-->
|
|
291
|
-
<svelte:window onkeydown={jump} />
|
|
292
|
-
|
|
293
|
-
{#if active}
|
|
294
|
-
<Portal>
|
|
295
|
-
<p class="hp-visually-hidden hp-toaster-status" role="status">
|
|
296
|
-
{#if shown}{#key shown.id}<span>{shown.message}</span>{/key}{/if}
|
|
297
|
-
</p>
|
|
298
|
-
{#if shown}
|
|
299
|
-
{@const lifted = viewport.current}
|
|
300
|
-
<section
|
|
301
|
-
bind:this={box}
|
|
302
|
-
{...rest}
|
|
303
|
-
class={['hp-toaster', className]}
|
|
304
|
-
aria-label={label}
|
|
305
|
-
aria-keyshortcuts="F8"
|
|
306
|
-
data-action={shown.action ? '' : undefined}
|
|
307
|
-
data-lifted={lifted ? '' : undefined}
|
|
308
|
-
style:bottom={lifted ? `calc(100% - ${lifted.bottom}px + var(--gutter))` : undefined}
|
|
309
|
-
{@attach listen}
|
|
310
|
-
{@attach swipeDismiss(swipeAway, { direction: ['left', 'right', 'down'] })}
|
|
311
|
-
transition:motion
|
|
312
|
-
>
|
|
313
|
-
<p class="hp-toaster-message">{shown.message}</p>
|
|
314
|
-
{#if shown.action}
|
|
315
|
-
<Button variant="tertiary" class="hp-toaster-action" onclick={() => shown && run(shown)}>
|
|
316
|
-
{shown.action.label}
|
|
317
|
-
</Button>
|
|
318
|
-
{/if}
|
|
319
|
-
<Button
|
|
320
|
-
variant="tertiary"
|
|
321
|
-
icon
|
|
322
|
-
class="hp-toaster-close"
|
|
323
|
-
aria-label={closeLabel}
|
|
324
|
-
onclick={() => shown && dismiss(shown)}
|
|
325
|
-
>
|
|
326
|
-
<svg
|
|
327
|
-
viewBox="0 0 16 16"
|
|
328
|
-
fill="none"
|
|
329
|
-
stroke="currentColor"
|
|
330
|
-
stroke-width="1.5"
|
|
331
|
-
stroke-linecap="square"
|
|
332
|
-
aria-hidden="true"
|
|
333
|
-
>
|
|
334
|
-
<path d="M3 3l10 10M13 3L3 13" />
|
|
335
|
-
</svg>
|
|
336
|
-
</Button>
|
|
337
|
-
</section>
|
|
338
|
-
{/if}
|
|
339
|
-
</Portal>
|
|
340
|
-
{/if}
|
|
341
|
-
|
|
342
|
-
<style>
|
|
343
|
-
/* The Tooltip's box at the bottom centre: black, the control boundary,
|
|
344
|
-
square. As wide as its text up to --toast-width, 30rem unless set (an
|
|
345
|
-
app sets it with a class on the Toaster and a rule in its stylesheet;
|
|
346
|
-
the default is the var() fallback, so any rule wins whatever its
|
|
347
|
-
specificity), inside the page gutters and the safe areas, so it never
|
|
348
|
-
meets a screen edge. Bits UI sets
|
|
349
|
-
pointer-events: none on <body> while a dialog or a list is open; the
|
|
350
|
-
toast takes presses anyway. A drag along either axis is the swipe's,
|
|
351
|
-
so it never scrolls the page under the toast; pinch zoom stays the
|
|
352
|
-
browser's. */
|
|
353
|
-
.hp-toaster {
|
|
354
|
-
position: fixed;
|
|
355
|
-
right: calc(var(--gutter) + env(safe-area-inset-right));
|
|
356
|
-
bottom: calc(var(--gutter) + env(safe-area-inset-bottom));
|
|
357
|
-
left: calc(var(--gutter) + env(safe-area-inset-left));
|
|
358
|
-
z-index: var(--layer-toast);
|
|
359
|
-
display: flex;
|
|
360
|
-
align-items: center;
|
|
361
|
-
box-sizing: border-box;
|
|
362
|
-
width: fit-content;
|
|
363
|
-
max-width: var(--toast-width, 30rem);
|
|
364
|
-
margin: 0 auto;
|
|
365
|
-
border: 1px solid var(--border-control);
|
|
366
|
-
background: var(--canvas);
|
|
367
|
-
color: var(--text);
|
|
368
|
-
font-family: var(--sans);
|
|
369
|
-
font-size: var(--type-body);
|
|
370
|
-
line-height: 24px;
|
|
371
|
-
pointer-events: auto;
|
|
372
|
-
touch-action: pinch-zoom;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/* Over AppShell's bottom bar (compact widths, data-bar), which is
|
|
376
|
-
--header-height high above the bottom safe area. While the on-screen
|
|
377
|
-
keyboard is up, the inline bottom wins: the bar is under the keyboard. */
|
|
378
|
-
@media (max-width: 599px) {
|
|
379
|
-
:global(:where(html:has(.hp-app-shell[data-bar]))) .hp-toaster {
|
|
380
|
-
bottom: calc(var(--header-height) + var(--gutter) + env(safe-area-inset-bottom));
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/* 16px where a coarse pointer reads it, the dense 14px on fine pointers. */
|
|
385
|
-
@media (pointer: fine) {
|
|
386
|
-
.hp-toaster {
|
|
387
|
-
font-size: var(--type-dense);
|
|
388
|
-
line-height: 20px;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/* One control high on one line, so a box with an action and one without
|
|
393
|
-
match; longer text wraps, never truncates. */
|
|
394
|
-
.hp-toaster-message {
|
|
395
|
-
display: flex;
|
|
396
|
-
flex: 1 1 auto;
|
|
397
|
-
align-items: center;
|
|
398
|
-
min-width: 0;
|
|
399
|
-
min-height: var(--control-height);
|
|
400
|
-
padding: var(--space-2) var(--space-3);
|
|
401
|
-
overflow-wrap: anywhere;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/* At the end: the action, a text button with its own padding, then the
|
|
405
|
-
close button, Dialog's × as a square tertiary icon button
|
|
406
|
-
(--control-height: 36px fine, 44px coarse). */
|
|
407
|
-
.hp-toaster :global(.hp-toaster-action),
|
|
408
|
-
.hp-toaster :global(.hp-toaster-close) {
|
|
409
|
-
flex: none;
|
|
410
|
-
}
|
|
411
|
-
</style>
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
/** Attributes and `class` go to the toast box while one shows. */
|
|
5
|
+
export type ToasterProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
|
|
6
|
+
/** The toast region's name, for landmark navigation (default "Notifications"). */
|
|
7
|
+
label?: string;
|
|
8
|
+
/** The close button's accessible name (default "Dismiss"). */
|
|
9
|
+
closeLabel?: string;
|
|
10
|
+
};
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<script lang="ts">
|
|
14
|
+
import { Portal } from 'bits-ui';
|
|
15
|
+
import { onMount, untrack } from 'svelte';
|
|
16
|
+
import { prefersReducedMotion } from 'svelte/motion';
|
|
17
|
+
import type { TransitionConfig } from 'svelte/transition';
|
|
18
|
+
import { curve, duration } from '../motion/index.js';
|
|
19
|
+
import Button from './Button.svelte';
|
|
20
|
+
import { swipeDismiss, type SwipeDirection } from './internal/swipe-dismiss.js';
|
|
21
|
+
import { followVisualViewport } from './internal/visual-viewport.svelte.js';
|
|
22
|
+
import { toastSlot as slot, type ShownToast } from './toast.svelte.js';
|
|
23
|
+
|
|
24
|
+
let { label = 'Notifications', closeLabel = 'Dismiss', class: className, ...rest }: ToasterProps = $props();
|
|
25
|
+
|
|
26
|
+
/** How far the box rises as it enters and drops as it leaves (px). */
|
|
27
|
+
const RISE = 16;
|
|
28
|
+
/** How far below rest a replacement's short entrance starts (px). */
|
|
29
|
+
const REPLAY_RISE = 8;
|
|
30
|
+
|
|
31
|
+
// The first Toaster mounted renders the slot; another one renders
|
|
32
|
+
// nothing until the first unmounts.
|
|
33
|
+
const self = {};
|
|
34
|
+
onMount(() => slot.register(self));
|
|
35
|
+
const active = $derived(slot.renders(self));
|
|
36
|
+
const shown = $derived(active ? slot.current : null);
|
|
37
|
+
|
|
38
|
+
let box = $state<HTMLElement | null>(null);
|
|
39
|
+
// Where focus was before it entered the toast, to give it back.
|
|
40
|
+
let origin: HTMLElement | null = null;
|
|
41
|
+
// The way a swipe sent the box off, until it is gone or back at rest.
|
|
42
|
+
let swiped: SwipeDirection | null = null;
|
|
43
|
+
// Svelte's transition on the box, from its start event to its end.
|
|
44
|
+
let transit: 'in' | 'out' | null = null;
|
|
45
|
+
// The id shown before the last change, and the replacement's entrance.
|
|
46
|
+
let lastId: number | null = null;
|
|
47
|
+
let replay: Animation | null = null;
|
|
48
|
+
|
|
49
|
+
// Above the on-screen keyboard (internal/visual-viewport.svelte.ts): while
|
|
50
|
+
// it shortens the visual viewport, the toast stands on that edge instead.
|
|
51
|
+
const viewport = followVisualViewport(
|
|
52
|
+
() => shown !== null,
|
|
53
|
+
() => null
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// A hidden page holds the timer, so a toast is never used up unseen.
|
|
57
|
+
$effect(() => {
|
|
58
|
+
if (!active) return;
|
|
59
|
+
const sync = () => (document.hidden ? slot.hold('hidden') : slot.release('hidden'));
|
|
60
|
+
untrack(sync);
|
|
61
|
+
document.addEventListener('visibilitychange', sync);
|
|
62
|
+
return () => {
|
|
63
|
+
document.removeEventListener('visibilitychange', sync);
|
|
64
|
+
untrack(() => {
|
|
65
|
+
slot.release('hidden');
|
|
66
|
+
slot.release('pointer');
|
|
67
|
+
slot.release('focus');
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Before the box changes: focus on a part that goes away (the whole box,
|
|
73
|
+
// or the action when the next toast has none) returns to where it came
|
|
74
|
+
// from. A box that goes away under a resting pointer or with focus gets
|
|
75
|
+
// no leave event, so its holds end here.
|
|
76
|
+
$effect.pre(() => {
|
|
77
|
+
const next = shown;
|
|
78
|
+
untrack(() => {
|
|
79
|
+
const focused = document.activeElement;
|
|
80
|
+
if (
|
|
81
|
+
box &&
|
|
82
|
+
focused &&
|
|
83
|
+
box.contains(focused) &&
|
|
84
|
+
(!next || (focused.matches('.hp-toaster-action') && !next.action))
|
|
85
|
+
) {
|
|
86
|
+
giveFocusBack();
|
|
87
|
+
}
|
|
88
|
+
if (!next && box) {
|
|
89
|
+
slot.release('pointer');
|
|
90
|
+
slot.release('focus');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// After the box changed: a toast that replaces the one on screen replays
|
|
96
|
+
// a short entrance. While Svelte's transition moves the box (entering,
|
|
97
|
+
// or leaving and reversed by this toast), the new text rides it.
|
|
98
|
+
$effect(() => {
|
|
99
|
+
const id = shown?.id ?? null;
|
|
100
|
+
untrack(() => {
|
|
101
|
+
const replaced = id !== null && lastId !== null && id !== lastId;
|
|
102
|
+
lastId = id;
|
|
103
|
+
if (replaced && box && !transit) replayEntrance(box);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The replacement's entrance: from 8px down and transparent to rest over
|
|
109
|
+
* --dur-pop on --settle, both read from the box. One that arrives during
|
|
110
|
+
* it starts again from where the box is. Reduced motion: opacity only.
|
|
111
|
+
*/
|
|
112
|
+
function replayEntrance(node: HTMLElement) {
|
|
113
|
+
const ms = duration(node, '--dur-pop');
|
|
114
|
+
if (ms <= 0) return;
|
|
115
|
+
const moves = !prefersReducedMotion.current;
|
|
116
|
+
const now = getComputedStyle(node);
|
|
117
|
+
const running = replay?.playState === 'running';
|
|
118
|
+
const from: Keyframe = { opacity: running ? now.opacity : 0 };
|
|
119
|
+
if (moves) from.transform = running ? now.transform : `translateY(${REPLAY_RISE}px)`;
|
|
120
|
+
const settle = now.getPropertyValue('--settle').trim();
|
|
121
|
+
replay?.cancel();
|
|
122
|
+
replay = node.animate([from, moves ? { opacity: 1, transform: 'none' } : { opacity: 1 }], {
|
|
123
|
+
duration: ms,
|
|
124
|
+
easing: CSS.supports('transition-timing-function', settle) ? settle : 'ease-out'
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Showing and dismissing, at the sheet timing read from the box
|
|
130
|
+
* (--dur-sheet in, --dur-sheet-exit out, on --settle): a 16px rise with
|
|
131
|
+
* a fade, and a 16px drop with a fade. After a swipe the box leaves the
|
|
132
|
+
* way the finger went, by its own size, from where the finger let go; a
|
|
133
|
+
* toast sent during that exit brings it back the same way. Reduced
|
|
134
|
+
* motion: a fade in place.
|
|
135
|
+
*/
|
|
136
|
+
function motion(node: Element) {
|
|
137
|
+
return ({ direction }: { direction?: 'in' | 'out' } = {}): TransitionConfig => {
|
|
138
|
+
const way = swiped;
|
|
139
|
+
const path = (u: number) =>
|
|
140
|
+
way === 'left' || way === 'right'
|
|
141
|
+
? `translateX(${(way === 'left' ? -u : u) * 100}%)`
|
|
142
|
+
: way === 'down'
|
|
143
|
+
? `translateY(${u * 100}%)`
|
|
144
|
+
: `translateY(${u * RISE}px)`;
|
|
145
|
+
return {
|
|
146
|
+
duration: duration(node, direction === 'out' ? '--dur-sheet-exit' : '--dur-sheet'),
|
|
147
|
+
easing: curve(node),
|
|
148
|
+
css: prefersReducedMotion.current
|
|
149
|
+
? (t) => `opacity: ${t}`
|
|
150
|
+
: (t, u) => `opacity: ${t}; transform: ${path(u)}`
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function giveFocusBack() {
|
|
156
|
+
const focused = document.activeElement;
|
|
157
|
+
if (!box || !(focused instanceof HTMLElement) || !box.contains(focused)) return;
|
|
158
|
+
if (origin?.isConnected) origin.focus({ preventScroll: true });
|
|
159
|
+
else focused.blur();
|
|
160
|
+
origin = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// F8 moves focus to the action, or to the close button when there is none.
|
|
164
|
+
function jump(e: KeyboardEvent) {
|
|
165
|
+
if (e.key !== 'F8' || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || !box) return;
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
box.querySelector<HTMLElement>('.hp-toaster-action, .hp-toaster-close')?.focus();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The close button, Escape, a swipe and the action: focus inside goes
|
|
171
|
+
// back to where it came from, and the box leaves.
|
|
172
|
+
function dismiss(toast: ShownToast) {
|
|
173
|
+
giveFocusBack();
|
|
174
|
+
slot.dismiss(toast.id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function run(toast: ShownToast) {
|
|
178
|
+
dismiss(toast);
|
|
179
|
+
toast.action?.onclick();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function swipeAway(way: SwipeDirection) {
|
|
183
|
+
if (!shown) return;
|
|
184
|
+
swiped = way;
|
|
185
|
+
dismiss(shown);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function escape(e: KeyboardEvent) {
|
|
189
|
+
if (e.key !== 'Escape' || !shown) return;
|
|
190
|
+
// Escape here belongs to the toast, not to a dialog or menu under it.
|
|
191
|
+
e.stopPropagation();
|
|
192
|
+
dismiss(shown);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function focusIn(e: FocusEvent) {
|
|
196
|
+
// An open dialog's focus trap may already have pulled focus back.
|
|
197
|
+
if (!box?.contains(document.activeElement)) return;
|
|
198
|
+
const from = e.relatedTarget;
|
|
199
|
+
if (!(from instanceof Node) || !box.contains(from)) origin = from instanceof HTMLElement ? from : null;
|
|
200
|
+
slot.hold('focus');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function focusOut(e: FocusEvent) {
|
|
204
|
+
const to = e.relatedTarget;
|
|
205
|
+
if (to instanceof Node && box?.contains(to)) return;
|
|
206
|
+
slot.release('focus');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A press keeps focus where it is, so a field keeps the on-screen
|
|
210
|
+
// keyboard up, and it is not a press outside an open dialog, popover or
|
|
211
|
+
// menu, which would close it. The action's and close button's clicks
|
|
212
|
+
// still run.
|
|
213
|
+
function press(e: PointerEvent) {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
e.stopPropagation();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A pointer over the box holds the timer. On touch the finger is over it
|
|
219
|
+
// from touchdown to lift, so a drag holds it too.
|
|
220
|
+
const enter = () => slot.hold('pointer');
|
|
221
|
+
const leave = () => slot.release('pointer');
|
|
222
|
+
|
|
223
|
+
function introStart() {
|
|
224
|
+
transit = 'in';
|
|
225
|
+
// A toast sent during a swipe's exit brings the box back on the
|
|
226
|
+
// swipe's path; the finger's offset springs back with it.
|
|
227
|
+
if (swiped && box) {
|
|
228
|
+
box.style.transition = prefersReducedMotion.current ? '' : 'translate var(--dur-sheet) var(--settle)';
|
|
229
|
+
box.style.translate = '';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const outroStart = () => (transit = 'out');
|
|
233
|
+
function atRest() {
|
|
234
|
+
transit = null;
|
|
235
|
+
swiped = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// On the box itself, not delegated, so a stopped press or Escape never
|
|
239
|
+
// reaches Bits UI's listeners on the document.
|
|
240
|
+
function listen(node: HTMLElement) {
|
|
241
|
+
// A new box carries nothing over from one that left mid-transition.
|
|
242
|
+
atRest();
|
|
243
|
+
replay = null;
|
|
244
|
+
node.addEventListener('pointerdown', press);
|
|
245
|
+
node.addEventListener('pointerenter', enter);
|
|
246
|
+
node.addEventListener('pointerleave', leave);
|
|
247
|
+
node.addEventListener('focusin', focusIn);
|
|
248
|
+
node.addEventListener('focusout', focusOut);
|
|
249
|
+
node.addEventListener('keydown', escape);
|
|
250
|
+
node.addEventListener('introstart', introStart);
|
|
251
|
+
node.addEventListener('introend', atRest);
|
|
252
|
+
node.addEventListener('outrostart', outroStart);
|
|
253
|
+
node.addEventListener('outroend', atRest);
|
|
254
|
+
return () => {
|
|
255
|
+
node.removeEventListener('pointerdown', press);
|
|
256
|
+
node.removeEventListener('pointerenter', enter);
|
|
257
|
+
node.removeEventListener('pointerleave', leave);
|
|
258
|
+
node.removeEventListener('focusin', focusIn);
|
|
259
|
+
node.removeEventListener('focusout', focusOut);
|
|
260
|
+
node.removeEventListener('keydown', escape);
|
|
261
|
+
node.removeEventListener('introstart', introStart);
|
|
262
|
+
node.removeEventListener('introend', atRest);
|
|
263
|
+
node.removeEventListener('outrostart', outroStart);
|
|
264
|
+
node.removeEventListener('outroend', atRest);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
</script>
|
|
268
|
+
|
|
269
|
+
<!--
|
|
270
|
+
The one toast, at the bottom centre over everything but tooltips. It
|
|
271
|
+
rises 16px with a fade over --dur-sheet and drops 16px with a fade over
|
|
272
|
+
--dur-sheet-exit (reduced motion fades only). A new toast replaces the
|
|
273
|
+
text in place: the box keeps its place, changes size at once and replays
|
|
274
|
+
a short entrance (8px over --dur-pop); a toast sent while the box leaves
|
|
275
|
+
takes it back instead.
|
|
276
|
+
|
|
277
|
+
It dismisses on its timer, the close button at its end, Escape (with
|
|
278
|
+
focus inside), running the action, and on touch a swipe left, right or
|
|
279
|
+
down (internal/swipe-dismiss.ts). After a swipe it leaves the way the
|
|
280
|
+
finger went.
|
|
281
|
+
|
|
282
|
+
Screen readers hear each message from a visually hidden role="status"
|
|
283
|
+
line that stays mounted. Each toast is a new node in it, so the same
|
|
284
|
+
message twice is announced twice. The box is not a live region and never
|
|
285
|
+
takes focus by itself: it is a region landmark named by `label`, last in
|
|
286
|
+
the page. F8 moves focus to the action, or to the close button when
|
|
287
|
+
there is none; Escape, the close button and the action dismiss it and
|
|
288
|
+
give focus back. The timer waits while a pointer or finger is on the
|
|
289
|
+
box, while focus is inside it and while the page is hidden.
|
|
290
|
+
-->
|
|
291
|
+
<svelte:window onkeydown={jump} />
|
|
292
|
+
|
|
293
|
+
{#if active}
|
|
294
|
+
<Portal>
|
|
295
|
+
<p class="hp-visually-hidden hp-toaster-status" role="status">
|
|
296
|
+
{#if shown}{#key shown.id}<span>{shown.message}</span>{/key}{/if}
|
|
297
|
+
</p>
|
|
298
|
+
{#if shown}
|
|
299
|
+
{@const lifted = viewport.current}
|
|
300
|
+
<section
|
|
301
|
+
bind:this={box}
|
|
302
|
+
{...rest}
|
|
303
|
+
class={['hp-toaster', className]}
|
|
304
|
+
aria-label={label}
|
|
305
|
+
aria-keyshortcuts="F8"
|
|
306
|
+
data-action={shown.action ? '' : undefined}
|
|
307
|
+
data-lifted={lifted ? '' : undefined}
|
|
308
|
+
style:bottom={lifted ? `calc(100% - ${lifted.bottom}px + var(--gutter))` : undefined}
|
|
309
|
+
{@attach listen}
|
|
310
|
+
{@attach swipeDismiss(swipeAway, { direction: ['left', 'right', 'down'] })}
|
|
311
|
+
transition:motion
|
|
312
|
+
>
|
|
313
|
+
<p class="hp-toaster-message">{shown.message}</p>
|
|
314
|
+
{#if shown.action}
|
|
315
|
+
<Button variant="tertiary" class="hp-toaster-action" onclick={() => shown && run(shown)}>
|
|
316
|
+
{shown.action.label}
|
|
317
|
+
</Button>
|
|
318
|
+
{/if}
|
|
319
|
+
<Button
|
|
320
|
+
variant="tertiary"
|
|
321
|
+
icon
|
|
322
|
+
class="hp-toaster-close"
|
|
323
|
+
aria-label={closeLabel}
|
|
324
|
+
onclick={() => shown && dismiss(shown)}
|
|
325
|
+
>
|
|
326
|
+
<svg
|
|
327
|
+
viewBox="0 0 16 16"
|
|
328
|
+
fill="none"
|
|
329
|
+
stroke="currentColor"
|
|
330
|
+
stroke-width="1.5"
|
|
331
|
+
stroke-linecap="square"
|
|
332
|
+
aria-hidden="true"
|
|
333
|
+
>
|
|
334
|
+
<path d="M3 3l10 10M13 3L3 13" />
|
|
335
|
+
</svg>
|
|
336
|
+
</Button>
|
|
337
|
+
</section>
|
|
338
|
+
{/if}
|
|
339
|
+
</Portal>
|
|
340
|
+
{/if}
|
|
341
|
+
|
|
342
|
+
<style>
|
|
343
|
+
/* The Tooltip's box at the bottom centre: black, the control boundary,
|
|
344
|
+
square. As wide as its text up to --toast-width, 30rem unless set (an
|
|
345
|
+
app sets it with a class on the Toaster and a rule in its stylesheet;
|
|
346
|
+
the default is the var() fallback, so any rule wins whatever its
|
|
347
|
+
specificity), inside the page gutters and the safe areas, so it never
|
|
348
|
+
meets a screen edge. Bits UI sets
|
|
349
|
+
pointer-events: none on <body> while a dialog or a list is open; the
|
|
350
|
+
toast takes presses anyway. A drag along either axis is the swipe's,
|
|
351
|
+
so it never scrolls the page under the toast; pinch zoom stays the
|
|
352
|
+
browser's. */
|
|
353
|
+
.hp-toaster {
|
|
354
|
+
position: fixed;
|
|
355
|
+
right: calc(var(--gutter) + env(safe-area-inset-right));
|
|
356
|
+
bottom: calc(var(--gutter) + env(safe-area-inset-bottom));
|
|
357
|
+
left: calc(var(--gutter) + env(safe-area-inset-left));
|
|
358
|
+
z-index: var(--layer-toast);
|
|
359
|
+
display: flex;
|
|
360
|
+
align-items: center;
|
|
361
|
+
box-sizing: border-box;
|
|
362
|
+
width: fit-content;
|
|
363
|
+
max-width: var(--toast-width, 30rem);
|
|
364
|
+
margin: 0 auto;
|
|
365
|
+
border: 1px solid var(--border-control);
|
|
366
|
+
background: var(--canvas);
|
|
367
|
+
color: var(--text);
|
|
368
|
+
font-family: var(--sans);
|
|
369
|
+
font-size: var(--type-body);
|
|
370
|
+
line-height: 24px;
|
|
371
|
+
pointer-events: auto;
|
|
372
|
+
touch-action: pinch-zoom;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* Over AppShell's bottom bar (compact widths, data-bar), which is
|
|
376
|
+
--header-height high above the bottom safe area. While the on-screen
|
|
377
|
+
keyboard is up, the inline bottom wins: the bar is under the keyboard. */
|
|
378
|
+
@media (max-width: 599px) {
|
|
379
|
+
:global(:where(html:has(.hp-app-shell[data-bar]))) .hp-toaster {
|
|
380
|
+
bottom: calc(var(--header-height) + var(--gutter) + env(safe-area-inset-bottom));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* 16px where a coarse pointer reads it, the dense 14px on fine pointers. */
|
|
385
|
+
@media (pointer: fine) {
|
|
386
|
+
.hp-toaster {
|
|
387
|
+
font-size: var(--type-dense);
|
|
388
|
+
line-height: 20px;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* One control high on one line, so a box with an action and one without
|
|
393
|
+
match; longer text wraps, never truncates. */
|
|
394
|
+
.hp-toaster-message {
|
|
395
|
+
display: flex;
|
|
396
|
+
flex: 1 1 auto;
|
|
397
|
+
align-items: center;
|
|
398
|
+
min-width: 0;
|
|
399
|
+
min-height: var(--control-height);
|
|
400
|
+
padding: var(--space-2) var(--space-3);
|
|
401
|
+
overflow-wrap: anywhere;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* At the end: the action, a text button with its own padding, then the
|
|
405
|
+
close button, Dialog's × as a square tertiary icon button
|
|
406
|
+
(--control-height: 36px fine, 44px coarse). */
|
|
407
|
+
.hp-toaster :global(.hp-toaster-action),
|
|
408
|
+
.hp-toaster :global(.hp-toaster-close) {
|
|
409
|
+
flex: none;
|
|
410
|
+
}
|
|
411
|
+
</style>
|