@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.
@@ -0,0 +1,31 @@
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 tuck, only while `before` */
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
+ export declare function stackPaint(place: StackPlace | undefined): {
21
+ className?: string;
22
+ style?: CSSProperties;
23
+ busy?: true;
24
+ };
25
+ /**
26
+ * The card's own pull, refresh and focus. Listeners sit on this card's head and body.
27
+ * `--_pull` / `--_lift` are written while a finger is down, the same way a single Sheet writes `--_drag`.
28
+ */
29
+ export declare function useStackCard(ref: RefObject<HTMLElement | null>, place: StackPlace | undefined, swallow: {
30
+ current: boolean;
31
+ }): void;
@@ -0,0 +1,389 @@
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 } 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
+ export function stackPaint(place) {
12
+ if (!place)
13
+ return {};
14
+ const loading = place.open && place.refreshing;
15
+ const className = ['stack-card', place.open && 'is-open', place.before && 'is-before', place.after && 'is-after', loading && 'is-loading'].filter(Boolean).join(' ');
16
+ return {
17
+ className,
18
+ style: place.before ? { '--stagger': String(place.stagger) } : undefined,
19
+ busy: loading || undefined,
20
+ };
21
+ }
22
+ const pxFromRem = (rem) => parseFloat(getComputedStyle(document.documentElement).fontSize) * rem;
23
+ /* the pull's reach is the room the sheet still has to travel — measured, not a class */
24
+ const sheetsIn = (stack) => [...stack.children]
25
+ .filter((c) => !c.classList.contains('sheet-stack__base'))
26
+ .map((c) => (c.classList.contains('card') ? c : c.querySelector(':scope > .card')))
27
+ .filter((c) => c instanceof HTMLElement);
28
+ const restPullPx = (card, stack) => {
29
+ if (!stack.classList.contains('sheet-stack--closed'))
30
+ return card.offsetTop;
31
+ const sheets = sheetsIn(stack);
32
+ const step = sheets[1]?.offsetTop ?? 0;
33
+ return Math.max(0, stack.clientHeight - sheets.length * step) + card.offsetTop;
34
+ };
35
+ const readFrame = (el) => { el.getBoundingClientRect(); };
36
+ const along = (dir, startY, y) => (dir === 'down' ? Math.max(0, y - startY) : Math.max(0, startY - y));
37
+ const pullsHead = (target, head) => {
38
+ if (!(target instanceof Element))
39
+ return false;
40
+ return target === head || !!target.closest('.sheet-stack__back') || !!target.closest('.card__head > :is(h1, h2, h3)');
41
+ };
42
+ /**
43
+ * The card's own pull, refresh and focus. Listeners sit on this card's head and body.
44
+ * `--_pull` / `--_lift` are written while a finger is down, the same way a single Sheet writes `--_drag`.
45
+ */
46
+ export function useStackCard(ref, place, swallow) {
47
+ const placeRef = useRef(place);
48
+ placeRef.current = place;
49
+ const live = useRef(null);
50
+ const headPull = useRef(null);
51
+ const bodyPull = useRef(null);
52
+ useLayoutEffect(() => {
53
+ const el = ref.current;
54
+ if (!el || !place)
55
+ return;
56
+ const g = live.current;
57
+ if (g) {
58
+ el.classList.add('is-dragging');
59
+ el.style.setProperty(g.prop, g.px);
60
+ return;
61
+ }
62
+ /* a finger let go: the prop leaves in this commit, so the sheet eases from the frame just read */
63
+ el.style.removeProperty('--_pull');
64
+ el.style.removeProperty('--_lift');
65
+ });
66
+ useEffect(() => {
67
+ if (!place?.open || !ref.current)
68
+ return;
69
+ const cancel = focusWhenActive(ref.current, { scope: ':scope > .sheet__body' });
70
+ return () => { cancel(); };
71
+ }, [place?.open, ref]);
72
+ /* open: a pull down on the head folds it; a pull down in the content, already at the top, asks for a reload */
73
+ useEffect(() => {
74
+ const card = ref.current;
75
+ const here = placeRef.current;
76
+ if (!card || !here?.open)
77
+ return;
78
+ const stack = card.closest('.sheet-stack');
79
+ const head = card.querySelector(':scope > .card__head');
80
+ const scroller = card.querySelector(':scope > .sheet__body') ?? card;
81
+ let stopDoc = () => { };
82
+ const listen = (move, up, cancel) => {
83
+ stopDoc();
84
+ document.addEventListener('pointermove', move, { passive: false });
85
+ document.addEventListener('pointerup', up);
86
+ document.addEventListener('pointercancel', cancel);
87
+ stopDoc = () => {
88
+ document.removeEventListener('pointermove', move);
89
+ document.removeEventListener('pointerup', up);
90
+ document.removeEventListener('pointercancel', cancel);
91
+ stopDoc = () => { };
92
+ };
93
+ };
94
+ const primary = (e) => e.isPrimary && e.button === 0;
95
+ const armSwallow = () => {
96
+ swallow.current = true;
97
+ requestAnimationFrame(() => { swallow.current = false; });
98
+ };
99
+ const release = (prop) => {
100
+ live.current = null;
101
+ card.classList.remove('is-dragging');
102
+ readFrame(card);
103
+ card.style.removeProperty(prop);
104
+ };
105
+ const onHeadDown = (e) => {
106
+ e.stopPropagation();
107
+ if (!head || !stack || !primary(e) || headPull.current || bodyPull.current)
108
+ return;
109
+ if (!pullsHead(e.target, head))
110
+ return;
111
+ const slop = pxFromRem(SLOP_REM);
112
+ const commit = pxFromRem(COMMIT_REM);
113
+ const pull = { pointerId: e.pointerId, startY: e.clientY, last: 0, cap: Math.max(restPullPx(card, stack), commit), commit, slop, moved: false };
114
+ headPull.current = pull;
115
+ const onMove = (ev) => {
116
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
117
+ return;
118
+ const dy = along('down', pull.startY, ev.clientY);
119
+ pull.last = dy;
120
+ if (dy < pull.slop)
121
+ return;
122
+ pull.moved = true;
123
+ ev.preventDefault();
124
+ try {
125
+ if (head && !head.hasPointerCapture(ev.pointerId))
126
+ head.setPointerCapture(ev.pointerId);
127
+ }
128
+ catch { /* the pointer is already gone */ }
129
+ live.current = { prop: '--_pull', px: `${Math.min(dy, pull.cap)}px` };
130
+ card.classList.add('is-dragging');
131
+ card.style.setProperty('--_pull', live.current.px);
132
+ };
133
+ const onUp = (ev) => {
134
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
135
+ return;
136
+ const dy = Math.max(pull.last, along('down', pull.startY, ev.clientY));
137
+ const moved = pull.moved || dy >= pull.slop;
138
+ headPull.current = null;
139
+ stopDoc();
140
+ if (moved && dy >= pull.commit) {
141
+ card.classList.remove('is-dragging');
142
+ readFrame(card);
143
+ live.current = null;
144
+ flushSync(() => placeRef.current?.onClose());
145
+ if (card.isConnected && card.classList.contains('is-open') && card.style.getPropertyValue('--_pull'))
146
+ release('--_pull');
147
+ }
148
+ else
149
+ release('--_pull');
150
+ if (moved)
151
+ armSwallow();
152
+ };
153
+ const onCancel = (ev) => {
154
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
155
+ return;
156
+ headPull.current = null;
157
+ stopDoc();
158
+ release('--_pull');
159
+ };
160
+ listen(onMove, onUp, onCancel);
161
+ };
162
+ const onBodyDown = (e) => {
163
+ const ask = placeRef.current;
164
+ if (!primary(e) || headPull.current || bodyPull.current || !ask?.onRefresh || (ask.open && ask.refreshing))
165
+ return;
166
+ if (scroller.scrollTop > 0)
167
+ return;
168
+ if (e.target instanceof Element && e.target.closest('.card__head'))
169
+ return;
170
+ const slop = pxFromRem(SLOP_REM);
171
+ const commit = pxFromRem(COMMIT_REM);
172
+ const pull = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, lastDy: 0, commit, slop, moved: false, drop: false };
173
+ bodyPull.current = pull;
174
+ const onMove = (ev) => {
175
+ if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
176
+ return;
177
+ if (scroller.scrollTop > 0) {
178
+ pull.drop = true;
179
+ return;
180
+ }
181
+ if (pull.drop)
182
+ return;
183
+ const dx = ev.clientX - pull.startX;
184
+ const dy = ev.clientY - pull.startY;
185
+ pull.lastDy = Math.max(0, dy);
186
+ if (!pull.moved && !pull.drop) {
187
+ if (Math.max(Math.abs(dx), Math.abs(dy)) < pull.slop)
188
+ return;
189
+ if (Math.abs(dx) > dy || dy <= 0) {
190
+ pull.drop = true;
191
+ return;
192
+ }
193
+ }
194
+ pull.moved = true;
195
+ ev.preventDefault();
196
+ try {
197
+ if (!scroller.hasPointerCapture(ev.pointerId))
198
+ scroller.setPointerCapture(ev.pointerId);
199
+ }
200
+ catch { /* the pointer is already gone */ }
201
+ scroller.classList.add('is-refreshing');
202
+ scroller.style.setProperty('--_refresh', `${Math.min(dy, pull.commit)}px`);
203
+ };
204
+ const finish = () => {
205
+ scroller.classList.remove('is-refreshing');
206
+ scroller.style.removeProperty('--_refresh');
207
+ };
208
+ const onUp = (ev) => {
209
+ if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
210
+ return;
211
+ const dy = Math.max(pull.lastDy, Math.max(0, ev.clientY - pull.startY));
212
+ const go = pull.moved && !pull.drop && scroller.scrollTop <= 0 && dy >= pull.commit;
213
+ bodyPull.current = null;
214
+ stopDoc();
215
+ readFrame(scroller);
216
+ finish();
217
+ if (go)
218
+ placeRef.current?.onRefresh?.();
219
+ if (pull.moved)
220
+ armSwallow();
221
+ };
222
+ const onCancel = (ev) => {
223
+ if (ev.pointerId !== pull.pointerId || bodyPull.current !== pull)
224
+ return;
225
+ bodyPull.current = null;
226
+ stopDoc();
227
+ finish();
228
+ };
229
+ listen(onMove, onUp, onCancel);
230
+ };
231
+ /* a finger: the browser takes a touch for its own scroll at the first move unless that move is refused here */
232
+ let touch = null;
233
+ const onTouchStart = (e) => {
234
+ const t = e.touches[0];
235
+ const ask = placeRef.current;
236
+ const inHead = e.target instanceof Element && !!e.target.closest('.card__head');
237
+ touch = e.touches.length === 1 && t && ask?.onRefresh && !(ask.open && ask.refreshing) && scroller.scrollTop <= 0 && !inHead
238
+ ? { x: t.clientX, y: t.clientY, ours: null } : null;
239
+ };
240
+ const onTouchMove = (e) => {
241
+ const t = e.touches[0];
242
+ if (!touch || !t)
243
+ return;
244
+ const dx = t.clientX - touch.x;
245
+ const dy = t.clientY - touch.y;
246
+ if (touch.ours == null)
247
+ touch.ours = dy > 0 && dy >= Math.abs(dx) && scroller.scrollTop <= 0;
248
+ if (touch.ours && e.cancelable)
249
+ e.preventDefault();
250
+ };
251
+ head?.addEventListener('pointerdown', onHeadDown);
252
+ scroller.addEventListener('pointerdown', onBodyDown);
253
+ scroller.addEventListener('touchstart', onTouchStart, { passive: true });
254
+ scroller.addEventListener('touchmove', onTouchMove, { passive: false });
255
+ return () => {
256
+ scroller.removeEventListener('touchstart', onTouchStart);
257
+ scroller.removeEventListener('touchmove', onTouchMove);
258
+ stopDoc();
259
+ headPull.current = null;
260
+ bodyPull.current = null;
261
+ if (live.current?.prop === '--_pull')
262
+ release('--_pull');
263
+ head?.removeEventListener('pointerdown', onHeadDown);
264
+ scroller.removeEventListener('pointerdown', onBodyDown);
265
+ };
266
+ }, [place?.open, place?.refreshing, !!place?.onRefresh, ref, swallow]);
267
+ /* none open: a pull up on a peeked head follows the finger and, past the commit, opens this sheet */
268
+ useEffect(() => {
269
+ const card = ref.current;
270
+ const here = placeRef.current;
271
+ if (!card || !here || here.open || here.before || here.after)
272
+ return;
273
+ const stack = card.closest('.sheet-stack');
274
+ const head = card.querySelector(':scope > .card__head');
275
+ if (!head || !stack)
276
+ return;
277
+ let stopDoc = () => { };
278
+ const listen = (move, up, cancel) => {
279
+ stopDoc();
280
+ document.addEventListener('pointermove', move, { passive: false });
281
+ document.addEventListener('pointerup', up);
282
+ document.addEventListener('pointercancel', cancel);
283
+ stopDoc = () => {
284
+ document.removeEventListener('pointermove', move);
285
+ document.removeEventListener('pointerup', up);
286
+ document.removeEventListener('pointercancel', cancel);
287
+ stopDoc = () => { };
288
+ };
289
+ };
290
+ const armSwallow = () => {
291
+ swallow.current = true;
292
+ requestAnimationFrame(() => { swallow.current = false; });
293
+ };
294
+ const release = () => {
295
+ live.current = null;
296
+ card.classList.remove('is-dragging');
297
+ readFrame(card);
298
+ card.style.removeProperty('--_lift');
299
+ };
300
+ const onDown = (e) => {
301
+ if (!e.isPrimary || e.button !== 0 || headPull.current)
302
+ return;
303
+ if (!pullsHead(e.target, head)) {
304
+ if (!(e.target instanceof Element) || !e.target.closest('button, a'))
305
+ return;
306
+ const startY = e.clientY;
307
+ const id = e.pointerId;
308
+ const slop = pxFromRem(SLOP_REM);
309
+ const move = (ev) => {
310
+ if (ev.pointerId !== id)
311
+ return;
312
+ if (Math.abs(ev.clientY - startY) >= slop)
313
+ swallow.current = true;
314
+ };
315
+ const up = (ev) => {
316
+ if (ev.pointerId !== id)
317
+ return;
318
+ document.removeEventListener('pointermove', move);
319
+ document.removeEventListener('pointerup', up);
320
+ if (swallow.current)
321
+ requestAnimationFrame(() => { swallow.current = false; });
322
+ };
323
+ document.addEventListener('pointermove', move);
324
+ document.addEventListener('pointerup', up);
325
+ return;
326
+ }
327
+ e.stopPropagation();
328
+ const slop = pxFromRem(SLOP_REM);
329
+ const commit = pxFromRem(COMMIT_REM);
330
+ const travel = card.getBoundingClientRect().top - stack.getBoundingClientRect().top;
331
+ const pull = { pointerId: e.pointerId, startY: e.clientY, last: 0, cap: Math.max(travel, commit), commit, slop, moved: false };
332
+ headPull.current = pull;
333
+ const onMove = (ev) => {
334
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
335
+ return;
336
+ const upPx = along('up', pull.startY, ev.clientY);
337
+ pull.last = upPx;
338
+ if (upPx < pull.slop)
339
+ return;
340
+ pull.moved = true;
341
+ ev.preventDefault();
342
+ try {
343
+ if (!head.hasPointerCapture(ev.pointerId))
344
+ head.setPointerCapture(ev.pointerId);
345
+ }
346
+ catch { /* the pointer is already gone */ }
347
+ live.current = { prop: '--_lift', px: `${Math.min(upPx, pull.cap)}px` };
348
+ card.classList.add('is-dragging');
349
+ card.style.setProperty('--_lift', live.current.px);
350
+ };
351
+ const onUp = (ev) => {
352
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
353
+ return;
354
+ const upPx = Math.max(pull.last, along('up', pull.startY, ev.clientY));
355
+ const moved = pull.moved || upPx >= pull.slop;
356
+ headPull.current = null;
357
+ stopDoc();
358
+ if (moved && upPx >= pull.commit) {
359
+ card.classList.remove('is-dragging');
360
+ readFrame(card);
361
+ live.current = null;
362
+ flushSync(() => placeRef.current?.onOpen());
363
+ if (card.isConnected && !card.classList.contains('is-open') && card.style.getPropertyValue('--_lift'))
364
+ release();
365
+ }
366
+ else
367
+ release();
368
+ if (moved)
369
+ armSwallow();
370
+ };
371
+ const onCancel = (ev) => {
372
+ if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
373
+ return;
374
+ headPull.current = null;
375
+ stopDoc();
376
+ release();
377
+ };
378
+ listen(onMove, onUp, onCancel);
379
+ };
380
+ head.addEventListener('pointerdown', onDown);
381
+ return () => {
382
+ stopDoc();
383
+ headPull.current = null;
384
+ if (live.current?.prop === '--_lift')
385
+ release();
386
+ head.removeEventListener('pointerdown', onDown);
387
+ };
388
+ }, [place?.open, place?.before, place?.after, ref, swallow]);
389
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.53",
3
+ "version": "0.7.54",
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.",