@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,247 @@
|
|
|
1
|
+
// Image resolve (BRIEF, motion rule 10; da3-57, da3-59), for MediaImage in
|
|
2
|
+
// the expressive tier. Internal: the package does not export it.
|
|
3
|
+
//
|
|
4
|
+
// An image that arrives after its skeleton showed appears as a mosaic of
|
|
5
|
+
// square blocks, each about the average colour of the part of the image it
|
|
6
|
+
// covers, and sharpens in discrete steps to the image itself:
|
|
7
|
+
//
|
|
8
|
+
// step 1 blocks of about 32 CSS px step 4 about 4 CSS px
|
|
9
|
+
// step 2 about 16 step 5 about 2
|
|
10
|
+
// step 3 about 8 then the image
|
|
11
|
+
//
|
|
12
|
+
// Each step lasts `--resolve-step` (42ms, one reel frame at 24 fps; the
|
|
13
|
+
// caller reads it from the element when the resolve starts), counted from
|
|
14
|
+
// the first frame on screen, so the image shows five steps (210ms) after
|
|
15
|
+
// the mosaic first did, inside the rule's 300ms. The reel's photo goes from
|
|
16
|
+
// blocks of about 36px to crisp in 7 frames (da3-57), and its mosaics settle
|
|
17
|
+
// in about 8 frames, halving about every 1.5 (measurements §4.7 to §4.9);
|
|
18
|
+
// this halves every step, a shorter form of the same ramp.
|
|
19
|
+
//
|
|
20
|
+
// Crisp squares: the canvas bitmap has exactly the device pixels its box
|
|
21
|
+
// covers on screen (a ResizeObserver on `device-pixel-content-box`, where
|
|
22
|
+
// the browser has it), and every block is a whole number of device pixels:
|
|
23
|
+
// `round(2 × devicePixelRatio)` for the finest step, doubled for each
|
|
24
|
+
// coarser one, on a grid from the top left corner. So each block splits
|
|
25
|
+
// into four of the next step and every edge lands on a device pixel. The
|
|
26
|
+
// box rarely holds a whole number of blocks: the last column and row cover
|
|
27
|
+
// what is left, and when that is less than half a block, the column or row
|
|
28
|
+
// beside it widens over it, so no thin strip lines the right or bottom
|
|
29
|
+
// edge. Blocks are drawn by scaling a canvas of one pixel per block with
|
|
30
|
+
// smoothing off, by a whole factor except along those edges, and the
|
|
31
|
+
// canvas element has `image-rendering: pixelated`, so where the browser
|
|
32
|
+
// cannot report device pixels the blocks may differ by a pixel but never
|
|
33
|
+
// blur.
|
|
34
|
+
//
|
|
35
|
+
// No pixel is ever read back. The image is drawn once, whole, into a copy
|
|
36
|
+
// of about two pixels per finest block, with high-quality smoothing; that
|
|
37
|
+
// draw decodes it. The finest step takes each block's part of the copy down
|
|
38
|
+
// to one pixel, and each coarser step is the step before it drawn at half
|
|
39
|
+
// size with bilinear smoothing, which makes each block the mean of the four
|
|
40
|
+
// under it. Taking the parts from the copy, not from the image, matters:
|
|
41
|
+
// in Chrome, four draws of fractional parts of a 1200 x 800 JPEG cost two
|
|
42
|
+
// decodes (measured 2026-11-11). So the work is one decode and a few draws
|
|
43
|
+
// of small canvases (178 x 119 pixels for the finest step of a 356 x 237
|
|
44
|
+
// CSS px box at a ratio of 3), and an image from another origin served
|
|
45
|
+
// without CORS headers resolves like any other: drawing it only taints the
|
|
46
|
+
// canvases, which stay on screen and are never read.
|
|
47
|
+
//
|
|
48
|
+
// const stop = resolveImage(canvas, image, step, done);
|
|
49
|
+
//
|
|
50
|
+
// The image is drawn as `object-fit: cover` draws it. The caller keeps the
|
|
51
|
+
// real <img> (with its alt) in the document under the aria-hidden canvas,
|
|
52
|
+
// and removes the canvas when `done` runs. The mosaic is computed and its
|
|
53
|
+
// first step drawn before the canvas is first painted, in the
|
|
54
|
+
// ResizeObserver callback.
|
|
55
|
+
/** Steps from the coarsest blocks to the finest, before the image itself. */
|
|
56
|
+
const STEPS = 5;
|
|
57
|
+
/** The finest blocks, in CSS px; each coarser step doubles them. */
|
|
58
|
+
const FINEST = 2;
|
|
59
|
+
/**
|
|
60
|
+
* Shows `image` on `canvas` as a mosaic that sharpens one step per `step`
|
|
61
|
+
* ms, then calls `done`. The canvas carries `data-step` (1 to 5) and
|
|
62
|
+
* `data-block` (the block size in device pixels) for the step on screen.
|
|
63
|
+
* When the box is empty or the image has no size, `done` runs at once.
|
|
64
|
+
* Returns a stop function.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveImage(canvas, image, step, done) {
|
|
67
|
+
let stop;
|
|
68
|
+
// The first report comes after layout and before the canvas is first
|
|
69
|
+
// painted. Later size changes keep the bitmap, scaled. The device-pixel
|
|
70
|
+
// size counts only when it agrees with the CSS size times the ratio
|
|
71
|
+
// (Chromium's device emulation reports it unscaled).
|
|
72
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
73
|
+
observer.disconnect();
|
|
74
|
+
const ratio = devicePixelRatio;
|
|
75
|
+
const css = entry.contentRect;
|
|
76
|
+
const near = (device, size) => device !== undefined && Math.abs(device - size * ratio) <= 1 ? device : Math.round(size * ratio);
|
|
77
|
+
const device = entry.devicePixelContentBoxSize?.[0];
|
|
78
|
+
const width = near(device?.inlineSize, css.width);
|
|
79
|
+
const height = near(device?.blockSize, css.height);
|
|
80
|
+
const steps = mosaic(image, width, height, ratio);
|
|
81
|
+
if (!steps) {
|
|
82
|
+
stop = () => { };
|
|
83
|
+
return done();
|
|
84
|
+
}
|
|
85
|
+
stop = play(canvas, width, height, steps, step, done);
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
observer.observe(canvas, { box: 'device-pixel-content-box' });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
observer.observe(canvas);
|
|
92
|
+
}
|
|
93
|
+
return () => {
|
|
94
|
+
observer.disconnect();
|
|
95
|
+
stop?.();
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** A canvas of `width` x `height` pixels and its context, smoothing at `quality`. */
|
|
99
|
+
function surface(width, height, quality) {
|
|
100
|
+
const canvas = document.createElement('canvas');
|
|
101
|
+
canvas.width = width;
|
|
102
|
+
canvas.height = height;
|
|
103
|
+
const ctx = canvas.getContext('2d');
|
|
104
|
+
if (!ctx)
|
|
105
|
+
return null;
|
|
106
|
+
ctx.imageSmoothingEnabled = true;
|
|
107
|
+
ctx.imageSmoothingQuality = quality;
|
|
108
|
+
return { canvas, ctx };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The grid's whole blocks and its last block along one axis, as
|
|
112
|
+
* `[first cell, cells, device px from, device px across]`: `count` cells of
|
|
113
|
+
* `block` px over `size` px, the last one covering what is left.
|
|
114
|
+
*/
|
|
115
|
+
function spans(count, block, size) {
|
|
116
|
+
const whole = (count - 1) * block;
|
|
117
|
+
const last = [count - 1, 1, whole, size - whole];
|
|
118
|
+
return count > 1 ? [[0, count - 1, 0, whole], last] : [last];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Halving `count` cells along one axis, as `[from, cells, to, cells]`:
|
|
122
|
+
* pairs draw at half size into one cell each, and an odd last cell is
|
|
123
|
+
* copied as it is.
|
|
124
|
+
*/
|
|
125
|
+
function halves(count) {
|
|
126
|
+
const paired = count - (count % 2);
|
|
127
|
+
const out = [];
|
|
128
|
+
if (paired)
|
|
129
|
+
out.push([0, paired, 0, paired / 2]);
|
|
130
|
+
if (count % 2)
|
|
131
|
+
out.push([count - 1, 1, paired / 2, 1]);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/** The mosaic steps of `image` over a `width` x `height` device pixel box, coarsest first. */
|
|
135
|
+
function mosaic(image, width, height, ratio) {
|
|
136
|
+
const iw = image.naturalWidth;
|
|
137
|
+
const ih = image.naturalHeight;
|
|
138
|
+
if (width < 1 || height < 1 || iw < 1 || ih < 1)
|
|
139
|
+
return null;
|
|
140
|
+
// The image as `object-fit: cover` draws it: `scale` device pixels per
|
|
141
|
+
// image pixel, centred.
|
|
142
|
+
const scale = Math.max(width / iw, height / ih);
|
|
143
|
+
const left = (iw - width / scale) / 2;
|
|
144
|
+
const top = (ih - height / scale) / 2;
|
|
145
|
+
// The image drawn once, whole, at about two pixels per finest block (at
|
|
146
|
+
// most its own size): the one draw that decodes it.
|
|
147
|
+
let block = Math.max(1, Math.round(FINEST * ratio));
|
|
148
|
+
const shrink = Math.min(1, (2 * scale) / block);
|
|
149
|
+
const copy = surface(Math.max(1, Math.round(iw * shrink)), Math.max(1, Math.round(ih * shrink)), 'high');
|
|
150
|
+
if (!copy)
|
|
151
|
+
return null;
|
|
152
|
+
copy.ctx.drawImage(image, 0, 0, copy.canvas.width, copy.canvas.height);
|
|
153
|
+
const kx = copy.canvas.width / iw;
|
|
154
|
+
const ky = copy.canvas.height / ih;
|
|
155
|
+
// The finest step: each block's part of the copy drawn down to one pixel.
|
|
156
|
+
let cols = Math.ceil(width / block);
|
|
157
|
+
let rows = Math.ceil(height / block);
|
|
158
|
+
let cells = surface(cols, rows, 'low');
|
|
159
|
+
if (!cells)
|
|
160
|
+
return null;
|
|
161
|
+
for (const [dx, dw, sx, sw] of spans(cols, block, width)) {
|
|
162
|
+
for (const [dy, dh, sy, sh] of spans(rows, block, height)) {
|
|
163
|
+
const x = (left + sx / scale) * kx;
|
|
164
|
+
const y = (top + sy / scale) * ky;
|
|
165
|
+
cells.ctx.drawImage(copy.canvas, x, y, (sw / scale) * kx, (sh / scale) * ky, dx, dy, dw, dh);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const steps = [];
|
|
169
|
+
for (let k = 0;; k++) {
|
|
170
|
+
const restX = width - (cols - 1) * block;
|
|
171
|
+
const restY = height - (rows - 1) * block;
|
|
172
|
+
const joinX = cols > 1 && restX < block / 2;
|
|
173
|
+
const joinY = rows > 1 && restY < block / 2;
|
|
174
|
+
steps.unshift({
|
|
175
|
+
block,
|
|
176
|
+
cells: cells.canvas,
|
|
177
|
+
cols: joinX ? cols - 1 : cols,
|
|
178
|
+
rows: joinY ? rows - 1 : rows,
|
|
179
|
+
lastWidth: joinX ? block + restX : restX,
|
|
180
|
+
lastHeight: joinY ? block + restY : restY
|
|
181
|
+
});
|
|
182
|
+
if (k === STEPS - 1)
|
|
183
|
+
return steps;
|
|
184
|
+
// Four blocks make one of the next step: half size, bilinear, so each
|
|
185
|
+
// new pixel is the mean of the four under it.
|
|
186
|
+
const next = surface(Math.ceil(cols / 2), Math.ceil(rows / 2), 'low');
|
|
187
|
+
if (!next)
|
|
188
|
+
return null;
|
|
189
|
+
for (const [sx, sw, dx, dw] of halves(cols)) {
|
|
190
|
+
for (const [sy, sh, dy, dh] of halves(rows)) {
|
|
191
|
+
next.ctx.drawImage(cells.canvas, sx, sy, sw, sh, dx, dy, dw, dh);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
cells = next;
|
|
195
|
+
cols = next.canvas.width;
|
|
196
|
+
rows = next.canvas.height;
|
|
197
|
+
block *= 2;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Draws the first step now and one more per `step` ms after it, then calls `done`. */
|
|
201
|
+
function play(canvas, width, height, steps, step, done) {
|
|
202
|
+
canvas.width = width;
|
|
203
|
+
canvas.height = height;
|
|
204
|
+
const ctx = canvas.getContext('2d');
|
|
205
|
+
if (!ctx) {
|
|
206
|
+
done();
|
|
207
|
+
return () => { };
|
|
208
|
+
}
|
|
209
|
+
ctx.imageSmoothingEnabled = false;
|
|
210
|
+
let shown = 0;
|
|
211
|
+
const show = (n) => {
|
|
212
|
+
const { block, cells, cols, rows, lastWidth, lastHeight } = steps[n];
|
|
213
|
+
// Whole blocks at a whole scale, then the last column, the last row
|
|
214
|
+
// and the corner, stretched to the edges. Cleared first, so an image
|
|
215
|
+
// with transparent parts never stacks two steps.
|
|
216
|
+
const c = cols - 1;
|
|
217
|
+
const r = rows - 1;
|
|
218
|
+
const x = c * block;
|
|
219
|
+
const y = r * block;
|
|
220
|
+
ctx.clearRect(0, 0, width, height);
|
|
221
|
+
if (c && r)
|
|
222
|
+
ctx.drawImage(cells, 0, 0, c, r, 0, 0, x, y);
|
|
223
|
+
if (r)
|
|
224
|
+
ctx.drawImage(cells, c, 0, 1, r, x, 0, lastWidth, y);
|
|
225
|
+
if (c)
|
|
226
|
+
ctx.drawImage(cells, 0, r, c, 1, 0, y, x, lastHeight);
|
|
227
|
+
ctx.drawImage(cells, c, r, 1, 1, x, y, lastWidth, lastHeight);
|
|
228
|
+
canvas.dataset.step = String(n + 1);
|
|
229
|
+
canvas.dataset.block = String(block);
|
|
230
|
+
shown = n;
|
|
231
|
+
};
|
|
232
|
+
show(0);
|
|
233
|
+
// The first step is painted with this frame, once the work above is
|
|
234
|
+
// done, so its time starts now.
|
|
235
|
+
const start = performance.now();
|
|
236
|
+
let raf = 0;
|
|
237
|
+
const tick = (now) => {
|
|
238
|
+
const n = Math.floor((now - start) / step);
|
|
239
|
+
if (n >= steps.length)
|
|
240
|
+
return done();
|
|
241
|
+
if (n > shown)
|
|
242
|
+
show(n);
|
|
243
|
+
raf = requestAnimationFrame(tick);
|
|
244
|
+
};
|
|
245
|
+
raf = requestAnimationFrame(tick);
|
|
246
|
+
return () => cancelAnimationFrame(raf);
|
|
247
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
/**
|
|
3
|
+
* A way a swipe sends the panel off screen: `down` for a bottom sheet,
|
|
4
|
+
* `left` for a drawer from the left edge, and `left`, `right` or `down` for
|
|
5
|
+
* the toast.
|
|
6
|
+
*/
|
|
7
|
+
export type SwipeDirection = 'down' | 'left' | 'right';
|
|
8
|
+
export interface SwipeDismissOptions {
|
|
9
|
+
/** The way a swipe dismisses, or several ways. Default `down`. */
|
|
10
|
+
direction?: SwipeDirection | readonly SwipeDirection[];
|
|
11
|
+
/** Share of the panel's height (`down`) or width (`left`, `right`) a release must pass to dismiss. Default 0.25. */
|
|
12
|
+
distance?: number;
|
|
13
|
+
/** Release speed in px/ms towards a direction that dismisses at any distance. Default 0.5. */
|
|
14
|
+
velocity?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Swipe to dismiss: down for a bottom sheet (BRIEF, Apps and phones:
|
|
18
|
+
* Overlays), left for the shell's drawer, and left, right or down for the
|
|
19
|
+
* toast. Attach it to the panel:
|
|
20
|
+
* `{@attach swipeDismiss(() => (open = false))}`,
|
|
21
|
+
* `{@attach swipeDismiss(close, { direction: 'left' })}`, or
|
|
22
|
+
* `{@attach swipeDismiss(away, { direction: ['left', 'right', 'down'] })}`.
|
|
23
|
+
*
|
|
24
|
+
* The panel follows the finger through the CSS `translate` property, so
|
|
25
|
+
* `transform` stays free for the transitions: after a dismiss the exit plays
|
|
26
|
+
* on from where the finger let go, and under reduced motion it fades in
|
|
27
|
+
* place. A release past `distance` of the panel's size along the swipe, or a
|
|
28
|
+
* flick the way the panel moved, calls `onDismiss` with that direction, so
|
|
29
|
+
* the exit can carry on that way; a shorter drag springs back over
|
|
30
|
+
* `--dur-sheet-exit` (at once under reduced motion).
|
|
31
|
+
*
|
|
32
|
+
* With several directions, a drag takes the first one the finger moves
|
|
33
|
+
* towards more than across it and stays on that axis: a panel that takes
|
|
34
|
+
* `left` and `right` follows the finger both ways along it, with the slop
|
|
35
|
+
* on either side of where the touch began.
|
|
36
|
+
*
|
|
37
|
+
* A touch that starts inside a list that can still scroll the way the
|
|
38
|
+
* finger moves scrolls the list: a sheet moves only once its list sits at
|
|
39
|
+
* its top. A move no direction takes (sideways on a sheet, up or down in a
|
|
40
|
+
* drawer) is the browser's, so the gesture never fights the scroll; give a
|
|
41
|
+
* drawer `touch-action: pan-y`, so a sideways move reaches the swipe, and a
|
|
42
|
+
* panel that swipes along both axes `touch-action: pinch-zoom`. A drag never
|
|
43
|
+
* activates what it started on: its `pointerup` and any `click` stop at the
|
|
44
|
+
* panel. Touch only; a mouse closes a panel through its close button, the
|
|
45
|
+
* scrim or Escape.
|
|
46
|
+
*/
|
|
47
|
+
export declare function swipeDismiss(onDismiss: (direction: SwipeDirection) => void, { direction, distance, velocity }?: SwipeDismissOptions): Attachment<HTMLElement>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { prefersReducedMotion } from 'svelte/motion';
|
|
2
|
+
/** Each direction's axis, and the sign of a screen coordinate moving that way. */
|
|
3
|
+
const WAYS = {
|
|
4
|
+
down: { axis: 'y', sign: 1 },
|
|
5
|
+
left: { axis: 'x', sign: -1 },
|
|
6
|
+
right: { axis: 'x', sign: 1 }
|
|
7
|
+
};
|
|
8
|
+
/** Travel before a touch counts as a drag; it is not added to the panel's offset. */
|
|
9
|
+
const SLOP = 8;
|
|
10
|
+
/** A flick counts only if the finger was still moving this recently (ms). */
|
|
11
|
+
const FLICK_WINDOW = 100;
|
|
12
|
+
/**
|
|
13
|
+
* Swipe to dismiss: down for a bottom sheet (BRIEF, Apps and phones:
|
|
14
|
+
* Overlays), left for the shell's drawer, and left, right or down for the
|
|
15
|
+
* toast. Attach it to the panel:
|
|
16
|
+
* `{@attach swipeDismiss(() => (open = false))}`,
|
|
17
|
+
* `{@attach swipeDismiss(close, { direction: 'left' })}`, or
|
|
18
|
+
* `{@attach swipeDismiss(away, { direction: ['left', 'right', 'down'] })}`.
|
|
19
|
+
*
|
|
20
|
+
* The panel follows the finger through the CSS `translate` property, so
|
|
21
|
+
* `transform` stays free for the transitions: after a dismiss the exit plays
|
|
22
|
+
* on from where the finger let go, and under reduced motion it fades in
|
|
23
|
+
* place. A release past `distance` of the panel's size along the swipe, or a
|
|
24
|
+
* flick the way the panel moved, calls `onDismiss` with that direction, so
|
|
25
|
+
* the exit can carry on that way; a shorter drag springs back over
|
|
26
|
+
* `--dur-sheet-exit` (at once under reduced motion).
|
|
27
|
+
*
|
|
28
|
+
* With several directions, a drag takes the first one the finger moves
|
|
29
|
+
* towards more than across it and stays on that axis: a panel that takes
|
|
30
|
+
* `left` and `right` follows the finger both ways along it, with the slop
|
|
31
|
+
* on either side of where the touch began.
|
|
32
|
+
*
|
|
33
|
+
* A touch that starts inside a list that can still scroll the way the
|
|
34
|
+
* finger moves scrolls the list: a sheet moves only once its list sits at
|
|
35
|
+
* its top. A move no direction takes (sideways on a sheet, up or down in a
|
|
36
|
+
* drawer) is the browser's, so the gesture never fights the scroll; give a
|
|
37
|
+
* drawer `touch-action: pan-y`, so a sideways move reaches the swipe, and a
|
|
38
|
+
* panel that swipes along both axes `touch-action: pinch-zoom`. A drag never
|
|
39
|
+
* activates what it started on: its `pointerup` and any `click` stop at the
|
|
40
|
+
* panel. Touch only; a mouse closes a panel through its close button, the
|
|
41
|
+
* scrim or Escape.
|
|
42
|
+
*/
|
|
43
|
+
export function swipeDismiss(onDismiss, { direction = 'down', distance = 0.25, velocity = 0.5 } = {}) {
|
|
44
|
+
const ways = typeof direction === 'string' ? [direction] : direction;
|
|
45
|
+
const usesX = ways.some((w) => WAYS[w].axis === 'x');
|
|
46
|
+
const usesY = ways.some((w) => WAYS[w].axis === 'y');
|
|
47
|
+
/** The panel's direction along `axis` towards `sign`, if it takes one. */
|
|
48
|
+
const wayTo = (axis, sign) => ways.find((w) => WAYS[w].axis === axis && WAYS[w].sign === sign);
|
|
49
|
+
return (node) => {
|
|
50
|
+
let phase = 'idle';
|
|
51
|
+
let dragged = false;
|
|
52
|
+
let startX = 0;
|
|
53
|
+
let startY = 0;
|
|
54
|
+
// While dragging: the axis it moves on, the panel's offset along it in
|
|
55
|
+
// screen pixels, and the finger's last travel from the start and its
|
|
56
|
+
// speed along it (px/ms).
|
|
57
|
+
let axis = 'y';
|
|
58
|
+
let offset = 0;
|
|
59
|
+
let last = 0;
|
|
60
|
+
let lastT = 0;
|
|
61
|
+
let speed = 0;
|
|
62
|
+
let scrollerX = null;
|
|
63
|
+
let scrollerY = null;
|
|
64
|
+
function place(shift, animate) {
|
|
65
|
+
node.style.transition = animate ? 'translate var(--dur-sheet-exit) var(--settle)' : '';
|
|
66
|
+
node.style.translate = shift !== 0 ? (axis === 'x' ? `${shift}px 0` : `0 ${shift}px`) : '';
|
|
67
|
+
}
|
|
68
|
+
/** The nearest ancestor of the touch, inside the panel, that scrolls along `along`. */
|
|
69
|
+
function scrollerOf(target, along) {
|
|
70
|
+
for (let el = target instanceof Element ? target : null; el && el !== node; el = el.parentElement) {
|
|
71
|
+
if (!(el instanceof HTMLElement))
|
|
72
|
+
continue;
|
|
73
|
+
if (along === 'x' ? el.scrollWidth <= el.clientWidth : el.scrollHeight <= el.clientHeight)
|
|
74
|
+
continue;
|
|
75
|
+
const style = getComputedStyle(el);
|
|
76
|
+
const overflow = along === 'x' ? style.overflowX : style.overflowY;
|
|
77
|
+
if (overflow === 'auto' || overflow === 'scroll')
|
|
78
|
+
return el;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether a list under the touch still scrolls the way the finger
|
|
84
|
+
* moves: a sheet's list away from its top, a drawer's row short of its
|
|
85
|
+
* end, a row scrolled away from its start.
|
|
86
|
+
*/
|
|
87
|
+
function scrolls(way) {
|
|
88
|
+
if (way === 'down')
|
|
89
|
+
return !!scrollerY && scrollerY.scrollTop > 0;
|
|
90
|
+
if (!scrollerX)
|
|
91
|
+
return false;
|
|
92
|
+
if (way === 'left')
|
|
93
|
+
return Math.ceil(scrollerX.scrollLeft) + scrollerX.clientWidth < scrollerX.scrollWidth;
|
|
94
|
+
return scrollerX.scrollLeft > 0;
|
|
95
|
+
}
|
|
96
|
+
function release() {
|
|
97
|
+
if (phase === 'dragging')
|
|
98
|
+
place(0, !prefersReducedMotion.current);
|
|
99
|
+
phase = 'idle';
|
|
100
|
+
offset = 0;
|
|
101
|
+
}
|
|
102
|
+
function onTouchStart(e) {
|
|
103
|
+
if (e.touches.length !== 1) {
|
|
104
|
+
// A second finger is not a swipe.
|
|
105
|
+
release();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const touch = e.touches[0];
|
|
109
|
+
startX = touch.clientX;
|
|
110
|
+
startY = touch.clientY;
|
|
111
|
+
lastT = e.timeStamp;
|
|
112
|
+
speed = 0;
|
|
113
|
+
offset = 0;
|
|
114
|
+
scrollerX = usesX ? scrollerOf(e.target, 'x') : null;
|
|
115
|
+
scrollerY = usesY ? scrollerOf(e.target, 'y') : null;
|
|
116
|
+
phase = 'pending';
|
|
117
|
+
}
|
|
118
|
+
function onTouchMove(e) {
|
|
119
|
+
if (phase === 'idle')
|
|
120
|
+
return;
|
|
121
|
+
const touch = e.touches[0];
|
|
122
|
+
const dx = touch.clientX - startX;
|
|
123
|
+
const dy = touch.clientY - startY;
|
|
124
|
+
if (phase === 'pending') {
|
|
125
|
+
// The first direction the finger moves towards more than across it.
|
|
126
|
+
const way = ways.find((w) => {
|
|
127
|
+
const { axis: a, sign } = WAYS[w];
|
|
128
|
+
const travel = sign * (a === 'x' ? dx : dy);
|
|
129
|
+
return travel > 0 && travel >= Math.abs(a === 'x' ? dy : dx);
|
|
130
|
+
});
|
|
131
|
+
if (!way || scrolls(way)) {
|
|
132
|
+
// Back, across, or a list with room to scroll: the browser's gesture.
|
|
133
|
+
if (Math.abs(dx) + Math.abs(dy) > 2)
|
|
134
|
+
phase = 'idle';
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Towards dismissal: the panel takes the gesture, so the list
|
|
138
|
+
// neither scrolls nor overscrolls.
|
|
139
|
+
if (e.cancelable)
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
const { axis: a, sign } = WAYS[way];
|
|
142
|
+
const along = a === 'x' ? dx : dy;
|
|
143
|
+
if (sign * along < SLOP)
|
|
144
|
+
return;
|
|
145
|
+
phase = 'dragging';
|
|
146
|
+
dragged = true;
|
|
147
|
+
axis = a;
|
|
148
|
+
last = along;
|
|
149
|
+
lastT = e.timeStamp;
|
|
150
|
+
}
|
|
151
|
+
if (e.cancelable)
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
// The travel from the touch's start less the slop, towards the
|
|
154
|
+
// panel's directions only.
|
|
155
|
+
const moved = axis === 'x' ? dx : dy;
|
|
156
|
+
offset = moved > SLOP && wayTo(axis, 1) ? moved - SLOP : moved < -SLOP && wayTo(axis, -1) ? moved + SLOP : 0;
|
|
157
|
+
const dt = e.timeStamp - lastT;
|
|
158
|
+
if (dt > 0)
|
|
159
|
+
speed = (moved - last) / dt;
|
|
160
|
+
last = moved;
|
|
161
|
+
lastT = e.timeStamp;
|
|
162
|
+
place(offset, false);
|
|
163
|
+
}
|
|
164
|
+
function onTouchEnd(e) {
|
|
165
|
+
if (phase !== 'dragging') {
|
|
166
|
+
phase = 'idle';
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// The way the panel moved; at rest, the way the finger was going.
|
|
170
|
+
const way = wayTo(axis, Math.sign(offset || speed));
|
|
171
|
+
const flick = !!way && WAYS[way].sign * speed > velocity && e.timeStamp - lastT < FLICK_WINDOW;
|
|
172
|
+
const size = axis === 'x' ? node.offsetWidth : node.offsetHeight;
|
|
173
|
+
if (way && (Math.abs(offset) > size * distance || flick)) {
|
|
174
|
+
// Keep the offset: the exit transition continues from here.
|
|
175
|
+
phase = 'idle';
|
|
176
|
+
onDismiss(way);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
release();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Any new press clears the flag left by the last drag.
|
|
183
|
+
function onPointerDown() {
|
|
184
|
+
dragged = false;
|
|
185
|
+
}
|
|
186
|
+
// Pointer events fire before their touch events, so the drag is still
|
|
187
|
+
// in progress when its pointerup arrives.
|
|
188
|
+
function onPointerUp(e) {
|
|
189
|
+
if (e.pointerType === 'touch' && phase === 'dragging')
|
|
190
|
+
e.stopPropagation();
|
|
191
|
+
}
|
|
192
|
+
// A drag's click, if the browser sends one, never reaches the content.
|
|
193
|
+
// A click from the keyboard or from code (detail 0) is never a drag's,
|
|
194
|
+
// and it comes with no press to clear the flag.
|
|
195
|
+
function onClick(e) {
|
|
196
|
+
if (!dragged || e.detail === 0)
|
|
197
|
+
return;
|
|
198
|
+
dragged = false;
|
|
199
|
+
e.stopPropagation();
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
}
|
|
202
|
+
const capture = { capture: true };
|
|
203
|
+
node.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
204
|
+
node.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
205
|
+
node.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
206
|
+
node.addEventListener('touchcancel', release, { passive: true });
|
|
207
|
+
node.addEventListener('pointerdown', onPointerDown, capture);
|
|
208
|
+
node.addEventListener('pointerup', onPointerUp, capture);
|
|
209
|
+
node.addEventListener('click', onClick, capture);
|
|
210
|
+
return () => {
|
|
211
|
+
node.removeEventListener('touchstart', onTouchStart);
|
|
212
|
+
node.removeEventListener('touchmove', onTouchMove);
|
|
213
|
+
node.removeEventListener('touchend', onTouchEnd);
|
|
214
|
+
node.removeEventListener('touchcancel', release);
|
|
215
|
+
node.removeEventListener('pointerdown', onPointerDown, capture);
|
|
216
|
+
node.removeEventListener('pointerup', onPointerUp, capture);
|
|
217
|
+
node.removeEventListener('click', onClick, capture);
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The visual viewport while it is at least 1px shorter than the layout
|
|
3
|
+
* viewport, as when the on-screen keyboard is up: its top and bottom edges,
|
|
4
|
+
* measured from the layout viewport's top, and its height, in px.
|
|
5
|
+
*/
|
|
6
|
+
export interface VisualViewportLift {
|
|
7
|
+
top: number;
|
|
8
|
+
bottom: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The on-screen keyboard for a panel fixed to the layout viewport: the
|
|
13
|
+
* shared Sheet and the full-screen Dialog follow the visual viewport while
|
|
14
|
+
* they are open (decided 2026-11-10). A fixed panel sits on the layout
|
|
15
|
+
* viewport, and the keyboard shrinks only the visual viewport (Chrome on
|
|
16
|
+
* Android by default, iOS always), so it covers the panel's lower part.
|
|
17
|
+
* Call this while the component initialises; place the panel from
|
|
18
|
+
* `current`, which is null while the visual viewport is not shorter.
|
|
19
|
+
*
|
|
20
|
+
* While `open()` is true it listens to `visualViewport` `resize` and
|
|
21
|
+
* `scroll`. A pinch zoom shrinks the visual viewport on purpose; the panel
|
|
22
|
+
* then keeps its last place, as omp-remote's shell does. Each opening
|
|
23
|
+
* starts from the layout viewport, so a keyboard line left from the last
|
|
24
|
+
* opening never carries over; closing keeps the last place, so the exit
|
|
25
|
+
* plays from there. The listeners start before the panel mounts, so it
|
|
26
|
+
* opens in place.
|
|
27
|
+
*
|
|
28
|
+
* When the panel moves or shrinks, the focused element inside `body()` (the
|
|
29
|
+
* panel's scrolling body) stays in sight: the body scrolls it into view,
|
|
30
|
+
* the nearest way, top first if it is taller than the body. Only the body,
|
|
31
|
+
* so neither the page nor the visual viewport moves.
|
|
32
|
+
*/
|
|
33
|
+
export declare function followVisualViewport(open: () => boolean, body: () => HTMLElement | null): {
|
|
34
|
+
readonly current: VisualViewportLift | null;
|
|
35
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
/** Room kept round a field the body scrolls into view: its focus contour (2px, 4px out) and some air. */
|
|
3
|
+
const REVEAL_MARGIN = 8;
|
|
4
|
+
/**
|
|
5
|
+
* The on-screen keyboard for a panel fixed to the layout viewport: the
|
|
6
|
+
* shared Sheet and the full-screen Dialog follow the visual viewport while
|
|
7
|
+
* they are open (decided 2026-11-10). A fixed panel sits on the layout
|
|
8
|
+
* viewport, and the keyboard shrinks only the visual viewport (Chrome on
|
|
9
|
+
* Android by default, iOS always), so it covers the panel's lower part.
|
|
10
|
+
* Call this while the component initialises; place the panel from
|
|
11
|
+
* `current`, which is null while the visual viewport is not shorter.
|
|
12
|
+
*
|
|
13
|
+
* While `open()` is true it listens to `visualViewport` `resize` and
|
|
14
|
+
* `scroll`. A pinch zoom shrinks the visual viewport on purpose; the panel
|
|
15
|
+
* then keeps its last place, as omp-remote's shell does. Each opening
|
|
16
|
+
* starts from the layout viewport, so a keyboard line left from the last
|
|
17
|
+
* opening never carries over; closing keeps the last place, so the exit
|
|
18
|
+
* plays from there. The listeners start before the panel mounts, so it
|
|
19
|
+
* opens in place.
|
|
20
|
+
*
|
|
21
|
+
* When the panel moves or shrinks, the focused element inside `body()` (the
|
|
22
|
+
* panel's scrolling body) stays in sight: the body scrolls it into view,
|
|
23
|
+
* the nearest way, top first if it is taller than the body. Only the body,
|
|
24
|
+
* so neither the page nor the visual viewport moves.
|
|
25
|
+
*/
|
|
26
|
+
export function followVisualViewport(open, body) {
|
|
27
|
+
let lifted = $state(null);
|
|
28
|
+
$effect.pre(() => {
|
|
29
|
+
if (!open())
|
|
30
|
+
return;
|
|
31
|
+
const viewport = window.visualViewport;
|
|
32
|
+
if (!viewport)
|
|
33
|
+
return;
|
|
34
|
+
const sync = () => {
|
|
35
|
+
if (Math.abs(viewport.scale - 1) > 0.01)
|
|
36
|
+
return;
|
|
37
|
+
if (document.documentElement.clientHeight - viewport.height < 1) {
|
|
38
|
+
lifted = null;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const top = viewport.offsetTop;
|
|
42
|
+
const bottom = top + viewport.height;
|
|
43
|
+
if (lifted?.top !== top || lifted.bottom !== bottom || lifted.height !== viewport.height) {
|
|
44
|
+
lifted = { top, bottom, height: viewport.height };
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
untrack(() => {
|
|
48
|
+
lifted = null;
|
|
49
|
+
sync();
|
|
50
|
+
});
|
|
51
|
+
viewport.addEventListener('resize', sync);
|
|
52
|
+
viewport.addEventListener('scroll', sync);
|
|
53
|
+
return () => {
|
|
54
|
+
viewport.removeEventListener('resize', sync);
|
|
55
|
+
viewport.removeEventListener('scroll', sync);
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
$effect(() => {
|
|
59
|
+
const scroller = lifted && body();
|
|
60
|
+
if (!scroller)
|
|
61
|
+
return;
|
|
62
|
+
const field = document.activeElement;
|
|
63
|
+
if (!(field instanceof HTMLElement) || !scroller.contains(field) || field === scroller)
|
|
64
|
+
return;
|
|
65
|
+
const view = scroller.getBoundingClientRect();
|
|
66
|
+
const box = field.getBoundingClientRect();
|
|
67
|
+
const above = view.top - (box.top - REVEAL_MARGIN);
|
|
68
|
+
const below = box.bottom + REVEAL_MARGIN - view.bottom;
|
|
69
|
+
if (above > 0)
|
|
70
|
+
scroller.scrollTop -= above;
|
|
71
|
+
else if (below > 0)
|
|
72
|
+
scroller.scrollTop += Math.min(below, -above);
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
get current() {
|
|
76
|
+
return lifted;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|