@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.
- package/CHANGELOG.md +18 -0
- package/LICENSE +25 -0
- package/README.md +64 -3
- package/dist/base.css +346 -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 +198 -0
- package/dist/components/Checkbox.svelte.d.ts +16 -0
- package/dist/components/CheckboxGroup.svelte +99 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +25 -0
- package/dist/components/Combobox.svelte +824 -0
- package/dist/components/Combobox.svelte.d.ts +63 -0
- package/dist/components/Dialog.svelte +356 -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/KeyValueList.svelte +114 -0
- package/dist/components/KeyValueList.svelte.d.ts +43 -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/NativeSelect.svelte +119 -0
- package/dist/components/NativeSelect.svelte.d.ts +46 -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/Select.svelte +405 -0
- package/dist/components/Select.svelte.d.ts +42 -0
- package/dist/components/Skeleton.svelte +149 -0
- package/dist/components/Skeleton.svelte.d.ts +24 -0
- package/dist/components/Spinner.svelte +117 -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/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/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 +39 -0
- package/dist/index.js +39 -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 +279 -0
- package/package.json +76 -6
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
|
|
5
|
+
export type MediaFrameProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
6
|
+
/**
|
|
7
|
+
* The frame's aspect ratio, any CSS `aspect-ratio` value such as
|
|
8
|
+
* `16 / 9`. Without it the frame takes its content's height, unless an
|
|
9
|
+
* ancestor sets `--media-frame-ratio` (a page sets that custom property
|
|
10
|
+
* in its media queries to change the ratio by breakpoint).
|
|
11
|
+
*/
|
|
12
|
+
ratio?: string;
|
|
13
|
+
/** The inner width in px, inside the 1px frame. Bindable, read-only. */
|
|
14
|
+
width?: number;
|
|
15
|
+
/** The inner height in px, inside the 1px frame. Bindable, read-only. */
|
|
16
|
+
height?: number;
|
|
17
|
+
/** The image, diagram or chart. */
|
|
18
|
+
children?: Snippet;
|
|
19
|
+
};
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
let {
|
|
24
|
+
ratio,
|
|
25
|
+
width = $bindable(),
|
|
26
|
+
height = $bindable(),
|
|
27
|
+
class: className,
|
|
28
|
+
children,
|
|
29
|
+
...rest
|
|
30
|
+
}: MediaFrameProps = $props();
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<!--
|
|
34
|
+
A 1px hairline frame with small + registration marks over its corners and
|
|
35
|
+
top centre (BRIEF, visual rule 8; da3-55, da3-61), for images and
|
|
36
|
+
diagrams, never text blocks. The marks paint over the content. Content
|
|
37
|
+
that fills the frame positions itself (absolute, inset 0) and reads
|
|
38
|
+
bind:width and bind:height to draw at the frame's real size.
|
|
39
|
+
-->
|
|
40
|
+
<div
|
|
41
|
+
{...rest}
|
|
42
|
+
class={['hp-media-frame', className]}
|
|
43
|
+
style:--media-frame-ratio={ratio}
|
|
44
|
+
bind:clientWidth={width}
|
|
45
|
+
bind:clientHeight={height}
|
|
46
|
+
>
|
|
47
|
+
{@render children?.()}
|
|
48
|
+
<span class="reg top left" aria-hidden="true"></span>
|
|
49
|
+
<span class="reg top mid" aria-hidden="true"></span>
|
|
50
|
+
<span class="reg top right" aria-hidden="true"></span>
|
|
51
|
+
<span class="reg bottom left" aria-hidden="true"></span>
|
|
52
|
+
<span class="reg bottom right" aria-hidden="true"></span>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.hp-media-frame {
|
|
57
|
+
position: relative;
|
|
58
|
+
aspect-ratio: var(--media-frame-ratio, auto);
|
|
59
|
+
border: 1px solid var(--border);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* A 13px + whose 1px strokes cross at its centre pixel, 12px in from
|
|
63
|
+
the frame's inner edge. */
|
|
64
|
+
.reg {
|
|
65
|
+
position: absolute;
|
|
66
|
+
/* matches the approved specimen */
|
|
67
|
+
width: 13px;
|
|
68
|
+
/* matches the approved specimen */
|
|
69
|
+
height: 13px;
|
|
70
|
+
pointer-events: none;
|
|
71
|
+
}
|
|
72
|
+
.top {
|
|
73
|
+
top: 12px;
|
|
74
|
+
}
|
|
75
|
+
.bottom {
|
|
76
|
+
bottom: 12px;
|
|
77
|
+
}
|
|
78
|
+
.left {
|
|
79
|
+
left: 12px;
|
|
80
|
+
}
|
|
81
|
+
.right {
|
|
82
|
+
right: 12px;
|
|
83
|
+
}
|
|
84
|
+
.mid {
|
|
85
|
+
/* matches the approved specimen */
|
|
86
|
+
left: calc(50% - 6px);
|
|
87
|
+
}
|
|
88
|
+
.reg::before,
|
|
89
|
+
.reg::after {
|
|
90
|
+
content: '';
|
|
91
|
+
position: absolute;
|
|
92
|
+
background: var(--text);
|
|
93
|
+
}
|
|
94
|
+
.reg::before {
|
|
95
|
+
/* matches the approved specimen */
|
|
96
|
+
top: 6px;
|
|
97
|
+
left: 0;
|
|
98
|
+
/* matches the approved specimen */
|
|
99
|
+
width: 13px;
|
|
100
|
+
height: 1px;
|
|
101
|
+
}
|
|
102
|
+
.reg::after {
|
|
103
|
+
top: 0;
|
|
104
|
+
/* matches the approved specimen */
|
|
105
|
+
left: 6px;
|
|
106
|
+
width: 1px;
|
|
107
|
+
/* matches the approved specimen */
|
|
108
|
+
height: 13px;
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
export type MediaFrameProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
4
|
+
/**
|
|
5
|
+
* The frame's aspect ratio, any CSS `aspect-ratio` value such as
|
|
6
|
+
* `16 / 9`. Without it the frame takes its content's height, unless an
|
|
7
|
+
* ancestor sets `--media-frame-ratio` (a page sets that custom property
|
|
8
|
+
* in its media queries to change the ratio by breakpoint).
|
|
9
|
+
*/
|
|
10
|
+
ratio?: string;
|
|
11
|
+
/** The inner width in px, inside the 1px frame. Bindable, read-only. */
|
|
12
|
+
width?: number;
|
|
13
|
+
/** The inner height in px, inside the 1px frame. Bindable, read-only. */
|
|
14
|
+
height?: number;
|
|
15
|
+
/** The image, diagram or chart. */
|
|
16
|
+
children?: Snippet;
|
|
17
|
+
};
|
|
18
|
+
declare const MediaFrame: import("svelte").Component<MediaFrameProps, {}, "width" | "height">;
|
|
19
|
+
type MediaFrame = ReturnType<typeof MediaFrame>;
|
|
20
|
+
export default MediaFrame;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { HTMLImgAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type MediaImageProps = Omit<HTMLImgAttributes, 'src' | 'alt' | 'children'> & {
|
|
5
|
+
/**
|
|
6
|
+
* The image's URL. Leave it undefined while the app does not know it
|
|
7
|
+
* yet: the frame waits, as it does while the image loads. A different
|
|
8
|
+
* URL starts a new load; the same one changes nothing.
|
|
9
|
+
*/
|
|
10
|
+
src?: string;
|
|
11
|
+
/** The text alternative, required. `""` for a decorative image. */
|
|
12
|
+
alt: string;
|
|
13
|
+
/** The `<img>` element, while there is a `src`. Bindable, read-only. */
|
|
14
|
+
ref?: HTMLImageElement | null;
|
|
15
|
+
};
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { untrack } from 'svelte';
|
|
20
|
+
import { useMotion } from '../motion/context.js';
|
|
21
|
+
import { duration } from '../motion/transitions.js';
|
|
22
|
+
import Skeleton from './Skeleton.svelte';
|
|
23
|
+
import { resolveImage } from './internal/resolve.js';
|
|
24
|
+
|
|
25
|
+
let { src, alt, ref = $bindable(null), class: className, ...rest }: MediaImageProps = $props();
|
|
26
|
+
|
|
27
|
+
const motion = useMotion();
|
|
28
|
+
|
|
29
|
+
// waiting: no src yet, or the image has not loaded; the block shows
|
|
30
|
+
// after --skeleton-delay. fade and resolve: the image reveals over the
|
|
31
|
+
// block. shown: the image alone. error: the image failed to load.
|
|
32
|
+
type Phase = 'waiting' | 'fade' | 'resolve' | 'shown' | 'error';
|
|
33
|
+
let phase = $state<Phase>('waiting');
|
|
34
|
+
|
|
35
|
+
let root = $state<HTMLDivElement>();
|
|
36
|
+
/** The src the phase belongs to. */
|
|
37
|
+
let current = untrack(() => src);
|
|
38
|
+
/** The image a resolve draws, and its step in ms, read when it arrived. */
|
|
39
|
+
let arrived: HTMLImageElement | undefined;
|
|
40
|
+
let step = 0;
|
|
41
|
+
|
|
42
|
+
// Before the DOM update, so the image of a new src starts hidden. A block
|
|
43
|
+
// already on screen stays: the wait goes on with the new src.
|
|
44
|
+
$effect.pre(() => {
|
|
45
|
+
const next = src;
|
|
46
|
+
untrack(() => {
|
|
47
|
+
if (next === current) return;
|
|
48
|
+
current = next;
|
|
49
|
+
phase = 'waiting';
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// One <img> per src (see the {#key} below), so this runs once per load.
|
|
54
|
+
// The app's own onload and onerror sit on the element through `rest` and
|
|
55
|
+
// fire as usual.
|
|
56
|
+
function watch(img: HTMLImageElement) {
|
|
57
|
+
return untrack(() => {
|
|
58
|
+
let alive = true;
|
|
59
|
+
let pending = false;
|
|
60
|
+
let cancel: (() => void) | undefined;
|
|
61
|
+
|
|
62
|
+
const fail = () => {
|
|
63
|
+
if (alive && phase === 'waiting') phase = 'error';
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// The image is decoded, so its first frame on screen is never blank.
|
|
67
|
+
const arrive = () => {
|
|
68
|
+
if (!alive || phase !== 'waiting') return;
|
|
69
|
+
const node = root;
|
|
70
|
+
// Before the block showed, or with no box to reveal in: at once.
|
|
71
|
+
const block = node?.querySelector(':scope > .hp-skeleton');
|
|
72
|
+
if (!node || block?.getAttribute('data-state') !== 'visible' || !node.clientWidth || !node.clientHeight) {
|
|
73
|
+
phase = 'shown';
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Read now, so a token retuned during the load applies.
|
|
77
|
+
if (motion.expressive && img.naturalWidth > 0) {
|
|
78
|
+
step = duration(node, '--resolve-step');
|
|
79
|
+
if (step > 0) {
|
|
80
|
+
arrived = img;
|
|
81
|
+
phase = 'resolve';
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const ms = duration(node, '--dur-reveal');
|
|
86
|
+
if (ms <= 0) {
|
|
87
|
+
phase = 'shown';
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const settle = getComputedStyle(node).getPropertyValue('--settle').trim();
|
|
91
|
+
// Started before the DOM update, so the image's first frame is
|
|
92
|
+
// already at 0 over the block.
|
|
93
|
+
const fade = img.animate(
|
|
94
|
+
{ opacity: [0, 1] },
|
|
95
|
+
{ duration: ms, easing: CSS.supports('transition-timing-function', settle) ? settle : 'ease-out' }
|
|
96
|
+
);
|
|
97
|
+
fade.onfinish = () => {
|
|
98
|
+
if (alive) phase = 'shown';
|
|
99
|
+
};
|
|
100
|
+
cancel = () => fade.cancel();
|
|
101
|
+
phase = 'fade';
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const onload = () => {
|
|
105
|
+
// Only the first load counts: a later one is a new srcset
|
|
106
|
+
// candidate, or an event Svelte replays after hydration.
|
|
107
|
+
if (pending || phase !== 'waiting') return;
|
|
108
|
+
pending = true;
|
|
109
|
+
img.decode().then(arrive, () => {
|
|
110
|
+
if (img.naturalWidth > 0) arrive();
|
|
111
|
+
else fail();
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (img.complete) {
|
|
116
|
+
// Cached, or loaded before hydration: at once. A complete image
|
|
117
|
+
// without a size is broken, unless it is an SVG without one.
|
|
118
|
+
if (img.naturalWidth > 0) phase = 'shown';
|
|
119
|
+
else
|
|
120
|
+
img.decode().then(() => {
|
|
121
|
+
if (alive && phase === 'waiting') phase = 'shown';
|
|
122
|
+
}, fail);
|
|
123
|
+
}
|
|
124
|
+
img.addEventListener('load', onload);
|
|
125
|
+
img.addEventListener('error', fail);
|
|
126
|
+
return () => {
|
|
127
|
+
alive = false;
|
|
128
|
+
cancel?.();
|
|
129
|
+
img.removeEventListener('load', onload);
|
|
130
|
+
img.removeEventListener('error', fail);
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The canvas mounts with the resolve, after the DOM update and before it
|
|
136
|
+
// is painted; it unmounts when the resolve ends or a new src starts.
|
|
137
|
+
function mosaic(canvas: HTMLCanvasElement) {
|
|
138
|
+
return untrack(() => {
|
|
139
|
+
const img = arrived;
|
|
140
|
+
if (!img) {
|
|
141
|
+
phase = 'shown';
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
return resolveImage(canvas, img, step, () => {
|
|
145
|
+
if (phase === 'resolve') phase = 'shown';
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<!--
|
|
152
|
+
An image that fills a MediaFrame and takes over from a skeleton block
|
|
153
|
+
when it loads (BRIEF, motion rules 2 and 10):
|
|
154
|
+
|
|
155
|
+
<MediaFrame ratio="3 / 2"><MediaImage src={url} alt="…" /></MediaFrame>
|
|
156
|
+
|
|
157
|
+
It lies over the frame's whole inner box (absolute, inset 0) and covers
|
|
158
|
+
it (`object-fit: cover`, the only fit for now: a frame crops, it never
|
|
159
|
+
letterboxes). So the frame needs a size of its own: a `ratio`, or
|
|
160
|
+
`--media-frame-ratio`. A frame without one takes its content's height,
|
|
161
|
+
and this has none, so the frame shrinks to its two border lines.
|
|
162
|
+
|
|
163
|
+
While there is no `src` or the image has not loaded, a block Skeleton
|
|
164
|
+
fills the frame; it appears after `--skeleton-delay`, as everywhere else.
|
|
165
|
+
An image already complete when it mounts (cached, or loaded before
|
|
166
|
+
hydration), or one that arrives before the block showed, appears at
|
|
167
|
+
once. Otherwise it reveals once `img.decode()` is done, so its first
|
|
168
|
+
frame is never blank: in the expressive tier (`useMotion().expressive`)
|
|
169
|
+
it resolves from a mosaic that sharpens in five steps of `--resolve-step`
|
|
170
|
+
(internal/resolve.ts); everywhere else, and so with reduced motion, it
|
|
171
|
+
fades in over the block for `--dur-reveal` on `--settle`, as LoadRegion's
|
|
172
|
+
crossfade does. The fade changes only opacity, so it runs under reduced
|
|
173
|
+
motion. `--resolve-step`, `--dur-reveal` and `--settle` are read from
|
|
174
|
+
the element when the image arrives. An image from another origin
|
|
175
|
+
without CORS headers resolves too: the mosaic is drawn, never read. An
|
|
176
|
+
image rendered on the server stays hidden until hydration.
|
|
177
|
+
|
|
178
|
+
On a load error the block goes and no broken-image glyph shows: the
|
|
179
|
+
frame shows the `alt` as a muted caption in its middle, so a sighted
|
|
180
|
+
user still learns what the image held, or nothing for `alt=""`. The
|
|
181
|
+
app's `onerror` fires; a different `src` tries again.
|
|
182
|
+
|
|
183
|
+
The <img> with its `alt` is in the DOM and the accessibility tree from
|
|
184
|
+
the moment it has a `src`; while it waits it is hidden by opacity only.
|
|
185
|
+
The block, the canvas and the caption are `aria-hidden`, and pointer
|
|
186
|
+
events pass through them. No live region: the page says what loads. It
|
|
187
|
+
never takes focus. `class` and other attributes go to the <img>
|
|
188
|
+
(`srcset`, `sizes`, `loading`, `width`, `height`, handlers). The root
|
|
189
|
+
`div.hp-media-image` carries `data-state` waiting | fade | resolve |
|
|
190
|
+
shown | error.
|
|
191
|
+
-->
|
|
192
|
+
<div class="hp-media-image" data-state={phase} bind:this={root}>
|
|
193
|
+
{#if phase === 'waiting' || phase === 'fade' || phase === 'resolve'}
|
|
194
|
+
<Skeleton variant="block" height="100%" />
|
|
195
|
+
{/if}
|
|
196
|
+
{#if src}
|
|
197
|
+
{#key src}
|
|
198
|
+
<img {...rest} {src} {alt} class={className} bind:this={ref} {@attach watch} />
|
|
199
|
+
{/key}
|
|
200
|
+
{/if}
|
|
201
|
+
{#if phase === 'resolve'}
|
|
202
|
+
<canvas aria-hidden="true" {@attach mosaic}></canvas>
|
|
203
|
+
{/if}
|
|
204
|
+
{#if phase === 'error' && alt}
|
|
205
|
+
<span class="caption hp-caption" aria-hidden="true">{alt}</span>
|
|
206
|
+
{/if}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<style>
|
|
210
|
+
/* Over the frame's inner box; the frame's marks paint over it. */
|
|
211
|
+
.hp-media-image {
|
|
212
|
+
position: absolute;
|
|
213
|
+
inset: 0;
|
|
214
|
+
overflow: hidden;
|
|
215
|
+
}
|
|
216
|
+
.hp-media-image > :global(.hp-skeleton),
|
|
217
|
+
canvas {
|
|
218
|
+
pointer-events: none;
|
|
219
|
+
}
|
|
220
|
+
img,
|
|
221
|
+
canvas {
|
|
222
|
+
position: absolute;
|
|
223
|
+
inset: 0;
|
|
224
|
+
display: block;
|
|
225
|
+
width: 100%;
|
|
226
|
+
height: 100%;
|
|
227
|
+
}
|
|
228
|
+
img {
|
|
229
|
+
object-fit: cover;
|
|
230
|
+
}
|
|
231
|
+
/* Hidden by opacity only, so the image stays in the accessibility tree.
|
|
232
|
+
A broken image would draw the browser's glyph and its alt. */
|
|
233
|
+
[data-state='waiting'] > img,
|
|
234
|
+
[data-state='resolve'] > img,
|
|
235
|
+
[data-state='error'] > img {
|
|
236
|
+
opacity: 0;
|
|
237
|
+
}
|
|
238
|
+
/* Nearest neighbour if the box does not fill whole device pixels: the
|
|
239
|
+
blocks stay square and crisp, never blurred. */
|
|
240
|
+
canvas {
|
|
241
|
+
image-rendering: pixelated;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* The alt of an image that failed, centred, clear of the frame's marks. */
|
|
245
|
+
.caption {
|
|
246
|
+
position: absolute;
|
|
247
|
+
inset: 0;
|
|
248
|
+
display: grid;
|
|
249
|
+
place-items: center;
|
|
250
|
+
padding: var(--space-8);
|
|
251
|
+
overflow: hidden;
|
|
252
|
+
color: var(--muted);
|
|
253
|
+
text-align: center;
|
|
254
|
+
pointer-events: none;
|
|
255
|
+
}
|
|
256
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HTMLImgAttributes } from 'svelte/elements';
|
|
2
|
+
export type MediaImageProps = Omit<HTMLImgAttributes, 'src' | 'alt' | 'children'> & {
|
|
3
|
+
/**
|
|
4
|
+
* The image's URL. Leave it undefined while the app does not know it
|
|
5
|
+
* yet: the frame waits, as it does while the image loads. A different
|
|
6
|
+
* URL starts a new load; the same one changes nothing.
|
|
7
|
+
*/
|
|
8
|
+
src?: string;
|
|
9
|
+
/** The text alternative, required. `""` for a decorative image. */
|
|
10
|
+
alt: string;
|
|
11
|
+
/** The `<img>` element, while there is a `src`. Bindable, read-only. */
|
|
12
|
+
ref?: HTMLImageElement | null;
|
|
13
|
+
};
|
|
14
|
+
declare const MediaImage: import("svelte").Component<MediaImageProps, {}, "ref">;
|
|
15
|
+
type MediaImage = ReturnType<typeof MediaImage>;
|
|
16
|
+
export default MediaImage;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { DropdownMenu as BitsDropdownMenu } from 'bits-ui';
|
|
4
|
+
|
|
5
|
+
export type MenuTriggerSnippetProps = {
|
|
6
|
+
/** Spread onto the button that opens the menu. */
|
|
7
|
+
props: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Attributes and handlers not listed here go to the menu element (Bits UI
|
|
12
|
+
* `DropdownMenu.Content`), among them `side` (default `bottom`), `align`
|
|
13
|
+
* (default `start`), `sideOffset` (default 8), `collisionPadding`
|
|
14
|
+
* (default 8), `loop`, `onCloseAutoFocus`, `onEscapeKeydown` and
|
|
15
|
+
* `onInteractOutside`. The placement props apply from 600px only; below it
|
|
16
|
+
* the menu is a bottom sheet.
|
|
17
|
+
*/
|
|
18
|
+
export type MenuProps = Omit<BitsDropdownMenu.ContentProps, 'child' | 'children' | 'forceMount' | 'ref'> & {
|
|
19
|
+
open?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The menu's accessible name, and the sheet's title below 600px. Use the
|
|
22
|
+
* trigger's accessible name: its text, or an icon button's `aria-label`.
|
|
23
|
+
* A fixed string the app defines, set in caps as the sheet's title, so it
|
|
24
|
+
* never carries user data; name a row's item through the trigger's
|
|
25
|
+
* `aria-describedby`.
|
|
26
|
+
*/
|
|
27
|
+
label: string;
|
|
28
|
+
/** The button the menu opens from: spread `props` onto it. */
|
|
29
|
+
trigger: Snippet<[MenuTriggerSnippetProps]>;
|
|
30
|
+
/** The items: MenuItem, MenuCheckboxItem, MenuRadioGroup, MenuGroup and MenuSeparator. */
|
|
31
|
+
children: Snippet;
|
|
32
|
+
/** The sheet's close button's accessible name (below 600px). */
|
|
33
|
+
closeLabel?: string;
|
|
34
|
+
/** The menu element, `role="menu"`. */
|
|
35
|
+
ref?: HTMLElement | null;
|
|
36
|
+
};
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<script lang="ts">
|
|
40
|
+
import { DropdownMenu } from 'bits-ui';
|
|
41
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
42
|
+
import { pop } from '../motion/index.js';
|
|
43
|
+
import Sheet from './internal/Sheet.svelte';
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
open = $bindable(false),
|
|
47
|
+
label,
|
|
48
|
+
trigger,
|
|
49
|
+
children,
|
|
50
|
+
closeLabel = 'Close',
|
|
51
|
+
ref = $bindable(null),
|
|
52
|
+
class: className,
|
|
53
|
+
onInteractOutside,
|
|
54
|
+
onCloseAutoFocus,
|
|
55
|
+
side = 'bottom',
|
|
56
|
+
align = 'start',
|
|
57
|
+
sideOffset = 8,
|
|
58
|
+
collisionPadding = 8,
|
|
59
|
+
alignOffset,
|
|
60
|
+
arrowPadding,
|
|
61
|
+
avoidCollisions,
|
|
62
|
+
collisionBoundary,
|
|
63
|
+
sticky,
|
|
64
|
+
hideWhenDetached,
|
|
65
|
+
updatePositionStrategy,
|
|
66
|
+
strategy,
|
|
67
|
+
customAnchor,
|
|
68
|
+
...rest
|
|
69
|
+
}: MenuProps = $props();
|
|
70
|
+
|
|
71
|
+
// Placement belongs to the menu under the trigger; the sheet always sits
|
|
72
|
+
// at the bottom edge, so ContentStatic never receives these.
|
|
73
|
+
const placement = $derived({
|
|
74
|
+
side,
|
|
75
|
+
align,
|
|
76
|
+
sideOffset,
|
|
77
|
+
collisionPadding,
|
|
78
|
+
alignOffset,
|
|
79
|
+
arrowPadding,
|
|
80
|
+
avoidCollisions,
|
|
81
|
+
collisionBoundary,
|
|
82
|
+
sticky,
|
|
83
|
+
hideWhenDetached,
|
|
84
|
+
updatePositionStrategy,
|
|
85
|
+
strategy,
|
|
86
|
+
customAnchor
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// The form follows the width (BRIEF, Apps and phones: Overlays): a menu
|
|
90
|
+
// under its trigger from 600px, the shared bottom sheet below it.
|
|
91
|
+
const wide = new MediaQuery('min-width: 600px', true);
|
|
92
|
+
|
|
93
|
+
// Bits UI moves real focus to the highlighted item, whether keys or a
|
|
94
|
+
// pointer moved it. Only a highlight the keys moved draws the focus
|
|
95
|
+
// contour; a pointer's draws the tint alone. The items read the contour
|
|
96
|
+
// from --menu-contour, which .hp-menu[data-keyboard] sets.
|
|
97
|
+
let keyboard = $state(false);
|
|
98
|
+
|
|
99
|
+
let sheetNode = $state<HTMLElement | null>(null);
|
|
100
|
+
let triggerNode = $state<HTMLElement | null>(null);
|
|
101
|
+
|
|
102
|
+
function close() {
|
|
103
|
+
open = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The sheet's title row and handle sit outside the menu element; pressing
|
|
107
|
+
// them is not an outside press.
|
|
108
|
+
function insideSheet(e: PointerEvent) {
|
|
109
|
+
if (sheetNode && e.target instanceof Node && sheetNode.contains(e.target)) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
onInteractOutside?.(e);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Bits UI leaves focus where an outside press put it, so a click on
|
|
117
|
+
// another control keeps it. Below 600px the only thing outside is the
|
|
118
|
+
// scrim, and a press there is a dismiss like the close button, so focus
|
|
119
|
+
// returns to the trigger as it does after the close button.
|
|
120
|
+
function returnFocus(e: Event) {
|
|
121
|
+
onCloseAutoFocus?.(e);
|
|
122
|
+
if (e.defaultPrevented) return;
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
triggerNode?.focus();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Listens on the menu element without replacing Bits UI's own handlers
|
|
128
|
+
// in `props`.
|
|
129
|
+
function trackInput(node: HTMLElement) {
|
|
130
|
+
const keys = () => (keyboard = true);
|
|
131
|
+
const pointer = () => (keyboard = false);
|
|
132
|
+
node.addEventListener('keydown', keys, true);
|
|
133
|
+
node.addEventListener('pointerdown', pointer, true);
|
|
134
|
+
node.addEventListener('pointermove', pointer, true);
|
|
135
|
+
return () => {
|
|
136
|
+
node.removeEventListener('keydown', keys, true);
|
|
137
|
+
node.removeEventListener('pointerdown', pointer, true);
|
|
138
|
+
node.removeEventListener('pointermove', pointer, true);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
</script>
|
|
142
|
+
|
|
143
|
+
<!--
|
|
144
|
+
Bits UI gives the trigger aria-haspopup="menu", aria-expanded and
|
|
145
|
+
aria-controls; the menu element takes role="menu" and `label` as its
|
|
146
|
+
name. Opened by keys, focus moves to the first item; opened by a pointer,
|
|
147
|
+
to the menu itself. Arrow keys move the highlight and loop, letters jump
|
|
148
|
+
by typeahead, Enter or Space runs the item. Escape, choosing an item and,
|
|
149
|
+
below 600px, the close button, a swipe down or a press on the scrim close
|
|
150
|
+
it and return focus to the trigger. From 600px a press outside closes it
|
|
151
|
+
and leaves focus where the press put it; Tab closes it and moves on.
|
|
152
|
+
-->
|
|
153
|
+
<DropdownMenu.Root bind:open>
|
|
154
|
+
<DropdownMenu.Trigger
|
|
155
|
+
bind:ref={triggerNode}
|
|
156
|
+
onkeydowncapture={() => (keyboard = true)}
|
|
157
|
+
onpointerdowncapture={() => (keyboard = false)}
|
|
158
|
+
>
|
|
159
|
+
{#snippet child({ props })}
|
|
160
|
+
{@render trigger({ props })}
|
|
161
|
+
{/snippet}
|
|
162
|
+
</DropdownMenu.Trigger>
|
|
163
|
+
|
|
164
|
+
<DropdownMenu.Portal>
|
|
165
|
+
{#if wide.current}
|
|
166
|
+
<DropdownMenu.Content forceMount bind:ref {...placement} {onInteractOutside} {onCloseAutoFocus} {...rest}>
|
|
167
|
+
{#snippet child({ wrapperProps, props, open: shown })}
|
|
168
|
+
{#if shown}
|
|
169
|
+
<div {...wrapperProps}>
|
|
170
|
+
<div
|
|
171
|
+
{...props}
|
|
172
|
+
class={['hp-menu', className]}
|
|
173
|
+
data-form="popover"
|
|
174
|
+
data-keyboard={keyboard || undefined}
|
|
175
|
+
aria-label={label}
|
|
176
|
+
transition:pop
|
|
177
|
+
{@attach trackInput}
|
|
178
|
+
>
|
|
179
|
+
{@render children()}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
{/if}
|
|
183
|
+
{/snippet}
|
|
184
|
+
</DropdownMenu.Content>
|
|
185
|
+
{:else}
|
|
186
|
+
<DropdownMenu.ContentStatic
|
|
187
|
+
forceMount
|
|
188
|
+
bind:ref
|
|
189
|
+
{...rest}
|
|
190
|
+
preventScroll
|
|
191
|
+
onInteractOutside={insideSheet}
|
|
192
|
+
onCloseAutoFocus={returnFocus}
|
|
193
|
+
>
|
|
194
|
+
{#snippet child({ props, open: shown })}
|
|
195
|
+
<Sheet open={shown} layer="popover" title={label} {closeLabel} onclose={close} bind:ref={sheetNode}>
|
|
196
|
+
<div
|
|
197
|
+
{...props}
|
|
198
|
+
class={['hp-menu', className]}
|
|
199
|
+
data-form="sheet"
|
|
200
|
+
data-keyboard={keyboard || undefined}
|
|
201
|
+
aria-label={label}
|
|
202
|
+
{@attach trackInput}
|
|
203
|
+
>
|
|
204
|
+
{@render children()}
|
|
205
|
+
</div>
|
|
206
|
+
</Sheet>
|
|
207
|
+
{/snippet}
|
|
208
|
+
</DropdownMenu.ContentStatic>
|
|
209
|
+
{/if}
|
|
210
|
+
</DropdownMenu.Portal>
|
|
211
|
+
</DropdownMenu.Root>
|
|
212
|
+
|
|
213
|
+
<style>
|
|
214
|
+
/* The list in both forms. Items read two inherited properties:
|
|
215
|
+
--menu-inset, the row's side padding, so item text lines up with the
|
|
216
|
+
menu's edge here and with the sheet's title there; and --menu-contour,
|
|
217
|
+
the outline a highlighted row draws, which only keys turn on. The menu
|
|
218
|
+
element takes focus when a pointer opens it; the rows show where focus
|
|
219
|
+
is, so the menu itself draws no contour. */
|
|
220
|
+
.hp-menu {
|
|
221
|
+
--menu-inset: var(--space-3);
|
|
222
|
+
--menu-contour: none;
|
|
223
|
+
|
|
224
|
+
box-sizing: border-box;
|
|
225
|
+
padding-block: var(--space-1);
|
|
226
|
+
background: var(--canvas);
|
|
227
|
+
color: var(--text);
|
|
228
|
+
font-family: var(--sans);
|
|
229
|
+
outline: none;
|
|
230
|
+
}
|
|
231
|
+
.hp-menu[data-keyboard] {
|
|
232
|
+
--menu-contour: 2px solid var(--focus);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ---- Popover (600px and up): a black panel under its trigger --------- */
|
|
236
|
+
|
|
237
|
+
/* On the popover layer (tokens.css), over the shell and over an open
|
|
238
|
+
dialog; Bits UI copies the z-index to the positioned wrapper. At least
|
|
239
|
+
as wide as the trigger and 192px, at most 320px, and as tall as the
|
|
240
|
+
space beside the trigger; a longer list scrolls inside. */
|
|
241
|
+
.hp-menu[data-form='popover'] {
|
|
242
|
+
position: relative;
|
|
243
|
+
z-index: var(--layer-popover);
|
|
244
|
+
width: max-content;
|
|
245
|
+
min-width: max(var(--bits-dropdown-menu-anchor-width), 192px);
|
|
246
|
+
max-width: min(320px, var(--bits-dropdown-menu-content-available-width));
|
|
247
|
+
max-height: var(--bits-dropdown-menu-content-available-height);
|
|
248
|
+
overflow-y: auto;
|
|
249
|
+
overscroll-behavior: contain;
|
|
250
|
+
border: 1px solid var(--border-control);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ---- Sheet (compact): the list is the sheet's body, which scrolls ---- */
|
|
254
|
+
|
|
255
|
+
.hp-menu[data-form='sheet'] {
|
|
256
|
+
--menu-inset: var(--gutter);
|
|
257
|
+
}
|
|
258
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { DropdownMenu as BitsDropdownMenu } from 'bits-ui';
|
|
3
|
+
export type MenuTriggerSnippetProps = {
|
|
4
|
+
/** Spread onto the button that opens the menu. */
|
|
5
|
+
props: Record<string, unknown>;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Attributes and handlers not listed here go to the menu element (Bits UI
|
|
9
|
+
* `DropdownMenu.Content`), among them `side` (default `bottom`), `align`
|
|
10
|
+
* (default `start`), `sideOffset` (default 8), `collisionPadding`
|
|
11
|
+
* (default 8), `loop`, `onCloseAutoFocus`, `onEscapeKeydown` and
|
|
12
|
+
* `onInteractOutside`. The placement props apply from 600px only; below it
|
|
13
|
+
* the menu is a bottom sheet.
|
|
14
|
+
*/
|
|
15
|
+
export type MenuProps = Omit<BitsDropdownMenu.ContentProps, 'child' | 'children' | 'forceMount' | 'ref'> & {
|
|
16
|
+
open?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The menu's accessible name, and the sheet's title below 600px. Use the
|
|
19
|
+
* trigger's accessible name: its text, or an icon button's `aria-label`.
|
|
20
|
+
* A fixed string the app defines, set in caps as the sheet's title, so it
|
|
21
|
+
* never carries user data; name a row's item through the trigger's
|
|
22
|
+
* `aria-describedby`.
|
|
23
|
+
*/
|
|
24
|
+
label: string;
|
|
25
|
+
/** The button the menu opens from: spread `props` onto it. */
|
|
26
|
+
trigger: Snippet<[MenuTriggerSnippetProps]>;
|
|
27
|
+
/** The items: MenuItem, MenuCheckboxItem, MenuRadioGroup, MenuGroup and MenuSeparator. */
|
|
28
|
+
children: Snippet;
|
|
29
|
+
/** The sheet's close button's accessible name (below 600px). */
|
|
30
|
+
closeLabel?: string;
|
|
31
|
+
/** The menu element, `role="menu"`. */
|
|
32
|
+
ref?: HTMLElement | null;
|
|
33
|
+
};
|
|
34
|
+
declare const Menu: import("svelte").Component<MenuProps, {}, "ref" | "open">;
|
|
35
|
+
type Menu = ReturnType<typeof Menu>;
|
|
36
|
+
export default Menu;
|