@mystravil/hardpoint 0.2.0 → 0.2.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +42 -38
  2. package/LICENSE +25 -25
  3. package/README.md +67 -64
  4. package/dist/base.css +625 -625
  5. package/dist/components/AppShell.svelte +698 -698
  6. package/dist/components/Button.svelte +174 -174
  7. package/dist/components/Checkbox.svelte +235 -235
  8. package/dist/components/CheckboxGroup.svelte +207 -207
  9. package/dist/components/Combobox.svelte +901 -901
  10. package/dist/components/Dialog.svelte +359 -359
  11. package/dist/components/Disclosure.svelte +201 -201
  12. package/dist/components/EmptyState.svelte +88 -88
  13. package/dist/components/ErrorState.svelte +135 -135
  14. package/dist/components/ImageViewer.svelte +764 -764
  15. package/dist/components/KeyValueList.svelte +114 -114
  16. package/dist/components/ListDetail.svelte +218 -218
  17. package/dist/components/LoadRegion.svelte +219 -219
  18. package/dist/components/MediaFrame.svelte +110 -110
  19. package/dist/components/MediaImage.svelte +256 -256
  20. package/dist/components/Menu.svelte +258 -258
  21. package/dist/components/MenuCheckboxItem.svelte +119 -119
  22. package/dist/components/MenuGroup.svelte +50 -50
  23. package/dist/components/MenuItem.svelte +94 -94
  24. package/dist/components/MenuRadioGroup.svelte +62 -62
  25. package/dist/components/MenuRadioItem.svelte +110 -110
  26. package/dist/components/MenuSeparator.svelte +28 -28
  27. package/dist/components/Meter.svelte +283 -283
  28. package/dist/components/NativeSelect.svelte +135 -135
  29. package/dist/components/Notice.svelte +262 -262
  30. package/dist/components/Popover.svelte +222 -222
  31. package/dist/components/Progress.svelte +157 -157
  32. package/dist/components/RadioGroup.svelte +440 -440
  33. package/dist/components/Select.svelte +471 -471
  34. package/dist/components/Skeleton.svelte +149 -149
  35. package/dist/components/Spinner.svelte +118 -118
  36. package/dist/components/Stat.svelte +253 -253
  37. package/dist/components/Steps.svelte +189 -189
  38. package/dist/components/Switch.svelte +222 -222
  39. package/dist/components/Table.svelte +504 -504
  40. package/dist/components/Tabs.svelte +246 -246
  41. package/dist/components/Tag.svelte +40 -40
  42. package/dist/components/TextArea.svelte +272 -272
  43. package/dist/components/TextField.svelte +256 -256
  44. package/dist/components/Timeline.svelte +156 -156
  45. package/dist/components/Toaster.svelte +411 -411
  46. package/dist/components/Tooltip.svelte +200 -200
  47. package/dist/components/TooltipGroup.svelte +46 -46
  48. package/dist/components/internal/Scrim.svelte +52 -52
  49. package/dist/components/internal/Sheet.svelte +263 -263
  50. package/dist/fonts.css +71 -71
  51. package/dist/tokens.css +283 -283
  52. 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>