@usableapp/cardds 0.7.29 → 0.7.31
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 +14 -0
- package/README.md +4 -0
- package/css/layover.css +1 -0
- package/dist/actions/Btn.d.ts +4 -4
- package/dist/actions/IconBtn.d.ts +1 -1
- package/dist/cardds.css +1 -0
- package/dist/media/Tile.d.ts +2 -2
- package/dist/sheets/Drawer.js +12 -2
- package/dist/sheets/Sheet.js +15 -23
- package/dist/sheets/SheetStack.js +9 -2
- package/dist/sheets/SheetSteps.js +7 -10
- package/dist/state.d.ts +10 -0
- package/dist/state.js +33 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.31 — 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- **One rule, one mechanic: a part that becomes active has its field waiting with the cursor** (Lh 2026-09-29). Every component that becomes active now uses the same helper inside cardds (`focusWhenActive`): once the part has settled, the cursor goes to the field marked `data-autofocus`, else its first field, else its heading; a field that already has the cursor keeps it.
|
|
9
|
+
- `Sheet` (as 0.7.30) and `SheetSteps` (as before) — now on the one mechanic.
|
|
10
|
+
- `SheetStack` (so `Home`): a sheet opened → the first field in its body has the cursor after the slide. New. A section with no field: nothing moves.
|
|
11
|
+
- `Modal`: arrived → the cursor is in it (its field, else the `Drawer`'s question — no ring), not left on the button behind the scrim. New.
|
|
12
|
+
Nothing to change in a screen: remove any focus code of your own for these parts.
|
|
13
|
+
|
|
14
|
+
## 0.7.30 — 2026-09-29
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- `Sheet`: **the sheet comes up → its field has the cursor, always** (Lh 2026-09-29 — a habit of the Sheet, not of a screen). Whenever it rises — onto the screen (mounted up, or from `away`), or from peek to any open state, by a button, the handle, a drag or the app — once it has finished sliding the cursor goes to the field marked `data-autofocus` (SheetSteps marks its open step's first field), else the first field in its `SheetBody`. With no field, a sheet that came onto the screen gives it to its handle. A field that already has the cursor keeps it. Before: only an arrival from `away` (0.7.29: and a raise from peek) moved it, and only to a marked field. Nothing to change in a screen — remove any focus code of your own for a sheet's field.
|
|
18
|
+
|
|
5
19
|
## 0.7.29 — 2026-09-29
|
|
6
20
|
|
|
7
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -302,6 +302,10 @@ whose markup has rules, because the sheet's box is the step itself:
|
|
|
302
302
|
A sheet always ARRIVES and LEAVES by sliding: mounted already up, it starts `away` for one frame and slides in. To
|
|
303
303
|
take it off the screen set `state="away"` and unmount it in `onSettled` (`s === 'away'`) — unmounting it while it is up
|
|
304
304
|
only makes it vanish (dev warning). Story: `Sheet · MountedOnDemand`.
|
|
305
|
+
- **A part that becomes active has its field waiting with the cursor** (Lh 2026-09-29) — a `Sheet` come up, a `SheetStep`
|
|
306
|
+
opened, a `SheetStack` sheet opened, a `Modal` arrived. Once it has settled: the field marked `data-autofocus`, else its
|
|
307
|
+
first field, else its heading (a step's title, a modal's question) or, for a sheet come onto the screen, its handle. A field
|
|
308
|
+
that already has the cursor keeps it. One mechanic for all of them (`focusWhenActive`, src/state.ts); an app writes no focus code.
|
|
305
309
|
|
|
306
310
|
## Centred card markup
|
|
307
311
|
|
package/css/layover.css
CHANGED
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
/* the CONFIRM: a Modal with no Lift — the drawer IS the card, as tall as its words (no fixed band, no second beat) */
|
|
83
83
|
.modal:not(:has(> .modal__lift)) > .modal__drawer { height: auto; padding-block: var(--modal-confirm-pad-y, var(--sp-6)); animation: none; }
|
|
84
84
|
.modal__drawer .t-caption { opacity: var(--dim); }
|
|
85
|
+
.modal__drawer > h2:focus { outline: none; } /* the question takes the cursor when the modal arrives (for a screen reader, the keyboard's way in) — it is not a control: no ring */
|
|
85
86
|
.modal__drawer .icon-btn { background: transparent; color: inherit; border-color: var(--ink-rail); }
|
|
86
87
|
.modal__drawer .icon-btn--invert { background: var(--accent); color: var(--on-accent); border-color: transparent; width: var(--tap-lg); height: var(--tap-lg); }
|
|
87
88
|
@media (prefers-reduced-motion: no-preference) {
|
package/dist/actions/Btn.d.ts
CHANGED
|
@@ -19,11 +19,13 @@ export interface BtnProps extends Omit<ComponentProps<'button'>, 'form'> {
|
|
|
19
19
|
* In a CardFoot up to four share the line; in an ActionBar it takes the width. An Icon xs may lead or trail the label.
|
|
20
20
|
*/
|
|
21
21
|
export declare function Btn({ primary, quiet, block, form, xl, href, className, type, ...rest }: BtnProps): import("react").DetailedReactHTMLElement<{
|
|
22
|
+
translate?: "yes" | "no" | undefined | undefined;
|
|
22
23
|
onChange?: import("react").ChangeEventHandler<HTMLButtonElement, Element> | undefined;
|
|
23
|
-
name?: string | undefined | undefined;
|
|
24
24
|
slot?: string | undefined | undefined;
|
|
25
25
|
style?: import("react").CSSProperties | undefined;
|
|
26
26
|
title?: string | undefined | undefined;
|
|
27
|
+
dir?: string | undefined | undefined;
|
|
28
|
+
name?: string | undefined | undefined;
|
|
27
29
|
children?: import("react").ReactNode;
|
|
28
30
|
suppressHydrationWarning?: boolean | undefined | undefined;
|
|
29
31
|
color?: string | undefined | undefined;
|
|
@@ -266,12 +268,10 @@ export declare function Btn({ primary, quiet, block, form, xl, href, className,
|
|
|
266
268
|
autoFocus?: boolean | undefined | undefined;
|
|
267
269
|
contentEditable?: (boolean | "true" | "false") | "inherit" | "plaintext-only" | undefined;
|
|
268
270
|
contextMenu?: string | undefined | undefined;
|
|
269
|
-
dir?: string | undefined | undefined;
|
|
270
271
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
271
272
|
enterKeyHint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
272
273
|
hidden?: boolean | undefined | undefined;
|
|
273
274
|
spellCheck?: (boolean | "true" | "false") | undefined;
|
|
274
|
-
translate?: "yes" | "no" | undefined | undefined;
|
|
275
275
|
radioGroup?: string | undefined | undefined;
|
|
276
276
|
about?: string | undefined | undefined;
|
|
277
277
|
content?: string | undefined | undefined;
|
|
@@ -309,6 +309,6 @@ export declare function Btn({ primary, quiet, block, form, xl, href, className,
|
|
|
309
309
|
formTarget?: string | undefined | undefined;
|
|
310
310
|
value?: string | number | readonly string[] | undefined;
|
|
311
311
|
href: string | undefined;
|
|
312
|
-
type: "
|
|
312
|
+
type: "reset" | "submit" | "button" | undefined;
|
|
313
313
|
className: string;
|
|
314
314
|
}, HTMLButtonElement>;
|
|
@@ -306,6 +306,6 @@ export declare function IconBtn({ label, icon, sm, invert, href, className, type
|
|
|
306
306
|
onTransitionStart?: import("react").TransitionEventHandler<HTMLButtonElement> | undefined;
|
|
307
307
|
onTransitionStartCapture?: import("react").TransitionEventHandler<HTMLButtonElement> | undefined;
|
|
308
308
|
href: string | undefined;
|
|
309
|
-
type: "
|
|
309
|
+
type: "reset" | "submit" | "button" | undefined;
|
|
310
310
|
className: string;
|
|
311
311
|
}, HTMLButtonElement>;
|
package/dist/cardds.css
CHANGED
|
@@ -3652,6 +3652,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3652
3652
|
/* the CONFIRM: a Modal with no Lift — the drawer IS the card, as tall as its words (no fixed band, no second beat) */
|
|
3653
3653
|
.modal:not(:has(> .modal__lift)) > .modal__drawer { height: auto; padding-block: var(--modal-confirm-pad-y, var(--sp-6)); animation: none; }
|
|
3654
3654
|
.modal__drawer .t-caption { opacity: var(--dim); }
|
|
3655
|
+
.modal__drawer > h2:focus { outline: none; } /* the question takes the cursor when the modal arrives (for a screen reader, the keyboard's way in) — it is not a control: no ring */
|
|
3655
3656
|
.modal__drawer .icon-btn { background: transparent; color: inherit; border-color: var(--ink-rail); }
|
|
3656
3657
|
.modal__drawer .icon-btn--invert { background: var(--accent); color: var(--on-accent); border-color: transparent; width: var(--tap-lg); height: var(--tap-lg); }
|
|
3657
3658
|
@media (prefers-reduced-motion: no-preference) {
|
package/dist/media/Tile.d.ts
CHANGED
|
@@ -38,9 +38,11 @@ export interface TileProps extends Omit<ComponentProps<'div'>, 'onChange'> {
|
|
|
38
38
|
}
|
|
39
39
|
/** Tile — a big square: icon top, label bottom; `pick` toggles, `stat` shows a value, `upload` is the dashed slot. */
|
|
40
40
|
export declare function Tile({ icon, label, value, sub, pick, type, name, checked, defaultChecked, onChange, on, stat, upload, wide, tone, as, className, children, ...rest }: TileProps): import("react").DetailedReactHTMLElement<{
|
|
41
|
+
translate?: "yes" | "no" | undefined | undefined;
|
|
41
42
|
slot?: string | undefined | undefined;
|
|
42
43
|
style?: import("react").CSSProperties | undefined;
|
|
43
44
|
title?: string | undefined | undefined;
|
|
45
|
+
dir?: string | undefined | undefined;
|
|
44
46
|
suppressHydrationWarning?: boolean | undefined | undefined;
|
|
45
47
|
color?: string | undefined | undefined;
|
|
46
48
|
id?: string | undefined | undefined;
|
|
@@ -281,12 +283,10 @@ export declare function Tile({ icon, label, value, sub, pick, type, name, checke
|
|
|
281
283
|
autoFocus?: boolean | undefined | undefined;
|
|
282
284
|
contentEditable?: (boolean | "true" | "false") | "inherit" | "plaintext-only" | undefined;
|
|
283
285
|
contextMenu?: string | undefined | undefined;
|
|
284
|
-
dir?: string | undefined | undefined;
|
|
285
286
|
draggable?: (boolean | "true" | "false") | undefined;
|
|
286
287
|
enterKeyHint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
|
|
287
288
|
hidden?: boolean | undefined | undefined;
|
|
288
289
|
spellCheck?: (boolean | "true" | "false") | undefined;
|
|
289
|
-
translate?: "yes" | "no" | undefined | undefined;
|
|
290
290
|
radioGroup?: string | undefined | undefined;
|
|
291
291
|
about?: string | undefined | undefined;
|
|
292
292
|
content?: string | undefined | undefined;
|
package/dist/sheets/Drawer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Children, Fragment, useEffect, useRef } from 'react';
|
|
3
|
-
import { useLatest } from '../state.js';
|
|
3
|
+
import { focusWhenActive, useLatest } from '../state.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
/**
|
|
6
6
|
* Modal — the ask: ONE card at the centre of a `SheetStage dim`, two parts — a `Lift` (the thing being asked about, light, scrolls
|
|
@@ -28,6 +28,16 @@ export function Modal({ open = true, onClosed, onDismiss, className, ...rest })
|
|
|
28
28
|
closed.current?.(); });
|
|
29
29
|
return () => { live = false; };
|
|
30
30
|
}, [open]);
|
|
31
|
+
/* it has arrived → the cursor is in it, not left behind the scrim: its field if it holds one, else its question (the one rule of every
|
|
32
|
+
part that becomes active: focusWhenActive) — once the beats have played */
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const el = ref.current;
|
|
35
|
+
if (!el || !open)
|
|
36
|
+
return;
|
|
37
|
+
const beats = Math.max(0, ...el.getAnimations({ subtree: true }).map((a) => Number(a.effect?.getComputedTiming().endTime) || 0));
|
|
38
|
+
const cancel = focusWhenActive(el, { wait: beats, fallback: () => el.querySelector('.modal__drawer > h2') });
|
|
39
|
+
return () => { cancel(); };
|
|
40
|
+
}, [open]);
|
|
31
41
|
const dismiss = useLatest(onDismiss);
|
|
32
42
|
const has = onDismiss !== undefined;
|
|
33
43
|
useEffect(() => {
|
|
@@ -52,7 +62,7 @@ export function Lift({ className, ...rest }) {
|
|
|
52
62
|
* with the app's `onClick`; the Drawer does nothing with them. A `Modal` holding ONLY a Drawer (no `Lift`) is the CONFIRM: a question with nothing
|
|
53
63
|
* to show above it — the Drawer is the whole card and comes in with it in one beat. */
|
|
54
64
|
export function Drawer({ title, caption, light, className, children, ...rest }) {
|
|
55
|
-
return (_jsxs("div", { className: cx('modal__drawer', light && 'modal__drawer--light', className), ...rest, children: [_jsx("h2", { className: "t-h2", children: title }), caption != null && _jsx("p", { className: "t-caption", children: caption }), children] }));
|
|
65
|
+
return (_jsxs("div", { className: cx('modal__drawer', light && 'modal__drawer--light', className), ...rest, children: [_jsx("h2", { className: "t-h2", tabIndex: -1, children: title }), caption != null && _jsx("p", { className: "t-caption", children: caption }), children] }));
|
|
56
66
|
}
|
|
57
67
|
/** BtnTrio — three round buttons with dotted connectors between them (× · ? · ✓); the middle one inverted. */
|
|
58
68
|
export function BtnTrio({ className, children, ...rest }) {
|
package/dist/sheets/Sheet.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
-
import { useControlled, useLatest, warnOnce, watchSize } from '../state.js';
|
|
3
|
+
import { focusWhenActive, useControlled, useLatest, warnOnce, watchSize } from '../state.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
|
|
6
|
+
const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
|
|
6
7
|
const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
|
|
7
8
|
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
|
|
8
9
|
/**
|
|
@@ -26,7 +27,7 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
|
|
|
26
27
|
const ref = useRef(null);
|
|
27
28
|
const handleRef = useRef(null);
|
|
28
29
|
const home = useRef(current !== 'peek' && current !== 'away' ? current : 'half'); // where a tap from peek returns to
|
|
29
|
-
const was = useRef(
|
|
30
|
+
const was = useRef('away'); // mounted up = it came onto the screen: it rises from away like any arrival
|
|
30
31
|
const skipClick = useRef(false);
|
|
31
32
|
const [entered, setEntered] = useState(false); // false for the first frame: the class says `away`, so a sheet mounted up has somewhere to slide from
|
|
32
33
|
const shown = entered ? current : 'away';
|
|
@@ -77,31 +78,22 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
|
|
|
77
78
|
});
|
|
78
79
|
};
|
|
79
80
|
}, [latest]);
|
|
81
|
+
/* THE SHEET COMES UP → ITS FIELD HAS THE CURSOR, always (Lh 2026-09-29): whenever it rises — onto the screen (mounted up, or from `away`),
|
|
82
|
+
or from peek to any open state, by a button, the handle, a drag or the app — once it has finished sliding (Lh 2026-09-24: the keyboard
|
|
83
|
+
must not come up while it still moves) the cursor goes to the field marked `data-autofocus` (a SheetSteps marks its open step's first
|
|
84
|
+
field), else the body's first field; with no field, a sheet that came onto the screen gives it to its handle. A field that already
|
|
85
|
+
has the cursor keeps it. */
|
|
80
86
|
useEffect(() => {
|
|
81
87
|
if (current !== 'peek' && current !== 'away')
|
|
82
88
|
home.current = current;
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
if ((was.current === 'away' && current !== 'away') || back) { // it just came onto the screen: the cursor goes to what is marked to take it first (`data-autofocus` — a SheetSteps marks its open step's first field), else the handle, for the keyboard user
|
|
86
|
-
// …once it has finished sliding in (Lh 2026-09-24): the keyboard must not come up while the sheet still moves
|
|
87
|
-
const el = ref.current;
|
|
88
|
-
let done = false;
|
|
89
|
-
const land = () => {
|
|
90
|
-
if (done)
|
|
91
|
-
return;
|
|
92
|
-
done = true;
|
|
93
|
-
el?.removeEventListener('transitionend', onEnd);
|
|
94
|
-
const first = el?.querySelector('[data-autofocus]:not([inert] *)');
|
|
95
|
-
(first ?? handleRef.current)?.focus({ preventScroll: true });
|
|
96
|
-
};
|
|
97
|
-
const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
|
|
98
|
-
land(); };
|
|
99
|
-
el?.addEventListener('transitionend', onEnd);
|
|
100
|
-
const t = setTimeout(land, 600); // no transition (reduced motion): land anyway
|
|
101
|
-
was.current = current;
|
|
102
|
-
return () => { clearTimeout(t); el?.removeEventListener('transitionend', onEnd); };
|
|
103
|
-
}
|
|
89
|
+
const rose = current !== 'peek' && current !== 'away' && RANK[current] > RANK[was.current];
|
|
90
|
+
const from = was.current;
|
|
104
91
|
was.current = current;
|
|
92
|
+
if (!rose)
|
|
93
|
+
return;
|
|
94
|
+
const cancel = focusWhenActive(ref.current, { scope: ':scope > .sheet__body', fallback: () => (from === 'away' ? handleRef.current : null) });
|
|
95
|
+
return () => { if (!cancel())
|
|
96
|
+
was.current = from; }; // cut short (StrictMode's trial run, or a new state mid-slide): the rise still counts from where it began
|
|
105
97
|
}, [current]);
|
|
106
98
|
/* geometry, read once per gesture — never stored: how far it has slid, and where each state rests, in px from its top at full */
|
|
107
99
|
const slid = (el) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
-
import { useControlled } from '../state.js';
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
3
|
+
import { focusWhenActive, useControlled } from '../state.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
const PHASE = 380; // ms before the before-pack un-tucks on close, so the after-pack is clear first
|
|
6
6
|
/* 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
|
|
@@ -67,6 +67,13 @@ export function SheetStack({ mode = 'tap', open, defaultOpen = null, onOpenChang
|
|
|
67
67
|
if (!untuck.current)
|
|
68
68
|
sheets.forEach((c) => c.classList.remove('is-open', 'is-before', 'is-after'));
|
|
69
69
|
});
|
|
70
|
+
/* a sheet opened → its field has the cursor once it has slid up (the one rule of every part that becomes active: focusWhenActive) */
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (current == null || mode !== 'tap' || !ref.current)
|
|
73
|
+
return;
|
|
74
|
+
const cancel = focusWhenActive(sheetsOf(ref.current)[current], { scope: ':scope > .sheet__body' });
|
|
75
|
+
return () => { cancel(); };
|
|
76
|
+
}, [current, mode]);
|
|
70
77
|
const click = (e) => {
|
|
71
78
|
onClick?.(e);
|
|
72
79
|
const stack = ref.current;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Children, cloneElement, createContext, isValidElement, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { useControlled, reducedMotion } from '../state.js';
|
|
3
|
+
import { focusWhenActive, useControlled, reducedMotion } from '../state.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
import { Icon } from '../type/Icon.js';
|
|
6
6
|
import { Link } from '../actions/Link.js';
|
|
@@ -102,15 +102,12 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
102
102
|
else
|
|
103
103
|
at.blur();
|
|
104
104
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
settle();
|
|
112
|
-
}, MOVE_MS);
|
|
113
|
-
return () => clearTimeout(t);
|
|
105
|
+
// the cursor stands by: the step's first field takes the focus (the keyboard comes with it); a step with no field — its
|
|
106
|
+
// title, so a screen reader hears where it is (the one rule of every part that becomes active: focusWhenActive)
|
|
107
|
+
const cancel = focusWhenActive(ref.current, { wait: MOVE_MS, scope: '.sheet-step--now .sheet-step__inner',
|
|
108
|
+
fallback: () => ref.current?.querySelector('.sheet-step--now .sheet-step__title'),
|
|
109
|
+
then: () => { glide.current = performance.now() + 900; settle(); } });
|
|
110
|
+
return () => { cancel(); };
|
|
114
111
|
}, [current]);
|
|
115
112
|
/* the keyboard comes and goes: the body's scroll JUMPS (the Sheet's reveal, the browser's own, ours below, the box growing back).
|
|
116
113
|
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
|
package/dist/state.d.ts
CHANGED
|
@@ -21,4 +21,14 @@ export declare function watchSize(targets: Array<Element | null | undefined>, fn
|
|
|
21
21
|
export declare const warnOnce: (key: string, text: string) => void;
|
|
22
22
|
/** the member asked for less motion (false where there is no window, e.g. a server render) */
|
|
23
23
|
export declare const reducedMotion: () => boolean;
|
|
24
|
+
/** give `box` the cursor once it has settled: after its own `translate` transition (600ms at most), or after `wait` ms. It lands on the field
|
|
25
|
+
* marked `data-autofocus`, else the first field inside `scope` (a selector under `box`; default: all of it), else `fallback`. A field in
|
|
26
|
+
* `box` that already has the cursor keeps it. Nothing moves the page (`preventScroll`). Returns the cancel — `true` when it had already
|
|
27
|
+
* landed (so a component can tell a move cut short from a finished one). */
|
|
28
|
+
export declare function focusWhenActive(box: HTMLElement | null | undefined, { scope, fallback, wait, then }?: {
|
|
29
|
+
scope?: string;
|
|
30
|
+
fallback?: () => HTMLElement | null | undefined;
|
|
31
|
+
wait?: 'translate' | number;
|
|
32
|
+
then?: () => void;
|
|
33
|
+
}): () => boolean;
|
|
24
34
|
export {};
|
package/dist/state.js
CHANGED
|
@@ -55,3 +55,36 @@ export const warnOnce = (key, text) => { if (!warned.has(key)) {
|
|
|
55
55
|
} };
|
|
56
56
|
/** the member asked for less motion (false where there is no window, e.g. a server render) */
|
|
57
57
|
export const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
58
|
+
/* THE RULE (Lh 2026-09-29): a part of the screen that has just become ACTIVE — a sheet come up, a step opened, a stack sheet opened, a
|
|
59
|
+
modal asking — has its field waiting with the cursor. ONE mechanic for every such part; the component only says WHEN it became active. */
|
|
60
|
+
const FIELD = 'input:not([type=checkbox], [type=radio], [type=hidden], [disabled], [tabindex="-1"]), textarea:not([disabled]), select:not([disabled]), [contenteditable="true"]';
|
|
61
|
+
/** give `box` the cursor once it has settled: after its own `translate` transition (600ms at most), or after `wait` ms. It lands on the field
|
|
62
|
+
* marked `data-autofocus`, else the first field inside `scope` (a selector under `box`; default: all of it), else `fallback`. A field in
|
|
63
|
+
* `box` that already has the cursor keeps it. Nothing moves the page (`preventScroll`). Returns the cancel — `true` when it had already
|
|
64
|
+
* landed (so a component can tell a move cut short from a finished one). */
|
|
65
|
+
export function focusWhenActive(box, { scope, fallback, wait = 'translate', then } = {}) {
|
|
66
|
+
if (!box)
|
|
67
|
+
return () => false;
|
|
68
|
+
let done = false;
|
|
69
|
+
const land = () => {
|
|
70
|
+
if (done)
|
|
71
|
+
return;
|
|
72
|
+
done = true;
|
|
73
|
+
stop();
|
|
74
|
+
const at = document.activeElement;
|
|
75
|
+
if (!(at instanceof HTMLElement && box.contains(at) && at.matches(FIELD))) {
|
|
76
|
+
const root = scope ? box.querySelector(scope) : box;
|
|
77
|
+
const target = box.querySelector('[data-autofocus]:not([inert] *)')
|
|
78
|
+
?? root?.querySelector(`:is(${FIELD}):not([inert] *)`) ?? fallback?.();
|
|
79
|
+
target?.focus({ preventScroll: true });
|
|
80
|
+
}
|
|
81
|
+
then?.();
|
|
82
|
+
};
|
|
83
|
+
const onEnd = (e) => { if (e.target === box && e.propertyName === 'translate')
|
|
84
|
+
land(); };
|
|
85
|
+
const t = setTimeout(land, wait === 'translate' ? 600 : wait); // no transition (reduced motion) or none came: land anyway
|
|
86
|
+
if (wait === 'translate')
|
|
87
|
+
box.addEventListener('transitionend', onEnd);
|
|
88
|
+
function stop() { clearTimeout(t); box.removeEventListener('transitionend', onEnd); }
|
|
89
|
+
return () => { const was = done; done = true; stop(); return was; };
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.31",
|
|
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.",
|