@usableapp/cardds 0.7.49 → 0.7.51
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 +16 -0
- package/README.md +5 -2
- package/cardds.js +6 -1
- package/css/choice.css +4 -3
- package/css/forms.css +3 -2
- package/css/stack.css +20 -4
- package/css/tokens.css +1 -1
- package/dist/cardds.css +28 -10
- package/dist/sheets/Sheet.d.ts +1 -3
- package/dist/sheets/Sheet.js +14 -207
- package/dist/sheets/SheetStack.d.ts +6 -3
- package/dist/sheets/SheetStack.js +328 -4
- package/dist/sheets/SheetSteps.js +70 -92
- package/dist/sheets/sheetDrag.d.ts +9 -0
- package/dist/sheets/sheetDrag.js +49 -0
- package/dist/sheets/sheetMeasure.d.ts +10 -0
- package/dist/sheets/sheetMeasure.js +84 -0
- package/dist/sheets/sheetSlide.d.ts +9 -0
- package/dist/sheets/sheetSlide.js +96 -0
- package/dist/sheets/sheetStepsPile.d.ts +8 -0
- package/dist/sheets/sheetStepsPile.js +70 -0
- package/dist/templates/Home.d.ts +3 -1
- package/dist/templates/Home.js +2 -2
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { flushSync } from 'react-dom';
|
|
4
|
+
import { focusWhenActive, useControlled, useLatest } from '../state.js';
|
|
4
5
|
import { cx } from '../cx.js';
|
|
5
6
|
const PHASE = 380; // ms before the before-pack un-tucks on close, so the after-pack is clear first
|
|
6
7
|
/* the stack's sheets, in slot order: its direct children — or, where a host wraps each child in one box of its own, the first .card in
|
|
@@ -9,6 +10,28 @@ const sheetsOf = (stack) => [...stack.children]
|
|
|
9
10
|
.filter((c) => !c.classList.contains('sheet-stack__base'))
|
|
10
11
|
.map((c) => (c.classList.contains('card') ? c : c.querySelector(':scope > .card')))
|
|
11
12
|
.filter((c) => c instanceof HTMLElement);
|
|
13
|
+
const SLOP_REM = 0.5;
|
|
14
|
+
const COMMIT_REM = 4.5;
|
|
15
|
+
const pxFromRem = (rem) => parseFloat(getComputedStyle(document.documentElement).fontSize) * rem;
|
|
16
|
+
const restPullPx = (card, stack) => {
|
|
17
|
+
if (!stack.classList.contains('sheet-stack--closed'))
|
|
18
|
+
return card.offsetTop;
|
|
19
|
+
const sheets = sheetsOf(stack);
|
|
20
|
+
const step = sheets[1]?.offsetTop ?? 0;
|
|
21
|
+
return Math.max(0, stack.clientHeight - sheets.length * step) + card.offsetTop;
|
|
22
|
+
};
|
|
23
|
+
const readFrame = (card) => { card.getBoundingClientRect(); };
|
|
24
|
+
const along = (dir, startY, y) => (dir === 'down' ? Math.max(0, y - startY) : Math.max(0, startY - y));
|
|
25
|
+
const releaseProp = (card, prop) => {
|
|
26
|
+
card.classList.remove('is-dragging');
|
|
27
|
+
readFrame(card);
|
|
28
|
+
card.style.removeProperty(prop);
|
|
29
|
+
};
|
|
30
|
+
const pullsHead = (target, head) => {
|
|
31
|
+
if (!(target instanceof Element))
|
|
32
|
+
return false;
|
|
33
|
+
return target === head || !!target.closest('.sheet-stack__back') || !!target.closest('.card__head > :is(h1, h2, h3)');
|
|
34
|
+
};
|
|
12
35
|
/**
|
|
13
36
|
* SheetStack — the MENU: sheets stacked like paper at the middle of the home screen, every one a main section.
|
|
14
37
|
* Children are Cards, direct children only, at most five: first = the back of the stack, last = the front (open) sheet.
|
|
@@ -19,21 +42,31 @@ const sheetsOf = (stack) => [...stack.children]
|
|
|
19
42
|
* with a picture over the menu); the `base` slot is rendered after the sheets, so it never takes a sheet's slot. This is the sheet STACK; a single bottom sheet is `Sheet`.
|
|
20
43
|
*
|
|
21
44
|
* A COMPONENT, not a wiring: WHICH sheet is open is the app's (`open` + `onOpenChange`, or `defaultOpen`) — so a button anywhere, a route or
|
|
22
|
-
* the phone's back button can open and close a section. Its own behaviour stays inside and only asks: a tap on a peeked head,
|
|
23
|
-
*
|
|
45
|
+
* the phone's back button can open and close a section. Its own behaviour stays inside and only asks: a tap on a peeked head, or a pull up on
|
|
46
|
+
* that head (the title, the back button, or the head itself — not a tab), opens it. The open sheet's StackBack or its title, tapped or pulled
|
|
47
|
+
* down, folds it. A pull down in the content, already at the top, asks `onRefresh` and does not fold. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
|
|
24
48
|
* before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
|
|
25
49
|
*/
|
|
26
|
-
export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }) {
|
|
50
|
+
export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, onRefresh, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }) {
|
|
27
51
|
const [current, request] = useControlled(open, defaultOpen, onOpenChange);
|
|
28
52
|
const ref = useRef(null);
|
|
29
53
|
const was = useRef(null);
|
|
30
54
|
const untuck = useRef(null);
|
|
55
|
+
const headPull = useRef(null);
|
|
56
|
+
const bodyPull = useRef(null);
|
|
57
|
+
const swallow = useRef(false);
|
|
58
|
+
const refreshRef = useLatest(onRefresh);
|
|
31
59
|
/* paint the state onto the sheets — after every render, so a re-rendered Card never loses its place */
|
|
32
60
|
useLayoutEffect(() => {
|
|
33
61
|
const stack = ref.current;
|
|
34
62
|
if (!stack || mode !== 'tap')
|
|
35
63
|
return;
|
|
36
64
|
const sheets = sheetsOf(stack);
|
|
65
|
+
for (const c of sheets)
|
|
66
|
+
if (!c.classList.contains('is-dragging')) {
|
|
67
|
+
c.style.removeProperty('--_pull');
|
|
68
|
+
c.style.removeProperty('--_lift');
|
|
69
|
+
}
|
|
37
70
|
// the keyboard's way in: a head's title takes the focus where a tap on it would do something — every title while none is
|
|
38
71
|
// open (Enter opens that sheet), only the open one's while one is (Enter folds it)
|
|
39
72
|
sheets.forEach((c, i) => {
|
|
@@ -74,8 +107,299 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
|
|
|
74
107
|
const cancel = focusWhenActive(sheetsOf(ref.current)[current], { scope: ':scope > .sheet__body' });
|
|
75
108
|
return () => { cancel(); };
|
|
76
109
|
}, [current, mode]);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const stack = ref.current;
|
|
112
|
+
if (!stack || mode !== 'tap' || current == null)
|
|
113
|
+
return;
|
|
114
|
+
const card = sheetsOf(stack)[current];
|
|
115
|
+
if (!card)
|
|
116
|
+
return;
|
|
117
|
+
const head = card.querySelector(':scope > .card__head');
|
|
118
|
+
const scroller = card.querySelector(':scope > .sheet__body') ?? card;
|
|
119
|
+
const index = current;
|
|
120
|
+
let stopDoc = () => { };
|
|
121
|
+
const listen = (move, up, cancel) => {
|
|
122
|
+
stopDoc();
|
|
123
|
+
document.addEventListener('pointermove', move, { passive: false });
|
|
124
|
+
document.addEventListener('pointerup', up);
|
|
125
|
+
document.addEventListener('pointercancel', cancel);
|
|
126
|
+
stopDoc = () => {
|
|
127
|
+
document.removeEventListener('pointermove', move);
|
|
128
|
+
document.removeEventListener('pointerup', up);
|
|
129
|
+
document.removeEventListener('pointercancel', cancel);
|
|
130
|
+
stopDoc = () => { };
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
const primary = (e) => e.isPrimary && e.button === 0;
|
|
134
|
+
const armSwallow = () => {
|
|
135
|
+
swallow.current = true;
|
|
136
|
+
requestAnimationFrame(() => { swallow.current = false; });
|
|
137
|
+
};
|
|
138
|
+
const onHeadDown = (e) => {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
if (!head || !primary(e) || headPull.current || bodyPull.current)
|
|
141
|
+
return;
|
|
142
|
+
if (!pullsHead(e.target, head))
|
|
143
|
+
return;
|
|
144
|
+
const slop = pxFromRem(SLOP_REM);
|
|
145
|
+
const commit = pxFromRem(COMMIT_REM);
|
|
146
|
+
const pull = {
|
|
147
|
+
pointerId: e.pointerId, card, index, dir: 'down', startY: e.clientY, last: 0,
|
|
148
|
+
cap: Math.max(restPullPx(card, stack), commit), commit, slop, moved: false,
|
|
149
|
+
};
|
|
150
|
+
headPull.current = pull;
|
|
151
|
+
const onMove = (ev) => {
|
|
152
|
+
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
153
|
+
return;
|
|
154
|
+
const dy = along('down', pull.startY, ev.clientY);
|
|
155
|
+
pull.last = dy;
|
|
156
|
+
if (dy < pull.slop)
|
|
157
|
+
return;
|
|
158
|
+
pull.moved = true;
|
|
159
|
+
ev.preventDefault();
|
|
160
|
+
try {
|
|
161
|
+
if (head && !head.hasPointerCapture(ev.pointerId))
|
|
162
|
+
head.setPointerCapture(ev.pointerId);
|
|
163
|
+
}
|
|
164
|
+
catch { /* the pointer is already gone */ }
|
|
165
|
+
card.classList.add('is-dragging');
|
|
166
|
+
card.style.setProperty('--_pull', `${Math.min(dy, pull.cap)}px`);
|
|
167
|
+
};
|
|
168
|
+
const onUp = (ev) => {
|
|
169
|
+
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
170
|
+
return;
|
|
171
|
+
const dy = Math.max(pull.last, along('down', pull.startY, ev.clientY));
|
|
172
|
+
const moved = pull.moved || dy >= pull.slop;
|
|
173
|
+
headPull.current = null;
|
|
174
|
+
stopDoc();
|
|
175
|
+
if (moved && dy >= pull.commit) {
|
|
176
|
+
card.classList.remove('is-dragging');
|
|
177
|
+
readFrame(card);
|
|
178
|
+
flushSync(() => request(null));
|
|
179
|
+
if (card.isConnected && card.classList.contains('is-open') && card.style.getPropertyValue('--_pull'))
|
|
180
|
+
releaseProp(card, '--_pull');
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
releaseProp(card, '--_pull');
|
|
184
|
+
if (moved)
|
|
185
|
+
armSwallow();
|
|
186
|
+
};
|
|
187
|
+
const onCancel = (ev) => {
|
|
188
|
+
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
189
|
+
return;
|
|
190
|
+
headPull.current = null;
|
|
191
|
+
stopDoc();
|
|
192
|
+
releaseProp(card, '--_pull');
|
|
193
|
+
};
|
|
194
|
+
listen(onMove, onUp, onCancel);
|
|
195
|
+
};
|
|
196
|
+
const onBodyDown = (e) => {
|
|
197
|
+
if (!primary(e) || headPull.current || bodyPull.current || !refreshRef.current)
|
|
198
|
+
return;
|
|
199
|
+
if (scroller.scrollTop > 0)
|
|
200
|
+
return;
|
|
201
|
+
if (e.target instanceof Element && e.target.closest('.card__head'))
|
|
202
|
+
return;
|
|
203
|
+
const slop = pxFromRem(SLOP_REM);
|
|
204
|
+
const commit = pxFromRem(COMMIT_REM);
|
|
205
|
+
const pull = {
|
|
206
|
+
pointerId: e.pointerId, scroller, index, startX: e.clientX, startY: e.clientY,
|
|
207
|
+
lastDy: 0, commit, slop, moved: false, drop: false,
|
|
208
|
+
};
|
|
209
|
+
bodyPull.current = pull;
|
|
210
|
+
const onMove = (ev) => {
|
|
211
|
+
if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
|
|
212
|
+
return;
|
|
213
|
+
if (scroller.scrollTop > 0) {
|
|
214
|
+
pull.drop = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (pull.drop)
|
|
218
|
+
return;
|
|
219
|
+
const dx = ev.clientX - pull.startX;
|
|
220
|
+
const dy = ev.clientY - pull.startY;
|
|
221
|
+
pull.lastDy = Math.max(0, dy);
|
|
222
|
+
if (!pull.moved && !pull.drop) {
|
|
223
|
+
if (Math.max(Math.abs(dx), Math.abs(dy)) < pull.slop)
|
|
224
|
+
return;
|
|
225
|
+
if (Math.abs(dx) > dy || dy <= 0) {
|
|
226
|
+
pull.drop = true;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
pull.moved = true;
|
|
231
|
+
ev.preventDefault();
|
|
232
|
+
try {
|
|
233
|
+
if (!scroller.hasPointerCapture(ev.pointerId))
|
|
234
|
+
scroller.setPointerCapture(ev.pointerId);
|
|
235
|
+
}
|
|
236
|
+
catch { /* the pointer is already gone */ }
|
|
237
|
+
scroller.classList.add('is-refreshing');
|
|
238
|
+
scroller.style.setProperty('--_refresh', `${Math.min(dy, pull.commit)}px`);
|
|
239
|
+
};
|
|
240
|
+
const onUp = (ev) => {
|
|
241
|
+
if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
|
|
242
|
+
return;
|
|
243
|
+
const dy = Math.max(pull.lastDy, Math.max(0, ev.clientY - pull.startY));
|
|
244
|
+
const ask = pull.moved && !pull.drop && scroller.scrollTop <= 0 && dy >= pull.commit;
|
|
245
|
+
bodyPull.current = null;
|
|
246
|
+
stopDoc();
|
|
247
|
+
scroller.classList.remove('is-refreshing');
|
|
248
|
+
readFrame(scroller);
|
|
249
|
+
scroller.style.removeProperty('--_refresh');
|
|
250
|
+
if (ask)
|
|
251
|
+
refreshRef.current?.(pull.index);
|
|
252
|
+
if (pull.moved)
|
|
253
|
+
armSwallow();
|
|
254
|
+
};
|
|
255
|
+
const onCancel = (ev) => {
|
|
256
|
+
if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
|
|
257
|
+
return;
|
|
258
|
+
bodyPull.current = null;
|
|
259
|
+
stopDoc();
|
|
260
|
+
scroller.classList.remove('is-refreshing');
|
|
261
|
+
scroller.style.removeProperty('--_refresh');
|
|
262
|
+
};
|
|
263
|
+
listen(onMove, onUp, onCancel);
|
|
264
|
+
};
|
|
265
|
+
head?.addEventListener('pointerdown', onHeadDown);
|
|
266
|
+
scroller.addEventListener('pointerdown', onBodyDown);
|
|
267
|
+
return () => {
|
|
268
|
+
stopDoc();
|
|
269
|
+
const held = headPull.current;
|
|
270
|
+
headPull.current = null;
|
|
271
|
+
bodyPull.current = null;
|
|
272
|
+
if (held?.card.classList.contains('is-dragging'))
|
|
273
|
+
releaseProp(held.card, '--_pull');
|
|
274
|
+
head?.removeEventListener('pointerdown', onHeadDown);
|
|
275
|
+
scroller.removeEventListener('pointerdown', onBodyDown);
|
|
276
|
+
};
|
|
277
|
+
}, [current, mode, request]);
|
|
278
|
+
/* none open: a pull UP on a peeked head (not a tab) follows the finger and, past the commit, opens that sheet */
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
const stack = ref.current;
|
|
281
|
+
if (!stack || mode !== 'tap' || current != null)
|
|
282
|
+
return;
|
|
283
|
+
const sheets = sheetsOf(stack);
|
|
284
|
+
let stopDoc = () => { };
|
|
285
|
+
const listen = (move, up, cancel) => {
|
|
286
|
+
stopDoc();
|
|
287
|
+
document.addEventListener('pointermove', move, { passive: false });
|
|
288
|
+
document.addEventListener('pointerup', up);
|
|
289
|
+
document.addEventListener('pointercancel', cancel);
|
|
290
|
+
stopDoc = () => {
|
|
291
|
+
document.removeEventListener('pointermove', move);
|
|
292
|
+
document.removeEventListener('pointerup', up);
|
|
293
|
+
document.removeEventListener('pointercancel', cancel);
|
|
294
|
+
stopDoc = () => { };
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
const armSwallow = () => {
|
|
298
|
+
swallow.current = true;
|
|
299
|
+
requestAnimationFrame(() => { swallow.current = false; });
|
|
300
|
+
};
|
|
301
|
+
const cleanups = [];
|
|
302
|
+
sheets.forEach((card, index) => {
|
|
303
|
+
const head = card.querySelector(':scope > .card__head');
|
|
304
|
+
if (!head)
|
|
305
|
+
return;
|
|
306
|
+
const onDown = (e) => {
|
|
307
|
+
if (!e.isPrimary || e.button !== 0 || headPull.current)
|
|
308
|
+
return;
|
|
309
|
+
if (!pullsHead(e.target, head)) {
|
|
310
|
+
if (!(e.target instanceof Element) || !e.target.closest('button, a'))
|
|
311
|
+
return;
|
|
312
|
+
const startY = e.clientY;
|
|
313
|
+
const id = e.pointerId;
|
|
314
|
+
const slop = pxFromRem(SLOP_REM);
|
|
315
|
+
const move = (ev) => {
|
|
316
|
+
if (ev.pointerId !== id)
|
|
317
|
+
return;
|
|
318
|
+
if (Math.abs(ev.clientY - startY) >= slop)
|
|
319
|
+
swallow.current = true;
|
|
320
|
+
};
|
|
321
|
+
const up = (ev) => {
|
|
322
|
+
if (ev.pointerId !== id)
|
|
323
|
+
return;
|
|
324
|
+
document.removeEventListener('pointermove', move);
|
|
325
|
+
document.removeEventListener('pointerup', up);
|
|
326
|
+
if (swallow.current)
|
|
327
|
+
requestAnimationFrame(() => { swallow.current = false; });
|
|
328
|
+
};
|
|
329
|
+
document.addEventListener('pointermove', move);
|
|
330
|
+
document.addEventListener('pointerup', up);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
e.stopPropagation();
|
|
334
|
+
const slop = pxFromRem(SLOP_REM);
|
|
335
|
+
const commit = pxFromRem(COMMIT_REM);
|
|
336
|
+
const travel = card.getBoundingClientRect().top - stack.getBoundingClientRect().top;
|
|
337
|
+
const pull = {
|
|
338
|
+
pointerId: e.pointerId, card, index, dir: 'up', startY: e.clientY, last: 0,
|
|
339
|
+
cap: Math.max(travel, commit), commit, slop, moved: false,
|
|
340
|
+
};
|
|
341
|
+
headPull.current = pull;
|
|
342
|
+
const onMove = (ev) => {
|
|
343
|
+
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
344
|
+
return;
|
|
345
|
+
const upPx = along('up', pull.startY, ev.clientY);
|
|
346
|
+
pull.last = upPx;
|
|
347
|
+
if (upPx < pull.slop)
|
|
348
|
+
return;
|
|
349
|
+
pull.moved = true;
|
|
350
|
+
ev.preventDefault();
|
|
351
|
+
try {
|
|
352
|
+
if (!head.hasPointerCapture(ev.pointerId))
|
|
353
|
+
head.setPointerCapture(ev.pointerId);
|
|
354
|
+
}
|
|
355
|
+
catch { /* the pointer is already gone */ }
|
|
356
|
+
card.classList.add('is-dragging');
|
|
357
|
+
card.style.setProperty('--_lift', `${Math.min(upPx, pull.cap)}px`);
|
|
358
|
+
};
|
|
359
|
+
const onUp = (ev) => {
|
|
360
|
+
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
361
|
+
return;
|
|
362
|
+
const upPx = Math.max(pull.last, along('up', pull.startY, ev.clientY));
|
|
363
|
+
const moved = pull.moved || upPx >= pull.slop;
|
|
364
|
+
headPull.current = null;
|
|
365
|
+
stopDoc();
|
|
366
|
+
if (moved && upPx >= pull.commit) {
|
|
367
|
+
card.classList.remove('is-dragging');
|
|
368
|
+
readFrame(card);
|
|
369
|
+
flushSync(() => request(pull.index));
|
|
370
|
+
if (card.isConnected && !card.classList.contains('is-open'))
|
|
371
|
+
releaseProp(card, '--_lift');
|
|
372
|
+
}
|
|
373
|
+
else
|
|
374
|
+
releaseProp(card, '--_lift');
|
|
375
|
+
if (moved)
|
|
376
|
+
armSwallow();
|
|
377
|
+
};
|
|
378
|
+
const onCancel = (ev) => {
|
|
379
|
+
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
380
|
+
return;
|
|
381
|
+
headPull.current = null;
|
|
382
|
+
stopDoc();
|
|
383
|
+
releaseProp(card, '--_lift');
|
|
384
|
+
};
|
|
385
|
+
listen(onMove, onUp, onCancel);
|
|
386
|
+
};
|
|
387
|
+
head.addEventListener('pointerdown', onDown);
|
|
388
|
+
cleanups.push(() => head.removeEventListener('pointerdown', onDown));
|
|
389
|
+
});
|
|
390
|
+
return () => {
|
|
391
|
+
stopDoc();
|
|
392
|
+
const held = headPull.current;
|
|
393
|
+
headPull.current = null;
|
|
394
|
+
if (held?.dir === 'up' && held.card.classList.contains('is-dragging'))
|
|
395
|
+
releaseProp(held.card, '--_lift');
|
|
396
|
+
cleanups.forEach((fn) => fn());
|
|
397
|
+
};
|
|
398
|
+
}, [current, mode, request]);
|
|
77
399
|
const click = (e) => {
|
|
78
400
|
onClick?.(e);
|
|
401
|
+
if (swallow.current)
|
|
402
|
+
return;
|
|
79
403
|
const stack = ref.current;
|
|
80
404
|
if (!stack || mode !== 'tap')
|
|
81
405
|
return;
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Children, cloneElement, createContext, isValidElement, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { focusWhenActive, useControlled
|
|
3
|
+
import { focusWhenActive, useControlled } from '../state.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
import { Icon } from '../type/Icon.js';
|
|
6
6
|
import { Link } from '../actions/Link.js';
|
|
7
7
|
import { FormNote } from '../forms/FormNote.js';
|
|
8
8
|
import { CheckContext } from '../forms/check.js';
|
|
9
9
|
import { word } from '../words.js';
|
|
10
|
+
import { usePile } from './sheetStepsPile.js';
|
|
10
11
|
const MOVE_MS = 350; // = --motion-sheet: the accordion's beat
|
|
11
|
-
const GLIDE_MS = 220; // the pile's slide out of the keyboard's way and back: quick, but the eye follows it
|
|
12
12
|
const SWIPE_REM = 3; // an up/down move longer than this (and mostly up/down) is a swipe
|
|
13
|
+
/* the open step's first field — the one that takes the cursor; NOW_INPUT: the one a typed character can be handed to */
|
|
14
|
+
const NOW_FIELD = '.sheet-step--now .sheet-step__inner :is(input:not([type=checkbox], [type=radio], [type=hidden]), textarea)';
|
|
15
|
+
const NOW_INPUT = '.sheet-step--now .sheet-step__inner input:not([type=checkbox], [type=radio], [type=hidden])';
|
|
16
|
+
const coarse = () => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches; // a phone: its keyboard follows a finger, not a script
|
|
13
17
|
const StepCtx = createContext(null);
|
|
14
18
|
/**
|
|
15
19
|
* SheetSteps — one task taken in steps INSIDE one sheet (phone · code · PIN · PIN again · face · notices). The done steps pile up
|
|
@@ -39,40 +43,25 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
39
43
|
moving.current = performance.now() + MOVE_MS + 100;
|
|
40
44
|
} // from the very render that changes it: the old field's blur and the keyboard's jump come in that same commit
|
|
41
45
|
const press = useRef(null);
|
|
46
|
+
/* the pile: where the body is scrolled (the two latest done rows in sight), and the glide of its jumps — sheetStepsPile.ts */
|
|
47
|
+
const { settle, glideNow } = usePile(ref, moving);
|
|
42
48
|
const go = (i, reason) => {
|
|
43
49
|
if (i >= 0 && i <= count)
|
|
44
50
|
request(i, reason);
|
|
45
51
|
};
|
|
46
|
-
/*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
above.unshift(el);
|
|
56
|
-
const bTop = body.getBoundingClientRect().top, at = (el) => body.scrollTop + el.getBoundingClientRect().top - bTop;
|
|
57
|
-
const third = above.length > 2 ? above[above.length - 3] : null; // 2½ rows show (Lh 2026-09-24): the two latest whole, the one before cut in half — a sign there is more above
|
|
58
|
-
let to = third ? at(third) + third.getBoundingClientRect().height / 2 : 0;
|
|
59
|
-
/* …unless the field with the focus would still be under the cut (a short phone, a big text size): then up as far as it needs —
|
|
60
|
-
but never past the LATEST done row, which always stays in sight */
|
|
61
|
-
const focused = document.activeElement instanceof HTMLElement && now.contains(document.activeElement) ? document.activeElement : null;
|
|
62
|
-
if (focused) {
|
|
63
|
-
const vv = window.visualViewport;
|
|
64
|
-
const cut = Math.min(body.getBoundingClientRect().bottom - (parseFloat(getComputedStyle(body).scrollPaddingBottom) || 0), vv ? vv.offsetTop + vv.height : Infinity);
|
|
65
|
-
const under = (focused.closest('.field, .pin') ?? focused).getBoundingClientRect().bottom + (body.scrollTop - to) - cut; // where its bottom will be once scrolled to `to`
|
|
66
|
-
const most = above.length ? at(above[above.length - 1]) : 0;
|
|
67
|
-
if (under > 0)
|
|
68
|
-
to = Math.min(to + under, Math.max(most, to));
|
|
69
|
-
}
|
|
70
|
-
if (Math.abs(to - body.scrollTop) <= 1)
|
|
52
|
+
/* ON A PHONE THE KEYBOARD STAYS WITH ONE ELEMENT (Lh 2026-10-04, timebank as a home-screen app on an iPhone: the pad went down at
|
|
53
|
+
every step change — there a cursor moved by a SCRIPT, after the server's answer, does not take the keyboard along). So the
|
|
54
|
+
cursor is parked in the holder INSIDE the press on the step's button (a finger's own event, which every phone follows), and it
|
|
55
|
+
STAYS there through the step change — no script moves it again. The open step's first field only SHOWS the cursor
|
|
56
|
+
(`data-cursor`); the first key typed is handed to it, and the cursor with it, from inside that key's event (handOver). */
|
|
57
|
+
const [parked, setParked] = useState(false);
|
|
58
|
+
const park = () => {
|
|
59
|
+
const at = document.activeElement, holder = holderRef.current;
|
|
60
|
+
if (!coarse() || !holder || at === holder || !(at instanceof HTMLInputElement || at instanceof HTMLTextAreaElement) || !ref.current?.contains(at))
|
|
71
61
|
return;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
body.scrollTo({ top: to, behavior: smooth ? 'smooth' : 'auto' });
|
|
62
|
+
holder.inputMode = at.inputMode || 'text';
|
|
63
|
+
holder.value = '';
|
|
64
|
+
holder.focus({ preventScroll: true });
|
|
76
65
|
};
|
|
77
66
|
/* the step changed, in TWO beats one right after the other: (1) the old step folds while the new one unfolds — nothing else
|
|
78
67
|
moves; (2) then the pile settles (the two latest done rows in sight), gliding. The cursor goes into the new step at once
|
|
@@ -80,7 +69,11 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
80
69
|
/* the open step's first field is the one to take the cursor when the sheet comes onto the screen (Sheet reads `data-autofocus`) */
|
|
81
70
|
useLayoutEffect(() => {
|
|
82
71
|
ref.current?.querySelectorAll('[data-autofocus]').forEach((el) => el.removeAttribute('data-autofocus'));
|
|
83
|
-
ref.current?.querySelector(
|
|
72
|
+
const field = ref.current?.querySelector(NOW_FIELD);
|
|
73
|
+
field?.setAttribute('data-autofocus', '');
|
|
74
|
+
ref.current?.querySelectorAll('[data-cursor]').forEach((el) => el.removeAttribute('data-cursor'));
|
|
75
|
+
if (parked && coarse())
|
|
76
|
+
field?.setAttribute('data-cursor', ''); // the keyboard is on the holder, for this field: it shows the cursor
|
|
84
77
|
});
|
|
85
78
|
useLayoutEffect(() => {
|
|
86
79
|
if (first.current) {
|
|
@@ -93,7 +86,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
93
86
|
// The cursor waits in a HOLDER while the fold plays — an invisible text field of the steps' own: the phone's keyboard stays up
|
|
94
87
|
// (a blur would drop it and the next focus bring it back — Lh 2026-09-24), and it takes the keyboard type of the field to come
|
|
95
88
|
const at = document.activeElement, holder = holderRef.current;
|
|
96
|
-
const nextField = ref.current?.querySelector(
|
|
89
|
+
const nextField = ref.current?.querySelector(NOW_FIELD);
|
|
97
90
|
if (at instanceof HTMLElement && ref.current?.contains(at)) {
|
|
98
91
|
if (holder && nextField && at.matches('input, textarea')) {
|
|
99
92
|
holder.inputMode = nextField.inputMode || 'text';
|
|
@@ -105,44 +98,21 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
105
98
|
}
|
|
106
99
|
// the cursor stands by: the step's first field takes the focus (the keyboard comes with it); a step with no field — its
|
|
107
100
|
// title, so a screen reader hears where it is (the one rule of every part that becomes active: focusWhenActive)
|
|
101
|
+
if (coarse() && document.activeElement === holder) { // a phone, the keyboard up on the holder: it stays there — only the pile settles
|
|
102
|
+
const t = setTimeout(() => { glideNow(); settle(); }, MOVE_MS);
|
|
103
|
+
return () => clearTimeout(t);
|
|
104
|
+
}
|
|
108
105
|
const cancel = focusWhenActive(ref.current, { wait: MOVE_MS, scope: '.sheet-step--now .sheet-step__inner',
|
|
109
106
|
fallback: () => ref.current?.querySelector('.sheet-step--now .sheet-step__title'),
|
|
110
|
-
then: () => {
|
|
107
|
+
then: () => { glideNow(); settle(); } });
|
|
111
108
|
return () => { cancel(); };
|
|
112
109
|
}, [current]);
|
|
113
|
-
/* the keyboard comes and goes: the body's scroll JUMPS (the Sheet's reveal, the browser's own, ours below, the box growing back).
|
|
114
|
-
For a short while after a field in the steps gains or loses the focus, every such jump is played as a quick slide (FLIP: the steps
|
|
115
|
-
are set back by the jump, then eased to rest) so the eye can follow the pile moving out of the way and back. */
|
|
116
|
-
const glide = useRef(0), last = useRef(0), own = useRef(0); // own: our smooth scroll is playing — not a jump to replay
|
|
117
|
-
useEffect(() => {
|
|
118
|
-
const el = ref.current, body = el?.closest('.sheet__body');
|
|
119
|
-
if (!el || !body)
|
|
120
|
-
return;
|
|
121
|
-
last.current = body.scrollTop;
|
|
122
|
-
const onScroll = () => {
|
|
123
|
-
const d = body.scrollTop - last.current;
|
|
124
|
-
last.current = body.scrollTop;
|
|
125
|
-
if (!d || performance.now() > glide.current || performance.now() < own.current || reducedMotion())
|
|
126
|
-
return;
|
|
127
|
-
const from = parseFloat(el.style.translate.split(' ')[1] || '0') || 0;
|
|
128
|
-
el.style.transition = 'none';
|
|
129
|
-
el.style.translate = `0 ${from + d}px`;
|
|
130
|
-
void el.offsetHeight; // the set-back lands before the ease
|
|
131
|
-
const hold = Math.max(0, moving.current - 100 - performance.now()); // during a step change: held until the fold is done (beat 2)
|
|
132
|
-
el.style.transition = `translate ${GLIDE_MS}ms ease-out ${hold}ms`;
|
|
133
|
-
el.style.translate = '0 0';
|
|
134
|
-
};
|
|
135
|
-
const clean = () => { el.style.removeProperty('transition'); el.style.removeProperty('translate'); };
|
|
136
|
-
body.addEventListener('scroll', onScroll);
|
|
137
|
-
el.addEventListener('transitionend', clean);
|
|
138
|
-
return () => { body.removeEventListener('scroll', onScroll); el.removeEventListener('transitionend', clean); };
|
|
139
|
-
}, []);
|
|
140
110
|
/* a field in the current step took the focus: once the keyboard is up (the Sheet has climbed), settle the pile — the two latest
|
|
141
111
|
done rows in sight. When the focus leaves the fields (the keyboard goes), the pile comes back down. Both moves glide. */
|
|
142
112
|
const typing = (t) => t instanceof HTMLElement && t.matches('input:not([type=checkbox], [type=radio], [type=range]), textarea');
|
|
143
113
|
const focusIn = (e) => {
|
|
144
114
|
onFocus?.(e);
|
|
145
|
-
|
|
115
|
+
glideNow();
|
|
146
116
|
const target = e.target;
|
|
147
117
|
if (!target.closest('.sheet-step--now') || !typing(target) || performance.now() < moving.current - 100)
|
|
148
118
|
return; // a step change settles on its own beat
|
|
@@ -152,7 +122,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
152
122
|
onBlur?.(e);
|
|
153
123
|
if (!typing(e.target) || performance.now() < moving.current)
|
|
154
124
|
return; // the old step's field losing the cursor to the new one
|
|
155
|
-
|
|
125
|
+
glideNow();
|
|
156
126
|
const body = ref.current?.closest('.sheet__body');
|
|
157
127
|
setTimeout(() => { if (!typing(document.activeElement) && body)
|
|
158
128
|
body.scrollTo({ top: 0 }); }, 0); // Tab to the next field keeps it up
|
|
@@ -180,30 +150,25 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
180
150
|
else if (dy < 0 && ref.current?.querySelector('.sheet-step--now[data-skippable]'))
|
|
181
151
|
go(to, 'skip'); // forward only, and only past a step that may be skipped
|
|
182
152
|
};
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
153
|
+
/* typed while the cursor waits in the holder (the fold is playing — or, on an iPhone, the keyboard never followed the cursor into the
|
|
154
|
+
new step: a focus set by script does not always take the keyboard with it, and every digit landed here and was dropped —
|
|
155
|
+
timebank, Lh 2026-10-03): what was typed belongs to the open step's first field. Hand it over, and the cursor with it —
|
|
156
|
+
from inside the key's own event, which the phone does follow. */
|
|
157
|
+
const handOver = (e) => {
|
|
158
|
+
const typed = e.currentTarget.value;
|
|
159
|
+
e.currentTarget.value = '';
|
|
160
|
+
const field = ref.current?.querySelector(NOW_INPUT);
|
|
161
|
+
if (!typed || !field)
|
|
162
|
+
return;
|
|
163
|
+
field.focus({ preventScroll: true });
|
|
164
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(field, field.value + typed);
|
|
165
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
166
|
+
};
|
|
167
|
+
return (_jsxs("div", { ref: ref, className: cx('sheet-steps', !reversible && 'sheet-steps--locked', className), style: style, onFocus: focusIn, onBlur: focusOut, onPointerDown: down, onPointerUp: up, onPointerCancel: (e) => { onPointerCancel?.(e); press.current = null; }, ...rest, children: [_jsx("input", { ref: holderRef, className: "sheet-steps__holder", "aria-hidden": true, tabIndex: -1, autoComplete: "off", onFocus: () => setParked(true), onBlur: () => setParked(false), onInput: handOver }), _jsx("ol", { className: "sheet-steps__track", children: steps.map((step, i) => (_jsx(StepCtx.Provider, { value: { numbered, i, at: current, furthest: reach, count, reversible, go, park }, children: step }, step.key ?? i))) }), finish != null && ( // always in sight; it takes taps only once every step is done (or skipped)
|
|
197
168
|
_jsx("div", { className: "sheet-steps__finish", children: isValidElement(finish) ? cloneElement(finish, { disabled: reach < count || finish.props.disabled }) : finish }))] }));
|
|
198
169
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const ctx = useContext(StepCtx);
|
|
202
|
-
if (!ctx)
|
|
203
|
-
throw new Error('SheetStep goes inside a SheetSteps');
|
|
204
|
-
const { numbered, i, at, furthest, count, reversible, go } = ctx;
|
|
205
|
-
const phase = i === at ? 'now' : i < furthest ? 'done' : 'next';
|
|
206
|
-
const off = phase !== 'now';
|
|
170
|
+
/* the step's format checks, as one group — see SheetStep */
|
|
171
|
+
function useStepChecks(off, park) {
|
|
207
172
|
/* the step is the FORM GROUP of the format checks inside it (Field format/digits/required, Pin match): the words of the first
|
|
208
173
|
control that fails show under the button; a press on a button of the step checks them all first and holds the press back
|
|
209
174
|
when one fails (the app's onClick never hears it); a Pin that does not match shakes, shows its words and empties itself — the step stays */
|
|
@@ -221,13 +186,6 @@ export function SheetStep({ title, summary, skippable, skipLabel = word('skip'),
|
|
|
221
186
|
checks.current.set(c, null);
|
|
222
187
|
setNote(null);
|
|
223
188
|
} }, [off]);
|
|
224
|
-
/* a press on the step's button or link keeps the cursor where it is: the button still clicks, but the field keeps the focus, so a
|
|
225
|
-
phone's keyboard does not drop and come back (the pile jumped up and down with it — Lh 2026-09-24) */
|
|
226
|
-
const keepCursor = (e) => {
|
|
227
|
-
const at = document.activeElement;
|
|
228
|
-
if (e.target.closest('.btn, .link') && at instanceof HTMLElement && at.matches('input, textarea') && e.currentTarget.contains(at))
|
|
229
|
-
e.preventDefault();
|
|
230
|
-
};
|
|
231
189
|
const press = (e) => {
|
|
232
190
|
const btn = e.target.closest('button.btn'); // a FileBtn is a label.btn: picking a file is not the step's press
|
|
233
191
|
if (!btn || btn.disabled)
|
|
@@ -245,6 +203,26 @@ export function SheetStep({ title, summary, skippable, skipLabel = word('skip'),
|
|
|
245
203
|
e.preventDefault();
|
|
246
204
|
bad.focus();
|
|
247
205
|
}
|
|
206
|
+
else
|
|
207
|
+
park(); // the press goes through: on a phone the cursor waits in the holder from HERE — inside the finger's own event
|
|
208
|
+
};
|
|
209
|
+
return { note, group, press };
|
|
210
|
+
}
|
|
211
|
+
/** SheetStep — one step of a SheetSteps: its title, then what the member does (a Field, a Pin, a Toggle, the step's own Btn). */
|
|
212
|
+
export function SheetStep({ title, summary, skippable, skipLabel = word('skip'), error, busy, className, children, ...rest }) {
|
|
213
|
+
const ctx = useContext(StepCtx);
|
|
214
|
+
if (!ctx)
|
|
215
|
+
throw new Error('SheetStep goes inside a SheetSteps');
|
|
216
|
+
const { numbered, i, at, furthest, count, reversible, go, park } = ctx;
|
|
217
|
+
const phase = i === at ? 'now' : i < furthest ? 'done' : 'next';
|
|
218
|
+
const off = phase !== 'now';
|
|
219
|
+
const { note, group, press } = useStepChecks(off, park);
|
|
220
|
+
/* a press on the step's button or link keeps the cursor where it is: the button still clicks, but the field keeps the focus, so a
|
|
221
|
+
phone's keyboard does not drop and come back (the pile jumped up and down with it — Lh 2026-09-24) */
|
|
222
|
+
const keepCursor = (e) => {
|
|
223
|
+
const at = document.activeElement;
|
|
224
|
+
if (e.target.closest('.btn, .link') && at instanceof HTMLElement && at.matches('input, textarea') && e.currentTarget.contains(at))
|
|
225
|
+
e.preventDefault();
|
|
248
226
|
};
|
|
249
227
|
/* Enter on the keyboard in a field of the step = a press on the step's button (the phone's "go"/"next" key, a desktop's Enter);
|
|
250
228
|
the press goes through the step's checks like a tap (Lh 2026-09-25) */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PointerEvent as ReactPointerEvent, type RefObject } from 'react';
|
|
2
|
+
import { type Resting } from './sheetMeasure.js';
|
|
3
|
+
/** the handle's pointer handlers, and `afterDrag()` — true once for the click that follows a drag (it is not a tap) */
|
|
4
|
+
export declare function useSheetDrag(ref: RefObject<HTMLElement | null>, allowed: () => Resting[], snap: (to: Resting) => void): {
|
|
5
|
+
down: (e: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
6
|
+
move: (e: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
7
|
+
up: () => void;
|
|
8
|
+
afterDrag: () => boolean;
|
|
9
|
+
};
|