@usableapp/cardds 0.7.30 → 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 CHANGED
@@ -2,6 +2,15 @@
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
+
5
14
  ## 0.7.30 — 2026-09-29
6
15
 
7
16
  ### Changed
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) {
@@ -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: "button" | "submit" | "reset" | undefined;
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: "button" | "submit" | "reset" | undefined;
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) {
@@ -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;
@@ -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 }) {
@@ -1,6 +1,6 @@
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
6
  const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
@@ -91,25 +91,8 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
91
91
  was.current = current;
92
92
  if (!rose)
93
93
  return;
94
- const el = ref.current;
95
- let done = false;
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)
94
+ const cancel = focusWhenActive(ref.current, { scope: ':scope > .sheet__body', fallback: () => (from === 'away' ? handleRef.current : null) });
95
+ return () => { if (!cancel())
113
96
  was.current = from; }; // cut short (StrictMode's trial run, or a new state mid-slide): the rise still counts from where it began
114
97
  }, [current]);
115
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 */
@@ -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
- const t = setTimeout(() => {
106
- const now = ref.current?.querySelector('.sheet-step--now');
107
- // the cursor stands by: the step's first field takes the focus (the keyboard comes with it); a step with no field — its
108
- // title, so a screen reader hears where it is
109
- (now?.querySelector('.sheet-step__inner :is(input:not([type=checkbox], [type=radio], [type=hidden]), textarea)') ?? now?.querySelector('.sheet-step__title'))?.focus({ preventScroll: true });
110
- glide.current = performance.now() + 900;
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.30",
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.",