@usableapp/cardds 0.7.49 → 0.7.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +5 -2
- package/cardds.js +6 -1
- package/css/choice.css +4 -3
- package/css/forms.css +3 -2
- package/css/stack.css +20 -4
- package/css/tokens.css +1 -1
- package/dist/cardds.css +28 -10
- package/dist/sheets/Sheet.d.ts +1 -3
- package/dist/sheets/Sheet.js +14 -207
- package/dist/sheets/SheetStack.d.ts +6 -3
- package/dist/sheets/SheetStack.js +328 -4
- package/dist/sheets/SheetSteps.js +70 -92
- package/dist/sheets/sheetDrag.d.ts +9 -0
- package/dist/sheets/sheetDrag.js +49 -0
- package/dist/sheets/sheetMeasure.d.ts +10 -0
- package/dist/sheets/sheetMeasure.js +84 -0
- package/dist/sheets/sheetSlide.d.ts +9 -0
- package/dist/sheets/sheetSlide.js +96 -0
- package/dist/sheets/sheetStepsPile.d.ts +8 -0
- package/dist/sheets/sheetStepsPile.js +70 -0
- package/dist/templates/Home.d.ts +3 -1
- package/dist/templates/Home.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* the Sheet's handle held and moved — its own behaviour, not exported from the package: the sheet follows the finger (`--_drag`),
|
|
2
|
+
and on release asks for the nearest of its states. A move shorter than a tap is left to the click. */
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
import { slid, stopsOf } from './sheetMeasure.js';
|
|
5
|
+
const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
|
|
6
|
+
/** the handle's pointer handlers, and `afterDrag()` — true once for the click that follows a drag (it is not a tap) */
|
|
7
|
+
export function useSheetDrag(ref, allowed, snap) {
|
|
8
|
+
const skipClick = useRef(false);
|
|
9
|
+
const drag = useRef(null);
|
|
10
|
+
const down = (e) => {
|
|
11
|
+
const el = ref.current;
|
|
12
|
+
if (!el || !e.isPrimary || e.button !== 0)
|
|
13
|
+
return;
|
|
14
|
+
const list = allowed(), stops = stopsOf(el), y = slid(el); // where it is now — mid-slide too, so a grab never jumps
|
|
15
|
+
drag.current = { startY: e.clientY, y0: y, y, stops, allowed: list, moved: false,
|
|
16
|
+
min: Math.min(...list.map((s) => stops[s])), max: Math.max(...list.map((s) => stops[s])),
|
|
17
|
+
tap: TAP_REM * parseFloat(getComputedStyle(document.documentElement).fontSize) };
|
|
18
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
19
|
+
};
|
|
20
|
+
const move = (e) => {
|
|
21
|
+
const d = drag.current, el = ref.current;
|
|
22
|
+
if (!d || !el || !e.isPrimary)
|
|
23
|
+
return;
|
|
24
|
+
const dy = e.clientY - d.startY;
|
|
25
|
+
if (!d.moved) {
|
|
26
|
+
if (Math.abs(dy) < d.tap)
|
|
27
|
+
return;
|
|
28
|
+
d.moved = true;
|
|
29
|
+
el.classList.add('is-dragging');
|
|
30
|
+
}
|
|
31
|
+
d.y = Math.min(Math.max(d.y0 + dy, d.min), d.max); // never above full, never below its lowest state
|
|
32
|
+
el.style.setProperty('--_drag', `${d.y - d.y0}px`); // the one thing written while held — the CSS adds it to the state's translate
|
|
33
|
+
};
|
|
34
|
+
const up = () => {
|
|
35
|
+
const d = drag.current, el = ref.current;
|
|
36
|
+
drag.current = null;
|
|
37
|
+
if (!d || !el)
|
|
38
|
+
return;
|
|
39
|
+
el.classList.remove('is-dragging');
|
|
40
|
+
if (!d.moved)
|
|
41
|
+
return; // a tap: the click that follows toggles
|
|
42
|
+
el.style.removeProperty('--_drag'); // same state, or the app says no: it eases back on its own
|
|
43
|
+
snap(d.allowed.reduce((a, b) => (Math.abs(d.stops[b] - d.y) < Math.abs(d.stops[a] - d.y) ? b : a)));
|
|
44
|
+
skipClick.current = true;
|
|
45
|
+
requestAnimationFrame(() => { skipClick.current = false; });
|
|
46
|
+
};
|
|
47
|
+
const afterDrag = () => { const skip = skipClick.current; skipClick.current = false; return skip; };
|
|
48
|
+
return { down, move, up, afterDrag };
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
export declare const ORDER: readonly ["peek", "half", "3q", "full"];
|
|
3
|
+
export type Resting = (typeof ORDER)[number];
|
|
4
|
+
type SheetRef = RefObject<HTMLElement | null>;
|
|
5
|
+
export declare const slid: (el: HTMLElement) => number;
|
|
6
|
+
export declare const stopsOf: (el: HTMLElement) => Record<Resting, number>;
|
|
7
|
+
export declare function usePeekStrip(ref: SheetRef): void;
|
|
8
|
+
export declare function useTitleRun(ref: SheetRef): void;
|
|
9
|
+
export declare function useFootRoom(ref: SheetRef): void;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* what a Sheet MEASURES — its own behaviour, not exported from the package: where it is and where each state rests (read once per
|
|
2
|
+
gesture, never stored), and the three numbers it publishes for its CSS: the peek strip, the title's run, the foot's room. */
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { watchSize } from '../state.js';
|
|
5
|
+
export const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
|
|
6
|
+
/* geometry, read once per gesture — never stored: how far it has slid, and where each state rests, in px from its top at full */
|
|
7
|
+
export const slid = (el) => {
|
|
8
|
+
const box = (el.offsetParent ?? el.parentElement);
|
|
9
|
+
return el.getBoundingClientRect().top - (box.getBoundingClientRect().top + box.clientTop + el.offsetTop);
|
|
10
|
+
};
|
|
11
|
+
export const stopsOf = (el) => {
|
|
12
|
+
const cs = getComputedStyle(el), height = el.getBoundingClientRect().height;
|
|
13
|
+
return {
|
|
14
|
+
full: 0,
|
|
15
|
+
half: height * (parseFloat(cs.getPropertyValue('--sheet-half')) || 0),
|
|
16
|
+
'3q': height * (parseFloat(cs.getPropertyValue('--sheet-3q')) || 0),
|
|
17
|
+
peek: height - (parseFloat(cs.getPropertyValue('--sheet-peek')) || 0), // registered in tokens.css: reads back resolved
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/* the peek strip holds the handle and the WHOLE title row. CSS gives the box the strip for a one-line head (--sheet-peek in
|
|
21
|
+
sheet.css); when the head is taller than that floor — a title wrapped on a narrow phone at a large text size — the sheet
|
|
22
|
+
measures its head and publishes the real strip on its box, so nothing of the title is under the cut (2026-09-22, with the
|
|
23
|
+
22-base type scale). Removed when the head fits the floor again, so the CSS number is the one in force. */
|
|
24
|
+
export function usePeekStrip(ref) {
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const el = ref.current, box = el?.parentElement, head = el?.querySelector(':scope > .card__head, :scope > .sheet__head');
|
|
27
|
+
if (!el || !box || !head)
|
|
28
|
+
return;
|
|
29
|
+
const publish = () => {
|
|
30
|
+
box.style.removeProperty('--sheet-peek'); // read the CSS strip, not our own
|
|
31
|
+
const cs = getComputedStyle(el);
|
|
32
|
+
const strip = parseFloat(cs.getPropertyValue('--sheet-peek')) || 0, floor = parseFloat(cs.getPropertyValue('--sheet-head-h')) || 0;
|
|
33
|
+
const over = head.getBoundingClientRect().height - floor;
|
|
34
|
+
if (over > 0.5)
|
|
35
|
+
box.style.setProperty('--sheet-peek', `${strip + over}px`);
|
|
36
|
+
};
|
|
37
|
+
const stop = watchSize([head], publish);
|
|
38
|
+
return () => { stop(); box.style.removeProperty('--sheet-peek'); };
|
|
39
|
+
}, []);
|
|
40
|
+
}
|
|
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) {
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const el = ref.current, title = el?.querySelector(':scope > .card__head > :is(h1, h2, h3):first-child, :scope > .sheet__head .sheet__title');
|
|
47
|
+
if (!el || !title)
|
|
48
|
+
return;
|
|
49
|
+
const measure = () => {
|
|
50
|
+
const words = document.createRange();
|
|
51
|
+
words.selectNodeContents(title);
|
|
52
|
+
const over = Math.max(0, Math.ceil(words.getBoundingClientRect().width - title.clientWidth));
|
|
53
|
+
title.style.setProperty('--_over', `${over}px`);
|
|
54
|
+
title.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
|
|
55
|
+
title.classList.toggle('is-long', over > 0);
|
|
56
|
+
};
|
|
57
|
+
const stop = watchSize([title], measure);
|
|
58
|
+
const mo = new MutationObserver(measure);
|
|
59
|
+
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'); };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/* 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
|
|
64
|
+
line may stand over it (an error) — the CSS strip was a fixed sum (--btn-h), so the body's words ran under the button. The
|
|
65
|
+
sheet measures its foot's rows and publishes --sheet-foot-h; the strip, its ground and the body's stop all read it. */
|
|
66
|
+
export function useFootRoom(ref) {
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const el = ref.current, foot = el?.querySelector(':scope > .card__foot');
|
|
69
|
+
if (!el || !foot) {
|
|
70
|
+
el?.style.removeProperty('--sheet-foot-h');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const publish = () => {
|
|
74
|
+
const rows = [...foot.children].map((c) => c.getBoundingClientRect()).filter((r) => r.height > 0);
|
|
75
|
+
if (!rows.length)
|
|
76
|
+
return el.style.removeProperty('--sheet-foot-h');
|
|
77
|
+
el.style.setProperty('--sheet-foot-h', `${Math.max(...rows.map((r) => r.bottom)) - Math.min(...rows.map((r) => r.top))}px`);
|
|
78
|
+
};
|
|
79
|
+
const stop = watchSize([...foot.children], publish);
|
|
80
|
+
const mo = new MutationObserver(publish);
|
|
81
|
+
mo.observe(foot, { childList: true });
|
|
82
|
+
return () => { stop(); mo.disconnect(); };
|
|
83
|
+
}); // every render: the foot's rows are the app's, they come and go
|
|
84
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import type { SheetState } from './Sheet.js';
|
|
3
|
+
/** `shown` = the state its class says · `live` = where it is as far as its own habits go (a held sheet has not come up yet) ·
|
|
4
|
+
* `held` = it waits for the sheet going away before it to be settled */
|
|
5
|
+
export declare function useSheetSlide(ref: RefObject<HTMLElement | null>, current: SheetState, onSettled?: (state: SheetState) => void): {
|
|
6
|
+
shown: SheetState;
|
|
7
|
+
live: SheetState;
|
|
8
|
+
held: boolean;
|
|
9
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* how a Sheet comes and goes — its own behaviour, not exported from the package: the first frame `away` (so it slides in), the rest
|
|
2
|
+
after it went away, the wait of a sheet asked up meanwhile, `onSettled`, and the warning when it is unmounted while up. */
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { reducedMotion, useLatest, warnOnce } from '../state.js';
|
|
5
|
+
/* A SHEET THAT GOES AWAY IS NOT DONE WHEN IT LANDS (Lh 2026-10-03): it holds its box for a beat more (--motion-sheet-rest) — the base
|
|
6
|
+
is seen — and only then is it settled. `out` = the sheets still on their way out, per box; a sheet asked up in that box waits for it to empty. */
|
|
7
|
+
const out = new WeakMap();
|
|
8
|
+
const waiting = new WeakMap();
|
|
9
|
+
const leave = (box, key) => { if (!out.has(box))
|
|
10
|
+
out.set(box, new Set()); out.get(box).add(key); };
|
|
11
|
+
const gone = (box, key) => { const set = out.get(box); if (set?.delete(key) && !set.size)
|
|
12
|
+
[...(waiting.get(box) ?? [])].forEach((go) => go()); };
|
|
13
|
+
const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim().endsWith('ms') ? 1 : 1000) || 0)); // a computed `0.35s, 0s` list → its longest, in ms
|
|
14
|
+
/** `shown` = the state its class says · `live` = where it is as far as its own habits go (a held sheet has not come up yet) ·
|
|
15
|
+
* `held` = it waits for the sheet going away before it to be settled */
|
|
16
|
+
export function useSheetSlide(ref, current, onSettled) {
|
|
17
|
+
const [entered, setEntered] = useState(false); // false for the first frame: the class says `away`, so a sheet mounted up has somewhere to slide from
|
|
18
|
+
const me = useRef({}).current; // this sheet, in its box's list of sheets on their way out
|
|
19
|
+
const [held, setHeld] = useState(false); // true while it waits for the sheet going away before it to be settled
|
|
20
|
+
const [prev, setPrev] = useState('away'); // asked up from `away` (or mounted up): it holds first, and rises once its box is free
|
|
21
|
+
if (prev !== current) {
|
|
22
|
+
setPrev(current);
|
|
23
|
+
setHeld(prev === 'away' && current !== 'away');
|
|
24
|
+
}
|
|
25
|
+
const shown = entered && !held ? current : 'away';
|
|
26
|
+
const live = held ? 'away' : current; // where it is as far as its own habits go: a held sheet has not come up yet
|
|
27
|
+
const latest = useLatest({ current, onSettled });
|
|
28
|
+
const last = useRef('away'); // where it was last SHOWN (the settle effect keeps it)
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
void ref.current?.offsetHeight; // the browser styles the `away` frame first, or there is no transition to run
|
|
31
|
+
setEntered(true);
|
|
32
|
+
}, []);
|
|
33
|
+
/* it starts on its way out: said in the commit itself, before any sheet of the box decides to rise */
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const box = ref.current?.parentElement;
|
|
36
|
+
if (box && current === 'away' && last.current !== 'away' && !reducedMotion())
|
|
37
|
+
leave(box, me);
|
|
38
|
+
}, [current, me]);
|
|
39
|
+
/* held: it rises when no sheet of its box is on its way out — at once if none is */
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const box = ref.current?.parentElement;
|
|
42
|
+
if (!held)
|
|
43
|
+
return;
|
|
44
|
+
if (!box || !out.get(box)?.size) {
|
|
45
|
+
setHeld(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const go = () => setHeld(false);
|
|
49
|
+
if (!waiting.has(box))
|
|
50
|
+
waiting.set(box, new Set());
|
|
51
|
+
waiting.get(box).add(go);
|
|
52
|
+
return () => { waiting.get(box)?.delete(go); };
|
|
53
|
+
}, [held]);
|
|
54
|
+
/* it has finished moving: the transition ended (or at once where there is none) — say so once per move; a move cut short by another says nothing */
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const el = ref.current, box = el?.parentElement;
|
|
57
|
+
if (!el || last.current === shown)
|
|
58
|
+
return;
|
|
59
|
+
last.current = shown;
|
|
60
|
+
const cs = getComputedStyle(el);
|
|
61
|
+
let done = false, rest = 0;
|
|
62
|
+
const settled = () => { if (box)
|
|
63
|
+
gone(box, me); latest.current.onSettled?.(latest.current.current); };
|
|
64
|
+
const end = () => {
|
|
65
|
+
if (done)
|
|
66
|
+
return;
|
|
67
|
+
done = true;
|
|
68
|
+
clearTimeout(t);
|
|
69
|
+
el.removeEventListener('transitionend', onEnd);
|
|
70
|
+
if (shown !== 'away' || reducedMotion())
|
|
71
|
+
return settled();
|
|
72
|
+
rest = window.setTimeout(settled, ms(cs.getPropertyValue('--motion-sheet-rest') || '0s')); // landed: the base is seen for the rest, THEN it is settled
|
|
73
|
+
};
|
|
74
|
+
const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
|
|
75
|
+
end(); };
|
|
76
|
+
el.addEventListener('transitionend', onEnd);
|
|
77
|
+
const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100); // no transition (motion off) or none came: settled anyway
|
|
78
|
+
return () => { done = true; clearTimeout(t); clearTimeout(rest); el.removeEventListener('transitionend', onEnd); if (box && shown === 'away')
|
|
79
|
+
gone(box, me); }; // cut short on its way out (raised again, unmounted): the box is free
|
|
80
|
+
}, [shown, latest, me]);
|
|
81
|
+
/* taken out of the tree while it is still up — the box stays, the sheet goes: nothing to slide, so it vanishes. Said once in dev.
|
|
82
|
+
(A whole screen leaving takes the box too; StrictMode's trial unmount is over by the time the timer looks.) */
|
|
83
|
+
const alive = useRef(false);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
alive.current = true;
|
|
86
|
+
const box = ref.current?.parentElement;
|
|
87
|
+
return () => {
|
|
88
|
+
alive.current = false;
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
if (!alive.current && box?.isConnected && latest.current.current !== 'away')
|
|
91
|
+
warnOnce('sheet-unmount', 'cardds Sheet: unmounted while on the screen, so it vanished instead of sliding out. Set state="away" and unmount it in onSettled (state === "away").');
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
}, [latest]);
|
|
95
|
+
return { shown, live, held };
|
|
96
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
export declare const GLIDE_MS = 220;
|
|
3
|
+
/** `settle()` scrolls the pile to its place · `glideNow()` = the next jumps are played as slides. `moving` = until when a step
|
|
4
|
+
* change is playing its beats (the SheetSteps keeps it). */
|
|
5
|
+
export declare function usePile(ref: RefObject<HTMLElement | null>, moving: RefObject<number>): {
|
|
6
|
+
settle: () => void;
|
|
7
|
+
glideNow: () => void;
|
|
8
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* the PILE of a SheetSteps — its own behaviour, not exported from the package: where the body is scrolled when a step opens or a
|
|
2
|
+
field takes the keyboard, and the quick slide that replays every jump of that scroll so the eye can follow it. */
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { reducedMotion } from '../state.js';
|
|
5
|
+
export const GLIDE_MS = 220; // the pile's slide out of the keyboard's way and back: quick, but the eye follows it
|
|
6
|
+
const GLIDE_FOR = 900; // how long after a focus move (or a step change) a jump of the scroll is replayed as a slide
|
|
7
|
+
/** `settle()` scrolls the pile to its place · `glideNow()` = the next jumps are played as slides. `moving` = until when a step
|
|
8
|
+
* change is playing its beats (the SheetSteps keeps it). */
|
|
9
|
+
export function usePile(ref, moving) {
|
|
10
|
+
const glide = useRef(0), last = useRef(0), own = useRef(0); // own: our smooth scroll is playing — not a jump to replay
|
|
11
|
+
const glideNow = () => { glide.current = performance.now() + GLIDE_FOR; };
|
|
12
|
+
/* how far the body is scrolled when a step opens or a field in it takes the keyboard: the TWO latest done rows always show
|
|
13
|
+
above the open step, and half of the one before — older ones go up out of sight, one row at a time, so what was done never seems to vanish (Lh
|
|
14
|
+
2026-09-24: moving the pile further startled; with two rows or fewer nothing moves) */
|
|
15
|
+
const settle = () => {
|
|
16
|
+
const list = ref.current, body = list?.closest('.sheet__body'), now = list?.querySelector('.sheet-step--now');
|
|
17
|
+
if (!list || !body || !now)
|
|
18
|
+
return;
|
|
19
|
+
const above = [];
|
|
20
|
+
for (let el = now.previousElementSibling; el; el = el.previousElementSibling)
|
|
21
|
+
above.unshift(el);
|
|
22
|
+
const bTop = body.getBoundingClientRect().top, at = (el) => body.scrollTop + el.getBoundingClientRect().top - bTop;
|
|
23
|
+
const third = above.length > 2 ? above[above.length - 3] : null; // 2½ rows show (Lh 2026-09-24): the two latest whole, the one before cut in half — a sign there is more above
|
|
24
|
+
let to = third ? at(third) + third.getBoundingClientRect().height / 2 : 0;
|
|
25
|
+
/* …unless the field with the focus would still be under the cut (a short phone, a big text size): then up as far as it needs —
|
|
26
|
+
but never past the LATEST done row, which always stays in sight */
|
|
27
|
+
const focused = document.activeElement instanceof HTMLElement && now.contains(document.activeElement) ? document.activeElement : null;
|
|
28
|
+
if (focused) {
|
|
29
|
+
const vv = window.visualViewport;
|
|
30
|
+
const cut = Math.min(body.getBoundingClientRect().bottom - (parseFloat(getComputedStyle(body).scrollPaddingBottom) || 0), vv ? vv.offsetTop + vv.height : Infinity);
|
|
31
|
+
const under = (focused.closest('.field, .pin') ?? focused).getBoundingClientRect().bottom + (body.scrollTop - to) - cut; // where its bottom will be once scrolled to `to`
|
|
32
|
+
const most = above.length ? at(above[above.length - 1]) : 0;
|
|
33
|
+
if (under > 0)
|
|
34
|
+
to = Math.min(to + under, Math.max(most, to));
|
|
35
|
+
}
|
|
36
|
+
if (Math.abs(to - body.scrollTop) <= 1)
|
|
37
|
+
return;
|
|
38
|
+
// the pile SCROLLS there, animated (Lh 2026-09-24) — the browser's own smooth scroll; its frames are not replayed as jumps
|
|
39
|
+
const smooth = !reducedMotion();
|
|
40
|
+
own.current = performance.now() + (smooth ? 700 : 0);
|
|
41
|
+
body.scrollTo({ top: to, behavior: smooth ? 'smooth' : 'auto' });
|
|
42
|
+
};
|
|
43
|
+
/* the keyboard comes and goes: the body's scroll JUMPS (the Sheet's reveal, the browser's own, ours below, the box growing back).
|
|
44
|
+
For a short while after a field in the steps gains or loses the focus, every such jump is played as a quick slide (FLIP: the steps
|
|
45
|
+
are set back by the jump, then eased to rest) so the eye can follow the pile moving out of the way and back. */
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const el = ref.current, body = el?.closest('.sheet__body');
|
|
48
|
+
if (!el || !body)
|
|
49
|
+
return;
|
|
50
|
+
last.current = body.scrollTop;
|
|
51
|
+
const onScroll = () => {
|
|
52
|
+
const d = body.scrollTop - last.current;
|
|
53
|
+
last.current = body.scrollTop;
|
|
54
|
+
if (!d || performance.now() > glide.current || performance.now() < own.current || reducedMotion())
|
|
55
|
+
return;
|
|
56
|
+
const from = parseFloat(el.style.translate.split(' ')[1] || '0') || 0;
|
|
57
|
+
el.style.transition = 'none';
|
|
58
|
+
el.style.translate = `0 ${from + d}px`;
|
|
59
|
+
void el.offsetHeight; // the set-back lands before the ease
|
|
60
|
+
const hold = Math.max(0, moving.current - 100 - performance.now()); // during a step change: held until the fold is done (beat 2)
|
|
61
|
+
el.style.transition = `translate ${GLIDE_MS}ms ease-out ${hold}ms`;
|
|
62
|
+
el.style.translate = '0 0';
|
|
63
|
+
};
|
|
64
|
+
const clean = () => { el.style.removeProperty('transition'); el.style.removeProperty('translate'); };
|
|
65
|
+
body.addEventListener('scroll', onScroll);
|
|
66
|
+
el.addEventListener('transitionend', clean);
|
|
67
|
+
return () => { body.removeEventListener('scroll', onScroll); el.removeEventListener('transitionend', clean); };
|
|
68
|
+
}, []);
|
|
69
|
+
return { settle, glideNow };
|
|
70
|
+
}
|
package/dist/templates/Home.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface HomeProps extends Omit<ComponentProps<typeof Screen>, 'children
|
|
|
29
29
|
open?: number | null;
|
|
30
30
|
defaultOpen?: number | null;
|
|
31
31
|
onOpenChange?: (open: number | null) => void;
|
|
32
|
+
/** the open sheet's scroller was already at the top and the finger pulled down past the commit. The stack asks with that index. It does not fetch. Omit it and a pull in the body only scrolls */
|
|
33
|
+
onRefresh?: (index: number) => void;
|
|
32
34
|
/** CONTENT above the sheet heads — one `Page`: a welcome, the news, a picture. It reads
|
|
33
35
|
* from the top; a sheet coming up covers it */
|
|
34
36
|
base?: ReactNode;
|
|
@@ -48,4 +50,4 @@ export interface HomeProps extends Omit<ComponentProps<typeof Screen>, 'children
|
|
|
48
50
|
* 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
|
|
49
51
|
* sections on one home never cost three pages (`keep` holds a body once opened).
|
|
50
52
|
*/
|
|
51
|
-
export declare function Home({ top, title, sheets, open: openProp, defaultOpen, onOpenChange, base, keep, backLabel, backAt, children, ...rest }: HomeProps): import("react").JSX.Element;
|
|
53
|
+
export declare function Home({ top, title, sheets, open: openProp, defaultOpen, onOpenChange, onRefresh, base, keep, backLabel, backAt, children, ...rest }: HomeProps): import("react").JSX.Element;
|
package/dist/templates/Home.js
CHANGED
|
@@ -15,7 +15,7 @@ const LEAVE = 800; // ms: a closing sheet keeps its body until it has slid home
|
|
|
15
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
16
|
* sections on one home never cost three pages (`keep` holds a body once opened).
|
|
17
17
|
*/
|
|
18
|
-
export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, base, keep, backLabel = word('back'), backAt, children, ...rest }) {
|
|
18
|
+
export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, onRefresh, base, keep, backLabel = word('back'), backAt, children, ...rest }) {
|
|
19
19
|
const [open, change] = useControlled(openProp, defaultOpen, onOpenChange);
|
|
20
20
|
const [leaving, setLeaving] = useState(null);
|
|
21
21
|
const [seen, setSeen] = useState(() => new Set(open != null ? [open] : []));
|
|
@@ -35,5 +35,5 @@ export function Home({ top, title, sheets, open: openProp, defaultOpen = null, o
|
|
|
35
35
|
}, [leaving]);
|
|
36
36
|
const [tabs, setTabs] = useState(() => Object.fromEntries(sheets.map((x, i) => [i, x.defaultTab ?? x.tabs?.[0]?.id])));
|
|
37
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, 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] }));
|
|
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, 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
39
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.51",
|
|
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.",
|