@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.
@@ -1,37 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useRef } from 'react';
3
- import { flushSync } from 'react-dom';
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
- /* 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
8
- each. Never deeper: a card inside a sheet's content is content. The base is what shows behind the sheets, never a sheet. */
9
- const sheetsOf = (stack) => [...stack.children]
10
- .filter((c) => !c.classList.contains('sheet-stack__base'))
11
- .map((c) => (c.classList.contains('card') ? c : c.querySelector(':scope > .card')))
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
- };
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. 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; `refreshing` fades that sheet's four grip dots up and down while the app reloads. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
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, onClick, onKeyDown, ...rest }) {
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 ref = useRef(null);
53
- const was = useRef(null);
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);
59
- const loadingRef = useLatest(refreshing);
60
- /* paint the state onto the sheets — after every render, so a re-rendered Card never loses its place */
61
- useLayoutEffect(() => {
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
- const stack = ref.current;
313
- if (!stack || mode !== 'tap' || current != null)
52
+ if (closingFrom == null)
314
53
  return;
315
- const sheets = sheetsOf(stack);
316
- let stopDoc = () => { };
317
- const listen = (move, up, cancel) => {
318
- stopDoc();
319
- document.addEventListener('pointermove', move, { passive: false });
320
- document.addEventListener('pointerup', up);
321
- document.addEventListener('pointercancel', cancel);
322
- stopDoc = () => {
323
- document.removeEventListener('pointermove', move);
324
- document.removeEventListener('pointerup', up);
325
- document.removeEventListener('pointercancel', cancel);
326
- stopDoc = () => { };
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
- const armSwallow = () => {
330
- swallow.current = true;
331
- requestAnimationFrame(() => { swallow.current = false; });
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
- /* the open step's first field is the one to take the cursor when the sheet comes onto the screen (Sheet reads `data-autofocus`) */
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
  }