@usableapp/cardds 0.7.53 → 0.7.55

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,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { useControlled, watchSize } from '../state.js';
4
4
  import { cx } from '../cx.js';
5
5
  import { Card } from '../cards/Card.js';
@@ -19,11 +19,12 @@ 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
26
26
  const titleRef = useRef(null);
27
+ const [long, setLong] = useState(false);
27
28
  useEffect(() => {
28
29
  const h = titleRef.current, run = h?.firstElementChild;
29
30
  if (!h || !run)
@@ -32,10 +33,10 @@ export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab,
32
33
  const over = Math.max(0, Math.ceil(run.scrollWidth - h.clientWidth));
33
34
  h.style.setProperty('--_over', `${over}px`);
34
35
  h.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
35
- h.classList.toggle('is-long', over > 0);
36
+ setLong(over > 0);
36
37
  };
37
38
  measure();
38
39
  return watchSize([h, run], measure);
39
40
  }, [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 })] }));
41
+ 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: cx('stack-sheet__title', long && 'is-long'), 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
42
  }
@@ -5,6 +5,6 @@ type SheetRef = RefObject<HTMLElement | null>;
5
5
  export declare const slid: (el: HTMLElement) => number;
6
6
  export declare const stopsOf: (el: HTMLElement) => Record<Resting, number>;
7
7
  export declare function usePeekStrip(ref: SheetRef): void;
8
- export declare function useTitleRun(ref: SheetRef): void;
8
+ export declare function useTitleRun(ref: SheetRef, onLong: (long: boolean) => void): void;
9
9
  export declare function useFootRoom(ref: SheetRef): void;
10
10
  export {};
@@ -39,25 +39,30 @@ export function usePeekStrip(ref) {
39
39
  }, []);
40
40
  }
41
41
  /* the TITLE is one line; too long for it, it runs sideways like a song title (Lh 2026-10-03): the sheet measures how far its
42
- words overflow the line and hands it to the CSS (--_over, --_run, .is-long — sheet.css). Measured on the words themselves
43
- (a range), so the running indent never feeds back into the number. */
44
- export function useTitleRun(ref) {
42
+ words overflow the line and hands the widths to the CSS (--_over, --_run). The heading renders `.is-long` from what this
43
+ reports. Measured on the words themselves (a range), so the running indent never feeds back into the number. */
44
+ export function useTitleRun(ref, onLong) {
45
45
  useEffect(() => {
46
46
  const el = ref.current, title = el?.querySelector(':scope > .card__head > :is(h1, h2, h3):first-child, :scope > .sheet__head .sheet__title');
47
47
  if (!el || !title)
48
48
  return;
49
+ let was = false;
49
50
  const measure = () => {
50
51
  const words = document.createRange();
51
52
  words.selectNodeContents(title);
52
53
  const over = Math.max(0, Math.ceil(words.getBoundingClientRect().width - title.clientWidth));
53
54
  title.style.setProperty('--_over', `${over}px`);
54
55
  title.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
55
- title.classList.toggle('is-long', over > 0);
56
+ const long = over > 0;
57
+ if (long !== was) {
58
+ was = long;
59
+ onLong(long);
60
+ }
56
61
  };
57
62
  const stop = watchSize([title], measure);
58
63
  const mo = new MutationObserver(measure);
59
64
  mo.observe(title, { childList: true, characterData: true, subtree: true }); // the app changed the words
60
- return () => { stop(); mo.disconnect(); title.classList.remove('is-long'); title.style.removeProperty('--_over'); title.style.removeProperty('--_run'); };
65
+ return () => { stop(); mo.disconnect(); title.style.removeProperty('--_over'); title.style.removeProperty('--_run'); };
61
66
  });
62
67
  }
63
68
  /* the FOOT's room follows what the foot really holds (timebank, Lh 2026-10-01): a button grows with the member's text size, a
@@ -0,0 +1,33 @@
1
+ import { type CSSProperties, type RefObject } from 'react';
2
+ /** where one sheet sits, derived by the stack from the app's indexes and handed to the card as a prop */
3
+ export interface StackPlace {
4
+ /** this sheet is the one the app has open */
5
+ open: boolean;
6
+ /** the app is reloading this sheet */
7
+ refreshing: boolean;
8
+ /** tucked under the open sheet. Stays through the first beat of a close */
9
+ before: boolean;
10
+ /** in front of the open sheet, leaving below */
11
+ after: boolean;
12
+ /** nearest-first delay, while tucked and while those sheets return */
13
+ stagger: number;
14
+ onOpen: () => void;
15
+ onClose: () => void;
16
+ onRefresh?: () => void;
17
+ }
18
+ /** the head's title is a tab stop while this sheet is the one Enter acts on: every title when none is open, only the open one otherwise */
19
+ export declare const StackHeadCtx: import("react").Context<0 | -1 | null>;
20
+ /** a finger is pulling this card's content. The body renders `is-refreshing` from it */
21
+ export declare const StackPullCtx: import("react").Context<boolean>;
22
+ export declare function stackPaint(place: StackPlace | undefined, pulling?: boolean): {
23
+ className?: string;
24
+ style?: CSSProperties;
25
+ busy?: true;
26
+ };
27
+ /**
28
+ * The card's own pull, refresh and focus. Listeners sit on this card's head and body.
29
+ * `--_pull` / `--_lift` are written while a finger is down, the same way a single Sheet writes `--_drag`.
30
+ */
31
+ export declare function useStackCard(ref: RefObject<HTMLElement | null>, place: StackPlace | undefined, swallow: {
32
+ current: boolean;
33
+ }): boolean;
@@ -0,0 +1,396 @@
1
+ /* one sheet of a tap stack — its own behaviour, not exported from the package.
2
+ The app says which sheet is open and whether its data is in (`SheetStack`'s `open` and `refreshing`).
3
+ This card renders what those two mean: the place classes, the grip, the pull. Nothing stamps them on afterwards. */
4
+ import { createContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
5
+ import { flushSync } from 'react-dom';
6
+ import { focusWhenActive } from '../state.js';
7
+ const SLOP_REM = 0.5;
8
+ const COMMIT_REM = 4.5;
9
+ /** the head's title is a tab stop while this sheet is the one Enter acts on: every title when none is open, only the open one otherwise */
10
+ export const StackHeadCtx = createContext(null);
11
+ /** a finger is pulling this card's content. The body renders `is-refreshing` from it */
12
+ export const StackPullCtx = createContext(false);
13
+ export function stackPaint(place, pulling = false) {
14
+ if (!place)
15
+ return {};
16
+ const loading = place.open && place.refreshing;
17
+ const className = ['stack-card', place.open && 'is-open', place.before && 'is-before', place.after && 'is-after', loading && 'is-loading', pulling && 'is-refreshing'].filter(Boolean).join(' ');
18
+ return {
19
+ className,
20
+ style: place.stagger > 0 ? { '--stagger': String(place.stagger) } : undefined,
21
+ busy: loading || undefined,
22
+ };
23
+ }
24
+ const pxFromRem = (rem) => parseFloat(getComputedStyle(document.documentElement).fontSize) * rem;
25
+ /* the pull's reach is the room the sheet still has to travel — measured, not a class */
26
+ const sheetsIn = (stack) => [...stack.children]
27
+ .filter((c) => !c.classList.contains('sheet-stack__base'))
28
+ .map((c) => (c.classList.contains('card') ? c : c.querySelector(':scope > .card')))
29
+ .filter((c) => c instanceof HTMLElement);
30
+ const restPullPx = (card, stack) => {
31
+ if (!stack.classList.contains('sheet-stack--closed'))
32
+ return card.offsetTop;
33
+ const sheets = sheetsIn(stack);
34
+ const step = sheets[1]?.offsetTop ?? 0;
35
+ return Math.max(0, stack.clientHeight - sheets.length * step) + card.offsetTop;
36
+ };
37
+ const readFrame = (el) => { el.getBoundingClientRect(); };
38
+ const along = (dir, startY, y) => (dir === 'down' ? Math.max(0, y - startY) : Math.max(0, startY - y));
39
+ const pullsHead = (target, head) => {
40
+ if (!(target instanceof Element))
41
+ return false;
42
+ return target === head || !!target.closest('.sheet-stack__back') || !!target.closest('.card__head > :is(h1, h2, h3)');
43
+ };
44
+ /**
45
+ * The card's own pull, refresh and focus. Listeners sit on this card's head and body.
46
+ * `--_pull` / `--_lift` are written while a finger is down, the same way a single Sheet writes `--_drag`.
47
+ */
48
+ export function useStackCard(ref, place, swallow) {
49
+ const placeRef = useRef(place);
50
+ placeRef.current = place;
51
+ const live = useRef(null);
52
+ const headPull = useRef(null);
53
+ const bodyPull = useRef(null);
54
+ const [pulling, setPulling] = useState(false);
55
+ useLayoutEffect(() => {
56
+ const el = ref.current;
57
+ if (!el || !place)
58
+ return;
59
+ const g = live.current;
60
+ if (g) {
61
+ el.classList.add('is-dragging');
62
+ el.style.setProperty(g.prop, g.px);
63
+ return;
64
+ }
65
+ /* a finger let go: the prop leaves in this commit, so the sheet eases from the frame just read */
66
+ el.style.removeProperty('--_pull');
67
+ el.style.removeProperty('--_lift');
68
+ });
69
+ useEffect(() => {
70
+ if (!place?.open || !ref.current)
71
+ return;
72
+ const cancel = focusWhenActive(ref.current, { scope: ':scope > .sheet__body' });
73
+ return () => { cancel(); };
74
+ }, [place?.open, ref]);
75
+ /* open: a pull down on the head folds it; a pull down in the content, already at the top, asks for a reload */
76
+ useEffect(() => {
77
+ const card = ref.current;
78
+ const here = placeRef.current;
79
+ if (!card || !here?.open)
80
+ return;
81
+ const stack = card.closest('.sheet-stack');
82
+ const head = card.querySelector(':scope > .card__head');
83
+ const scroller = card.querySelector(':scope > .sheet__body') ?? card;
84
+ let stopDoc = () => { };
85
+ const listen = (move, up, cancel) => {
86
+ stopDoc();
87
+ document.addEventListener('pointermove', move, { passive: false });
88
+ document.addEventListener('pointerup', up);
89
+ document.addEventListener('pointercancel', cancel);
90
+ stopDoc = () => {
91
+ document.removeEventListener('pointermove', move);
92
+ document.removeEventListener('pointerup', up);
93
+ document.removeEventListener('pointercancel', cancel);
94
+ stopDoc = () => { };
95
+ };
96
+ };
97
+ const primary = (e) => e.isPrimary && e.button === 0;
98
+ const armSwallow = () => {
99
+ swallow.current = true;
100
+ requestAnimationFrame(() => { swallow.current = false; });
101
+ };
102
+ const release = (prop) => {
103
+ live.current = null;
104
+ card.classList.remove('is-dragging');
105
+ readFrame(card);
106
+ card.style.removeProperty(prop);
107
+ };
108
+ const onHeadDown = (e) => {
109
+ e.stopPropagation();
110
+ if (!head || !stack || !primary(e) || headPull.current || bodyPull.current)
111
+ return;
112
+ if (!pullsHead(e.target, head))
113
+ return;
114
+ const slop = pxFromRem(SLOP_REM);
115
+ const commit = pxFromRem(COMMIT_REM);
116
+ const pull = { pointerId: e.pointerId, startY: e.clientY, last: 0, cap: Math.max(restPullPx(card, stack), commit), commit, slop, moved: false };
117
+ headPull.current = pull;
118
+ const onMove = (ev) => {
119
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
120
+ return;
121
+ const dy = along('down', pull.startY, ev.clientY);
122
+ pull.last = dy;
123
+ if (dy < pull.slop)
124
+ return;
125
+ pull.moved = true;
126
+ ev.preventDefault();
127
+ try {
128
+ if (head && !head.hasPointerCapture(ev.pointerId))
129
+ head.setPointerCapture(ev.pointerId);
130
+ }
131
+ catch { /* the pointer is already gone */ }
132
+ live.current = { prop: '--_pull', px: `${Math.min(dy, pull.cap)}px` };
133
+ card.classList.add('is-dragging');
134
+ card.style.setProperty('--_pull', live.current.px);
135
+ };
136
+ const onUp = (ev) => {
137
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
138
+ return;
139
+ const dy = Math.max(pull.last, along('down', pull.startY, ev.clientY));
140
+ const moved = pull.moved || dy >= pull.slop;
141
+ headPull.current = null;
142
+ stopDoc();
143
+ if (moved && dy >= pull.commit) {
144
+ card.classList.remove('is-dragging');
145
+ readFrame(card);
146
+ live.current = null;
147
+ flushSync(() => placeRef.current?.onClose());
148
+ if (card.isConnected && card.classList.contains('is-open') && card.style.getPropertyValue('--_pull'))
149
+ release('--_pull');
150
+ }
151
+ else
152
+ release('--_pull');
153
+ if (moved)
154
+ armSwallow();
155
+ };
156
+ const onCancel = (ev) => {
157
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
158
+ return;
159
+ headPull.current = null;
160
+ stopDoc();
161
+ release('--_pull');
162
+ };
163
+ listen(onMove, onUp, onCancel);
164
+ };
165
+ const onBodyDown = (e) => {
166
+ const ask = placeRef.current;
167
+ if (!primary(e) || headPull.current || bodyPull.current || !ask?.onRefresh || (ask.open && ask.refreshing))
168
+ return;
169
+ if (scroller.scrollTop > 0)
170
+ return;
171
+ if (e.target instanceof Element && e.target.closest('.card__head'))
172
+ return;
173
+ const slop = pxFromRem(SLOP_REM);
174
+ const commit = pxFromRem(COMMIT_REM);
175
+ const pull = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, lastDy: 0, commit, slop, moved: false, drop: false };
176
+ bodyPull.current = pull;
177
+ const onMove = (ev) => {
178
+ if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
179
+ return;
180
+ if (scroller.scrollTop > 0) {
181
+ pull.drop = true;
182
+ return;
183
+ }
184
+ if (pull.drop)
185
+ return;
186
+ const dx = ev.clientX - pull.startX;
187
+ const dy = ev.clientY - pull.startY;
188
+ pull.lastDy = Math.max(0, dy);
189
+ if (!pull.moved && !pull.drop) {
190
+ if (Math.max(Math.abs(dx), Math.abs(dy)) < pull.slop)
191
+ return;
192
+ if (Math.abs(dx) > dy || dy <= 0) {
193
+ pull.drop = true;
194
+ return;
195
+ }
196
+ }
197
+ if (!pull.moved)
198
+ flushSync(() => setPulling(true));
199
+ pull.moved = true;
200
+ ev.preventDefault();
201
+ try {
202
+ if (!scroller.hasPointerCapture(ev.pointerId))
203
+ scroller.setPointerCapture(ev.pointerId);
204
+ }
205
+ catch { /* the pointer is already gone */ }
206
+ scroller.style.setProperty('--_refresh', `${Math.min(dy, pull.commit)}px`);
207
+ };
208
+ const finish = () => {
209
+ scroller.style.removeProperty('--_refresh');
210
+ setPulling(false);
211
+ };
212
+ const onUp = (ev) => {
213
+ if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
214
+ return;
215
+ const dy = Math.max(pull.lastDy, Math.max(0, ev.clientY - pull.startY));
216
+ const go = pull.moved && !pull.drop && scroller.scrollTop <= 0 && dy >= pull.commit;
217
+ bodyPull.current = null;
218
+ stopDoc();
219
+ readFrame(scroller);
220
+ finish();
221
+ if (go)
222
+ placeRef.current?.onRefresh?.();
223
+ if (pull.moved)
224
+ armSwallow();
225
+ };
226
+ const onCancel = (ev) => {
227
+ if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
228
+ return;
229
+ bodyPull.current = null;
230
+ stopDoc();
231
+ finish();
232
+ };
233
+ listen(onMove, onUp, onCancel);
234
+ };
235
+ /* a finger: the browser takes a touch for its own scroll at the first move unless that move is refused here */
236
+ let touch = null;
237
+ const onTouchStart = (e) => {
238
+ const t = e.touches[0];
239
+ const ask = placeRef.current;
240
+ const inHead = e.target instanceof Element && !!e.target.closest('.card__head');
241
+ touch = e.touches.length === 1 && t && ask?.onRefresh && !(ask.open && ask.refreshing) && scroller.scrollTop <= 0 && !inHead
242
+ ? { x: t.clientX, y: t.clientY, ours: null } : null;
243
+ };
244
+ const onTouchMove = (e) => {
245
+ const t = e.touches[0];
246
+ if (!touch || !t)
247
+ return;
248
+ const dx = t.clientX - touch.x;
249
+ const dy = t.clientY - touch.y;
250
+ if (touch.ours == null)
251
+ touch.ours = dy > 0 && dy >= Math.abs(dx) && scroller.scrollTop <= 0;
252
+ if (touch.ours && e.cancelable)
253
+ e.preventDefault();
254
+ };
255
+ head?.addEventListener('pointerdown', onHeadDown);
256
+ scroller.addEventListener('pointerdown', onBodyDown);
257
+ scroller.addEventListener('touchstart', onTouchStart, { passive: true });
258
+ scroller.addEventListener('touchmove', onTouchMove, { passive: false });
259
+ return () => {
260
+ scroller.removeEventListener('touchstart', onTouchStart);
261
+ scroller.removeEventListener('touchmove', onTouchMove);
262
+ stopDoc();
263
+ headPull.current = null;
264
+ bodyPull.current = null;
265
+ scroller.style.removeProperty('--_refresh');
266
+ setPulling(false);
267
+ if (live.current?.prop === '--_pull')
268
+ release('--_pull');
269
+ head?.removeEventListener('pointerdown', onHeadDown);
270
+ scroller.removeEventListener('pointerdown', onBodyDown);
271
+ };
272
+ }, [place?.open, !!place?.onRefresh, ref, swallow]);
273
+ /* none open: a pull up on a peeked head follows the finger and, past the commit, opens this sheet */
274
+ useEffect(() => {
275
+ const card = ref.current;
276
+ const here = placeRef.current;
277
+ if (!card || !here || here.open || here.before || here.after)
278
+ return;
279
+ const stack = card.closest('.sheet-stack');
280
+ const head = card.querySelector(':scope > .card__head');
281
+ if (!head || !stack)
282
+ return;
283
+ let stopDoc = () => { };
284
+ const listen = (move, up, cancel) => {
285
+ stopDoc();
286
+ document.addEventListener('pointermove', move, { passive: false });
287
+ document.addEventListener('pointerup', up);
288
+ document.addEventListener('pointercancel', cancel);
289
+ stopDoc = () => {
290
+ document.removeEventListener('pointermove', move);
291
+ document.removeEventListener('pointerup', up);
292
+ document.removeEventListener('pointercancel', cancel);
293
+ stopDoc = () => { };
294
+ };
295
+ };
296
+ const armSwallow = () => {
297
+ swallow.current = true;
298
+ requestAnimationFrame(() => { swallow.current = false; });
299
+ };
300
+ const release = () => {
301
+ live.current = null;
302
+ card.classList.remove('is-dragging');
303
+ readFrame(card);
304
+ card.style.removeProperty('--_lift');
305
+ };
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 = { pointerId: e.pointerId, startY: e.clientY, last: 0, cap: Math.max(travel, commit), commit, slop, moved: false };
338
+ headPull.current = pull;
339
+ const onMove = (ev) => {
340
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
341
+ return;
342
+ const upPx = along('up', pull.startY, ev.clientY);
343
+ pull.last = upPx;
344
+ if (upPx < pull.slop)
345
+ return;
346
+ pull.moved = true;
347
+ ev.preventDefault();
348
+ try {
349
+ if (!head.hasPointerCapture(ev.pointerId))
350
+ head.setPointerCapture(ev.pointerId);
351
+ }
352
+ catch { /* the pointer is already gone */ }
353
+ live.current = { prop: '--_lift', px: `${Math.min(upPx, pull.cap)}px` };
354
+ card.classList.add('is-dragging');
355
+ card.style.setProperty('--_lift', live.current.px);
356
+ };
357
+ const onUp = (ev) => {
358
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
359
+ return;
360
+ const upPx = Math.max(pull.last, along('up', pull.startY, ev.clientY));
361
+ const moved = pull.moved || upPx >= pull.slop;
362
+ headPull.current = null;
363
+ stopDoc();
364
+ if (moved && upPx >= pull.commit) {
365
+ card.classList.remove('is-dragging');
366
+ readFrame(card);
367
+ live.current = null;
368
+ flushSync(() => placeRef.current?.onOpen());
369
+ if (card.isConnected && !card.classList.contains('is-open') && card.style.getPropertyValue('--_lift'))
370
+ release();
371
+ }
372
+ else
373
+ release();
374
+ if (moved)
375
+ armSwallow();
376
+ };
377
+ const onCancel = (ev) => {
378
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
379
+ return;
380
+ headPull.current = null;
381
+ stopDoc();
382
+ release();
383
+ };
384
+ listen(onMove, onUp, onCancel);
385
+ };
386
+ head.addEventListener('pointerdown', onDown);
387
+ return () => {
388
+ stopDoc();
389
+ headPull.current = null;
390
+ if (live.current?.prop === '--_lift')
391
+ release();
392
+ head.removeEventListener('pointerdown', onDown);
393
+ };
394
+ }, [place?.open, place?.before, place?.after, ref, swallow]);
395
+ return pulling;
396
+ }
@@ -0,0 +1,12 @@
1
+ export interface StepCursorValue {
2
+ parked: boolean;
3
+ /** cleared by the open step at the start of its render; the first field this render writes its id, and keeps the mark if it renders again on its own */
4
+ slot: {
5
+ id: string | null;
6
+ };
7
+ }
8
+ export declare const StepCursor: import("react").Context<StepCursorValue | null>;
9
+ export declare function useStepCursor(): {
10
+ 'data-autofocus'?: '';
11
+ 'data-cursor'?: '';
12
+ };
@@ -0,0 +1,15 @@
1
+ /* the open step's first field renders its own cursor marks. Sheet reads `data-autofocus` when it rises;
2
+ `data-cursor` is the caret a phone shows while the keyboard waits on the steps' holder. */
3
+ import { createContext, useContext, useId } from 'react';
4
+ export const StepCursor = createContext(null);
5
+ export function useStepCursor() {
6
+ const ctx = useContext(StepCursor);
7
+ const id = useId();
8
+ if (!ctx)
9
+ return {};
10
+ if (ctx.slot.id == null)
11
+ ctx.slot.id = id;
12
+ if (ctx.slot.id !== id)
13
+ return {};
14
+ return ctx.parked ? { 'data-autofocus': '', 'data-cursor': '' } : { 'data-autofocus': '' };
15
+ }
@@ -49,7 +49,7 @@ export interface HomeProps extends Omit<ComponentProps<typeof Screen>, 'children
49
49
  * Home — TEMPLATE, a `Screen` with its parts filled — top: the AppBar · content: the `base` (title, welcome…) over a closed `SheetStack` · no bottom
50
50
  * (the stack is content, never the bottom: open, a sheet fills everything under the top).
51
51
  * Every section is a `StackSheet` of three parts: SHEET HEAD (title + icon tabs, a count over a tab; the back button once open) · SHEET BODY (scrolls) · SHEET FOOT (the CTA,
52
- * at the bottom). A tap on a head brings that sheet up to fill everything under the top. Only the open sheet's body is rendered — three
52
+ * at the bottom). A tap on a head brings that sheet up to fill everything under the top. With a LayerHistory above and no `open` prop, that open sheet is one history entry named `home`. Only the open sheet's body is rendered — three
53
53
  * sections on one home never cost three pages (`keep` holds a body once opened).
54
54
  */
55
55
  export declare function Home({ top, title, sheets, open: openProp, defaultOpen, onOpenChange, onRefresh, refreshing, base, keep, backLabel, backAt, children, ...rest }: HomeProps): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { useControlled } from '../state.js';
4
+ import { useOptionalLayer } from '../scaffold/LayerHistory.js';
4
5
  import { Screen } from '../scaffold/Screen.js';
5
6
  import { SheetStack } from '../sheets/SheetStack.js';
6
7
  import { StackSheet } from '../sheets/StackSheet.js';
@@ -12,20 +13,34 @@ const LEAVE = 800; // ms: a closing sheet keeps its body until it has slid home
12
13
  * Home — TEMPLATE, a `Screen` with its parts filled — top: the AppBar · content: the `base` (title, welcome…) over a closed `SheetStack` · no bottom
13
14
  * (the stack is content, never the bottom: open, a sheet fills everything under the top).
14
15
  * Every section is a `StackSheet` of three parts: SHEET HEAD (title + icon tabs, a count over a tab; the back button once open) · SHEET BODY (scrolls) · SHEET FOOT (the CTA,
15
- * at the bottom). A tap on a head brings that sheet up to fill everything under the top. Only the open sheet's body is rendered — three
16
+ * at the bottom). A tap on a head brings that sheet up to fill everything under the top. With a LayerHistory above and no `open` prop, that open sheet is one history entry named `home`. Only the open sheet's body is rendered — three
16
17
  * sections on one home never cost three pages (`keep` holds a body once opened).
17
18
  */
18
19
  export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, onRefresh, refreshing, base, keep, backLabel = word('back'), backAt, children, ...rest }) {
19
- const [open, change] = useControlled(openProp, defaultOpen, onOpenChange);
20
+ const layer = useOptionalLayer('home');
21
+ const [local, change] = useControlled(openProp, defaultOpen, onOpenChange);
22
+ // A passed `open` is the parent's. With no LayerHistory the index stays local, so a story without a router still opens sheets.
23
+ const shown = openProp !== undefined ? local : layer == null ? local : typeof layer.value === 'number' ? layer.value : null;
24
+ const request = (next) => {
25
+ if (openProp !== undefined || layer == null) {
26
+ change(next);
27
+ return;
28
+ }
29
+ onOpenChange?.(next);
30
+ if (next == null)
31
+ layer.close();
32
+ else
33
+ layer.open(next);
34
+ };
20
35
  const [leaving, setLeaving] = useState(null);
21
- const [seen, setSeen] = useState(() => new Set(open != null ? [open] : []));
22
- const [was, setWas] = useState(open);
23
- if (was !== open) { // derived during render: which body must stay while its sheet slides home
24
- if (was != null && open == null)
36
+ const [seen, setSeen] = useState(() => new Set(shown != null ? [shown] : []));
37
+ const [was, setWas] = useState(shown);
38
+ if (was !== shown) { // derived during render: which body must stay while its sheet slides home
39
+ if (was != null && shown == null)
25
40
  setLeaving(was);
26
- if (open != null && !seen.has(open))
27
- setSeen(new Set(seen).add(open));
28
- setWas(open);
41
+ if (shown != null && !seen.has(shown))
42
+ setSeen(new Set(seen).add(shown));
43
+ setWas(shown);
29
44
  }
30
45
  useEffect(() => {
31
46
  if (leaving == null)
@@ -34,6 +49,6 @@ export function Home({ top, title, sheets, open: openProp, defaultOpen = null, o
34
49
  return () => clearTimeout(t);
35
50
  }, [leaving]);
36
51
  const [tabs, setTabs] = useState(() => Object.fromEntries(sheets.map((x, i) => [i, x.defaultTab ?? x.tabs?.[0]?.id])));
37
- const shows = (i) => i === open || i === leaving || (keep && seen.has(i));
38
- return (_jsxs(Screen, { fill: true, top: top, ...rest, children: [_jsx(Text, { level: "h1", as: "h1", sr: true, children: title }), _jsx(SheetStack, { closed: true, open: open, onOpenChange: change, onRefresh: onRefresh, refreshing: refreshing, base: base, children: sheets.map((s, i) => (_jsx(StackSheet, { tone: s.tone ?? TONES[i], title: s.title, icon: s.icon, tabs: s.tabs, tab: tabs[i], onTabChange: (t) => setTabs((v) => ({ ...v, [i]: t })), backLabel: backLabel, backAt: backAt, foot: typeof s.foot === 'function' ? s.foot(tabs[i]) : s.foot, children: shows(i) ? (typeof s.body === 'function' ? s.body(tabs[i]) : s.body) : null }, i))) }), children] }));
52
+ const shows = (i) => i === shown || i === leaving || (keep && seen.has(i));
53
+ return (_jsxs(Screen, { fill: true, top: top, ...rest, children: [_jsx(Text, { level: "h1", as: "h1", sr: true, children: title }), _jsx(SheetStack, { closed: true, open: shown, onOpenChange: request, onRefresh: onRefresh, refreshing: refreshing, base: base, children: sheets.map((s, i) => (_jsx(StackSheet, { tone: s.tone ?? TONES[i], title: s.title, icon: s.icon, tabs: s.tabs, tab: tabs[i], onTabChange: (t) => setTabs((v) => ({ ...v, [i]: t })), backLabel: backLabel, backAt: backAt, foot: typeof s.foot === 'function' ? s.foot(tabs[i]) : s.foot, children: shows(i) ? (typeof s.body === 'function' ? s.body(tabs[i]) : s.body) : null }, i))) }), children] }));
39
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.53",
3
+ "version": "0.7.55",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",