@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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +25 -0
- package/README.md +64 -3
- package/dist/base.css +625 -0
- package/dist/components/AppShell.svelte +698 -0
- package/dist/components/AppShell.svelte.d.ts +77 -0
- package/dist/components/Button.svelte +174 -0
- package/dist/components/Button.svelte.d.ts +20 -0
- package/dist/components/Checkbox.svelte +235 -0
- package/dist/components/Checkbox.svelte.d.ts +23 -0
- package/dist/components/CheckboxGroup.svelte +207 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +44 -0
- package/dist/components/Combobox.svelte +901 -0
- package/dist/components/Combobox.svelte.d.ts +72 -0
- package/dist/components/Dialog.svelte +359 -0
- package/dist/components/Dialog.svelte.d.ts +43 -0
- package/dist/components/Disclosure.svelte +201 -0
- package/dist/components/Disclosure.svelte.d.ts +27 -0
- package/dist/components/EmptyState.svelte +88 -0
- package/dist/components/EmptyState.svelte.d.ts +22 -0
- package/dist/components/ErrorState.svelte +135 -0
- package/dist/components/ErrorState.svelte.d.ts +26 -0
- package/dist/components/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/KeyValueList.svelte +114 -0
- package/dist/components/KeyValueList.svelte.d.ts +43 -0
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -0
- package/dist/components/LoadRegion.svelte +219 -0
- package/dist/components/LoadRegion.svelte.d.ts +27 -0
- package/dist/components/MediaFrame.svelte +110 -0
- package/dist/components/MediaFrame.svelte.d.ts +20 -0
- package/dist/components/MediaImage.svelte +256 -0
- package/dist/components/MediaImage.svelte.d.ts +16 -0
- package/dist/components/Menu.svelte +258 -0
- package/dist/components/Menu.svelte.d.ts +36 -0
- package/dist/components/MenuCheckboxItem.svelte +119 -0
- package/dist/components/MenuCheckboxItem.svelte.d.ts +15 -0
- package/dist/components/MenuGroup.svelte +50 -0
- package/dist/components/MenuGroup.svelte.d.ts +15 -0
- package/dist/components/MenuItem.svelte +94 -0
- package/dist/components/MenuItem.svelte.d.ts +19 -0
- package/dist/components/MenuRadioGroup.svelte +62 -0
- package/dist/components/MenuRadioGroup.svelte.d.ts +20 -0
- package/dist/components/MenuRadioItem.svelte +110 -0
- package/dist/components/MenuRadioItem.svelte.d.ts +17 -0
- package/dist/components/MenuSeparator.svelte +28 -0
- package/dist/components/MenuSeparator.svelte.d.ts +6 -0
- package/dist/components/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +135 -0
- package/dist/components/NativeSelect.svelte.d.ts +52 -0
- package/dist/components/Notice.svelte +262 -0
- package/dist/components/Notice.svelte.d.ts +30 -0
- package/dist/components/Pagination.svelte +132 -0
- package/dist/components/Pagination.svelte.d.ts +18 -0
- package/dist/components/Popover.svelte +222 -0
- package/dist/components/Popover.svelte.d.ts +35 -0
- package/dist/components/Progress.svelte +157 -0
- package/dist/components/Progress.svelte.d.ts +34 -0
- package/dist/components/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +471 -0
- package/dist/components/Select.svelte.d.ts +48 -0
- package/dist/components/Skeleton.svelte +149 -0
- package/dist/components/Skeleton.svelte.d.ts +24 -0
- package/dist/components/Spinner.svelte +118 -0
- package/dist/components/Spinner.svelte.d.ts +13 -0
- package/dist/components/Stat.svelte +253 -0
- package/dist/components/Stat.svelte.d.ts +46 -0
- package/dist/components/Steps.svelte +189 -0
- package/dist/components/Steps.svelte.d.ts +31 -0
- package/dist/components/Switch.svelte +222 -0
- package/dist/components/Switch.svelte.d.ts +18 -0
- package/dist/components/Table.svelte +504 -0
- package/dist/components/Table.svelte.d.ts +92 -0
- package/dist/components/Tabs.svelte +246 -0
- package/dist/components/Tabs.svelte.d.ts +44 -0
- package/dist/components/Tag.svelte +40 -0
- package/dist/components/Tag.svelte.d.ts +15 -0
- package/dist/components/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -0
- package/dist/components/TextField.svelte +256 -0
- package/dist/components/TextField.svelte.d.ts +32 -0
- package/dist/components/Timeline.svelte +156 -0
- package/dist/components/Timeline.svelte.d.ts +30 -0
- package/dist/components/Toaster.svelte +411 -0
- package/dist/components/Toaster.svelte.d.ts +11 -0
- package/dist/components/Tooltip.svelte +200 -0
- package/dist/components/Tooltip.svelte.d.ts +38 -0
- package/dist/components/TooltipGroup.svelte +46 -0
- package/dist/components/TooltipGroup.svelte.d.ts +29 -0
- package/dist/components/internal/Scrim.svelte +52 -0
- package/dist/components/internal/Scrim.svelte.d.ts +15 -0
- package/dist/components/internal/Sheet.svelte +263 -0
- package/dist/components/internal/Sheet.svelte.d.ts +36 -0
- package/dist/components/internal/fade-in.d.ts +10 -0
- package/dist/components/internal/fade-in.js +20 -0
- package/dist/components/internal/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -0
- package/dist/components/internal/resolve.d.ts +8 -0
- package/dist/components/internal/resolve.js +247 -0
- package/dist/components/internal/swipe-dismiss.d.ts +47 -0
- package/dist/components/internal/swipe-dismiss.js +220 -0
- package/dist/components/internal/visual-viewport.svelte.d.ts +35 -0
- package/dist/components/internal/visual-viewport.svelte.js +79 -0
- package/dist/components/toast.svelte.d.ts +57 -0
- package/dist/components/toast.svelte.js +84 -0
- package/dist/fonts/OFL-geist-pixel.txt +92 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/fonts/geist-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-variable.woff2 +0 -0
- package/dist/fonts/geist-pixel-square.woff2 +0 -0
- package/dist/fonts.css +71 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +45 -0
- package/dist/motion/Motion.svelte +15 -0
- package/dist/motion/Motion.svelte.d.ts +9 -0
- package/dist/motion/context.d.ts +18 -0
- package/dist/motion/context.js +26 -0
- package/dist/motion/index.d.ts +3 -0
- package/dist/motion/index.js +3 -0
- package/dist/motion/transitions.d.ts +31 -0
- package/dist/motion/transitions.js +112 -0
- package/dist/tokens.css +283 -0
- 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;
|