@mystravil/hardpoint 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -18
- package/LICENSE +25 -25
- package/README.md +67 -64
- package/dist/base.css +625 -346
- package/dist/components/AppShell.svelte +698 -698
- package/dist/components/Button.svelte +174 -174
- package/dist/components/Checkbox.svelte +235 -198
- package/dist/components/Checkbox.svelte.d.ts +9 -2
- package/dist/components/CheckboxGroup.svelte +207 -99
- package/dist/components/CheckboxGroup.svelte.d.ts +23 -4
- package/dist/components/Combobox.svelte +901 -824
- package/dist/components/Combobox.svelte.d.ts +10 -1
- package/dist/components/Dialog.svelte +359 -356
- package/dist/components/Disclosure.svelte +201 -201
- package/dist/components/EmptyState.svelte +88 -88
- package/dist/components/ErrorState.svelte +135 -135
- package/dist/components/ImageViewer.svelte +764 -0
- package/dist/components/ImageViewer.svelte.d.ts +55 -0
- package/dist/components/KeyValueList.svelte +114 -114
- package/dist/components/ListDetail.svelte +218 -0
- package/dist/components/ListDetail.svelte.d.ts +37 -0
- package/dist/components/LoadRegion.svelte +219 -219
- package/dist/components/MediaFrame.svelte +110 -110
- package/dist/components/MediaImage.svelte +256 -256
- package/dist/components/Menu.svelte +258 -258
- package/dist/components/MenuCheckboxItem.svelte +119 -119
- package/dist/components/MenuGroup.svelte +50 -50
- package/dist/components/MenuItem.svelte +94 -94
- package/dist/components/MenuRadioGroup.svelte +62 -62
- package/dist/components/MenuRadioItem.svelte +110 -110
- package/dist/components/MenuSeparator.svelte +28 -28
- package/dist/components/Meter.svelte +283 -0
- package/dist/components/Meter.svelte.d.ts +66 -0
- package/dist/components/NativeSelect.svelte +18 -2
- package/dist/components/NativeSelect.svelte.d.ts +6 -0
- package/dist/components/Notice.svelte +262 -0
- package/dist/components/Notice.svelte.d.ts +30 -0
- package/dist/components/Popover.svelte +222 -222
- package/dist/components/Progress.svelte +157 -157
- package/dist/components/RadioGroup.svelte +440 -0
- package/dist/components/RadioGroup.svelte.d.ts +50 -0
- package/dist/components/Select.svelte +471 -405
- package/dist/components/Select.svelte.d.ts +6 -0
- package/dist/components/Skeleton.svelte +149 -149
- package/dist/components/Spinner.svelte +118 -117
- package/dist/components/Stat.svelte +253 -253
- package/dist/components/Steps.svelte +189 -189
- package/dist/components/Switch.svelte +222 -222
- package/dist/components/Table.svelte +504 -504
- package/dist/components/Tabs.svelte +246 -246
- package/dist/components/Tag.svelte +40 -40
- package/dist/components/TextArea.svelte +272 -0
- package/dist/components/TextArea.svelte.d.ts +28 -0
- package/dist/components/TextField.svelte +256 -256
- package/dist/components/Timeline.svelte +156 -156
- package/dist/components/Toaster.svelte +411 -411
- package/dist/components/Tooltip.svelte +200 -200
- package/dist/components/TooltipGroup.svelte +46 -46
- package/dist/components/internal/Scrim.svelte +52 -52
- package/dist/components/internal/Sheet.svelte +263 -263
- package/dist/components/internal/groups.d.ts +14 -0
- package/dist/components/internal/groups.js +23 -0
- package/dist/fonts.css +71 -71
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/tokens.css +283 -279
- package/package.json +81 -76
|
@@ -1,256 +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>
|
|
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>
|