@usableapp/cardds 0.7.53 → 0.7.54
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 +7 -0
- package/README.md +16 -8
- package/css/sheet.css +44 -14
- package/css/stack.css +15 -10
- package/css/theme.template.css +1 -1
- package/css/tokens.css +5 -5
- package/dist/cardds.css +64 -29
- package/dist/cards/Card.d.ts +868 -6
- package/dist/cards/Card.js +43 -7
- package/dist/cards/CardHead.d.ts +1 -6
- package/dist/cards/CardHead.js +26 -1
- package/dist/choice/Slider.js +9 -13
- package/dist/forms/Composer.js +3 -1
- package/dist/forms/Field.js +3 -1
- package/dist/forms/FileBtn.js +3 -1
- package/dist/forms/Pin.js +3 -1
- package/dist/sheets/Sheet.d.ts +7 -1
- package/dist/sheets/Sheet.js +2 -2
- package/dist/sheets/SheetStack.d.ts +4 -5
- package/dist/sheets/SheetStack.js +60 -442
- package/dist/sheets/SheetSteps.js +9 -13
- package/dist/sheets/StackSheet.d.ts +4 -1
- package/dist/sheets/StackSheet.js +2 -2
- package/dist/sheets/stackCard.d.ts +31 -0
- package/dist/sheets/stackCard.js +389 -0
- package/dist/sheets/stepCursor.d.ts +12 -0
- package/dist/sheets/stepCursor.js +15 -0
- package/package.json +1 -1
|
@@ -1,37 +1,27 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { focusWhenActive, useControlled, useLatest } from '../state.js';
|
|
2
|
+
import { Children, cloneElement, isValidElement, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useControlled, warnOnce } from '../state.js';
|
|
5
4
|
import { cx } from '../cx.js';
|
|
5
|
+
import { Card } from '../cards/Card.js';
|
|
6
|
+
import { StackSheet } from './StackSheet.js';
|
|
6
7
|
const PHASE = 380; // ms before the before-pack un-tucks on close, so the after-pack is clear first
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
};
|
|
23
|
-
|
|
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
|
-
};
|
|
8
|
+
const SHEET = new Set([Card, StackSheet]);
|
|
9
|
+
/* one wrapper around a sheet is fine (a host's own box). The props land on the Card or StackSheet inside it, so the card renders them. */
|
|
10
|
+
function stamp(child, place) {
|
|
11
|
+
if (SHEET.has(child.type))
|
|
12
|
+
return cloneElement(child, { stack: place });
|
|
13
|
+
const inner = Children.toArray(child.props.children);
|
|
14
|
+
let found = false;
|
|
15
|
+
const next = inner.map((c) => {
|
|
16
|
+
if (found || !isValidElement(c) || !SHEET.has(c.type))
|
|
17
|
+
return c;
|
|
18
|
+
found = true;
|
|
19
|
+
return cloneElement(c, { stack: place });
|
|
20
|
+
});
|
|
21
|
+
if (!found)
|
|
22
|
+
warnOnce('stack-child', 'cardds SheetStack: a sheet is a Card or a StackSheet — one wrapper around it is fine. open and refreshing are that card\'s props; a plain element cannot hold them.');
|
|
23
|
+
return found ? cloneElement(child, {}, ...next) : child;
|
|
24
|
+
}
|
|
35
25
|
/**
|
|
36
26
|
* SheetStack — the MENU: sheets stacked like paper at the middle of the home screen, every one a main section.
|
|
37
27
|
* Children are Cards, direct children only, at most five: first = the back of the stack, last = the front (open) sheet.
|
|
@@ -42,420 +32,48 @@ const pullsHead = (target, head) => {
|
|
|
42
32
|
* 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`.
|
|
43
33
|
*
|
|
44
34
|
* A COMPONENT, not a wiring: WHICH sheet is open is the app's (`open` + `onOpenChange`, or `defaultOpen`) — so a button anywhere, a route or
|
|
45
|
-
* the phone's back button can open and close a section.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
|
|
35
|
+
* the phone's back button can open and close a section. WHAT the sheet does with that, and with `refreshing`, lives on the card (`stack`):
|
|
36
|
+
* the place classes, the grip, the pull. The stack only says which index. Sheets are rigid — the open one slides up, the ones after it leave
|
|
37
|
+
* below, the ones before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); never a size.
|
|
49
38
|
*/
|
|
50
|
-
export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, onRefresh, refreshing = null, fanned, closed, base, className, children,
|
|
39
|
+
export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, onRefresh, refreshing = null, fanned, closed, base, className, children, ...rest }) {
|
|
51
40
|
const [current, request] = useControlled(open, defaultOpen, onOpenChange);
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const stack = ref.current;
|
|
63
|
-
if (!stack || mode !== 'tap')
|
|
64
|
-
return;
|
|
65
|
-
const sheets = sheetsOf(stack);
|
|
66
|
-
for (const c of sheets)
|
|
67
|
-
if (!c.classList.contains('is-dragging')) {
|
|
68
|
-
c.style.removeProperty('--_pull');
|
|
69
|
-
c.style.removeProperty('--_lift');
|
|
70
|
-
}
|
|
71
|
-
// 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
|
|
72
|
-
// open (Enter opens that sheet), only the open one's while one is (Enter folds it)
|
|
73
|
-
sheets.forEach((c, i) => {
|
|
74
|
-
const title = c.querySelector(':scope > .card__head > :is(h1, h2, h3)');
|
|
75
|
-
if (title)
|
|
76
|
-
title.tabIndex = current == null || i === current ? 0 : -1;
|
|
77
|
-
});
|
|
78
|
-
sheets.forEach((c, i) => {
|
|
79
|
-
const loading = refreshing === i && current === i;
|
|
80
|
-
c.classList.toggle('is-loading', loading);
|
|
81
|
-
if (loading)
|
|
82
|
-
c.setAttribute('aria-busy', 'true');
|
|
83
|
-
else
|
|
84
|
-
c.removeAttribute('aria-busy');
|
|
85
|
-
});
|
|
86
|
-
const prev = was.current;
|
|
87
|
-
was.current = current;
|
|
88
|
-
if (current != null) {
|
|
89
|
-
if (untuck.current) {
|
|
90
|
-
clearTimeout(untuck.current);
|
|
91
|
-
untuck.current = null;
|
|
92
|
-
}
|
|
93
|
-
sheets.forEach((c, i) => {
|
|
94
|
-
c.classList.toggle('is-open', i === current);
|
|
95
|
-
c.classList.toggle('is-before', i < current); // already behind it — tuck under the next strip and get covered…
|
|
96
|
-
c.classList.toggle('is-after', i > current); // in front of it — these leave below
|
|
97
|
-
c.style.setProperty('--stagger', String(i < current ? current - 1 - i : 0)); // …nearest one first
|
|
98
|
-
});
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (prev != null && prev !== current) {
|
|
102
|
-
// beat 1: the after-pack comes back in while the opened sheet drops into its slot, over the still-covered before-pack
|
|
103
|
-
sheets.forEach((c) => c.classList.remove('is-open', 'is-after'));
|
|
104
|
-
// beat 2: then the before-pack slides back up from behind it, the same order it tucked away
|
|
105
|
-
untuck.current = setTimeout(() => { untuck.current = null; if (ref.current)
|
|
106
|
-
sheetsOf(ref.current).forEach((c) => c.classList.remove('is-before')); }, PHASE);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
if (!untuck.current)
|
|
110
|
-
sheets.forEach((c) => c.classList.remove('is-open', 'is-before', 'is-after'));
|
|
111
|
-
});
|
|
112
|
-
/* a sheet opened → its field has the cursor once it has slid up (the one rule of every part that becomes active: focusWhenActive) */
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
if (current == null || mode !== 'tap' || !ref.current)
|
|
115
|
-
return;
|
|
116
|
-
const cancel = focusWhenActive(sheetsOf(ref.current)[current], { scope: ':scope > .sheet__body' });
|
|
117
|
-
return () => { cancel(); };
|
|
118
|
-
}, [current, mode]);
|
|
119
|
-
useEffect(() => {
|
|
120
|
-
const stack = ref.current;
|
|
121
|
-
if (!stack || mode !== 'tap' || current == null)
|
|
122
|
-
return;
|
|
123
|
-
const card = sheetsOf(stack)[current];
|
|
124
|
-
if (!card)
|
|
125
|
-
return;
|
|
126
|
-
const head = card.querySelector(':scope > .card__head');
|
|
127
|
-
const scroller = card.querySelector(':scope > .sheet__body') ?? card;
|
|
128
|
-
const index = current;
|
|
129
|
-
let stopDoc = () => { };
|
|
130
|
-
const listen = (move, up, cancel) => {
|
|
131
|
-
stopDoc();
|
|
132
|
-
document.addEventListener('pointermove', move, { passive: false });
|
|
133
|
-
document.addEventListener('pointerup', up);
|
|
134
|
-
document.addEventListener('pointercancel', cancel);
|
|
135
|
-
stopDoc = () => {
|
|
136
|
-
document.removeEventListener('pointermove', move);
|
|
137
|
-
document.removeEventListener('pointerup', up);
|
|
138
|
-
document.removeEventListener('pointercancel', cancel);
|
|
139
|
-
stopDoc = () => { };
|
|
140
|
-
};
|
|
141
|
-
};
|
|
142
|
-
const primary = (e) => e.isPrimary && e.button === 0;
|
|
143
|
-
const armSwallow = () => {
|
|
144
|
-
swallow.current = true;
|
|
145
|
-
requestAnimationFrame(() => { swallow.current = false; });
|
|
146
|
-
};
|
|
147
|
-
const onHeadDown = (e) => {
|
|
148
|
-
e.stopPropagation();
|
|
149
|
-
if (!head || !primary(e) || headPull.current || bodyPull.current)
|
|
150
|
-
return;
|
|
151
|
-
if (!pullsHead(e.target, head))
|
|
152
|
-
return;
|
|
153
|
-
const slop = pxFromRem(SLOP_REM);
|
|
154
|
-
const commit = pxFromRem(COMMIT_REM);
|
|
155
|
-
const pull = {
|
|
156
|
-
pointerId: e.pointerId, card, index, dir: 'down', startY: e.clientY, last: 0,
|
|
157
|
-
cap: Math.max(restPullPx(card, stack), commit), commit, slop, moved: false,
|
|
158
|
-
};
|
|
159
|
-
headPull.current = pull;
|
|
160
|
-
const onMove = (ev) => {
|
|
161
|
-
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
162
|
-
return;
|
|
163
|
-
const dy = along('down', pull.startY, ev.clientY);
|
|
164
|
-
pull.last = dy;
|
|
165
|
-
if (dy < pull.slop)
|
|
166
|
-
return;
|
|
167
|
-
pull.moved = true;
|
|
168
|
-
ev.preventDefault();
|
|
169
|
-
try {
|
|
170
|
-
if (head && !head.hasPointerCapture(ev.pointerId))
|
|
171
|
-
head.setPointerCapture(ev.pointerId);
|
|
172
|
-
}
|
|
173
|
-
catch { /* the pointer is already gone */ }
|
|
174
|
-
card.classList.add('is-dragging');
|
|
175
|
-
card.style.setProperty('--_pull', `${Math.min(dy, pull.cap)}px`);
|
|
176
|
-
};
|
|
177
|
-
const onUp = (ev) => {
|
|
178
|
-
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
179
|
-
return;
|
|
180
|
-
const dy = Math.max(pull.last, along('down', pull.startY, ev.clientY));
|
|
181
|
-
const moved = pull.moved || dy >= pull.slop;
|
|
182
|
-
headPull.current = null;
|
|
183
|
-
stopDoc();
|
|
184
|
-
if (moved && dy >= pull.commit) {
|
|
185
|
-
card.classList.remove('is-dragging');
|
|
186
|
-
readFrame(card);
|
|
187
|
-
flushSync(() => request(null));
|
|
188
|
-
if (card.isConnected && card.classList.contains('is-open') && card.style.getPropertyValue('--_pull'))
|
|
189
|
-
releaseProp(card, '--_pull');
|
|
190
|
-
}
|
|
191
|
-
else
|
|
192
|
-
releaseProp(card, '--_pull');
|
|
193
|
-
if (moved)
|
|
194
|
-
armSwallow();
|
|
195
|
-
};
|
|
196
|
-
const onCancel = (ev) => {
|
|
197
|
-
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
198
|
-
return;
|
|
199
|
-
headPull.current = null;
|
|
200
|
-
stopDoc();
|
|
201
|
-
releaseProp(card, '--_pull');
|
|
202
|
-
};
|
|
203
|
-
listen(onMove, onUp, onCancel);
|
|
204
|
-
};
|
|
205
|
-
const onBodyDown = (e) => {
|
|
206
|
-
if (!primary(e) || headPull.current || bodyPull.current || !refreshRef.current || loadingRef.current === index)
|
|
207
|
-
return;
|
|
208
|
-
if (scroller.scrollTop > 0)
|
|
209
|
-
return;
|
|
210
|
-
if (e.target instanceof Element && e.target.closest('.card__head'))
|
|
211
|
-
return;
|
|
212
|
-
const slop = pxFromRem(SLOP_REM);
|
|
213
|
-
const commit = pxFromRem(COMMIT_REM);
|
|
214
|
-
const pull = {
|
|
215
|
-
pointerId: e.pointerId, scroller, index, startX: e.clientX, startY: e.clientY,
|
|
216
|
-
lastDy: 0, commit, slop, moved: false, drop: false,
|
|
217
|
-
};
|
|
218
|
-
bodyPull.current = pull;
|
|
219
|
-
const onMove = (ev) => {
|
|
220
|
-
if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
|
|
221
|
-
return;
|
|
222
|
-
if (scroller.scrollTop > 0) {
|
|
223
|
-
pull.drop = true;
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
if (pull.drop)
|
|
227
|
-
return;
|
|
228
|
-
const dx = ev.clientX - pull.startX;
|
|
229
|
-
const dy = ev.clientY - pull.startY;
|
|
230
|
-
pull.lastDy = Math.max(0, dy);
|
|
231
|
-
if (!pull.moved && !pull.drop) {
|
|
232
|
-
if (Math.max(Math.abs(dx), Math.abs(dy)) < pull.slop)
|
|
233
|
-
return;
|
|
234
|
-
if (Math.abs(dx) > dy || dy <= 0) {
|
|
235
|
-
pull.drop = true;
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
pull.moved = true;
|
|
240
|
-
ev.preventDefault();
|
|
241
|
-
try {
|
|
242
|
-
if (!scroller.hasPointerCapture(ev.pointerId))
|
|
243
|
-
scroller.setPointerCapture(ev.pointerId);
|
|
244
|
-
}
|
|
245
|
-
catch { /* the pointer is already gone */ }
|
|
246
|
-
scroller.classList.add('is-refreshing');
|
|
247
|
-
scroller.style.setProperty('--_refresh', `${Math.min(dy, pull.commit)}px`);
|
|
248
|
-
};
|
|
249
|
-
const onUp = (ev) => {
|
|
250
|
-
if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
|
|
251
|
-
return;
|
|
252
|
-
const dy = Math.max(pull.lastDy, Math.max(0, ev.clientY - pull.startY));
|
|
253
|
-
const ask = pull.moved && !pull.drop && scroller.scrollTop <= 0 && dy >= pull.commit;
|
|
254
|
-
bodyPull.current = null;
|
|
255
|
-
stopDoc();
|
|
256
|
-
scroller.classList.remove('is-refreshing');
|
|
257
|
-
readFrame(scroller);
|
|
258
|
-
scroller.style.removeProperty('--_refresh');
|
|
259
|
-
if (ask)
|
|
260
|
-
refreshRef.current?.(pull.index);
|
|
261
|
-
if (pull.moved)
|
|
262
|
-
armSwallow();
|
|
263
|
-
};
|
|
264
|
-
const onCancel = (ev) => {
|
|
265
|
-
if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
|
|
266
|
-
return;
|
|
267
|
-
bodyPull.current = null;
|
|
268
|
-
stopDoc();
|
|
269
|
-
scroller.classList.remove('is-refreshing');
|
|
270
|
-
scroller.style.removeProperty('--_refresh');
|
|
271
|
-
};
|
|
272
|
-
listen(onMove, onUp, onCancel);
|
|
273
|
-
};
|
|
274
|
-
/* a FINGER: the browser takes a touch for its own scroll at the first move and cancels the pointer — unless that first move is
|
|
275
|
-
refused. So the first move decides, once: down and at the top = the pull's (refused, no scroll); anything else = the scroll's */
|
|
276
|
-
let touch = null;
|
|
277
|
-
const onTouchStart = (e) => {
|
|
278
|
-
const t = e.touches[0];
|
|
279
|
-
const inHead = e.target instanceof Element && !!e.target.closest('.card__head');
|
|
280
|
-
touch = e.touches.length === 1 && t && refreshRef.current && loadingRef.current !== index && scroller.scrollTop <= 0 && !inHead ? { x: t.clientX, y: t.clientY, ours: null } : null;
|
|
281
|
-
};
|
|
282
|
-
const onTouchMove = (e) => {
|
|
283
|
-
const t = e.touches[0];
|
|
284
|
-
if (!touch || !t)
|
|
285
|
-
return;
|
|
286
|
-
const dx = t.clientX - touch.x;
|
|
287
|
-
const dy = t.clientY - touch.y;
|
|
288
|
-
if (touch.ours == null)
|
|
289
|
-
touch.ours = dy > 0 && dy >= Math.abs(dx) && scroller.scrollTop <= 0;
|
|
290
|
-
if (touch.ours && e.cancelable)
|
|
291
|
-
e.preventDefault();
|
|
292
|
-
};
|
|
293
|
-
head?.addEventListener('pointerdown', onHeadDown);
|
|
294
|
-
scroller.addEventListener('pointerdown', onBodyDown);
|
|
295
|
-
scroller.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
296
|
-
scroller.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
297
|
-
return () => {
|
|
298
|
-
scroller.removeEventListener('touchstart', onTouchStart);
|
|
299
|
-
scroller.removeEventListener('touchmove', onTouchMove);
|
|
300
|
-
stopDoc();
|
|
301
|
-
const held = headPull.current;
|
|
302
|
-
headPull.current = null;
|
|
303
|
-
bodyPull.current = null;
|
|
304
|
-
if (held?.card.classList.contains('is-dragging'))
|
|
305
|
-
releaseProp(held.card, '--_pull');
|
|
306
|
-
head?.removeEventListener('pointerdown', onHeadDown);
|
|
307
|
-
scroller.removeEventListener('pointerdown', onBodyDown);
|
|
308
|
-
};
|
|
309
|
-
}, [current, mode, request]);
|
|
310
|
-
/* none open: a pull UP on a peeked head (not a tab) follows the finger and, past the commit, opens that sheet */
|
|
41
|
+
const prev = useRef(current);
|
|
42
|
+
const [closingFrom, setClosingFrom] = useState(null);
|
|
43
|
+
if (prev.current !== current) {
|
|
44
|
+
const from = prev.current;
|
|
45
|
+
prev.current = current;
|
|
46
|
+
if (from != null && current == null)
|
|
47
|
+
setClosingFrom(from);
|
|
48
|
+
else if (current != null && closingFrom != null)
|
|
49
|
+
setClosingFrom(null);
|
|
50
|
+
}
|
|
311
51
|
useEffect(() => {
|
|
312
|
-
|
|
313
|
-
if (!stack || mode !== 'tap' || current != null)
|
|
52
|
+
if (closingFrom == null)
|
|
314
53
|
return;
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
54
|
+
const t = setTimeout(() => setClosingFrom(null), PHASE);
|
|
55
|
+
return () => clearTimeout(t);
|
|
56
|
+
}, [closingFrom]);
|
|
57
|
+
const closing = current == null && closingFrom != null;
|
|
58
|
+
const anchor = current ?? closingFrom;
|
|
59
|
+
const items = Children.toArray(children);
|
|
60
|
+
let sheet = 0;
|
|
61
|
+
const stamped = mode === 'tap' ? items.map((child) => {
|
|
62
|
+
if (!isValidElement(child))
|
|
63
|
+
return child;
|
|
64
|
+
const i = sheet++;
|
|
65
|
+
const before = anchor != null && i < anchor;
|
|
66
|
+
const place = {
|
|
67
|
+
open: !closing && current === i,
|
|
68
|
+
refreshing: refreshing === i,
|
|
69
|
+
before,
|
|
70
|
+
after: !closing && current != null && i > current,
|
|
71
|
+
stagger: before && anchor != null ? anchor - 1 - i : 0,
|
|
72
|
+
onOpen: () => request(i),
|
|
73
|
+
onClose: () => request(null),
|
|
74
|
+
onRefresh: onRefresh ? () => onRefresh(i) : undefined,
|
|
328
75
|
};
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
};
|
|
333
|
-
const cleanups = [];
|
|
334
|
-
sheets.forEach((card, index) => {
|
|
335
|
-
const head = card.querySelector(':scope > .card__head');
|
|
336
|
-
if (!head)
|
|
337
|
-
return;
|
|
338
|
-
const onDown = (e) => {
|
|
339
|
-
if (!e.isPrimary || e.button !== 0 || headPull.current)
|
|
340
|
-
return;
|
|
341
|
-
if (!pullsHead(e.target, head)) {
|
|
342
|
-
if (!(e.target instanceof Element) || !e.target.closest('button, a'))
|
|
343
|
-
return;
|
|
344
|
-
const startY = e.clientY;
|
|
345
|
-
const id = e.pointerId;
|
|
346
|
-
const slop = pxFromRem(SLOP_REM);
|
|
347
|
-
const move = (ev) => {
|
|
348
|
-
if (ev.pointerId !== id)
|
|
349
|
-
return;
|
|
350
|
-
if (Math.abs(ev.clientY - startY) >= slop)
|
|
351
|
-
swallow.current = true;
|
|
352
|
-
};
|
|
353
|
-
const up = (ev) => {
|
|
354
|
-
if (ev.pointerId !== id)
|
|
355
|
-
return;
|
|
356
|
-
document.removeEventListener('pointermove', move);
|
|
357
|
-
document.removeEventListener('pointerup', up);
|
|
358
|
-
if (swallow.current)
|
|
359
|
-
requestAnimationFrame(() => { swallow.current = false; });
|
|
360
|
-
};
|
|
361
|
-
document.addEventListener('pointermove', move);
|
|
362
|
-
document.addEventListener('pointerup', up);
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
e.stopPropagation();
|
|
366
|
-
const slop = pxFromRem(SLOP_REM);
|
|
367
|
-
const commit = pxFromRem(COMMIT_REM);
|
|
368
|
-
const travel = card.getBoundingClientRect().top - stack.getBoundingClientRect().top;
|
|
369
|
-
const pull = {
|
|
370
|
-
pointerId: e.pointerId, card, index, dir: 'up', startY: e.clientY, last: 0,
|
|
371
|
-
cap: Math.max(travel, commit), commit, slop, moved: false,
|
|
372
|
-
};
|
|
373
|
-
headPull.current = pull;
|
|
374
|
-
const onMove = (ev) => {
|
|
375
|
-
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
376
|
-
return;
|
|
377
|
-
const upPx = along('up', pull.startY, ev.clientY);
|
|
378
|
-
pull.last = upPx;
|
|
379
|
-
if (upPx < pull.slop)
|
|
380
|
-
return;
|
|
381
|
-
pull.moved = true;
|
|
382
|
-
ev.preventDefault();
|
|
383
|
-
try {
|
|
384
|
-
if (!head.hasPointerCapture(ev.pointerId))
|
|
385
|
-
head.setPointerCapture(ev.pointerId);
|
|
386
|
-
}
|
|
387
|
-
catch { /* the pointer is already gone */ }
|
|
388
|
-
card.classList.add('is-dragging');
|
|
389
|
-
card.style.setProperty('--_lift', `${Math.min(upPx, pull.cap)}px`);
|
|
390
|
-
};
|
|
391
|
-
const onUp = (ev) => {
|
|
392
|
-
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
393
|
-
return;
|
|
394
|
-
const upPx = Math.max(pull.last, along('up', pull.startY, ev.clientY));
|
|
395
|
-
const moved = pull.moved || upPx >= pull.slop;
|
|
396
|
-
headPull.current = null;
|
|
397
|
-
stopDoc();
|
|
398
|
-
if (moved && upPx >= pull.commit) {
|
|
399
|
-
card.classList.remove('is-dragging');
|
|
400
|
-
readFrame(card);
|
|
401
|
-
flushSync(() => request(pull.index));
|
|
402
|
-
if (card.isConnected && !card.classList.contains('is-open'))
|
|
403
|
-
releaseProp(card, '--_lift');
|
|
404
|
-
}
|
|
405
|
-
else
|
|
406
|
-
releaseProp(card, '--_lift');
|
|
407
|
-
if (moved)
|
|
408
|
-
armSwallow();
|
|
409
|
-
};
|
|
410
|
-
const onCancel = (ev) => {
|
|
411
|
-
if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
|
|
412
|
-
return;
|
|
413
|
-
headPull.current = null;
|
|
414
|
-
stopDoc();
|
|
415
|
-
releaseProp(card, '--_lift');
|
|
416
|
-
};
|
|
417
|
-
listen(onMove, onUp, onCancel);
|
|
418
|
-
};
|
|
419
|
-
head.addEventListener('pointerdown', onDown);
|
|
420
|
-
cleanups.push(() => head.removeEventListener('pointerdown', onDown));
|
|
421
|
-
});
|
|
422
|
-
return () => {
|
|
423
|
-
stopDoc();
|
|
424
|
-
const held = headPull.current;
|
|
425
|
-
headPull.current = null;
|
|
426
|
-
if (held?.dir === 'up' && held.card.classList.contains('is-dragging'))
|
|
427
|
-
releaseProp(held.card, '--_lift');
|
|
428
|
-
cleanups.forEach((fn) => fn());
|
|
429
|
-
};
|
|
430
|
-
}, [current, mode, request]);
|
|
431
|
-
const click = (e) => {
|
|
432
|
-
onClick?.(e);
|
|
433
|
-
if (swallow.current)
|
|
434
|
-
return;
|
|
435
|
-
const stack = ref.current;
|
|
436
|
-
if (!stack || mode !== 'tap')
|
|
437
|
-
return;
|
|
438
|
-
const target = e.target;
|
|
439
|
-
const sheets = sheetsOf(stack);
|
|
440
|
-
const at = sheets.findIndex((c) => c.contains(target)); // the sheet the tap landed in — not the nearest .card: a content card inside an open sheet must not count
|
|
441
|
-
if (target.closest('.sheet-stack__back')) {
|
|
442
|
-
request(null);
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
// the open sheet's TITLE folds it too (Lh 2026-09-23) — a bigger target than the back button; the back stays the keyboard's way
|
|
446
|
-
if (current != null && at === current && target.closest('.card__head > :is(h1, h2, h3)')) {
|
|
447
|
-
request(null);
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
if (at >= 0 && current == null)
|
|
451
|
-
request(at);
|
|
452
|
-
};
|
|
453
|
-
const key = (e) => {
|
|
454
|
-
onKeyDown?.(e);
|
|
455
|
-
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('.card__head > :is(h1, h2, h3)')) {
|
|
456
|
-
e.preventDefault();
|
|
457
|
-
e.target.click();
|
|
458
|
-
}
|
|
459
|
-
};
|
|
460
|
-
return (_jsxs("div", { ref: ref, onClick: click, onKeyDown: key, className: cx('sheet-stack', mode !== 'static' && `sheet-stack--${mode}`, fanned && 'sheet-stack--fanned', closed && 'sheet-stack--closed', className), ...rest, children: [children, base != null && _jsx("div", { className: "sheet-stack__base", children: base })] }));
|
|
76
|
+
return stamp(child, place);
|
|
77
|
+
}) : items;
|
|
78
|
+
return (_jsxs("div", { className: cx('sheet-stack', mode !== 'static' && `sheet-stack--${mode}`, fanned && 'sheet-stack--fanned', closed && 'sheet-stack--closed', className), ...rest, children: [stamped, base != null && _jsx("div", { className: "sheet-stack__base", children: base })] }));
|
|
461
79
|
}
|
|
@@ -8,6 +8,7 @@ import { FormNote } from '../forms/FormNote.js';
|
|
|
8
8
|
import { CheckContext } from '../forms/check.js';
|
|
9
9
|
import { word } from '../words.js';
|
|
10
10
|
import { usePile } from './sheetStepsPile.js';
|
|
11
|
+
import { StepCursor } from './stepCursor.js';
|
|
11
12
|
const MOVE_MS = 350; // = --motion-sheet: the accordion's beat
|
|
12
13
|
const SWIPE_REM = 3; // an up/down move longer than this (and mostly up/down) is a swipe
|
|
13
14
|
/* the open step's first field — the one that takes the cursor; NOW_INPUT: the one a typed character can be handed to */
|
|
@@ -65,16 +66,8 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
65
66
|
};
|
|
66
67
|
/* the step changed, in TWO beats one right after the other: (1) the old step folds while the new one unfolds — nothing else
|
|
67
68
|
moves; (2) then the pile settles (the two latest done rows in sight), gliding. The cursor goes into the new step at once
|
|
68
|
-
(so a phone's keyboard stays up) but without scrolling. Not on arrival — the body keeps its scroll.
|
|
69
|
-
|
|
70
|
-
useLayoutEffect(() => {
|
|
71
|
-
ref.current?.querySelectorAll('[data-autofocus]').forEach((el) => el.removeAttribute('data-autofocus'));
|
|
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
|
|
77
|
-
});
|
|
69
|
+
(so a phone's keyboard stays up) but without scrolling. Not on arrival — the body keeps its scroll.
|
|
70
|
+
The open step's first field renders `data-autofocus` itself (Sheet reads it when the sheet comes up). */
|
|
78
71
|
useLayoutEffect(() => {
|
|
79
72
|
if (first.current) {
|
|
80
73
|
first.current = false;
|
|
@@ -164,7 +157,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
164
157
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(field, field.value + typed);
|
|
165
158
|
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
166
159
|
};
|
|
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)
|
|
160
|
+
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, parked }, children: step }, step.key ?? i))) }), finish != null && ( // always in sight; it takes taps only once every step is done (or skipped)
|
|
168
161
|
_jsx("div", { className: "sheet-steps__finish", children: isValidElement(finish) ? cloneElement(finish, { disabled: reach < count || finish.props.disabled }) : finish }))] }));
|
|
169
162
|
}
|
|
170
163
|
/* the step's format checks, as one group — see SheetStep */
|
|
@@ -213,9 +206,12 @@ export function SheetStep({ title, summary, skippable, skipLabel = word('skip'),
|
|
|
213
206
|
const ctx = useContext(StepCtx);
|
|
214
207
|
if (!ctx)
|
|
215
208
|
throw new Error('SheetStep goes inside a SheetSteps');
|
|
216
|
-
const { numbered, i, at, furthest, count, reversible, go, park } = ctx;
|
|
209
|
+
const { numbered, i, at, furthest, count, reversible, go, park, parked } = ctx;
|
|
217
210
|
const phase = i === at ? 'now' : i < furthest ? 'done' : 'next';
|
|
218
211
|
const off = phase !== 'now';
|
|
212
|
+
const slot = useRef({ id: null });
|
|
213
|
+
slot.current.id = null;
|
|
214
|
+
const cursor = phase === 'now' ? { parked: parked && coarse(), slot: slot.current } : null;
|
|
219
215
|
const { note, group, press } = useStepChecks(off, park);
|
|
220
216
|
/* 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
217
|
phone's keyboard does not drop and come back (the pile jumped up and down with it — Lh 2026-09-24) */
|
|
@@ -235,5 +231,5 @@ export function SheetStep({ title, summary, skippable, skipLabel = word('skip'),
|
|
|
235
231
|
btn.click();
|
|
236
232
|
}
|
|
237
233
|
};
|
|
238
|
-
return (_jsxs("li", { className: cx('sheet-step', `sheet-step--${phase}`, busy != null && busy !== false && 'sheet-step--busy', className), "aria-current": phase === 'now' ? 'step' : undefined, "aria-busy": busy != null && busy !== false ? true : undefined, "data-skippable": skippable || undefined, ...rest, children: [_jsxs("div", { className: "sheet-step__head", inert: off || undefined, children: [numbered && _jsx("span", { className: "sheet-step__num", "aria-hidden": true, children: i + 1 }), _jsx("h3", { className: "sheet-step__title", tabIndex: -1, children: title }), phase === 'done' && summary != null && _jsx("span", { className: "sheet-step__summary t-caption", children: summary }), phase === 'done' && _jsx(Icon, { name: "check", size: "xs", className: "sheet-step__check" })] }), _jsx("div", { className: "sheet-step__body", children: _jsxs("div", { className: "sheet-step__inner", inert: off || undefined, onClickCapture: press, onPointerDown: keepCursor, onKeyDown: enter, children: [_jsx(CheckContext.Provider, { value: group, children: children }), _jsx(FormNote, { error: error ?? note, busy: busy }), skippable && _jsx(Link, { sm: true, muted: true, className: "sheet-step__skip", onClick: () => go(i + 1, 'skip'), children: skipLabel })] }) }), reversible && off && (_jsx("button", { type: "button", className: "sheet-step__go", "aria-label": typeof title === 'string' ? title : `${i + 1} / ${count}`, onClick: () => go(i, 'tap') }))] }));
|
|
234
|
+
return (_jsxs("li", { className: cx('sheet-step', `sheet-step--${phase}`, busy != null && busy !== false && 'sheet-step--busy', className), "aria-current": phase === 'now' ? 'step' : undefined, "aria-busy": busy != null && busy !== false ? true : undefined, "data-skippable": skippable || undefined, ...rest, children: [_jsxs("div", { className: "sheet-step__head", inert: off || undefined, children: [numbered && _jsx("span", { className: "sheet-step__num", "aria-hidden": true, children: i + 1 }), _jsx("h3", { className: "sheet-step__title", tabIndex: -1, children: title }), phase === 'done' && summary != null && _jsx("span", { className: "sheet-step__summary t-caption", children: summary }), phase === 'done' && _jsx(Icon, { name: "check", size: "xs", className: "sheet-step__check" })] }), _jsx("div", { className: "sheet-step__body", children: _jsxs("div", { className: "sheet-step__inner", inert: off || undefined, onClickCapture: press, onPointerDown: keepCursor, onKeyDown: enter, children: [_jsx(StepCursor.Provider, { value: cursor, children: _jsx(CheckContext.Provider, { value: group, children: children }) }), _jsx(FormNote, { error: error ?? note, busy: busy }), skippable && _jsx(Link, { sm: true, muted: true, className: "sheet-step__skip", onClick: () => go(i + 1, 'skip'), children: skipLabel })] }) }), reversible && off && (_jsx("button", { type: "button", className: "sheet-step__go", "aria-label": typeof title === 'string' ? title : `${i + 1} / ${count}`, onClick: () => go(i, 'tap') }))] }));
|
|
239
235
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ComponentProps, type ReactNode } from 'react';
|
|
2
2
|
import { type Tone } from '../cards/Card.js';
|
|
3
3
|
import { type IconName } from '../type/Icon.js';
|
|
4
|
+
import type { StackPlace } from './stackCard.js';
|
|
4
5
|
export interface StackSheetTab {
|
|
5
6
|
id: string;
|
|
6
7
|
icon: IconName;
|
|
@@ -29,6 +30,8 @@ export interface StackSheetProps extends Omit<ComponentProps<'article'>, 'title'
|
|
|
29
30
|
/** where the back button stands: `none` (default — hidden for now, Lh 2026-09-29: not used in real work yet; a tap on the open sheet's title folds it) ·
|
|
30
31
|
* `end` (after the tabs, chevron DOWN: the sheet folds down) · `start` (before the title, chevron left) */
|
|
31
32
|
backAt?: 'none' | 'start' | 'end';
|
|
33
|
+
/** set by `SheetStack` from the app's `open` and `refreshing`. This sheet renders what those mean */
|
|
34
|
+
stack?: StackPlace;
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
37
|
* StackSheet — ONE sheet of a `SheetStack`, in the one sheet shape: SHEET HEAD (back · title · tabs or an icon) pinned at the top ·
|
|
@@ -37,4 +40,4 @@ export interface StackSheetProps extends Omit<ComponentProps<'article'>, 'title'
|
|
|
37
40
|
* A count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
|
|
38
41
|
* is closed — see `Home`). A direct child of `SheetStack`.
|
|
39
42
|
*/
|
|
40
|
-
export declare function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel, backAt, className, children, ...rest }: StackSheetProps): import("react").JSX.Element;
|
|
43
|
+
export declare function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel, backAt, stack, className, children, ...rest }: StackSheetProps): import("react").JSX.Element;
|
|
@@ -19,7 +19,7 @@ import { word } from '../words.js';
|
|
|
19
19
|
* A count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
|
|
20
20
|
* is closed — see `Home`). A direct child of `SheetStack`.
|
|
21
21
|
*/
|
|
22
|
-
export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = word('back'), backAt = 'none', className, children, ...rest }) {
|
|
22
|
+
export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = word('back'), backAt = 'none', stack, className, children, ...rest }) {
|
|
23
23
|
const [tab, pick] = useControlled(tabProp, defaultTab ?? tabs?.[0]?.id, onTabChange);
|
|
24
24
|
// a title longer than its room does not wrap: it runs sideways, like a song title in a short slot — the room it overflows by
|
|
25
25
|
// is measured here (the tabs may grow with the text size) and handed to the CSS as --_over
|
|
@@ -37,5 +37,5 @@ export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab,
|
|
|
37
37
|
measure();
|
|
38
38
|
return watchSize([h, run], measure);
|
|
39
39
|
}, [title]);
|
|
40
|
-
return (_jsxs(Card, { tone: tone, className: cx('stack-sheet', className), ...rest, children: [_jsxs(CardHead, { children: [backAt === 'start' && _jsx(StackBack, { label: backLabel }), _jsx(Text, { level: "h2", ref: titleRef, className: "stack-sheet__title", children: _jsx("span", { className: "stack-sheet__run", children: title }) }), tabs?.length ? (_jsx("span", { className: "stack-sheet__tabs", role: "tablist", children: tabs.map((t) => (_jsxs(IconBtn, { sm: true, invert: t.id === tab, role: "tab", "aria-selected": t.id === tab, label: t.label, className: "stack-sheet__tab", onClick: () => pick(t.id), children: [_jsx(Icon, { name: t.icon }), t.count ? _jsx(Badge, { className: "stack-sheet__count", children: t.count > 99 ? '99+' : t.count }) : null] }, t.id))) })) : icon, backAt === 'end' && _jsx(StackBack, { end: true, label: backLabel })] }), _jsx(SheetBody, { role: tabs?.length ? 'tabpanel' : undefined, children: children }), foot != null && _jsx(CardFoot, { children: foot })] }));
|
|
40
|
+
return (_jsxs(Card, { stack: stack, tone: tone, className: cx('stack-sheet', className), ...rest, children: [_jsxs(CardHead, { children: [backAt === 'start' && _jsx(StackBack, { label: backLabel }), _jsx(Text, { level: "h2", ref: titleRef, className: "stack-sheet__title", children: _jsx("span", { className: "stack-sheet__run", children: title }) }), tabs?.length ? (_jsx("span", { className: "stack-sheet__tabs", role: "tablist", children: tabs.map((t) => (_jsxs(IconBtn, { sm: true, invert: t.id === tab, role: "tab", "aria-selected": t.id === tab, label: t.label, className: "stack-sheet__tab", onClick: () => pick(t.id), children: [_jsx(Icon, { name: t.icon }), t.count ? _jsx(Badge, { className: "stack-sheet__count", children: t.count > 99 ? '99+' : t.count }) : null] }, t.id))) })) : icon, backAt === 'end' && _jsx(StackBack, { end: true, label: backLabel })] }), _jsx(SheetBody, { role: tabs?.length ? 'tabpanel' : undefined, children: children }), foot != null && _jsx(CardFoot, { children: foot })] }));
|
|
41
41
|
}
|