@mystravil/hardpoint 0.0.0-stage → 0.2.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 (126) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +25 -0
  3. package/README.md +64 -3
  4. package/dist/base.css +625 -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 +235 -0
  10. package/dist/components/Checkbox.svelte.d.ts +23 -0
  11. package/dist/components/CheckboxGroup.svelte +207 -0
  12. package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
  13. package/dist/components/Combobox.svelte +901 -0
  14. package/dist/components/Combobox.svelte.d.ts +72 -0
  15. package/dist/components/Dialog.svelte +359 -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/ImageViewer.svelte +764 -0
  24. package/dist/components/ImageViewer.svelte.d.ts +55 -0
  25. package/dist/components/KeyValueList.svelte +114 -0
  26. package/dist/components/KeyValueList.svelte.d.ts +43 -0
  27. package/dist/components/ListDetail.svelte +218 -0
  28. package/dist/components/ListDetail.svelte.d.ts +37 -0
  29. package/dist/components/LoadRegion.svelte +219 -0
  30. package/dist/components/LoadRegion.svelte.d.ts +27 -0
  31. package/dist/components/MediaFrame.svelte +110 -0
  32. package/dist/components/MediaFrame.svelte.d.ts +20 -0
  33. package/dist/components/MediaImage.svelte +256 -0
  34. package/dist/components/MediaImage.svelte.d.ts +16 -0
  35. package/dist/components/Menu.svelte +258 -0
  36. package/dist/components/Menu.svelte.d.ts +36 -0
  37. package/dist/components/MenuCheckboxItem.svelte +119 -0
  38. package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
  39. package/dist/components/MenuGroup.svelte +50 -0
  40. package/dist/components/MenuGroup.svelte.d.ts +15 -0
  41. package/dist/components/MenuItem.svelte +94 -0
  42. package/dist/components/MenuItem.svelte.d.ts +19 -0
  43. package/dist/components/MenuRadioGroup.svelte +62 -0
  44. package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
  45. package/dist/components/MenuRadioItem.svelte +110 -0
  46. package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
  47. package/dist/components/MenuSeparator.svelte +28 -0
  48. package/dist/components/MenuSeparator.svelte.d.ts +6 -0
  49. package/dist/components/Meter.svelte +283 -0
  50. package/dist/components/Meter.svelte.d.ts +66 -0
  51. package/dist/components/NativeSelect.svelte +135 -0
  52. package/dist/components/NativeSelect.svelte.d.ts +52 -0
  53. package/dist/components/Notice.svelte +262 -0
  54. package/dist/components/Notice.svelte.d.ts +30 -0
  55. package/dist/components/Pagination.svelte +132 -0
  56. package/dist/components/Pagination.svelte.d.ts +18 -0
  57. package/dist/components/Popover.svelte +222 -0
  58. package/dist/components/Popover.svelte.d.ts +35 -0
  59. package/dist/components/Progress.svelte +157 -0
  60. package/dist/components/Progress.svelte.d.ts +34 -0
  61. package/dist/components/RadioGroup.svelte +440 -0
  62. package/dist/components/RadioGroup.svelte.d.ts +50 -0
  63. package/dist/components/Select.svelte +471 -0
  64. package/dist/components/Select.svelte.d.ts +48 -0
  65. package/dist/components/Skeleton.svelte +149 -0
  66. package/dist/components/Skeleton.svelte.d.ts +24 -0
  67. package/dist/components/Spinner.svelte +118 -0
  68. package/dist/components/Spinner.svelte.d.ts +13 -0
  69. package/dist/components/Stat.svelte +253 -0
  70. package/dist/components/Stat.svelte.d.ts +46 -0
  71. package/dist/components/Steps.svelte +189 -0
  72. package/dist/components/Steps.svelte.d.ts +31 -0
  73. package/dist/components/Switch.svelte +222 -0
  74. package/dist/components/Switch.svelte.d.ts +18 -0
  75. package/dist/components/Table.svelte +504 -0
  76. package/dist/components/Table.svelte.d.ts +92 -0
  77. package/dist/components/Tabs.svelte +246 -0
  78. package/dist/components/Tabs.svelte.d.ts +44 -0
  79. package/dist/components/Tag.svelte +40 -0
  80. package/dist/components/Tag.svelte.d.ts +15 -0
  81. package/dist/components/TextArea.svelte +272 -0
  82. package/dist/components/TextArea.svelte.d.ts +28 -0
  83. package/dist/components/TextField.svelte +256 -0
  84. package/dist/components/TextField.svelte.d.ts +32 -0
  85. package/dist/components/Timeline.svelte +156 -0
  86. package/dist/components/Timeline.svelte.d.ts +30 -0
  87. package/dist/components/Toaster.svelte +411 -0
  88. package/dist/components/Toaster.svelte.d.ts +11 -0
  89. package/dist/components/Tooltip.svelte +200 -0
  90. package/dist/components/Tooltip.svelte.d.ts +38 -0
  91. package/dist/components/TooltipGroup.svelte +46 -0
  92. package/dist/components/TooltipGroup.svelte.d.ts +29 -0
  93. package/dist/components/internal/Scrim.svelte +52 -0
  94. package/dist/components/internal/Scrim.svelte.d.ts +15 -0
  95. package/dist/components/internal/Sheet.svelte +263 -0
  96. package/dist/components/internal/Sheet.svelte.d.ts +36 -0
  97. package/dist/components/internal/fade-in.d.ts +10 -0
  98. package/dist/components/internal/fade-in.js +20 -0
  99. package/dist/components/internal/groups.d.ts +14 -0
  100. package/dist/components/internal/groups.js +23 -0
  101. package/dist/components/internal/resolve.d.ts +8 -0
  102. package/dist/components/internal/resolve.js +247 -0
  103. package/dist/components/internal/swipe-dismiss.d.ts +47 -0
  104. package/dist/components/internal/swipe-dismiss.js +220 -0
  105. package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
  106. package/dist/components/internal/visual-viewport.svelte.js +79 -0
  107. package/dist/components/toast.svelte.d.ts +57 -0
  108. package/dist/components/toast.svelte.js +84 -0
  109. package/dist/fonts/OFL-geist-pixel.txt +92 -0
  110. package/dist/fonts/OFL.txt +93 -0
  111. package/dist/fonts/geist-latin-variable.woff2 +0 -0
  112. package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
  113. package/dist/fonts/geist-pixel-square.woff2 +0 -0
  114. package/dist/fonts.css +71 -0
  115. package/dist/index.d.ts +45 -0
  116. package/dist/index.js +45 -0
  117. package/dist/motion/Motion.svelte +15 -0
  118. package/dist/motion/Motion.svelte.d.ts +9 -0
  119. package/dist/motion/context.d.ts +18 -0
  120. package/dist/motion/context.js +26 -0
  121. package/dist/motion/index.d.ts +3 -0
  122. package/dist/motion/index.js +3 -0
  123. package/dist/motion/transitions.d.ts +31 -0
  124. package/dist/motion/transitions.js +112 -0
  125. package/dist/tokens.css +283 -0
  126. package/package.json +76 -6
@@ -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, 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>
@@ -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;
@@ -0,0 +1,200 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import { Tooltip as BitsTooltip } from 'bits-ui';
4
+
5
+ export type TooltipTriggerSnippetProps = {
6
+ /**
7
+ * Spread onto the control the tooltip belongs to. Its own handlers may
8
+ * come before or after. A control with its own description writes
9
+ * `aria-describedby={[props['aria-describedby'], ownId].filter(Boolean).join(' ')}`
10
+ * after the spread, so it keeps both.
11
+ */
12
+ props: Record<string, unknown>;
13
+ };
14
+
15
+ /**
16
+ * Attributes and handlers not listed here go to the tooltip element (Bits
17
+ * UI `Tooltip.Content`), among them `side` (default `top`), `align`
18
+ * (default `center`), `sideOffset` (default 8) and `onEscapeKeydown`.
19
+ */
20
+ export type TooltipProps = Omit<BitsTooltip.ContentProps, 'child' | 'children' | 'forceMount' | 'ref'> & {
21
+ open?: boolean;
22
+ /**
23
+ * The tooltip, as written. It never opens on touch, so it is never the
24
+ * only path to its text: the control shows the same text, or carries it
25
+ * in `aria-label`.
26
+ */
27
+ text: string;
28
+ /** The control the tooltip belongs to: spread `props` onto it. */
29
+ trigger: Snippet<[TooltipTriggerSnippetProps]>;
30
+ /**
31
+ * Milliseconds a pointer rests on the trigger before the tooltip opens
32
+ * (default 700, Bits UI's, or the TooltipGroup's). Keyboard focus opens
33
+ * it at once.
34
+ */
35
+ delayDuration?: number;
36
+ /** The tooltip element. */
37
+ ref?: HTMLElement | null;
38
+ };
39
+
40
+ // Bits UI keeps one tooltip open at a time per provider, and each Tooltip
41
+ // outside a TooltipGroup has its own provider, so the rule spans them here:
42
+ // the close function of the open tooltip, if any.
43
+ let closeOpen: (() => void) | null = null;
44
+ </script>
45
+
46
+ <script lang="ts">
47
+ import { createAttachmentKey } from 'svelte/attachments';
48
+ import { pop } from '../motion/index.js';
49
+ import { inTooltipGroup } from './TooltipGroup.svelte';
50
+
51
+ let {
52
+ open = $bindable(false),
53
+ text,
54
+ trigger,
55
+ delayDuration,
56
+ ref = $bindable(null),
57
+ class: className,
58
+ ...rest
59
+ }: TooltipProps = $props();
60
+
61
+ const listen = createAttachmentKey();
62
+ const capture = createAttachmentKey();
63
+
64
+ // Inside a TooltipGroup the group's provider is shared, so the next
65
+ // tooltip in the row skips the delay; outside one, the tooltip has its own.
66
+ const grouped = inTooltipGroup();
67
+
68
+ // Opening this tooltip closes any other one, for example one a resting
69
+ // pointer opened when keyboard focus opens this.
70
+ const close = () => {
71
+ open = false;
72
+ };
73
+ $effect(() => {
74
+ if (!open) return;
75
+ if (closeOpen !== close) closeOpen?.();
76
+ closeOpen = close;
77
+ return () => {
78
+ if (closeOpen === close) closeOpen = null;
79
+ };
80
+ });
81
+
82
+ // The control, kept to compare its name with the text.
83
+ let control = $state<Element | null>(null);
84
+ const captureControl = (node: Element) => {
85
+ control = node;
86
+ };
87
+
88
+ // The usual tooltip repeats the control's name: an icon button's
89
+ // aria-label, or the whole of a shortened name. Describing the control by
90
+ // the same text would make screen readers read it twice.
91
+ function repeatsName(node: Element | null): boolean {
92
+ if (!node) return false;
93
+ const name = node.getAttribute('aria-label') ?? node.textContent ?? '';
94
+ return name.trim() === text.trim();
95
+ }
96
+
97
+ // A tooltip's control has its own action, so the app passes its own
98
+ // onclick beside the spread. Bits UI's handlers move into an attachment,
99
+ // so the spread order cannot drop either set: the control's onclick still
100
+ // runs, and the tooltip still closes on a press and opens on hover and
101
+ // focus. Bits UI's `disabled: false` and `tabindex: 0` are left out, so
102
+ // they never undo a disabled control (a disabled link Button keeps
103
+ // tabindex -1); the control is focusable on its own.
104
+ function triggerProps(props: Record<string, unknown>): Record<string, unknown> {
105
+ const { disabled: _disabled, tabindex: _tabindex, 'aria-describedby': describedBy, ...attrs } = props;
106
+ const listeners: [string, EventListener][] = [];
107
+ for (const [key, value] of Object.entries(attrs)) {
108
+ if (key.startsWith('on') && typeof value === 'function') {
109
+ listeners.push([key.slice(2), value as EventListener]);
110
+ delete attrs[key];
111
+ }
112
+ }
113
+ if (describedBy && !repeatsName(control)) attrs['aria-describedby'] = describedBy;
114
+ return {
115
+ ...attrs,
116
+ [capture]: captureControl,
117
+ [listen]: (node: Element) => {
118
+ for (const [type, fn] of listeners) node.addEventListener(type, fn);
119
+ return () => {
120
+ for (const [type, fn] of listeners) node.removeEventListener(type, fn);
121
+ };
122
+ }
123
+ };
124
+ }
125
+ </script>
126
+
127
+ <!--
128
+ Each tooltip outside a TooltipGroup has its own Bits UI provider, so an app
129
+ needs no shared wrapper. A resting mouse pointer opens it after the delay;
130
+ keyboard focus opens it at once, but only focus that shows the focus
131
+ contour, so focus returned to the trigger after a mouse press opens
132
+ nothing. Touch never opens it: a tap runs the control's own action.
133
+ Escape, moving the pointer away, blur and a press on the trigger close it.
134
+ While open, Bits UI points the trigger's aria-describedby at the tooltip,
135
+ unless the text repeats the control's name. The root carries the delay
136
+ and the focus rule, so they hold inside a group too.
137
+ -->
138
+ {#snippet tooltip()}
139
+ <BitsTooltip.Root bind:open {delayDuration} ignoreNonKeyboardFocus>
140
+ <BitsTooltip.Trigger>
141
+ {#snippet child({ props })}
142
+ {@render trigger({ props: triggerProps(props) })}
143
+ {/snippet}
144
+ </BitsTooltip.Trigger>
145
+
146
+ <BitsTooltip.Portal>
147
+ <BitsTooltip.Content forceMount bind:ref side="top" align="center" sideOffset={8} collisionPadding={8} {...rest}>
148
+ {#snippet child({ wrapperProps, props, open: shown })}
149
+ {#if shown}
150
+ <div {...wrapperProps}>
151
+ <div {...props} role="tooltip" class={['hp-tooltip', className]} transition:pop>{text}</div>
152
+ </div>
153
+ {/if}
154
+ {/snippet}
155
+ </BitsTooltip.Content>
156
+ </BitsTooltip.Portal>
157
+ </BitsTooltip.Root>
158
+ {/snippet}
159
+
160
+ {#if grouped}
161
+ {@render tooltip()}
162
+ {:else}
163
+ <BitsTooltip.Provider>
164
+ {@render tooltip()}
165
+ </BitsTooltip.Provider>
166
+ {/if}
167
+
168
+ <style>
169
+ /* On the tooltip layer (tokens.css), over popovers, menus and dialogs.
170
+ Bits UI copies the z-index to the positioned wrapper. A black box with
171
+ the control boundary, like the popover, as wide as its text up to
172
+ --tooltip-width, 280px unless set (an app sets it with a class on the
173
+ Tooltip and a rule in its stylesheet; the default is the var()
174
+ fallback, so any rule wins whatever its specificity) and never wider
175
+ than the space beside the trigger. */
176
+ .hp-tooltip {
177
+ position: relative;
178
+ z-index: var(--layer-tooltip);
179
+ box-sizing: border-box;
180
+ width: max-content;
181
+ max-width: min(var(--tooltip-width, 280px), var(--bits-tooltip-content-available-width));
182
+ padding: var(--space-1) var(--space-2);
183
+ border: 1px solid var(--border-control);
184
+ background: var(--canvas);
185
+ color: var(--text);
186
+ font-family: var(--sans);
187
+ font-size: var(--type-body);
188
+ line-height: 24px;
189
+ overflow-wrap: break-word;
190
+ }
191
+
192
+ /* 16px where a coarse pointer reads it (keyboard focus on a tablet), the
193
+ dense 14px on fine pointers. */
194
+ @media (pointer: fine) {
195
+ .hp-tooltip {
196
+ font-size: var(--type-dense);
197
+ line-height: 20px;
198
+ }
199
+ }
200
+ </style>
@@ -0,0 +1,38 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { Tooltip as BitsTooltip } from 'bits-ui';
3
+ export type TooltipTriggerSnippetProps = {
4
+ /**
5
+ * Spread onto the control the tooltip belongs to. Its own handlers may
6
+ * come before or after. A control with its own description writes
7
+ * `aria-describedby={[props['aria-describedby'], ownId].filter(Boolean).join(' ')}`
8
+ * after the spread, so it keeps both.
9
+ */
10
+ props: Record<string, unknown>;
11
+ };
12
+ /**
13
+ * Attributes and handlers not listed here go to the tooltip element (Bits
14
+ * UI `Tooltip.Content`), among them `side` (default `top`), `align`
15
+ * (default `center`), `sideOffset` (default 8) and `onEscapeKeydown`.
16
+ */
17
+ export type TooltipProps = Omit<BitsTooltip.ContentProps, 'child' | 'children' | 'forceMount' | 'ref'> & {
18
+ open?: boolean;
19
+ /**
20
+ * The tooltip, as written. It never opens on touch, so it is never the
21
+ * only path to its text: the control shows the same text, or carries it
22
+ * in `aria-label`.
23
+ */
24
+ text: string;
25
+ /** The control the tooltip belongs to: spread `props` onto it. */
26
+ trigger: Snippet<[TooltipTriggerSnippetProps]>;
27
+ /**
28
+ * Milliseconds a pointer rests on the trigger before the tooltip opens
29
+ * (default 700, Bits UI's, or the TooltipGroup's). Keyboard focus opens
30
+ * it at once.
31
+ */
32
+ delayDuration?: number;
33
+ /** The tooltip element. */
34
+ ref?: HTMLElement | null;
35
+ };
36
+ declare const Tooltip: import("svelte").Component<TooltipProps, {}, "ref" | "open">;
37
+ type Tooltip = ReturnType<typeof Tooltip>;
38
+ export default Tooltip;