@usableapp/cardds 0.7.36 → 0.7.37
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 +10 -0
- package/README.md +15 -0
- package/css/actions.css +4 -0
- package/dist/actions/Btn.d.ts +1 -1
- package/dist/actions/IconBtnGroup.d.ts +5 -2
- package/dist/actions/IconBtnGroup.js +4 -3
- package/dist/cardds.css +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lists/Row.d.ts +4 -2
- package/dist/lists/Row.js +46 -5
- package/dist/scaffold/LayerHistory.d.ts +53 -0
- package/dist/scaffold/LayerHistory.js +94 -0
- package/dist/scaffold/slideScreen.js +14 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
> For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap.
|
|
4
4
|
|
|
5
|
+
## 0.7.37 — 2026-10-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **`slideScreen` held nothing for a `ready={false}` screen under a router that renders a tick after `navigate()`** (react-router 7; found by timebank, 2026-10-01): any mutation in the page — a popover closing — ended the wait while the old screen was still the one there, so the new screen came after the transition, blank, then drawn, with no slide. Now the wait ends only when the SCREEN changed (another element, or the same one with new children), then a pending screen is held as 0.7.36 said. `ready` on a data screen works as described from this version.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`IconBtnGroup vertical`** (Lh 2026-10-01, asked by mailpool): the buttons one over the other, the panel one button wide (`--tap`), each button at least a tap tall, the rule between them across. For a `Row`'s `end` — a panel across took almost half the row and the title wrapped. `select` and `aria-pressed` as before.
|
|
12
|
+
- **`Rows` slides its rows when the app reorders them** (Lh 2026-10-01): the same keyed rows rendered in another order move to their new places (`--motion-sheet`), the one going up passing over the others; before, they jumped. Nothing to set; reduced motion = no slide. Story `IconBtnGroup · VerticalInARow` (up / down commands at a row's end).
|
|
13
|
+
- **The back stack: `useLayer` · `useLayers` · `LayerHistory`** (Lh 2026-10-01; the formula proven in timebank first). Back takes ONE thing off: a screen, or a LAYER — a whole bottom sheet, a modal. Before, each project wired this by hand (an open sheet in the query made the whole screen slide on back; in `useState`, back left the page). Now: wrap the screens once in `<LayerHistory state push replace back>` (your router's entry state and three moves — README has the react-router lines), then `const way = useLayer('way')`: `value` (null = not up) · `open(value)` (one entry; already up = the same entry, new value) · `close()` (the part's own close) · `arrive(value)` (a link straight to an open sheet). `useLayers()`: `up` · `closeAll(then)`. Nothing inside a layer takes an entry. The state key is `layers` (`{ [name]: { value, pushed } }`), other keys of the entry are kept. Rule added with it: a finished process is not gone back into — move on by `replace`, and a page that cannot be returned to checks on arrival (`Screen ready={false}`) and replaces itself.
|
|
14
|
+
|
|
5
15
|
## 0.7.36 — 2026-10-01
|
|
6
16
|
|
|
7
17
|
### Added
|
package/README.md
CHANGED
|
@@ -360,6 +360,21 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
|
|
|
360
360
|
is drawn; arrived at through `slideScreen` / the back button, the screen before stays until it is ready (at most 3 s), then
|
|
361
361
|
the right one slides in. Never one outcome first and another after (story `Step · ArrivesReady`). On a first load there is
|
|
362
362
|
no screen before: hold the `Splash` (`done`) until the first screen is known.
|
|
363
|
+
THE BACK STACK (Lh 2026-10-01): back takes ONE thing off — a screen (a route), or a LAYER: what lies over a screen, a whole
|
|
364
|
+
bottom sheet or a modal. A layer is one history entry, kept in the entry's STATE (the address stays the page's own, so the
|
|
365
|
+
screen behind does not slide): `const way = useLayer('way')` → `way.value` (null = not up) · `way.open(value)` (one entry;
|
|
366
|
+
already up = the same entry shows the new value) · `way.close()` (the part's own close: the handle, the scrim, a button —
|
|
367
|
+
back one entry, or, when it came with the page, the entry without it) · `way.arrive(value)` (a link straight to an open
|
|
368
|
+
sheet: read `?way=` once, no entry added, the query dropped). `useLayers().closeAll(then)` takes every layer off, then moves
|
|
369
|
+
(signing out). NOTHING INSIDE A LAYER TAKES AN ENTRY — a step, typed text, a sheet's rest, a tab: back at any step takes the
|
|
370
|
+
whole sheet down, and opened again it starts anew (a new `key` per opening). The project hands its router over once:
|
|
371
|
+
`<LayerHistory state push replace back>` around its screens — react-router: `state = useLocation().state`,
|
|
372
|
+
`push = (s) => navigate({ pathname }, { state: s })`, `replace = (s) => navigate({ pathname }, { replace: true, state: s })`,
|
|
373
|
+
`back = (n) => navigate(-n)`. cardds never calls the History API itself. Story `LayerHistory · Back`.
|
|
374
|
+
A FINISHED PROCESS IS NOT GONE BACK INTO: history cannot be deleted, so (a) inside a process every move on is a `replace`,
|
|
375
|
+
never a push — when it ends, back skips all of it; (b) a page that can no longer be returned to guards itself: it checks on
|
|
376
|
+
arrival (`Screen ready={false}` meanwhile) and, when its work is already done, replaces itself with the right screen — that
|
|
377
|
+
covers forward, an old link, and entries left ahead after `closeAll`.
|
|
363
378
|
- *Sheets the app calls:* the base offers WAYS IN — sign in · new phone · join — as buttons (`.cta-pack`); each
|
|
364
379
|
way has a sheet that holds its WHOLE form and its own submit on its foot, starting `.sheet--away` (not on the
|
|
365
380
|
screen: slid out, hidden once gone, `inert`). **The wiring is the app's, not cardds's**: a `useState` of which
|
package/css/actions.css
CHANGED
|
@@ -108,6 +108,10 @@
|
|
|
108
108
|
.icon-btn-group > .icon-btn { flex: 1 1 0; min-width: 0; width: auto; height: auto; border: 0; border-radius: 0; background: transparent; color: inherit; box-shadow: none; }
|
|
109
109
|
.icon-btn-group > .icon-btn + .icon-btn { border-inline-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
|
|
110
110
|
.card__foot > .icon-btn-group { flex: 1 1 0; }
|
|
111
|
+
/* --vertical: the buttons one over the other, ONE button wide — at a row's end, where a panel across would take the words' room */
|
|
112
|
+
.icon-btn-group--vertical { flex-direction: column; flex: none; width: var(--tap); }
|
|
113
|
+
.icon-btn-group--vertical > .icon-btn { flex: 1 0 auto; min-height: var(--tap); }
|
|
114
|
+
.icon-btn-group--vertical > .icon-btn + .icon-btn { border-inline-start: 0; border-block-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
|
|
111
115
|
.icon-btn-group > .icon-btn[aria-pressed="true"] { background: var(--icon-btn-group-on-bg, var(--accent)); color: var(--icon-btn-group-on-ink, var(--on-accent)); } /* `select`: a pressed button STAYS filled (Lh 2026-09-29) */
|
|
112
116
|
.icon-btn-group > .icon-btn:active:not(:disabled) { scale: 1; opacity: var(--press-dim); } /* a part of one frame DIMS, it never shrinks inside it */
|
|
113
117
|
|
package/dist/actions/Btn.d.ts
CHANGED
|
@@ -302,12 +302,12 @@ export declare function Btn({ primary, quiet, block, form, xl, href, className,
|
|
|
302
302
|
is?: string | undefined | undefined;
|
|
303
303
|
exportparts?: string | undefined | undefined;
|
|
304
304
|
disabled?: boolean | undefined | undefined;
|
|
305
|
+
value?: string | number | readonly string[] | undefined;
|
|
305
306
|
formAction?: string | ((formData: FormData) => void | Promise<void>) | undefined;
|
|
306
307
|
formEncType?: string | undefined | undefined;
|
|
307
308
|
formMethod?: string | undefined | undefined;
|
|
308
309
|
formNoValidate?: boolean | undefined | undefined;
|
|
309
310
|
formTarget?: string | undefined | undefined;
|
|
310
|
-
value?: string | number | readonly string[] | undefined;
|
|
311
311
|
href: string | undefined;
|
|
312
312
|
type: "reset" | "submit" | "button" | undefined;
|
|
313
313
|
className: string;
|
|
@@ -11,9 +11,12 @@ export interface IconBtnGroupProps extends Omit<ComponentProps<'div'>, 'onChange
|
|
|
11
11
|
defaultValue?: Picked;
|
|
12
12
|
/** asks the app: `one` → the pressed value · `many` → the new list */
|
|
13
13
|
onChange?: (value: Picked) => void;
|
|
14
|
+
/** the buttons one OVER the other, the panel one button wide — at a `Row`'s `end`, where a panel across would take the words' room */
|
|
15
|
+
vertical?: boolean;
|
|
14
16
|
}
|
|
15
17
|
/** IconBtnGroup — a panel of up to FOUR icon-only buttons in ONE frame, sharing its width, a rule between them in the frame's
|
|
16
18
|
* colour (Lh 2026-09-29). Each is an `IconBtn` (its `label` is its spoken name). Plain: each does its own thing on its onClick.
|
|
17
|
-
* With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot
|
|
18
|
-
|
|
19
|
+
* With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot;
|
|
20
|
+
* `vertical` at a row's end. */
|
|
21
|
+
export declare function IconBtnGroup({ label, select, value, defaultValue, onChange, vertical, className, children, ...rest }: IconBtnGroupProps): import("react").JSX.Element;
|
|
19
22
|
export {};
|
|
@@ -4,8 +4,9 @@ import { cx } from '../cx.js';
|
|
|
4
4
|
import { useControlled, warnOnce } from '../state.js';
|
|
5
5
|
/** IconBtnGroup — a panel of up to FOUR icon-only buttons in ONE frame, sharing its width, a rule between them in the frame's
|
|
6
6
|
* colour (Lh 2026-09-29). Each is an `IconBtn` (its `label` is its spoken name). Plain: each does its own thing on its onClick.
|
|
7
|
-
* With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot
|
|
8
|
-
|
|
7
|
+
* With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot;
|
|
8
|
+
* `vertical` at a row's end. */
|
|
9
|
+
export function IconBtnGroup({ label, select, value, defaultValue, onChange, vertical, className, children, ...rest }) {
|
|
9
10
|
if (Children.count(children) > 4)
|
|
10
11
|
warnOnce('icon-btn-group', 'cardds IconBtnGroup: more than 4 buttons — keep 4, put the rest in a Menu behind one of them');
|
|
11
12
|
const [shown, request] = useControlled(value, defaultValue ?? (select === 'many' ? [] : null), onChange);
|
|
@@ -30,5 +31,5 @@ export function IconBtnGroup({ label, select, value, defaultValue, onChange, cla
|
|
|
30
31
|
},
|
|
31
32
|
});
|
|
32
33
|
});
|
|
33
|
-
return _jsx("div", { role: "group", "aria-label": label, className: cx('icon-btn-group', className), ...rest, children: items });
|
|
34
|
+
return _jsx("div", { role: "group", "aria-label": label, className: cx('icon-btn-group', vertical && 'icon-btn-group--vertical', className), ...rest, children: items });
|
|
34
35
|
}
|
package/dist/cardds.css
CHANGED
|
@@ -2140,6 +2140,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
2140
2140
|
.icon-btn-group > .icon-btn { flex: 1 1 0; min-width: 0; width: auto; height: auto; border: 0; border-radius: 0; background: transparent; color: inherit; box-shadow: none; }
|
|
2141
2141
|
.icon-btn-group > .icon-btn + .icon-btn { border-inline-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
|
|
2142
2142
|
.card__foot > .icon-btn-group { flex: 1 1 0; }
|
|
2143
|
+
/* --vertical: the buttons one over the other, ONE button wide — at a row's end, where a panel across would take the words' room */
|
|
2144
|
+
.icon-btn-group--vertical { flex-direction: column; flex: none; width: var(--tap); }
|
|
2145
|
+
.icon-btn-group--vertical > .icon-btn { flex: 1 0 auto; min-height: var(--tap); }
|
|
2146
|
+
.icon-btn-group--vertical > .icon-btn + .icon-btn { border-inline-start: 0; border-block-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
|
|
2143
2147
|
.icon-btn-group > .icon-btn[aria-pressed="true"] { background: var(--icon-btn-group-on-bg, var(--accent)); color: var(--icon-btn-group-on-ink, var(--on-accent)); } /* `select`: a pressed button STAYS filled (Lh 2026-09-29) */
|
|
2144
2148
|
.icon-btn-group > .icon-btn:active:not(:disabled) { scale: 1; opacity: var(--press-dim); } /* a part of one frame DIMS, it never shrinks inside it */
|
|
2145
2149
|
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from './scaffold/Page.js';
|
|
|
17
17
|
export * from './scaffold/EmptyState.js';
|
|
18
18
|
export * from './scaffold/Centre.js';
|
|
19
19
|
export * from './scaffold/slideScreen.js';
|
|
20
|
+
export * from './scaffold/LayerHistory.js';
|
|
20
21
|
export * from './cards/Card.js';
|
|
21
22
|
export * from './cards/CardHead.js';
|
|
22
23
|
export * from './cards/CardFoot.js';
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export * from './scaffold/Page.js';
|
|
|
21
21
|
export * from './scaffold/EmptyState.js';
|
|
22
22
|
export * from './scaffold/Centre.js';
|
|
23
23
|
export * from './scaffold/slideScreen.js';
|
|
24
|
+
export * from './scaffold/LayerHistory.js';
|
|
24
25
|
export * from './cards/Card.js';
|
|
25
26
|
export * from './cards/CardHead.js';
|
|
26
27
|
export * from './cards/CardFoot.js';
|
package/dist/lists/Row.d.ts
CHANGED
|
@@ -45,5 +45,7 @@ export interface RowsProps extends ComponentProps<'div'> {
|
|
|
45
45
|
* Words = the small label; an ELEMENT (`<Text level="title" as="h3">`) is used as the heading itself — the group is labelled by it. */
|
|
46
46
|
heading?: ReactNode;
|
|
47
47
|
}
|
|
48
|
-
/** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it).
|
|
49
|
-
|
|
48
|
+
/** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it).
|
|
49
|
+
* REORDERED (the app renders the same keyed rows in another order): each row SLIDES to its new place — the one going up passes
|
|
50
|
+
* over the others — instead of jumping (Lh 2026-10-01). The order is the app's; the move is the list's. */
|
|
51
|
+
export declare function Rows({ heading, className, children, ref, ...rest }: RowsProps): import("react").JSX.Element;
|
package/dist/lists/Row.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { cloneElement, createElement, isValidElement, useId } from 'react';
|
|
2
|
+
import { cloneElement, createElement, isValidElement, useId, useLayoutEffect, useRef } from 'react';
|
|
3
3
|
import { cx } from '../cx.js';
|
|
4
4
|
import { Icon } from '../type/Icon.js';
|
|
5
|
-
import { useDisclosure } from '../state.js';
|
|
5
|
+
import { reducedMotion, useDisclosure } from '../state.js';
|
|
6
6
|
/**
|
|
7
7
|
* Row — a frameless row inside a card: lead · title + meta · end. 48px+ tall, a tap target.
|
|
8
8
|
* Several go in Rows (hairlines between). A row that IS a card is `Card row`.
|
|
@@ -18,9 +18,50 @@ export function Row({ lead, leadIcon, title, meta, body, end, endValue, endCapti
|
|
|
18
18
|
return row;
|
|
19
19
|
return (_jsxs("details", { className: "row-expand", ...rest, ...disclosure, children: [row, _jsx("div", { className: "row-expand__more", children: more })] }));
|
|
20
20
|
}
|
|
21
|
-
/** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it).
|
|
22
|
-
|
|
21
|
+
/** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it).
|
|
22
|
+
* REORDERED (the app renders the same keyed rows in another order): each row SLIDES to its new place — the one going up passes
|
|
23
|
+
* over the others — instead of jumping (Lh 2026-10-01). The order is the app's; the move is the list's. */
|
|
24
|
+
export function Rows({ heading, className, children, ref, ...rest }) {
|
|
23
25
|
const id = useId();
|
|
24
26
|
const named = heading != null;
|
|
25
|
-
|
|
27
|
+
const own = useRef(null);
|
|
28
|
+
const was = useRef(new Map());
|
|
29
|
+
const order = useRef([]);
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
const box = own.current;
|
|
32
|
+
if (!box)
|
|
33
|
+
return;
|
|
34
|
+
const rows = [...box.children].filter((el) => el.id !== id);
|
|
35
|
+
rows.forEach((el) => el.getAnimations().forEach((a) => { if (a.id === 'rows-move')
|
|
36
|
+
a.cancel(); })); // read the resting places
|
|
37
|
+
const at = new Map(rows.map((el) => [el, el.offsetTop]));
|
|
38
|
+
const before = order.current, moved = before.length === rows.length && rows.some((el, i) => el !== before[i]) && rows.every((el) => was.current.has(el));
|
|
39
|
+
if (moved && !reducedMotion()) {
|
|
40
|
+
const ms = (parseFloat(getComputedStyle(box).getPropertyValue('--motion-sheet')) || 0.35) * 1000;
|
|
41
|
+
for (const el of rows) {
|
|
42
|
+
const dy = (was.current.get(el) ?? 0) - (at.get(el) ?? 0);
|
|
43
|
+
if (!dy)
|
|
44
|
+
continue;
|
|
45
|
+
const up = dy > 0; // it goes UP: over the others, on the card's ground so they pass under it
|
|
46
|
+
const a = el.animate([{ translate: `0 ${dy}px` }, { translate: '0 0' }], { duration: ms, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' });
|
|
47
|
+
a.id = 'rows-move';
|
|
48
|
+
if (up) {
|
|
49
|
+
el.style.position = 'relative';
|
|
50
|
+
el.style.zIndex = '1';
|
|
51
|
+
const clear = () => { el.style.position = ''; el.style.zIndex = ''; };
|
|
52
|
+
a.finished.then(clear, clear);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
was.current = at;
|
|
57
|
+
order.current = rows;
|
|
58
|
+
});
|
|
59
|
+
const setRef = (node) => {
|
|
60
|
+
own.current = node;
|
|
61
|
+
if (typeof ref === 'function')
|
|
62
|
+
ref(node);
|
|
63
|
+
else if (ref)
|
|
64
|
+
ref.current = node;
|
|
65
|
+
};
|
|
66
|
+
return (_jsxs("div", { ref: setRef, className: cx('rows', className), role: named ? 'group' : undefined, "aria-labelledby": named ? id : undefined, ...rest, children: [named && (isValidElement(heading) ? cloneElement(heading, { id }) : _jsx("span", { className: "rows__head", id: id, children: heading })), children] }));
|
|
26
67
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export type LayerValue = string | number;
|
|
3
|
+
type EntryState = Record<string, unknown>;
|
|
4
|
+
export interface LayerHistoryProps {
|
|
5
|
+
/** the CURRENT history entry's state, as a value of this render — the hook re-reads it on every back / forward / push
|
|
6
|
+
* (react-router: `useLocation().state`) */
|
|
7
|
+
state: unknown;
|
|
8
|
+
/** a NEW entry for the same page with this state: the path kept, the query dropped (react-router:
|
|
9
|
+
* `navigate({ pathname }, { state })`) */
|
|
10
|
+
push: (state: EntryState) => void;
|
|
11
|
+
/** the SAME entry, its state replaced: the path kept, the query dropped (`navigate({ pathname }, { replace: true, state })`) */
|
|
12
|
+
replace: (state: EntryState) => void;
|
|
13
|
+
/** back by this many entries (`navigate(-entries)`) */
|
|
14
|
+
back: (entries: number) => void;
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* LayerHistory — the project's router, handed to cardds ONCE, around the app's screens (inside the router): the entry's `state`
|
|
19
|
+
* and the three moves `push` · `replace` · `back`. `useLayer` / `useLayers` stand on it. cardds never touches the History API
|
|
20
|
+
* itself — a router would not see it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function LayerHistory({ state, push, replace, back, children }: LayerHistoryProps): import("react").JSX.Element;
|
|
23
|
+
export interface LayerApi {
|
|
24
|
+
/** what the layer shows while it is up (which way's sheet, which question); `null` = not up */
|
|
25
|
+
value: LayerValue | null;
|
|
26
|
+
/** UP: one entry of its own. Already up → the new value on the entry it has, never a second entry (one layer, one back) —
|
|
27
|
+
* so `open` called from INSIDE the layer (a step that turns this sheet into another way's) is a replace, not a push. */
|
|
28
|
+
open: (value: LayerValue) => void;
|
|
29
|
+
/** the PART asked to close (a handle to away, `onDismiss`, a button): back one entry when the app put one there — else (it
|
|
30
|
+
* came with the page) the entry it is on, without it. Nothing when it is not up: the back button already took it. */
|
|
31
|
+
close: () => void;
|
|
32
|
+
/** it CAME WITH THE PAGE (a link straight to an open sheet, `?way=`): read the address once, call this — no entry is added,
|
|
33
|
+
* and the query is dropped. Closing it then stays on the page. */
|
|
34
|
+
arrive: (value: LayerValue) => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* useLayer — ONE layer of this screen, by its name: a whole bottom sheet, a modal. Back takes it off; the screen stays.
|
|
38
|
+
* `const way = useLayer('way')` → `<CallSheet open={way.value} onOpenChange={(up, i) => (up ? way.open(i!) : way.close())}>`.
|
|
39
|
+
* A Modal: `open={ask.value !== null}` + the app's own `mounted` flag cleared in `onClosed`. Opened again a layer starts anew —
|
|
40
|
+
* give what is inside a new `key` per opening.
|
|
41
|
+
*/
|
|
42
|
+
export declare function useLayer(name: string): LayerApi;
|
|
43
|
+
/**
|
|
44
|
+
* useLayers — every layer over this screen: `up` (how many), and `closeAll(then)` — take them ALL off, then run `then`: before a
|
|
45
|
+
* move that must leave nothing of this page's layers under it (signed out → replace with the sign-in page). The entries are
|
|
46
|
+
* counted when the move is MADE, not when it was asked (the member may press back while a call runs), and `then` runs once the
|
|
47
|
+
* history shows them gone.
|
|
48
|
+
*/
|
|
49
|
+
export declare function useLayers(): {
|
|
50
|
+
up: number;
|
|
51
|
+
closeAll: (then: () => void) => void;
|
|
52
|
+
};
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import { warnOnce } from '../state.js';
|
|
4
|
+
const Ctx = createContext(null);
|
|
5
|
+
/**
|
|
6
|
+
* LayerHistory — the project's router, handed to cardds ONCE, around the app's screens (inside the router): the entry's `state`
|
|
7
|
+
* and the three moves `push` · `replace` · `back`. `useLayer` / `useLayers` stand on it. cardds never touches the History API
|
|
8
|
+
* itself — a router would not see it.
|
|
9
|
+
*/
|
|
10
|
+
export function LayerHistory({ state, push, replace, back, children }) {
|
|
11
|
+
const value = useMemo(() => ({ state, push, replace, back }), [state, push, replace, back]);
|
|
12
|
+
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
13
|
+
}
|
|
14
|
+
/* the state is untyped — an old entry, another library's keys: read only what has the shape, keep the rest as it is */
|
|
15
|
+
const read = (state) => {
|
|
16
|
+
if (typeof state !== 'object' || state === null || Array.isArray(state))
|
|
17
|
+
return { rest: {}, layers: {} };
|
|
18
|
+
const { layers: raw, ...rest } = state;
|
|
19
|
+
const layers = {};
|
|
20
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
21
|
+
for (const [name, l] of Object.entries(raw)) {
|
|
22
|
+
const v = l?.value;
|
|
23
|
+
if (typeof v === 'string' || typeof v === 'number')
|
|
24
|
+
layers[name] = { value: v, pushed: l.pushed === true };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { rest, layers };
|
|
28
|
+
};
|
|
29
|
+
const NONE = { state: null, push: () => undefined, replace: () => undefined, back: () => undefined };
|
|
30
|
+
const useHistory = () => {
|
|
31
|
+
const h = useContext(Ctx);
|
|
32
|
+
if (!h)
|
|
33
|
+
warnOnce('layer-history', 'cardds useLayer: no <LayerHistory> above — hand the router over once, around the screens; until then a layer never comes up.');
|
|
34
|
+
return h ?? NONE;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* useLayer — ONE layer of this screen, by its name: a whole bottom sheet, a modal. Back takes it off; the screen stays.
|
|
38
|
+
* `const way = useLayer('way')` → `<CallSheet open={way.value} onOpenChange={(up, i) => (up ? way.open(i!) : way.close())}>`.
|
|
39
|
+
* A Modal: `open={ask.value !== null}` + the app's own `mounted` flag cleared in `onClosed`. Opened again a layer starts anew —
|
|
40
|
+
* give what is inside a new `key` per opening.
|
|
41
|
+
*/
|
|
42
|
+
export function useLayer(name) {
|
|
43
|
+
const { state, push, replace, back } = useHistory();
|
|
44
|
+
return useMemo(() => {
|
|
45
|
+
const { rest, layers } = read(state);
|
|
46
|
+
const layer = layers[name] ?? null;
|
|
47
|
+
const others = () => Object.fromEntries(Object.entries(layers).filter(([key]) => key !== name));
|
|
48
|
+
return {
|
|
49
|
+
value: layer?.value ?? null,
|
|
50
|
+
open: (to) => (layer ? replace({ ...rest, layers: { ...layers, [name]: { value: to, pushed: layer.pushed } } }) : push({ ...rest, layers: { ...layers, [name]: { value: to, pushed: true } } })),
|
|
51
|
+
// already up on an entry the app pushed (the link opened where the layer was): it stays a pushed one, or the close would leave that entry behind
|
|
52
|
+
arrive: (as) => replace({ ...rest, layers: { ...layers, [name]: { value: as, pushed: layer?.pushed ?? false } } }),
|
|
53
|
+
close: () => {
|
|
54
|
+
if (!layer)
|
|
55
|
+
return;
|
|
56
|
+
if (layer.pushed)
|
|
57
|
+
back(1);
|
|
58
|
+
else
|
|
59
|
+
replace({ ...rest, layers: others() });
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}, [state, push, replace, back, name]);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* useLayers — every layer over this screen: `up` (how many), and `closeAll(then)` — take them ALL off, then run `then`: before a
|
|
66
|
+
* move that must leave nothing of this page's layers under it (signed out → replace with the sign-in page). The entries are
|
|
67
|
+
* counted when the move is MADE, not when it was asked (the member may press back while a call runs), and `then` runs once the
|
|
68
|
+
* history shows them gone.
|
|
69
|
+
*/
|
|
70
|
+
export function useLayers() {
|
|
71
|
+
const { state, back } = useHistory();
|
|
72
|
+
const { layers } = read(state);
|
|
73
|
+
const up = Object.keys(layers).length;
|
|
74
|
+
const pushed = Object.values(layers).filter((l) => l.pushed).length;
|
|
75
|
+
const now = useRef(pushed);
|
|
76
|
+
const after = useRef(null);
|
|
77
|
+
const goBack = useRef(back);
|
|
78
|
+
goBack.current = back;
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
now.current = pushed;
|
|
81
|
+
if (pushed > 0 || !after.current)
|
|
82
|
+
return;
|
|
83
|
+
const then = after.current;
|
|
84
|
+
after.current = null;
|
|
85
|
+
then();
|
|
86
|
+
}, [pushed, up]);
|
|
87
|
+
const closeAll = useRef((then) => {
|
|
88
|
+
if (now.current === 0)
|
|
89
|
+
return then();
|
|
90
|
+
after.current = then;
|
|
91
|
+
goBack.current(now.current);
|
|
92
|
+
}).current;
|
|
93
|
+
return { up, closeAll };
|
|
94
|
+
}
|
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
old one out to the left, the new one in from the right; `back`: the reverse; `--motion-slide`, card.css). It is a view
|
|
3
3
|
transition named for the move only: the leaving `.screen` is `screen-out`, the arriving one `screen-in`. */
|
|
4
4
|
const screenIn = (scope) => scope.querySelector('.screen:not(.screen .screen)');
|
|
5
|
-
/** wait until the screen in `scope` CHANGED — another element, or
|
|
6
|
-
both routes render a Screen in the same place) — a route rendered, sync or not; at most `ms`.
|
|
7
|
-
|
|
5
|
+
/** wait until the screen in `scope` CHANGED — another element, or the same one with new children (React may keep the element
|
|
6
|
+
when both routes render a Screen in the same place) — a route rendered, sync or not; at most `ms`. Any OTHER mutation is not
|
|
7
|
+
the route (a router renders a tick later: a popover closing meanwhile must not end the wait — timebank, 2026-10-01). A new
|
|
8
|
+
Screen that is not `ready` yet (`data-pending`) is waited for too, at most `hold`: the old screen stays until the new one
|
|
9
|
+
knows what it is. */
|
|
8
10
|
function newScreen(scope, old, ms = 800, hold = 3000) {
|
|
9
11
|
return new Promise((done) => {
|
|
10
|
-
const pending = (el) => !!el?.hasAttribute('data-pending');
|
|
11
|
-
const now = screenIn(scope);
|
|
12
|
-
if (now !== old && !pending(now))
|
|
13
|
-
return done(now);
|
|
14
12
|
const root = scope instanceof Document ? scope.documentElement : scope;
|
|
13
|
+
let changed = false, held = false;
|
|
15
14
|
const end = () => { mo.disconnect(); clearTimeout(t); done(screenIn(scope)); };
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
15
|
+
const look = (records = []) => {
|
|
16
|
+
const now = screenIn(scope);
|
|
17
|
+
if (now !== old || records.some((r) => r.target === old))
|
|
18
|
+
changed = true; // its own children / its own data-pending
|
|
19
|
+
if (!changed)
|
|
20
|
+
return;
|
|
21
|
+
if (!now?.hasAttribute('data-pending'))
|
|
19
22
|
return end();
|
|
20
23
|
if (!held) {
|
|
21
24
|
held = true;
|
|
@@ -26,8 +29,7 @@ function newScreen(scope, old, ms = 800, hold = 3000) {
|
|
|
26
29
|
const mo = new MutationObserver(look);
|
|
27
30
|
mo.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-pending'] });
|
|
28
31
|
let t = setTimeout(end, ms);
|
|
29
|
-
|
|
30
|
-
look();
|
|
32
|
+
look();
|
|
31
33
|
});
|
|
32
34
|
}
|
|
33
35
|
let running = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.37",
|
|
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.",
|