@mystravil/hardpoint 0.0.0-stage → 0.1.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.
Files changed (112) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +346 -0
  5. package/dist/components/AppShell.svelte +698 -0
  6. package/dist/components/AppShell.svelte.d.ts +77 -0
  7. package/dist/components/Button.svelte +174 -0
  8. package/dist/components/Button.svelte.d.ts +20 -0
  9. package/dist/components/Checkbox.svelte +198 -0
  10. package/dist/components/Checkbox.svelte.d.ts +16 -0
  11. package/dist/components/CheckboxGroup.svelte +99 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +25 -0
  13. package/dist/components/Combobox.svelte +824 -0
  14. package/dist/components/Combobox.svelte.d.ts +63 -0
  15. package/dist/components/Dialog.svelte +356 -0
  16. package/dist/components/Dialog.svelte.d.ts +43 -0
  17. package/dist/components/Disclosure.svelte +201 -0
  18. package/dist/components/Disclosure.svelte.d.ts +27 -0
  19. package/dist/components/EmptyState.svelte +88 -0
  20. package/dist/components/EmptyState.svelte.d.ts +22 -0
  21. package/dist/components/ErrorState.svelte +135 -0
  22. package/dist/components/ErrorState.svelte.d.ts +26 -0
  23. package/dist/components/KeyValueList.svelte +114 -0
  24. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  25. package/dist/components/LoadRegion.svelte +219 -0
  26. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  27. package/dist/components/MediaFrame.svelte +110 -0
  28. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  29. package/dist/components/MediaImage.svelte +256 -0
  30. package/dist/components/MediaImage.svelte.d.ts +16 -0
  31. package/dist/components/Menu.svelte +258 -0
  32. package/dist/components/Menu.svelte.d.ts +36 -0
  33. package/dist/components/MenuCheckboxItem.svelte +119 -0
  34. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  35. package/dist/components/MenuGroup.svelte +50 -0
  36. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  37. package/dist/components/MenuItem.svelte +94 -0
  38. package/dist/components/MenuItem.svelte.d.ts +19 -0
  39. package/dist/components/MenuRadioGroup.svelte +62 -0
  40. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  41. package/dist/components/MenuRadioItem.svelte +110 -0
  42. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  43. package/dist/components/MenuSeparator.svelte +28 -0
  44. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  45. package/dist/components/NativeSelect.svelte +119 -0
  46. package/dist/components/NativeSelect.svelte.d.ts +46 -0
  47. package/dist/components/Pagination.svelte +132 -0
  48. package/dist/components/Pagination.svelte.d.ts +18 -0
  49. package/dist/components/Popover.svelte +222 -0
  50. package/dist/components/Popover.svelte.d.ts +35 -0
  51. package/dist/components/Progress.svelte +157 -0
  52. package/dist/components/Progress.svelte.d.ts +34 -0
  53. package/dist/components/Select.svelte +405 -0
  54. package/dist/components/Select.svelte.d.ts +42 -0
  55. package/dist/components/Skeleton.svelte +149 -0
  56. package/dist/components/Skeleton.svelte.d.ts +24 -0
  57. package/dist/components/Spinner.svelte +117 -0
  58. package/dist/components/Spinner.svelte.d.ts +13 -0
  59. package/dist/components/Stat.svelte +253 -0
  60. package/dist/components/Stat.svelte.d.ts +46 -0
  61. package/dist/components/Steps.svelte +189 -0
  62. package/dist/components/Steps.svelte.d.ts +31 -0
  63. package/dist/components/Switch.svelte +222 -0
  64. package/dist/components/Switch.svelte.d.ts +18 -0
  65. package/dist/components/Table.svelte +504 -0
  66. package/dist/components/Table.svelte.d.ts +92 -0
  67. package/dist/components/Tabs.svelte +246 -0
  68. package/dist/components/Tabs.svelte.d.ts +44 -0
  69. package/dist/components/Tag.svelte +40 -0
  70. package/dist/components/Tag.svelte.d.ts +15 -0
  71. package/dist/components/TextField.svelte +256 -0
  72. package/dist/components/TextField.svelte.d.ts +32 -0
  73. package/dist/components/Timeline.svelte +156 -0
  74. package/dist/components/Timeline.svelte.d.ts +30 -0
  75. package/dist/components/Toaster.svelte +411 -0
  76. package/dist/components/Toaster.svelte.d.ts +11 -0
  77. package/dist/components/Tooltip.svelte +200 -0
  78. package/dist/components/Tooltip.svelte.d.ts +38 -0
  79. package/dist/components/TooltipGroup.svelte +46 -0
  80. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  81. package/dist/components/internal/Scrim.svelte +52 -0
  82. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  83. package/dist/components/internal/Sheet.svelte +263 -0
  84. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  85. package/dist/components/internal/fade-in.d.ts +10 -0
  86. package/dist/components/internal/fade-in.js +20 -0
  87. package/dist/components/internal/resolve.d.ts +8 -0
  88. package/dist/components/internal/resolve.js +247 -0
  89. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  90. package/dist/components/internal/swipe-dismiss.js +220 -0
  91. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  92. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  93. package/dist/components/toast.svelte.d.ts +57 -0
  94. package/dist/components/toast.svelte.js +84 -0
  95. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  96. package/dist/fonts/OFL.txt +93 -0
  97. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  98. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  99. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  100. package/dist/fonts.css +71 -0
  101. package/dist/index.d.ts +39 -0
  102. package/dist/index.js +39 -0
  103. package/dist/motion/Motion.svelte +15 -0
  104. package/dist/motion/Motion.svelte.d.ts +9 -0
  105. package/dist/motion/context.d.ts +18 -0
  106. package/dist/motion/context.js +26 -0
  107. package/dist/motion/index.d.ts +3 -0
  108. package/dist/motion/index.js +3 -0
  109. package/dist/motion/transitions.d.ts +31 -0
  110. package/dist/motion/transitions.js +112 -0
  111. package/dist/tokens.css +279 -0
  112. package/package.json +76 -6
@@ -0,0 +1,156 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLOlAttributes } from 'svelte/elements';
3
+
4
+ /** A status hue for an event's node: `ok` done, `warn` waiting or needs you, `danger` failed. */
5
+ export type TimelineTone = 'ok' | 'warn' | 'danger';
6
+
7
+ export type TimelineEvent = {
8
+ /** What happened, as the app or a user wrote it. */
9
+ title: string;
10
+ /** A supporting line in `--muted`. */
11
+ detail?: string;
12
+ /** The date as shown. */
13
+ date: string;
14
+ /** The time as shown, after the date. */
15
+ time?: string;
16
+ /**
17
+ * The machine-readable moment for `<time datetime>`. Defaults to
18
+ * `date`, or `dateTtime` with a time, so ISO dates need nothing else.
19
+ */
20
+ datetime?: string;
21
+ /**
22
+ * Colours the node in a status hue. The title must say the same, because
23
+ * colour is never the only signal.
24
+ */
25
+ tone?: TimelineTone;
26
+ };
27
+
28
+ export type TimelineProps = Omit<HTMLOlAttributes, 'children'> & {
29
+ /** Oldest first or newest first, in the order given. */
30
+ events: TimelineEvent[];
31
+ };
32
+ </script>
33
+
34
+ <script lang="ts">
35
+ import { onMount, untrack } from 'svelte';
36
+ import { useMotion } from '../motion/index.js';
37
+
38
+ let { events, class: className, ...rest }: TimelineProps = $props();
39
+
40
+ // Expressive stagger (motion rule 7), as in Table: only the events of the
41
+ // first render enter, by their index. Each <li> carries its node and its
42
+ // rail segment, so they enter with it. Any change to `events` (a new array
43
+ // or a changed length) ends the entrance, and so does its last animation.
44
+ const motion = useMotion();
45
+ const firstEvents = untrack(() => events);
46
+ const firstCount = untrack(() => events.length);
47
+ let entering = $state.raw(motion.expressive);
48
+ let root: HTMLOListElement;
49
+
50
+ $effect.pre(() => {
51
+ if (events !== firstEvents || events.length !== firstCount) entering = false;
52
+ });
53
+
54
+ onMount(() => {
55
+ if (!entering) return;
56
+ const running = root
57
+ .getAnimations({ subtree: true })
58
+ .filter((a) => a instanceof CSSAnimation && a.animationName === 'hp-enter');
59
+ Promise.allSettled(running.map((a) => a.finished)).then(() => {
60
+ entering = false;
61
+ });
62
+ });
63
+ </script>
64
+
65
+ <!--
66
+ Square nodes on a 2px titanium rail (BRIEF, visual rule 7; da3-60), in
67
+ Geist Mono. Extra attributes and `class` go to the <ol>.
68
+ -->
69
+ <ol {...rest} bind:this={root} class={['hp-timeline', className]}>
70
+ {#each events as event, i}
71
+ <li data-tone={event.tone} class={[entering && 'hp-enter']} style:--i={entering ? i : undefined}>
72
+ <span>{event.title}</span>
73
+ {#if event.detail}
74
+ <span class="detail">{event.detail}</span>
75
+ {/if}
76
+ <span class="when"
77
+ ><time datetime={event.datetime ?? (event.time ? `${event.date}T${event.time}` : event.date)}
78
+ >{event.date}</time
79
+ >{#if event.time}<span>{event.time}</span>{/if}</span
80
+ >
81
+ </li>
82
+ {/each}
83
+ </ol>
84
+
85
+ <style>
86
+ /* 16px on touch, the dense 14px on fine pointers. A long identifier or
87
+ path breaks anywhere rather than widen the page. */
88
+ .hp-timeline {
89
+ margin: 0;
90
+ padding: 0 0 0 var(--space-8);
91
+ font-family: var(--mono);
92
+ font-size: var(--type-body);
93
+ line-height: 24px;
94
+ list-style: none;
95
+ overflow-wrap: anywhere;
96
+ }
97
+ @media (pointer: fine) {
98
+ .hp-timeline {
99
+ font-size: var(--type-dense);
100
+ line-height: 20px;
101
+ }
102
+ }
103
+
104
+ li {
105
+ position: relative;
106
+ display: grid;
107
+ padding-bottom: var(--space-6);
108
+ }
109
+ li:last-child {
110
+ padding-bottom: 0;
111
+ }
112
+
113
+ /* The node: a 12px square, titanium or the event's status hue, centred on
114
+ the first line (4px from the top at 20px lines, 6px at 24px). */
115
+ li::before {
116
+ content: '';
117
+ position: absolute;
118
+ top: calc((1lh - 12px) / 2);
119
+ left: calc(-1 * var(--space-8));
120
+ width: 12px;
121
+ height: 12px;
122
+ background: var(--accent);
123
+ }
124
+ li[data-tone='ok']::before {
125
+ background: var(--ok);
126
+ }
127
+ li[data-tone='warn']::before {
128
+ background: var(--warn);
129
+ }
130
+ li[data-tone='danger']::before {
131
+ background: var(--danger);
132
+ }
133
+
134
+ /* The rail: 2px, centred under the node, from its bottom edge down to the
135
+ next node's top edge. */
136
+ li:not(:last-child)::after {
137
+ content: '';
138
+ position: absolute;
139
+ top: calc((1lh + 12px) / 2);
140
+ bottom: calc((12px - 1lh) / 2);
141
+ /* matches the approved specimen */
142
+ left: calc(-1 * var(--space-8) + 5px);
143
+ /* matches the approved specimen */
144
+ width: 2px;
145
+ background: var(--accent);
146
+ }
147
+
148
+ .detail {
149
+ color: var(--muted);
150
+ }
151
+ .when {
152
+ display: flex;
153
+ /* matches the approved specimen */
154
+ gap: 4ch;
155
+ }
156
+ </style>
@@ -0,0 +1,30 @@
1
+ import type { HTMLOlAttributes } from 'svelte/elements';
2
+ /** A status hue for an event's node: `ok` done, `warn` waiting or needs you, `danger` failed. */
3
+ export type TimelineTone = 'ok' | 'warn' | 'danger';
4
+ export type TimelineEvent = {
5
+ /** What happened, as the app or a user wrote it. */
6
+ title: string;
7
+ /** A supporting line in `--muted`. */
8
+ detail?: string;
9
+ /** The date as shown. */
10
+ date: string;
11
+ /** The time as shown, after the date. */
12
+ time?: string;
13
+ /**
14
+ * The machine-readable moment for `<time datetime>`. Defaults to
15
+ * `date`, or `dateTtime` with a time, so ISO dates need nothing else.
16
+ */
17
+ datetime?: string;
18
+ /**
19
+ * Colours the node in a status hue. The title must say the same, because
20
+ * colour is never the only signal.
21
+ */
22
+ tone?: TimelineTone;
23
+ };
24
+ export type TimelineProps = Omit<HTMLOlAttributes, 'children'> & {
25
+ /** Oldest first or newest first, in the order given. */
26
+ events: TimelineEvent[];
27
+ };
28
+ declare const Timeline: import("svelte").Component<TimelineProps, {}, "">;
29
+ type Timeline = ReturnType<typeof Timeline>;
30
+ export default Timeline;
@@ -0,0 +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 (an app passes
345
+ style="--toast-width: …" for another cap), inside the page gutters and
346
+ the safe areas, so it never meets a screen edge. Bits UI sets
347
+ pointer-events: none on <body> while a dialog or a list is open; the
348
+ toast takes presses anyway. A drag along either axis is the swipe's,
349
+ so it never scrolls the page under the toast; pinch zoom stays the
350
+ browser's. */
351
+ .hp-toaster {
352
+ --toast-width: 30rem;
353
+
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);
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>
@@ -0,0 +1,11 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ /** Attributes and `class` go to the toast box while one shows. */
3
+ export type ToasterProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
4
+ /** The toast region's name, for landmark navigation (default "Notifications"). */
5
+ label?: string;
6
+ /** The close button's accessible name (default "Dismiss"). */
7
+ closeLabel?: string;
8
+ };
9
+ declare const Toaster: import("svelte").Component<ToasterProps, {}, "">;
10
+ type Toaster = ReturnType<typeof Toaster>;
11
+ export default Toaster;