@usableapp/cardds 0.7.30 → 0.7.32
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 +17 -0
- package/README.md +7 -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/cards/Placeholder.js +2 -1
- package/dist/choice/Calendar.js +2 -1
- package/dist/choice/Mood.js +2 -1
- package/dist/forms/Pin.js +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/media/Mosaic.js +3 -2
- package/dist/media/Tile.d.ts +2 -2
- package/dist/numbers/Track.js +2 -1
- package/dist/sheets/Drawer.js +12 -2
- package/dist/sheets/Sheet.js +5 -21
- package/dist/sheets/SheetStack.js +9 -2
- package/dist/sheets/SheetSteps.js +9 -11
- package/dist/sheets/StackBack.js +2 -1
- package/dist/sheets/StackSheet.js +2 -1
- package/dist/state.d.ts +10 -0
- package/dist/state.js +33 -0
- package/dist/templates/CallSheet.d.ts +6 -1
- package/dist/templates/CallSheet.js +2 -2
- package/dist/templates/Home.js +2 -1
- package/dist/templates/Walkthrough.js +2 -1
- package/dist/words.d.ts +7 -0
- package/dist/words.js +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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.32 — 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- **cardds names its own controls, in the page's language** (Lh 2026-09-29): the words a control reads out when the app gives none — a sheet's handle, a back / next / previous button, Skip, a Pin's cells, Progress, Image, Mood, Photos, Voice note — follow `<html lang>`. `th` ships (`ปรับความสูง`, `กลับ`, `ถัดไป`, `หลักที่`…), anything else is English as before. An app passes NOTHING per component: remove `handle="ปรับความสูง"`, `backLabel="กลับ"`, `label="หลักที่"`… where they only repeated these words. A prop still wins where a screen needs other words.
|
|
9
|
+
### Added
|
|
10
|
+
- `setWords(lang, { … })` — a project in another language adds its set once at app start (missing words fall back to English); `word(key)` reads one.
|
|
11
|
+
- `CallSheet handle` (+ a way's `sheet.handle`): override the sheets' handle name, or `false` for none.
|
|
12
|
+
|
|
13
|
+
## 0.7.31 — 2026-09-29
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **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.
|
|
17
|
+
- `Sheet` (as 0.7.30) and `SheetSteps` (as before) — now on the one mechanic.
|
|
18
|
+
- `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.
|
|
19
|
+
- `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.
|
|
20
|
+
Nothing to change in a screen: remove any focus code of your own for these parts.
|
|
21
|
+
|
|
5
22
|
## 0.7.30 — 2026-09-29
|
|
6
23
|
|
|
7
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -302,6 +302,13 @@ 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.
|
|
309
|
+
- **cardds names its own controls, in the page's language** (Lh 2026-09-29): a handle, a back button, a Pin's cells… read
|
|
310
|
+
their name from `src/words.ts` by `<html lang>` (en, th ship; `setWords(lang, {…})` adds one). An app passes no label that
|
|
311
|
+
only repeats these words; a prop still wins.
|
|
305
312
|
|
|
306
313
|
## Centred card markup
|
|
307
314
|
|
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) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from '../cx.js';
|
|
3
|
+
import { word } from '../words.js';
|
|
3
4
|
/** Placeholder — the wireframe's crossed box standing in for a photo or illustration (currentColor, survives any palette). */
|
|
4
|
-
export function Placeholder({ size, label = '
|
|
5
|
+
export function Placeholder({ size, label = word('image'), className, ...rest }) {
|
|
5
6
|
return _jsx("div", { className: cx('ph', size && `ph--${size}`, className), role: "img", "aria-label": label, ...rest });
|
|
6
7
|
}
|
package/dist/choice/Calendar.js
CHANGED
|
@@ -2,9 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useControlled } from '../state.js';
|
|
3
3
|
import { cx } from '../cx.js';
|
|
4
4
|
import { IconBtn } from '../actions/IconBtn.js';
|
|
5
|
+
import { word } from '../words.js';
|
|
5
6
|
/** Calendar — a month grid of 44px round cells: one accent day, optional marks; prev/next in the head. It shows ONE month and knows no dates:
|
|
6
7
|
* the app owns which month (`title`, `days`, `start` + `onPrev` / `onNext`) and which day (`selected` + `onSelect`, or `defaultSelected`). */
|
|
7
|
-
export function Calendar({ title, days = 30, start = 1, selected, defaultSelected, onSelect, onPrev, onNext, prevLabel = '
|
|
8
|
+
export function Calendar({ title, days = 30, start = 1, selected, defaultSelected, onSelect, onPrev, onNext, prevLabel = word('previous'), nextLabel = word('next'), marks = [], marks2 = [], disabled = [], weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], nav, className, ...rest }) {
|
|
8
9
|
const [day, pick] = useControlled(selected, defaultSelected, onSelect);
|
|
9
10
|
return (_jsxs("div", { className: cx('calendar', className), ...rest, children: [_jsxs("div", { className: "calendar__head", children: [_jsx("span", { className: "t-title", children: title }), nav ?? (_jsxs("span", { className: "icon-row", children: [_jsx(IconBtn, { sm: true, icon: "chevron-left", label: prevLabel, onClick: onPrev }), _jsx(IconBtn, { sm: true, icon: "chevron-right", label: nextLabel, onClick: onNext })] }))] }), _jsxs("div", { className: "calendar__grid", children: [weekdays.map((d) => _jsx("span", { className: "calendar__wd", children: d }, d)), Array.from({ length: days }, (_, i) => i + 1).map((d) => (_jsx("button", { type: "button", className: cx('calendar__day', marks.includes(d) && 'calendar__day--mark', marks2.includes(d) && 'calendar__day--mark2'), "aria-selected": d === day ? 'true' : undefined, onClick: () => pick(d), disabled: disabled.includes(d), style: d === 1 ? { '--start': start } : undefined, children: d }, d)))] })] }));
|
|
10
11
|
}
|
package/dist/choice/Mood.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from '../cx.js';
|
|
3
3
|
import { useControlled } from '../state.js';
|
|
4
|
+
import { word } from '../words.js';
|
|
4
5
|
/** Mood — five dots in a pill from bad to good; the picked one grows. */
|
|
5
|
-
export function Mood({ name = 'mood', value, defaultValue, label = '
|
|
6
|
+
export function Mood({ name = 'mood', value, defaultValue, label = word('mood'), onChange, className, ...rest }) {
|
|
6
7
|
const [picked, pick] = useControlled(value, defaultValue, onChange);
|
|
7
8
|
return (_jsx("fieldset", { className: cx('mood', className), "aria-label": label, ...rest, children: [1, 2, 3, 4, 5].map((n) => (_jsxs("label", { className: "mood__opt", children: [_jsx("input", { type: "radio", name: name, value: n, checked: n === picked, onChange: () => pick(n) }), _jsx("span", { className: "mood__dot" })] }, n))) }));
|
|
8
9
|
}
|
package/dist/forms/Pin.js
CHANGED
|
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
3
3
|
import { useControlled, useLatest } from '../state.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
import { useCheck } from './check.js';
|
|
6
|
+
import { word } from '../words.js';
|
|
6
7
|
const RESET_MS = 900; // a code that does not match: red + shake this long, then the cells empty for a new try
|
|
7
8
|
/**
|
|
8
9
|
* Pin — big code entry, one digit per pill; a filled cell turns accent. Lives in a Sheet.
|
|
@@ -10,7 +11,7 @@ const RESET_MS = 900; // a code that does not match: red + shake this long, then
|
|
|
10
11
|
* arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
|
|
11
12
|
* one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
|
|
12
13
|
*/
|
|
13
|
-
export function Pin({ length = 6, value, defaultValue = '', label = '
|
|
14
|
+
export function Pin({ length = 6, value, defaultValue = '', label = word('digit'), onChange, autoFocus, error, match, formatError, secret, className, onAnimationEnd, ...rest }) {
|
|
14
15
|
const ref = useRef(null);
|
|
15
16
|
const [typed, request] = useControlled(value, defaultValue, onChange);
|
|
16
17
|
const code = typed.replace(/\D/g, '').slice(0, length);
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from './type/Icon.js';
|
|
|
5
5
|
export { ICON_NAMES } from './type/icons.js';
|
|
6
6
|
export * from './type/Text.js';
|
|
7
7
|
export { setTextScale } from './type/textScale.js';
|
|
8
|
+
export { setWords, word, type WordKey } from './words.js';
|
|
8
9
|
export * from './scaffold/Screen.js';
|
|
9
10
|
export * from './scaffold/AppBar.js';
|
|
10
11
|
export * from './scaffold/TopBar.js';
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export * from './type/Icon.js';
|
|
|
9
9
|
export { ICON_NAMES } from './type/icons.js';
|
|
10
10
|
export * from './type/Text.js';
|
|
11
11
|
export { setTextScale } from './type/textScale.js';
|
|
12
|
+
export { setWords, word } from './words.js';
|
|
12
13
|
export * from './scaffold/Screen.js';
|
|
13
14
|
export * from './scaffold/AppBar.js';
|
|
14
15
|
export * from './scaffold/TopBar.js';
|
package/dist/media/Mosaic.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from '../cx.js';
|
|
3
|
+
import { word } from '../words.js';
|
|
3
4
|
/** Mosaic — one big photo + two small in a card (three Placeholders or imgs). */
|
|
4
|
-
export function Mosaic({ label = '
|
|
5
|
+
export function Mosaic({ label = word('photos'), className, ...rest }) {
|
|
5
6
|
return _jsx("div", { className: cx('mosaic', className), role: "img", "aria-label": label, ...rest });
|
|
6
7
|
}
|
|
7
8
|
/* a MOCK voice shape (Lh 2026-09-23): short and tall rounded bars, loud in bursts, quiet between — not a real recording */
|
|
8
9
|
const MOCK = [0.25, 0.45, 0.3, 0.6, 0.4, 0.2, 0.5, 0.85, 0.35, 0.7, 1, 0.55, 0.3, 0.75, 0.9, 0.45, 0.25, 0.6, 0.8, 0.4,
|
|
9
10
|
0.2, 0.5, 0.7, 0.35, 0.95, 0.6, 0.3, 0.45, 0.8, 0.5, 0.25, 0.65, 0.9, 0.4, 0.2, 0.55, 0.75, 0.35, 0.5, 0.3];
|
|
10
11
|
/** Wave — a voice note's waveform: rounded bars, short and tall, filling a Row's body. `levels` draws a real one; without it, a mock-up. */
|
|
11
|
-
export function Wave({ label = '
|
|
12
|
+
export function Wave({ label = word('voiceNote'), levels = MOCK, className, ...rest }) {
|
|
12
13
|
return (_jsx("span", { className: cx('wave', className), role: "img", "aria-label": label, ...rest, children: levels.map((v, i) => _jsx("i", { style: { '--wave-at': Math.max(0.12, Math.min(1, v)) } }, i)) }));
|
|
13
14
|
}
|
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/numbers/Track.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from '../cx.js';
|
|
3
|
+
import { word } from '../words.js';
|
|
3
4
|
/** Track — step progress in a dark pill: done · done · todo; children are TrackSteps holding an Icon each. */
|
|
4
|
-
export function Track({ label = '
|
|
5
|
+
export function Track({ label = word('progress'), className, ...rest }) {
|
|
5
6
|
return _jsx("ol", { className: cx('track', className), "aria-label": label, ...rest });
|
|
6
7
|
}
|
|
7
8
|
/** TrackStep — one step of a Track: an Icon (children) or its number (`num`) in a round; lit when done. */
|
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,7 +1,8 @@
|
|
|
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
|
+
import { word } from '../words.js';
|
|
5
6
|
const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
|
|
6
7
|
const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
|
|
7
8
|
const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
|
|
@@ -91,25 +92,8 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
|
|
|
91
92
|
was.current = current;
|
|
92
93
|
if (!rose)
|
|
93
94
|
return;
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const land = () => {
|
|
97
|
-
if (done || !el)
|
|
98
|
-
return;
|
|
99
|
-
done = true;
|
|
100
|
-
el.removeEventListener('transitionend', onEnd);
|
|
101
|
-
const at = document.activeElement;
|
|
102
|
-
if (at && el.contains(at) && at !== handleRef.current && at.matches('input, textarea, select, [contenteditable]'))
|
|
103
|
-
return;
|
|
104
|
-
const field = el.querySelector('[data-autofocus]:not([inert] *)')
|
|
105
|
-
?? el.querySelector(':scope > .sheet__body :is(input:not([type=checkbox], [type=radio], [type=hidden], [disabled]), textarea, select):not([inert] *)');
|
|
106
|
-
(field ?? (from === 'away' ? handleRef.current : null))?.focus({ preventScroll: true });
|
|
107
|
-
};
|
|
108
|
-
const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
|
|
109
|
-
land(); };
|
|
110
|
-
el?.addEventListener('transitionend', onEnd);
|
|
111
|
-
const t = setTimeout(land, 600); // no transition (reduced motion): land anyway
|
|
112
|
-
return () => { clearTimeout(t); el?.removeEventListener('transitionend', onEnd); if (!done)
|
|
95
|
+
const cancel = focusWhenActive(ref.current, { scope: ':scope > .sheet__body', fallback: () => (from === 'away' ? handleRef.current : null) });
|
|
96
|
+
return () => { if (!cancel())
|
|
113
97
|
was.current = from; }; // cut short (StrictMode's trial run, or a new state mid-slide): the rise still counts from where it began
|
|
114
98
|
}, [current]);
|
|
115
99
|
/* geometry, read once per gesture — never stored: how far it has slid, and where each state rests, in px from its top at full */
|
|
@@ -242,5 +226,5 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
|
|
|
242
226
|
skipClick.current = true;
|
|
243
227
|
requestAnimationFrame(() => { skipClick.current = false; });
|
|
244
228
|
};
|
|
245
|
-
return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${shown}`, 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 : '
|
|
229
|
+
return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${shown}`, 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 : word('resize'), onClick: tap, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up })), children] }));
|
|
246
230
|
}
|
|
@@ -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,11 +1,12 @@
|
|
|
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';
|
|
7
7
|
import { FormNote } from '../forms/FormNote.js';
|
|
8
8
|
import { CheckContext } from '../forms/check.js';
|
|
9
|
+
import { word } from '../words.js';
|
|
9
10
|
const MOVE_MS = 350; // = --motion-sheet: the accordion's beat
|
|
10
11
|
const GLIDE_MS = 220; // the pile's slide out of the keyboard's way and back: quick, but the eye follows it
|
|
11
12
|
const SWIPE_REM = 3; // an up/down move longer than this (and mostly up/down) is a swipe
|
|
@@ -102,15 +103,12 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
102
103
|
else
|
|
103
104
|
at.blur();
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
settle();
|
|
112
|
-
}, MOVE_MS);
|
|
113
|
-
return () => clearTimeout(t);
|
|
106
|
+
// the cursor stands by: the step's first field takes the focus (the keyboard comes with it); a step with no field — its
|
|
107
|
+
// title, so a screen reader hears where it is (the one rule of every part that becomes active: focusWhenActive)
|
|
108
|
+
const cancel = focusWhenActive(ref.current, { wait: MOVE_MS, scope: '.sheet-step--now .sheet-step__inner',
|
|
109
|
+
fallback: () => ref.current?.querySelector('.sheet-step--now .sheet-step__title'),
|
|
110
|
+
then: () => { glide.current = performance.now() + 900; settle(); } });
|
|
111
|
+
return () => { cancel(); };
|
|
114
112
|
}, [current]);
|
|
115
113
|
/* 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
114
|
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
|
|
@@ -186,7 +184,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
186
184
|
_jsx("div", { className: "sheet-steps__finish", children: isValidElement(finish) ? cloneElement(finish, { disabled: reach < count || finish.props.disabled }) : finish }))] }));
|
|
187
185
|
}
|
|
188
186
|
/** SheetStep — one step of a SheetSteps: its title, then what the member does (a Field, a Pin, a Toggle, the step's own Btn). */
|
|
189
|
-
export function SheetStep({ title, summary, skippable, skipLabel = '
|
|
187
|
+
export function SheetStep({ title, summary, skippable, skipLabel = word('skip'), error, busy, className, children, ...rest }) {
|
|
190
188
|
const ctx = useContext(StepCtx);
|
|
191
189
|
if (!ctx)
|
|
192
190
|
throw new Error('SheetStep goes inside a SheetSteps');
|
package/dist/sheets/StackBack.js
CHANGED
|
@@ -2,8 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { cx } from '../cx.js';
|
|
3
3
|
import { Icon } from '../type/Icon.js';
|
|
4
4
|
import { IconBtn } from '../actions/IconBtn.js';
|
|
5
|
+
import { word } from '../words.js';
|
|
5
6
|
/** StackBack — the back button a tapped stack sheet reveals in its head; first child of that sheet's CardHead. Hidden until the sheet is open.
|
|
6
7
|
* A SMALL disc (Lh 2026-09-23): 32px with a chevron — the head keeps its room for the title and the tabs; the 44px tap target stays. */
|
|
7
|
-
export function StackBack({ label = '
|
|
8
|
+
export function StackBack({ label = word('back'), end, className, ...rest }) {
|
|
8
9
|
return _jsx(IconBtn, { sm: true, label: label, className: cx('sheet-stack__back', end && 'sheet-stack__back--end', className), ...rest, children: _jsx(Icon, { name: end ? 'chevron-down' : 'chevron-left' }) });
|
|
9
10
|
}
|
|
@@ -11,13 +11,14 @@ import { StackBack } from './StackBack.js';
|
|
|
11
11
|
import { Text } from '../type/Text.js';
|
|
12
12
|
import { Icon } from '../type/Icon.js';
|
|
13
13
|
import { Badge } from '../numbers/Badge.js';
|
|
14
|
+
import { word } from '../words.js';
|
|
14
15
|
/**
|
|
15
16
|
* StackSheet — ONE sheet of a `SheetStack`, in the one sheet shape: SHEET HEAD (back · title · tabs or an icon) pinned at the top ·
|
|
16
17
|
* SHEET BODY (the children; scrolls) · SHEET FOOT (`foot`, the CTA) at the bottom. Its tabs are icon buttons, the chosen one filled;
|
|
17
18
|
* a count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
|
|
18
19
|
* is closed — see `Home`). A direct child of `SheetStack`.
|
|
19
20
|
*/
|
|
20
|
-
export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = '
|
|
21
|
+
export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = word('back'), backAt = 'none', className, children, ...rest }) {
|
|
21
22
|
const [tab, pick] = useControlled(tabProp, defaultTab ?? tabs?.[0]?.id, onTabChange);
|
|
22
23
|
// a title longer than its room does not wrap: it runs sideways, like a song title in a short slot — the room it overflows by
|
|
23
24
|
// is measured here (the tabs may grow with the text size) and handed to the CSS as --_over
|
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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentProps, type ReactNode } from 'react';
|
|
2
2
|
import { Screen } from '../scaffold/Screen.js';
|
|
3
|
+
import { type SheetProps } from '../sheets/Sheet.js';
|
|
3
4
|
export interface CallSheetSheet {
|
|
4
5
|
/** the sheet head's title */
|
|
5
6
|
title: ReactNode;
|
|
@@ -9,6 +10,8 @@ export interface CallSheetSheet {
|
|
|
9
10
|
foot?: ReactNode;
|
|
10
11
|
/** a small link UNDER that button (a `Link sm`, e.g. details for developers) */
|
|
11
12
|
footLink?: ReactNode;
|
|
13
|
+
/** this sheet's handle — its accessible name (a string), or `false` for none; overrides the CallSheet's `handle` */
|
|
14
|
+
handle?: SheetProps['handle'];
|
|
12
15
|
}
|
|
13
16
|
/** one way in: its button and the sheet it calls */
|
|
14
17
|
export interface CallSheetWay {
|
|
@@ -36,6 +39,8 @@ export interface CallSheetProps extends Omit<ComponentProps<typeof Screen>, 'chi
|
|
|
36
39
|
onOpenChange?: (open: boolean, way: number | null) => void;
|
|
37
40
|
/** the sheets' resting states once up (default "peek full") */
|
|
38
41
|
states?: string;
|
|
42
|
+
/** every sheet's handle: its accessible name in the app's language (a string — default "Resize"), or `false` for none. A sheet's own `handle` wins */
|
|
43
|
+
handle?: SheetProps['handle'];
|
|
39
44
|
/** layers over the screen */
|
|
40
45
|
children?: ReactNode;
|
|
41
46
|
}
|
|
@@ -45,4 +50,4 @@ export interface CallSheetProps extends Omit<ComponentProps<typeof Screen>, 'chi
|
|
|
45
50
|
* starts at the screen's lead (15%). The sheets wait `away`; a button brings its own up full; the handle takes it down to peek.
|
|
46
51
|
* One sheet up at a time — a sheet holds that way's WHOLE form.
|
|
47
52
|
*/
|
|
48
|
-
export declare function CallSheet({ media, heading, body, action, sheet, ways, open: openProp, defaultOpen, onOpenChange, states, children, ...rest }: CallSheetProps): import("react").JSX.Element;
|
|
53
|
+
export declare function CallSheet({ media, heading, body, action, sheet, ways, open: openProp, defaultOpen, onOpenChange, states, handle, children, ...rest }: CallSheetProps): import("react").JSX.Element;
|
|
@@ -17,12 +17,12 @@ const index = (o) => (o === true ? 0 : typeof o === 'number' ? o : null);
|
|
|
17
17
|
* starts at the screen's lead (15%). The sheets wait `away`; a button brings its own up full; the handle takes it down to peek.
|
|
18
18
|
* One sheet up at a time — a sheet holds that way's WHOLE form.
|
|
19
19
|
*/
|
|
20
|
-
export function CallSheet({ media, heading, body, action, sheet, ways, open: openProp, defaultOpen = null, onOpenChange, states = 'peek full', children, ...rest }) {
|
|
20
|
+
export function CallSheet({ media, heading, body, action, sheet, ways, open: openProp, defaultOpen = null, onOpenChange, states = 'peek full', handle, children, ...rest }) {
|
|
21
21
|
const list = ways ?? (sheet ? [{ action, sheet }] : []);
|
|
22
22
|
const [on, set] = useControlled(openProp === undefined ? undefined : index(openProp), index(defaultOpen), onOpenChange && ((next) => onOpenChange(next !== null, next)));
|
|
23
23
|
const [at, setAt] = useState('full');
|
|
24
24
|
return (_jsxs(Screen, { fill: true, ...rest, children: [_jsxs(Step, { children: [_jsxs(Page, { children: [media, _jsx(CardHead, { level: "h1", heading: heading }), typeof body === 'string' ? _jsx(Text, { level: "body", children: body }) : body, _jsx(CtaPack, { children: list.map((w, i) => (_jsx(Btn, { primary: i === 0, quiet: i > 0, "aria-expanded": on === i, onClick: () => { setAt('full'); set(i); }, children: w.action }, i))) })] }), list.map(({ sheet: sh }, i) => (_jsxs(Sheet, { state: on === i ? at : 'away', onStateChange: (s) => { if (s === 'away')
|
|
25
25
|
set(null);
|
|
26
26
|
else
|
|
27
|
-
setAt(s); }, states: states, children: [_jsx(CardHead, { heading: sh.title }), _jsx(SheetBody, { children: on === i ? (typeof sh.body === 'function' ? sh.body() : sh.body) : null }), (sh.foot != null || sh.footLink != null) && _jsx(CardFoot, { link: sh.footLink, children: sh.foot })] }, i)))] }), children] }));
|
|
27
|
+
setAt(s); }, states: states, handle: sh.handle ?? handle, children: [_jsx(CardHead, { heading: sh.title }), _jsx(SheetBody, { children: on === i ? (typeof sh.body === 'function' ? sh.body() : sh.body) : null }), (sh.foot != null || sh.footLink != null) && _jsx(CardFoot, { link: sh.footLink, children: sh.foot })] }, i)))] }), children] }));
|
|
28
28
|
}
|
package/dist/templates/Home.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Screen } from '../scaffold/Screen.js';
|
|
|
5
5
|
import { SheetStack } from '../sheets/SheetStack.js';
|
|
6
6
|
import { StackSheet } from '../sheets/StackSheet.js';
|
|
7
7
|
import { Text } from '../type/Text.js';
|
|
8
|
+
import { word } from '../words.js';
|
|
8
9
|
const TONES = [2, 3, 1, 2, 3];
|
|
9
10
|
const LEAVE = 800; // ms: a closing sheet keeps its body until it has slid home
|
|
10
11
|
/**
|
|
@@ -14,7 +15,7 @@ const LEAVE = 800; // ms: a closing sheet keeps its body until it has slid home
|
|
|
14
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
|
|
15
16
|
* sections on one home never cost three pages (`keep` holds a body once opened).
|
|
16
17
|
*/
|
|
17
|
-
export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, base, keep, backLabel = '
|
|
18
|
+
export function Home({ top, title, sheets, open: openProp, defaultOpen = null, onOpenChange, base, keep, backLabel = word('back'), backAt, children, ...rest }) {
|
|
18
19
|
const [open, change] = useControlled(openProp, defaultOpen, onOpenChange);
|
|
19
20
|
const [leaving, setLeaving] = useState(null);
|
|
20
21
|
const [seen, setSeen] = useState(() => new Set(open != null ? [open] : []));
|
|
@@ -10,13 +10,14 @@ import { ActionBar, ActionBarTier } from '../scaffold/ActionBar.js';
|
|
|
10
10
|
import { CardHead } from '../cards/CardHead.js';
|
|
11
11
|
import { Text } from '../type/Text.js';
|
|
12
12
|
import { IconBtn } from '../actions/IconBtn.js';
|
|
13
|
+
import { word } from '../words.js';
|
|
13
14
|
/**
|
|
14
15
|
* Walkthrough — TEMPLATE, a `Screen` with its three parts filled — no top · content: `Pages` (down to the screen's foot; each Page padded by the lead above and below, the whole run scrolls) · bottom: a LAYER over it — the dots, then the `ActionBar` rows.
|
|
15
16
|
* A walk of pages that tell (onboarding, an introduction). Each page = a picture, a heading,
|
|
16
17
|
* the words; the dots at the foot; one row of calls to action at the bottom. No top bar. Fixed: the order, the picture
|
|
17
18
|
* first and fixed while the words scroll, one CTA row. Yours: the pages, the row, the state.
|
|
18
19
|
*/
|
|
19
|
-
export function Walkthrough({ pages, at: atProp, defaultAt = 0, onAtChange, cta, controls = [], ctaLink, children, backLabel = '
|
|
20
|
+
export function Walkthrough({ pages, at: atProp, defaultAt = 0, onAtChange, cta, controls = [], ctaLink, children, backLabel = word('back'), nextLabel = word('next'), ...rest }) {
|
|
20
21
|
const [at, turn] = useControlled(atProp, defaultAt, onAtChange);
|
|
21
22
|
const step = useRef(null);
|
|
22
23
|
// a new page starts where a page starts — at its lead, not where the last one was scrolled to (Lh 2026-09-23). A LAYOUT effect:
|
package/dist/words.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type WordKey = 'resize' | 'back' | 'next' | 'previous' | 'skip' | 'digit' | 'progress' | 'image' | 'mood' | 'photos' | 'voiceNote';
|
|
2
|
+
type Words = Record<WordKey, string>;
|
|
3
|
+
/** add or retune the words for a language (`'lo'`, or `'th'` to change one) — once, at app start. Missing words fall back to English. */
|
|
4
|
+
export declare function setWords(lang: string, words: Partial<Words>): void;
|
|
5
|
+
/** the word for `key` in the page's language (`<html lang>`: "th", "th-TH" → th); English where the page names none cardds knows */
|
|
6
|
+
export declare function word(key: WordKey): string;
|
|
7
|
+
export {};
|
package/dist/words.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const sets = {
|
|
2
|
+
en: { resize: 'Resize', back: 'Back', next: 'Next', previous: 'Previous', skip: 'Skip', digit: 'Digit', progress: 'Progress', image: 'Image', mood: 'Mood', photos: 'Photos', voiceNote: 'Voice note' },
|
|
3
|
+
th: { resize: 'ปรับความสูง', back: 'กลับ', next: 'ถัดไป', previous: 'ก่อนหน้า', skip: 'ข้าม', digit: 'หลักที่', progress: 'ความคืบหน้า', image: 'รูปภาพ', mood: 'อารมณ์', photos: 'รูปภาพ', voiceNote: 'ข้อความเสียง' },
|
|
4
|
+
};
|
|
5
|
+
/** add or retune the words for a language (`'lo'`, or `'th'` to change one) — once, at app start. Missing words fall back to English. */
|
|
6
|
+
export function setWords(lang, words) {
|
|
7
|
+
const key = lang.toLowerCase();
|
|
8
|
+
sets[key] = { ...sets.en, ...sets[key], ...words };
|
|
9
|
+
}
|
|
10
|
+
/** the word for `key` in the page's language (`<html lang>`: "th", "th-TH" → th); English where the page names none cardds knows */
|
|
11
|
+
export function word(key) {
|
|
12
|
+
const lang = typeof document === 'undefined' ? '' : document.documentElement.lang.toLowerCase();
|
|
13
|
+
return (sets[lang] ?? sets[lang.split('-')[0]] ?? sets.en)[key];
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.32",
|
|
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.",
|