@usableapp/cardds 0.2.1 → 0.3.0
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/README.md +34 -104
- package/cardds.js +6 -445
- package/css/actions.css +3 -8
- package/css/card.css +2 -2
- package/css/sheet.css +26 -9
- package/css/step.css +6 -6
- package/css/tokens.css +2 -0
- package/dist/actions/Dropdown.d.ts +34 -17
- package/dist/actions/Dropdown.js +41 -11
- package/dist/actions/IconBtn.d.ts +296 -9
- package/dist/actions/IconBtn.js +6 -8
- package/dist/cardds.css +39 -25
- package/dist/choice/Calendar.d.ts +17 -6
- package/dist/choice/Calendar.js +9 -3
- package/dist/choice/Slider.d.ts +1 -1
- package/dist/choice/Slider.js +12 -8
- package/dist/forms/Pin.d.ts +9 -6
- package/dist/forms/Pin.js +47 -18
- package/dist/media/Postcard.d.ts +8 -4
- package/dist/media/Postcard.js +8 -3
- package/dist/numbers/Picker.d.ts +19 -7
- package/dist/numbers/Picker.js +51 -13
- package/dist/scaffold/BaseContent.d.ts +4 -4
- package/dist/scaffold/BaseContent.js +4 -4
- package/dist/sheets/Drawer.d.ts +14 -3
- package/dist/sheets/Drawer.js +29 -5
- package/dist/sheets/Sheet.d.ts +28 -15
- package/dist/sheets/Sheet.js +157 -11
- package/dist/sheets/SheetStack.d.ts +13 -1
- package/dist/sheets/SheetStack.js +74 -2
- package/package.json +1 -1
package/dist/sheets/Drawer.js
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Children, Fragment } from 'react';
|
|
2
|
+
import { Children, Fragment, useEffect, useRef } from 'react';
|
|
3
3
|
import { cx } from '../cx.js';
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Modal — the ask: ONE card at the centre of a `SheetStage ask`, two parts — a `Lift` (the thing being asked about, light, scrolls
|
|
6
|
+
* when tall) and a `Drawer` (the dark end that asks). No border. Two beats on appearing: drops in from above, then the drawer comes
|
|
7
|
+
* out below while the lift moves up.
|
|
8
|
+
* A COMPONENT, not a wiring: it does not know its buttons. They are plain `IconBtn`s / `Btn`s with the app's `onClick` — the app
|
|
9
|
+
* records the answer and sets `open={false}`; the modal's one behaviour is how it leaves.
|
|
10
|
+
*/
|
|
11
|
+
export function Modal({ open = true, onClosed, className, ...rest }) {
|
|
12
|
+
const ref = useRef(null);
|
|
13
|
+
const closed = useRef(onClosed);
|
|
14
|
+
closed.current = onClosed;
|
|
15
|
+
const shown = useRef(true); // the animations stand played forwards
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const el = ref.current;
|
|
18
|
+
if (!el || open === shown.current)
|
|
19
|
+
return;
|
|
20
|
+
shown.current = open;
|
|
21
|
+
const anims = el.getAnimations({ subtree: true });
|
|
22
|
+
anims.forEach((a) => a.reverse()); // the same keyframes, backwards to leave, forwards again to return — no closing keyframes to keep in step
|
|
23
|
+
if (open)
|
|
24
|
+
return;
|
|
25
|
+
let live = true;
|
|
26
|
+
void Promise.all(anims.map((a) => a.finished.catch(() => undefined))).then(() => { if (live)
|
|
27
|
+
closed.current?.(); });
|
|
28
|
+
return () => { live = false; };
|
|
29
|
+
}, [open]);
|
|
30
|
+
return _jsx("article", { ref: ref, className: cx('card modal', className), ...rest });
|
|
7
31
|
}
|
|
8
32
|
/** Lift — the light part of a `Modal`: the case, request or item the question is about (a CardHead, rows, a timeline); scrolls inside when tall. */
|
|
9
33
|
export function Lift({ className, ...rest }) {
|
|
10
34
|
return _jsx("div", { className: cx('modal__lift', className), ...rest });
|
|
11
35
|
}
|
|
12
|
-
/** Drawer — the dark end of a `Modal`: the question, its fine print, and a `BtnTrio` (or two buttons)
|
|
36
|
+
/** Drawer — the dark end of a `Modal`: the question, its fine print, and a `BtnTrio` (or two buttons) — plain buttons with the app's `onClick`; the Drawer does nothing with them. */
|
|
13
37
|
export function Drawer({ title, caption, className, children, ...rest }) {
|
|
14
38
|
return (_jsxs("div", { className: cx('modal__drawer', className), ...rest, children: [_jsx("h2", { className: "t-h2", children: title }), caption != null && _jsx("p", { className: "t-caption", children: caption }), children] }));
|
|
15
39
|
}
|
package/dist/sheets/Sheet.d.ts
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef } from 'react';
|
|
2
|
+
/** how far the sheet slid. `away` = not on the screen (slid out, inert) — a state the app sets, never one a drag or a tap lands on */
|
|
2
3
|
export type SheetState = 'peek' | 'half' | '3q' | 'full' | 'away';
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
/** what asked for the change: the handle's `tap`, a `drag` released, `focus` in a peeked sheet, the `keyboard` (or anything else) moving the cut */
|
|
5
|
+
export type SheetChangeReason = 'tap' | 'drag' | 'focus' | 'keyboard';
|
|
6
|
+
declare const ORDER: readonly ["peek", "half", "3q", "full"];
|
|
7
|
+
type Resting = (typeof ORDER)[number];
|
|
8
|
+
export interface SheetProps extends Omit<ComponentPropsWithoutRef<'article'>, 'onChange'> {
|
|
9
|
+
/** CONTROLLED: where the sheet is. The app owns it — pair it with `onStateChange`, or the sheet cannot move (like `value` without `onChange`).
|
|
10
|
+
* `peek` (handle + title row) · `half` · `3q` · `full` (its top edge at the top bar) · `away` (not on the screen). A class, never a height. */
|
|
5
11
|
state?: SheetState;
|
|
12
|
+
/** UNCONTROLLED: where it starts (default `half`); the sheet then keeps its own state. Ignored when `state` is given. */
|
|
13
|
+
defaultState?: SheetState;
|
|
14
|
+
/** the sheet ASKS to move — the handle was tapped or dragged, a field in a peeked sheet took focus, the keyboard moved the cut.
|
|
15
|
+
* Controlled: set your state to it (or don't — the sheet stays). This is where an app ties a sheet to its router / back button. */
|
|
16
|
+
onStateChange?: (state: SheetState, reason: SheetChangeReason) => void;
|
|
17
|
+
/** the states a drag snaps to and a reveal may climb through — "peek half full", or an array (default all four) */
|
|
18
|
+
states?: string | readonly Resting[];
|
|
6
19
|
/** floats above a footnote strip on a dim stage (bottom corners and border back) */
|
|
7
20
|
raised?: boolean;
|
|
8
|
-
/** the grab handle
|
|
21
|
+
/** the grab handle; `false` for a sheet with no handle, a string sets its accessible name */
|
|
9
22
|
handle?: boolean | string;
|
|
10
|
-
/** which states a drag snaps to, e.g. "peek half full" (default all four: peek half 3q full) */
|
|
11
|
-
states?: string;
|
|
12
23
|
}
|
|
13
24
|
/**
|
|
14
|
-
* Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
|
|
26
|
+
* box — a Step or a SheetStage — a state is only how far it slid (translate; the material never changes size).
|
|
27
|
+
* ONE SHAPE, written top-down: handle · head (a CardHead / SheetHead — pinned, never scrolls) · SheetBody (scrolls, as far as
|
|
28
|
+
* it needs) · CardFoot (the button that finishes the sheet — at the bottom, above the keyboard; gone at peek).
|
|
29
|
+
*
|
|
30
|
+
* It is a COMPONENT, not a wiring: it knows nothing of what opens it. The app owns `state` and sets it from whatever it
|
|
31
|
+
* likes — a button, a route, a timer: `<Btn onClick={() => setSheet('full')}>` … `<Sheet state={sheet} onStateChange={setSheet}>`.
|
|
32
|
+
* Its own behaviour stays inside it and only ever ASKS (`onStateChange`): a tap on the handle toggles its own state ↔ peek,
|
|
33
|
+
* a drag snaps to the nearest of `states`, focus in a peeked sheet raises it, and when the cut moves (the keyboard) it
|
|
34
|
+
* climbs until the focused control has room above the foot.
|
|
23
35
|
*/
|
|
24
|
-
export declare function Sheet({ state,
|
|
36
|
+
export declare function Sheet({ state, defaultState, onStateChange, states, raised, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
|
|
37
|
+
export {};
|
package/dist/sheets/Sheet.js
CHANGED
|
@@ -1,16 +1,162 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
3
|
import { cx } from '../cx.js';
|
|
4
|
+
const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
|
|
5
|
+
const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
|
|
3
6
|
/**
|
|
4
|
-
* Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
|
|
8
|
+
* box — a Step or a SheetStage — a state is only how far it slid (translate; the material never changes size).
|
|
9
|
+
* ONE SHAPE, written top-down: handle · head (a CardHead / SheetHead — pinned, never scrolls) · SheetBody (scrolls, as far as
|
|
10
|
+
* it needs) · CardFoot (the button that finishes the sheet — at the bottom, above the keyboard; gone at peek).
|
|
11
|
+
*
|
|
12
|
+
* It is a COMPONENT, not a wiring: it knows nothing of what opens it. The app owns `state` and sets it from whatever it
|
|
13
|
+
* likes — a button, a route, a timer: `<Btn onClick={() => setSheet('full')}>` … `<Sheet state={sheet} onStateChange={setSheet}>`.
|
|
14
|
+
* Its own behaviour stays inside it and only ever ASKS (`onStateChange`): a tap on the handle toggles its own state ↔ peek,
|
|
15
|
+
* a drag snaps to the nearest of `states`, focus in a peeked sheet raises it, and when the cut moves (the keyboard) it
|
|
16
|
+
* climbs until the focused control has room above the foot.
|
|
13
17
|
*/
|
|
14
|
-
export function Sheet({ state = 'half', raised, handle = true,
|
|
15
|
-
|
|
18
|
+
export function Sheet({ state, defaultState = 'half', onStateChange, states, raised, handle = true, className, children, onFocus, ...rest }) {
|
|
19
|
+
const [inner, setInner] = useState(defaultState);
|
|
20
|
+
const current = state ?? inner;
|
|
21
|
+
const controlled = state !== undefined;
|
|
22
|
+
const ref = useRef(null);
|
|
23
|
+
const handleRef = useRef(null);
|
|
24
|
+
const home = useRef(current !== 'peek' && current !== 'away' ? current : 'half'); // where a tap from peek returns to
|
|
25
|
+
const was = useRef(current);
|
|
26
|
+
const skipClick = useRef(false);
|
|
27
|
+
const drag = useRef(null);
|
|
28
|
+
const allowed = useCallback(() => {
|
|
29
|
+
const list = (typeof states === 'string' ? states.split(/\s+/) : [...(states ?? [])]).filter((s) => ORDER.includes(s));
|
|
30
|
+
return list.length ? ORDER.filter((s) => list.includes(s)) : [...ORDER];
|
|
31
|
+
}, [states]);
|
|
32
|
+
const request = useCallback((next, reason) => {
|
|
33
|
+
if (next === current)
|
|
34
|
+
return;
|
|
35
|
+
if (!controlled)
|
|
36
|
+
setInner(next);
|
|
37
|
+
onStateChange?.(next, reason);
|
|
38
|
+
}, [current, controlled, onStateChange]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (current !== 'peek' && current !== 'away')
|
|
41
|
+
home.current = current;
|
|
42
|
+
if (was.current === 'away' && current !== 'away')
|
|
43
|
+
handleRef.current?.focus({ preventScroll: true }); // it just came onto the screen: the keyboard user is on it
|
|
44
|
+
was.current = current;
|
|
45
|
+
}, [current]);
|
|
46
|
+
/* geometry, read once per gesture — never stored: how far it has slid, and where each state rests, in px from its top at full */
|
|
47
|
+
const slid = (el) => {
|
|
48
|
+
const box = (el.offsetParent ?? el.parentElement);
|
|
49
|
+
return el.getBoundingClientRect().top - (box.getBoundingClientRect().top + box.clientTop + el.offsetTop);
|
|
50
|
+
};
|
|
51
|
+
const stopsOf = (el) => {
|
|
52
|
+
const cs = getComputedStyle(el), height = el.getBoundingClientRect().height;
|
|
53
|
+
return {
|
|
54
|
+
full: 0,
|
|
55
|
+
half: height * (parseFloat(cs.getPropertyValue('--sheet-half')) || 0),
|
|
56
|
+
'3q': height * (parseFloat(cs.getPropertyValue('--sheet-3q')) || 0),
|
|
57
|
+
peek: height - (parseFloat(cs.getPropertyValue('--sheet-peek')) || 0), // registered in tokens.css: reads back resolved
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
/* bring a control into the part of the body that shows — between the pinned head and the foot's strip on the cut. The sheet asks
|
|
61
|
+
for the lowest of its states, from the current one (or `atLeast`) upward, that has room; if none has, its highest. */
|
|
62
|
+
const reveal = useCallback((target, reason, atLeast) => {
|
|
63
|
+
const el = ref.current;
|
|
64
|
+
if (!el || !target || !el.contains(target) || target.closest('.sheet__handle') || current === 'away')
|
|
65
|
+
return;
|
|
66
|
+
const control = target.closest('.field') ?? target; // the control's box, not the bare input inside it
|
|
67
|
+
const list = allowed(), stops = stopsOf(el);
|
|
68
|
+
const from = Math.max(list.indexOf(current), atLeast ? list.indexOf(atLeast) : -1, 0);
|
|
69
|
+
const rect = el.getBoundingClientRect();
|
|
70
|
+
const restTop = rect.top - slid(el);
|
|
71
|
+
const bodyTop = (el.querySelector(':scope > .sheet__body') ?? el).getBoundingClientRect().top - rect.top;
|
|
72
|
+
const box = el.parentElement.getBoundingClientRect(); // what clips it: the Step or the SheetStage
|
|
73
|
+
const vv = window.visualViewport;
|
|
74
|
+
const edge = Math.min(box.bottom, restTop + rect.height); // the sheet's own bottom edge at rest — above the box's on a stage that gave the keyboard its room
|
|
75
|
+
const cut = Math.min(edge, vv ? vv.offsetTop + vv.height : Infinity);
|
|
76
|
+
const button = el.querySelector(':scope > .card__foot')?.firstElementChild; // the foot's button stands on that edge at every state: the control has to clear its strip too
|
|
77
|
+
const need = control.getBoundingClientRect().height + (button ? edge - button.getBoundingClientRect().top : 0);
|
|
78
|
+
let to = list[list.length - 1];
|
|
79
|
+
for (let i = from; i < list.length; i++)
|
|
80
|
+
if (cut - (restTop + stops[list[i]] + bodyTop) >= need) {
|
|
81
|
+
to = list[i];
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
request(to, reason);
|
|
85
|
+
requestAnimationFrame(() => control.scrollIntoView({ block: 'nearest' })); // after the state (and its scroll-padding) landed
|
|
86
|
+
}, [allowed, current, request]);
|
|
87
|
+
/* the cut moved — the keyboard came up (the Step shrank by --kb, or the layout viewport did), the phone turned: if a control
|
|
88
|
+
in this sheet has the focus, make sure it still shows */
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const el = ref.current;
|
|
91
|
+
if (!el || typeof ResizeObserver === 'undefined')
|
|
92
|
+
return;
|
|
93
|
+
let first = true;
|
|
94
|
+
const ro = new ResizeObserver(() => {
|
|
95
|
+
if (first) {
|
|
96
|
+
first = false;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const at = document.activeElement;
|
|
100
|
+
if (at && el.contains(at))
|
|
101
|
+
reveal(at, 'keyboard');
|
|
102
|
+
});
|
|
103
|
+
ro.observe(el);
|
|
104
|
+
if (el.parentElement)
|
|
105
|
+
ro.observe(el.parentElement);
|
|
106
|
+
return () => ro.disconnect();
|
|
107
|
+
}, [reveal]);
|
|
108
|
+
const focus = (e) => {
|
|
109
|
+
onFocus?.(e);
|
|
110
|
+
if (current !== 'peek' || e.target.closest('.sheet__handle'))
|
|
111
|
+
return;
|
|
112
|
+
const body = ref.current?.querySelector(':scope > .sheet__body');
|
|
113
|
+
if (body)
|
|
114
|
+
body.scrollTop = 0; // the browser's own reveal scrolled it against the peek strip: start over
|
|
115
|
+
reveal(e.target, 'focus', home.current);
|
|
116
|
+
};
|
|
117
|
+
const tap = () => {
|
|
118
|
+
if (skipClick.current) {
|
|
119
|
+
skipClick.current = false;
|
|
120
|
+
return;
|
|
121
|
+
} // the click that follows a drag is not a tap
|
|
122
|
+
request(current === 'peek' ? (allowed().includes(home.current) ? home.current : allowed().find((s) => s !== 'peek') ?? 'half') : 'peek', 'tap');
|
|
123
|
+
};
|
|
124
|
+
const down = (e) => {
|
|
125
|
+
const el = ref.current;
|
|
126
|
+
if (!el || !e.isPrimary || e.button !== 0)
|
|
127
|
+
return;
|
|
128
|
+
const list = allowed(), stops = stopsOf(el), y = slid(el); // where it is now — mid-slide too, so a grab never jumps
|
|
129
|
+
drag.current = { startY: e.clientY, y0: y, y, stops, allowed: list, moved: false,
|
|
130
|
+
min: Math.min(...list.map((s) => stops[s])), max: Math.max(...list.map((s) => stops[s])),
|
|
131
|
+
tap: TAP_REM * parseFloat(getComputedStyle(document.documentElement).fontSize) };
|
|
132
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
133
|
+
};
|
|
134
|
+
const move = (e) => {
|
|
135
|
+
const d = drag.current, el = ref.current;
|
|
136
|
+
if (!d || !el || !e.isPrimary)
|
|
137
|
+
return;
|
|
138
|
+
const dy = e.clientY - d.startY;
|
|
139
|
+
if (!d.moved) {
|
|
140
|
+
if (Math.abs(dy) < d.tap)
|
|
141
|
+
return;
|
|
142
|
+
d.moved = true;
|
|
143
|
+
el.classList.add('is-dragging');
|
|
144
|
+
}
|
|
145
|
+
d.y = Math.min(Math.max(d.y0 + dy, d.min), d.max); // never above full, never below its lowest state
|
|
146
|
+
el.style.setProperty('--_drag', `${d.y - d.y0}px`); // the one thing written while held — the CSS adds it to the state's translate
|
|
147
|
+
};
|
|
148
|
+
const up = () => {
|
|
149
|
+
const d = drag.current, el = ref.current;
|
|
150
|
+
drag.current = null;
|
|
151
|
+
if (!d || !el)
|
|
152
|
+
return;
|
|
153
|
+
el.classList.remove('is-dragging');
|
|
154
|
+
if (!d.moved)
|
|
155
|
+
return; // a tap: the click that follows toggles
|
|
156
|
+
el.style.removeProperty('--_drag'); // same state, or the app says no: it eases back on its own
|
|
157
|
+
request(d.allowed.reduce((a, b) => (Math.abs(d.stops[b] - d.y) < Math.abs(d.stops[a] - d.y) ? b : a)), 'drag');
|
|
158
|
+
skipClick.current = true;
|
|
159
|
+
requestAnimationFrame(() => { skipClick.current = false; });
|
|
160
|
+
};
|
|
161
|
+
return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${current}`, raised && 'sheet--raised', className), inert: current === 'away' || undefined, onFocus: focus, ...rest, children: [handle !== false && (_jsx("button", { ref: handleRef, className: "sheet__handle", type: "button", "aria-label": typeof handle === 'string' ? handle : 'Resize', onClick: tap, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up })), children] }));
|
|
16
162
|
}
|
|
@@ -2,6 +2,13 @@ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
|
2
2
|
export interface SheetStackProps extends ComponentPropsWithoutRef<'div'> {
|
|
3
3
|
/** `tap` (default): a tapped head brings its sheet to the front, its back button returns it · `display`: the giant title is the peek (recents, a library) · `static`: peeks only, the last sheet open */
|
|
4
4
|
mode?: 'tap' | 'display' | 'static';
|
|
5
|
+
/** CONTROLLED (tap mode): which sheet is open — its index among the children, first = 0 — or `null` for none. The app owns it (a route, a
|
|
6
|
+
* button anywhere, the back button): pair it with `onOpenChange`, or the stack cannot move. */
|
|
7
|
+
open?: number | null;
|
|
8
|
+
/** UNCONTROLLED: the sheet open on arrival (default none); the stack then keeps its own */
|
|
9
|
+
defaultOpen?: number | null;
|
|
10
|
+
/** the stack ASKS: a peeked head was tapped (its index), or the open sheet's `CardBack` (`null`) */
|
|
11
|
+
onOpenChange?: (open: number | null) => void;
|
|
5
12
|
/** wider peeks so more of each title shows (display stacks) */
|
|
6
13
|
fanned?: boolean;
|
|
7
14
|
/** nothing open on arrival (tap mode): every sheet, the front one too, peeks at the stack's bottom; the room above is the base's (`base`). A tap opens any sheet over it */
|
|
@@ -17,5 +24,10 @@ export interface SheetStackProps extends ComponentPropsWithoutRef<'div'> {
|
|
|
17
24
|
* never the `heading` prop (it wraps the h2 in a div; the stack's one-line peek needs the bare h2). Put it last in a `Screen fill`, it bleeds
|
|
18
25
|
* off the bottom. `closed`: nothing open on arrival — every head peeks at the bottom and the base above shows `base` (a home screen
|
|
19
26
|
* 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`.
|
|
27
|
+
*
|
|
28
|
+
* A COMPONENT, not a wiring: WHICH sheet is open is the app's (`open` + `onOpenChange`, or `defaultOpen`) — so a button anywhere, a route or
|
|
29
|
+
* 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, the open
|
|
30
|
+
* sheet's CardBack. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
|
|
31
|
+
* before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
|
|
20
32
|
*/
|
|
21
|
-
export declare function SheetStack({ mode, fanned, closed, base, className, children, ...rest }: SheetStackProps): import("react").JSX.Element;
|
|
33
|
+
export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, fanned, closed, base, className, children, onClick, ...rest }: SheetStackProps): import("react").JSX.Element;
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
3
|
import { cx } from '../cx.js';
|
|
4
|
+
const PHASE = 380; // ms before the before-pack un-tucks on close, so the after-pack is clear first
|
|
5
|
+
/* 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
|
|
6
|
+
each. Never deeper: a card inside a sheet's content is content. The base is what shows behind the sheets, never a sheet. */
|
|
7
|
+
const sheetsOf = (stack) => [...stack.children]
|
|
8
|
+
.filter((c) => !c.classList.contains('sheet-stack__base'))
|
|
9
|
+
.map((c) => (c.classList.contains('card') ? c : c.querySelector(':scope > .card')))
|
|
10
|
+
.filter((c) => c instanceof HTMLElement);
|
|
3
11
|
/**
|
|
4
12
|
* SheetStack — the MENU: sheets stacked like paper at the middle of the home screen, every one a main section.
|
|
5
13
|
* Children are Cards, direct children only, at most five: first = the back of the stack, last = the front (open) sheet.
|
|
@@ -8,7 +16,71 @@ import { cx } from '../cx.js';
|
|
|
8
16
|
* never the `heading` prop (it wraps the h2 in a div; the stack's one-line peek needs the bare h2). Put it last in a `Screen fill`, it bleeds
|
|
9
17
|
* off the bottom. `closed`: nothing open on arrival — every head peeks at the bottom and the base above shows `base` (a home screen
|
|
10
18
|
* 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`.
|
|
19
|
+
*
|
|
20
|
+
* A COMPONENT, not a wiring: WHICH sheet is open is the app's (`open` + `onOpenChange`, or `defaultOpen`) — so a button anywhere, a route or
|
|
21
|
+
* 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, the open
|
|
22
|
+
* sheet's CardBack. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
|
|
23
|
+
* before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
|
|
11
24
|
*/
|
|
12
|
-
export function SheetStack({ mode = 'tap', fanned, closed, base, className, children, ...rest }) {
|
|
13
|
-
|
|
25
|
+
export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChange, fanned, closed, base, className, children, onClick, ...rest }) {
|
|
26
|
+
const [inner, setInner] = useState(defaultOpen);
|
|
27
|
+
const current = open !== undefined ? open : inner;
|
|
28
|
+
const ref = useRef(null);
|
|
29
|
+
const was = useRef(null);
|
|
30
|
+
const untuck = useRef(null);
|
|
31
|
+
const request = (next) => {
|
|
32
|
+
if (next === current)
|
|
33
|
+
return;
|
|
34
|
+
if (open === undefined)
|
|
35
|
+
setInner(next);
|
|
36
|
+
onOpenChange?.(next);
|
|
37
|
+
};
|
|
38
|
+
/* paint the state onto the sheets — after every render, so a re-rendered Card never loses its place */
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
const stack = ref.current;
|
|
41
|
+
if (!stack || mode !== 'tap')
|
|
42
|
+
return;
|
|
43
|
+
const sheets = sheetsOf(stack);
|
|
44
|
+
const prev = was.current;
|
|
45
|
+
was.current = current;
|
|
46
|
+
if (current != null) {
|
|
47
|
+
if (untuck.current) {
|
|
48
|
+
clearTimeout(untuck.current);
|
|
49
|
+
untuck.current = null;
|
|
50
|
+
}
|
|
51
|
+
sheets.forEach((c, i) => {
|
|
52
|
+
c.classList.toggle('is-open', i === current);
|
|
53
|
+
c.classList.toggle('is-before', i < current); // already behind it — tuck under the next strip and get covered…
|
|
54
|
+
c.classList.toggle('is-after', i > current); // in front of it — these leave below
|
|
55
|
+
c.style.setProperty('--stagger', String(i < current ? current - 1 - i : 0)); // …nearest one first
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (prev != null && prev !== current) {
|
|
60
|
+
// beat 1: the after-pack comes back in while the opened sheet drops into its slot, over the still-covered before-pack
|
|
61
|
+
sheets.forEach((c) => c.classList.remove('is-open', 'is-after'));
|
|
62
|
+
// beat 2: then the before-pack slides back up from behind it, the same order it tucked away
|
|
63
|
+
untuck.current = setTimeout(() => { untuck.current = null; if (ref.current)
|
|
64
|
+
sheetsOf(ref.current).forEach((c) => c.classList.remove('is-before')); }, PHASE);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!untuck.current)
|
|
68
|
+
sheets.forEach((c) => c.classList.remove('is-open', 'is-before', 'is-after'));
|
|
69
|
+
});
|
|
70
|
+
const click = (e) => {
|
|
71
|
+
onClick?.(e);
|
|
72
|
+
const stack = ref.current;
|
|
73
|
+
if (!stack || mode !== 'tap')
|
|
74
|
+
return;
|
|
75
|
+
const target = e.target;
|
|
76
|
+
const sheets = sheetsOf(stack);
|
|
77
|
+
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
|
|
78
|
+
if (target.closest('.card__back')) {
|
|
79
|
+
request(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (at >= 0 && current == null)
|
|
83
|
+
request(at);
|
|
84
|
+
};
|
|
85
|
+
return (_jsxs("div", { ref: ref, onClick: click, 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 })] }));
|
|
14
86
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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.",
|