@usableapp/cardds 0.7.2 → 0.7.4

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,27 @@
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.4 — 2026-09-24
6
+
7
+ ### New
8
+ - `SheetSteps` / `SheetStep` — one task taken in steps INSIDE one sheet (phone · code · PIN · PIN again · face · notices),
9
+ in a `SheetBody`. The done steps pile on top, one short line each (title · `summary` · ✓); the current step is open
10
+ under them, its form from the top at the form's own spacing; the next steps wait in a pile at the bottom (under the
11
+ keyboard when it is up). A thin rule over every step. Controlled: `at` + `onAtChange(at, 'tap' | 'swipe' | 'skip')`, or
12
+ `defaultAt`; the APP moves `at` when a step's work is really done; `at = count` = all done — the last step's own action
13
+ sends, or pass `finish` (a `Btn`) to stand under the steps on the last one. `reversible` = a tap on a done / next step
14
+ or an up/down swipe moves; without it only forward. `SheetStep skippable skipLabel="…"` = a small link that moves on.
15
+ The cursor stands in each new step's first field; with the keyboard up the field's middle is brought to eye level
16
+ (`--screen-centre` of what shows) and the done pile glides out of the way and back. Knobs `--sheet-steps-row-h`,
17
+ `--sheet-step-border`. Stories: `/SheetSteps` (Forward · Reversible · Start · Controlled · Finish · Long).
18
+
19
+ ## 0.7.3 — 2026-09-24
20
+
21
+ ### New
22
+ - `Modal onDismiss(reason)` — a tap on the scrim (anywhere outside the card) or Esc asks to leave unanswered
23
+ (`'scrim' | 'escape'`); the app sets `open={false}` and the modal leaves as usual. Without `onDismiss` the scrim does
24
+ nothing — a question that must be answered.
25
+
5
26
  ## 0.7.2 — 2026-09-24
6
27
 
7
28
  ### `Banner` is one light band (breaking)
package/css/sheet.css CHANGED
@@ -371,3 +371,35 @@
371
371
  z-index: 1;
372
372
  }
373
373
  .sheet-stage--dim .sheet { z-index: 2; }
374
+
375
+ /* ---- sheet steps: one task in steps inside one sheet (Lh 2026-09-24) ----
376
+ <div class="sheet-steps"> › <ol class="sheet-steps__track"> of <li class="sheet-step sheet-step--done|now|next">. The done steps
377
+ PILE UP on top, ALL of them, each folded to one short line (title · summary · ✓) — when the keyboard leaves too little room the
378
+ body scrolls them up only as far as the field needs; the current step is open under them — its title, then its form at
379
+ the form's own spacing, from the top (never spread or centred: the keyboard needs the room under it); the next steps wait in a pile at the
380
+ bottom of the body, each its title line. Every step has a thin rule on top. A step opens and folds as a 1fr ↔ 0fr grid row, so the pile rises and
381
+ falls — no height is written. Nothing fades: done and next read at full ink. */
382
+ .sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
383
+ .sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0px)); /* the keyboard's height more: the pile of next steps stays under the keyboard, it never rises with it */ display: flex; flex-direction: column; touch-action: pan-y; }
384
+ .sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
385
+ .sheet-step--now + .sheet-step { margin-top: auto; } /* the steps not done yet wait in a pile at the bottom, clear of the one being done */
386
+ .sheet-step {
387
+ position: relative; min-width: 0;
388
+ display: grid; grid-template-rows: auto 0fr;
389
+ border-top: var(--rule-w) solid var(--sheet-step-border, var(--hairline));
390
+ transition: grid-template-rows var(--motion-sheet) ease;
391
+ }
392
+ .sheet-step--now { grid-template-rows: auto 1fr; }
393
+ .sheet-step__head { display: flex; align-items: center; gap: var(--sp-2); min-height: var(--sheet-steps-row-h, var(--tap-sm)); }
394
+ .sheet-step__title { flex: 0 1 auto; min-width: 0; outline: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
395
+ .sheet-step__summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
396
+ .sheet-step__check { margin-inline-start: auto; }
397
+ .sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
398
+ .sheet-step__inner { display: grid; gap: var(--card-gap); padding-bottom: var(--card-gap); }
399
+ .sheet-step__inner > * { min-width: 0; }
400
+ .sheet-step__skip { justify-self: center; }
401
+ .sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
402
+ .sheet-step__go { position: absolute; inset: 0; margin: 0; padding: 0; border: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
403
+ /* the last step's finish, when the app gives one: under the steps */
404
+ .sheet-steps__finish { display: grid; padding-top: var(--card-gap); }
405
+ @media (prefers-reduced-motion: reduce) { .sheet-step { transition: none; } }
package/dist/cardds.css CHANGED
@@ -1548,6 +1548,38 @@ button { font: inherit; cursor: pointer; }
1548
1548
  }
1549
1549
  .sheet-stage--dim .sheet { z-index: 2; }
1550
1550
 
1551
+ /* ---- sheet steps: one task in steps inside one sheet (Lh 2026-09-24) ----
1552
+ <div class="sheet-steps"> › <ol class="sheet-steps__track"> of <li class="sheet-step sheet-step--done|now|next">. The done steps
1553
+ PILE UP on top, ALL of them, each folded to one short line (title · summary · ✓) — when the keyboard leaves too little room the
1554
+ body scrolls them up only as far as the field needs; the current step is open under them — its title, then its form at
1555
+ the form's own spacing, from the top (never spread or centred: the keyboard needs the room under it); the next steps wait in a pile at the
1556
+ bottom of the body, each its title line. Every step has a thin rule on top. A step opens and folds as a 1fr ↔ 0fr grid row, so the pile rises and
1557
+ falls — no height is written. Nothing fades: done and next read at full ink. */
1558
+ .sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
1559
+ .sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0px)); /* the keyboard's height more: the pile of next steps stays under the keyboard, it never rises with it */ display: flex; flex-direction: column; touch-action: pan-y; }
1560
+ .sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
1561
+ .sheet-step--now + .sheet-step { margin-top: auto; } /* the steps not done yet wait in a pile at the bottom, clear of the one being done */
1562
+ .sheet-step {
1563
+ position: relative; min-width: 0;
1564
+ display: grid; grid-template-rows: auto 0fr;
1565
+ border-top: var(--rule-w) solid var(--sheet-step-border, var(--hairline));
1566
+ transition: grid-template-rows var(--motion-sheet) ease;
1567
+ }
1568
+ .sheet-step--now { grid-template-rows: auto 1fr; }
1569
+ .sheet-step__head { display: flex; align-items: center; gap: var(--sp-2); min-height: var(--sheet-steps-row-h, var(--tap-sm)); }
1570
+ .sheet-step__title { flex: 0 1 auto; min-width: 0; outline: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1571
+ .sheet-step__summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1572
+ .sheet-step__check { margin-inline-start: auto; }
1573
+ .sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
1574
+ .sheet-step__inner { display: grid; gap: var(--card-gap); padding-bottom: var(--card-gap); }
1575
+ .sheet-step__inner > * { min-width: 0; }
1576
+ .sheet-step__skip { justify-self: center; }
1577
+ .sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
1578
+ .sheet-step__go { position: absolute; inset: 0; margin: 0; padding: 0; border: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
1579
+ /* the last step's finish, when the app gives one: under the steps */
1580
+ .sheet-steps__finish { display: grid; padding-top: var(--card-gap); }
1581
+ @media (prefers-reduced-motion: reduce) { .sheet-step { transition: none; } }
1582
+
1551
1583
  /* ---- css/step.css — .step: one screen, one task — card, optional sheet, bar outside ---- */
1552
1584
  /* ============================================================
1553
1585
  cardds/step.css — .step: one screen, one task.
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export * from './sheets/SheetHead.js';
27
27
  export * from './sheets/SheetBody.js';
28
28
  export * from './sheets/SheetStage.js';
29
29
  export * from './sheets/Step.js';
30
+ export * from './sheets/SheetSteps.js';
30
31
  export * from './sheets/Drawer.js';
31
32
  export * from './actions/Btn.js';
32
33
  export * from './actions/IconBtn.js';
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ export * from './sheets/SheetHead.js';
31
31
  export * from './sheets/SheetBody.js';
32
32
  export * from './sheets/SheetStage.js';
33
33
  export * from './sheets/Step.js';
34
+ export * from './sheets/SheetSteps.js';
34
35
  export * from './sheets/Drawer.js';
35
36
  export * from './actions/Btn.js';
36
37
  export * from './actions/IconBtn.js';
@@ -5,15 +5,19 @@ export interface ModalProps extends ComponentProps<'article'> {
5
5
  open?: boolean;
6
6
  /** the leaving has finished (at once, when motion is reduced) — unmount here */
7
7
  onClosed?: () => void;
8
+ /** the member asked to leave WITHOUT answering: a tap on the scrim (anywhere outside the card) or Esc. The modal only asks — the app
9
+ * sets `open={false}`. Without it the scrim does nothing (a question that must be answered). */
10
+ onDismiss?: (reason: 'scrim' | 'escape') => void;
8
11
  }
9
12
  /**
10
13
  * Modal — the ask: ONE card at the centre of a `SheetStage dim`, two parts — a `Lift` (the thing being asked about, light, scrolls
11
14
  * when tall) and a `Drawer` (the dark end that asks). No border. Two beats on appearing: drops in from above, then the drawer comes
12
15
  * out below while the lift moves up.
13
16
  * A COMPONENT, not a wiring: it does not know its buttons. They are plain `IconBtn`s / `Btn`s with the app's `onClick` — the app
14
- * records the answer and sets `open={false}`; the modal's one behaviour is how it leaves.
17
+ * records the answer and sets `open={false}`; the modal's one behaviour is how it leaves. `onDismiss` asks to leave unanswered (a tap on
18
+ * the scrim, Esc).
15
19
  */
16
- export declare function Modal({ open, onClosed, className, ...rest }: ModalProps): import("react").JSX.Element;
20
+ export declare function Modal({ open, onClosed, onDismiss, className, ...rest }: ModalProps): import("react").JSX.Element;
17
21
  export interface LiftProps extends ComponentProps<'div'> {
18
22
  }
19
23
  /** Lift — the light part of a `Modal`: the case, request or item the question is about (a CardHead, rows, a timeline); scrolls inside when tall. */
@@ -6,9 +6,10 @@ import { cx } from '../cx.js';
6
6
  * when tall) and a `Drawer` (the dark end that asks). No border. Two beats on appearing: drops in from above, then the drawer comes
7
7
  * out below while the lift moves up.
8
8
  * A COMPONENT, not a wiring: it does not know its buttons. They are plain `IconBtn`s / `Btn`s with the app's `onClick` — the app
9
- * records the answer and sets `open={false}`; the modal's one behaviour is how it leaves.
9
+ * records the answer and sets `open={false}`; the modal's one behaviour is how it leaves. `onDismiss` asks to leave unanswered (a tap on
10
+ * the scrim, Esc).
10
11
  */
11
- export function Modal({ open = true, onClosed, className, ...rest }) {
12
+ export function Modal({ open = true, onClosed, onDismiss, className, ...rest }) {
12
13
  const ref = useRef(null);
13
14
  const closed = useRef(onClosed);
14
15
  closed.current = onClosed;
@@ -27,6 +28,21 @@ export function Modal({ open = true, onClosed, className, ...rest }) {
27
28
  closed.current?.(); });
28
29
  return () => { live = false; };
29
30
  }, [open]);
31
+ const dismiss = useRef(onDismiss);
32
+ dismiss.current = onDismiss;
33
+ const has = onDismiss !== undefined;
34
+ useEffect(() => {
35
+ const el = ref.current, stage = el?.parentElement;
36
+ if (!el || !stage || !has || !open)
37
+ return;
38
+ const tap = (e) => { if (!el.contains(e.target))
39
+ dismiss.current?.('scrim'); }; // the scrim is the stage's own ::after
40
+ const key = (e) => { if (e.key === 'Escape' && !e.defaultPrevented)
41
+ dismiss.current?.('escape'); };
42
+ stage.addEventListener('click', tap);
43
+ document.addEventListener('keydown', key);
44
+ return () => { stage.removeEventListener('click', tap); document.removeEventListener('keydown', key); };
45
+ }, [has, open]);
30
46
  return _jsx("article", { ref: ref, className: cx('card modal', className), ...rest });
31
47
  }
32
48
  /** Lift — the light part of a `Modal`: the case, request or item the question is about (a CardHead, rows, a timeline); scrolls inside when tall. */
@@ -0,0 +1,37 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+ /** what asked for the move: a `tap` on another step, a `swipe` across the steps, the step's `skip` link */
3
+ export type SheetStepsReason = 'tap' | 'swipe' | 'skip';
4
+ export interface SheetStepsProps extends ComponentProps<'div'> {
5
+ /** optional: what stands under the steps on the LAST step — a `Btn` that finishes. None = the last step's own action sends. */
6
+ finish?: ReactNode;
7
+ /** CONTROLLED: the step the member is on (0-based). Steps before it are done, after it are next. The APP moves it — when a step's
8
+ * work is really finished (the code checked by the server, the two PINs match), `setAt(at + 1)`; after the last step the app sends. `at = count` = every step done. */
9
+ at?: number;
10
+ /** UNCONTROLLED: the step it starts on (default 0) */
11
+ defaultAt?: number;
12
+ /** the steps ASK to move — a tap on another step, a swipe, a skip. Controlled: set `at` to it (or don't). */
13
+ onAtChange?: (at: number, reason: SheetStepsReason) => void;
14
+ /** true: the member may go back and forth — a tap on a done or next step opens it, an up/down swipe moves one step.
15
+ * false (default): only forward — the done steps stay in the pile, never opened again (a swipe up passes only a `skippable` step). */
16
+ reversible?: boolean;
17
+ }
18
+ /**
19
+ * SheetSteps — one task taken in steps INSIDE one sheet (phone · code · PIN · PIN again · face · notices). The done steps pile up
20
+ * on top, each one line (title · summary · ✓); the current step is open under them, its form at the form's own spacing; the next
21
+ * steps wait below as their titles. A thin rule over every step. The last step's own action sends, or a `finish` stands under the
22
+ * steps on the last one. Goes in a `SheetBody`.
23
+ * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
24
+ */
25
+ export declare function SheetSteps({ at, defaultAt, onAtChange, reversible, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }: SheetStepsProps): import("react").JSX.Element;
26
+ export interface SheetStepProps extends Omit<ComponentProps<'li'>, 'title'> {
27
+ /** the step's name — always shown: over the open step, on the folded line of a done one */
28
+ title: ReactNode;
29
+ /** the done step's one-line answer ("081-xxx-4567", "set") — shown on its folded line */
30
+ summary?: ReactNode;
31
+ /** the member may pass this step without doing it: a small link at its end moves on (reason `skip`) */
32
+ skippable?: boolean;
33
+ /** the skip link's words (default "Skip") */
34
+ skipLabel?: ReactNode;
35
+ }
36
+ /** SheetStep — one step of a SheetSteps: its title, then what the member does (a Field, a Pin, a Toggle, the step's own Btn). */
37
+ export declare function SheetStep({ title, summary, skippable, skipLabel, className, children, ...rest }: SheetStepProps): import("react").JSX.Element;
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, createContext, isValidElement, useContext, useEffect, useRef, useState } from 'react';
3
+ import { cx } from '../cx.js';
4
+ import { Icon } from '../type/Icon.js';
5
+ import { Link } from '../actions/Link.js';
6
+ const GLIDE_MS = 220; // the pile's slide out of the keyboard's way and back: quick, but the eye follows it
7
+ const SWIPE_REM = 3; // an up/down move longer than this (and mostly up/down) is a swipe
8
+ const StepCtx = createContext(null);
9
+ /**
10
+ * SheetSteps — one task taken in steps INSIDE one sheet (phone · code · PIN · PIN again · face · notices). The done steps pile up
11
+ * on top, each one line (title · summary · ✓); the current step is open under them, its form at the form's own spacing; the next
12
+ * steps wait below as their titles. A thin rule over every step. The last step's own action sends, or a `finish` stands under the
13
+ * steps on the last one. Goes in a `SheetBody`.
14
+ * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
15
+ */
16
+ export function SheetSteps({ at, defaultAt = 0, onAtChange, reversible = false, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }) {
17
+ const [inner, setInner] = useState(defaultAt);
18
+ const current = at ?? inner;
19
+ const steps = Children.toArray(children).filter(isValidElement);
20
+ const count = steps.length;
21
+ const ref = useRef(null);
22
+ const first = useRef(true);
23
+ const press = useRef(null);
24
+ const go = (i, reason) => {
25
+ if (i === current || i < 0 || i > count)
26
+ return;
27
+ if (at === undefined)
28
+ setInner(i);
29
+ onAtChange?.(i, reason);
30
+ };
31
+ /* the step changed: put the cursor in it, and once the pile has moved, bring the step
32
+ into view in the body. Not on arrival — the body keeps its scroll. */
33
+ useEffect(() => {
34
+ if (first.current) {
35
+ first.current = false;
36
+ return;
37
+ }
38
+ const now = ref.current?.querySelector('.sheet-step--now');
39
+ // the cursor stands by: the step's first field takes the focus (the keyboard comes with it); a step with no field — its title,
40
+ // so a screen reader hears where it is
41
+ (now?.querySelector('.sheet-step__inner :is(input:not([type=checkbox], [type=radio], [type=hidden]), textarea)') ?? now?.querySelector('.sheet-step__title'))?.focus({ preventScroll: true });
42
+ const t = setTimeout(() => now?.scrollIntoView({ block: 'nearest', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }), 350); // = --motion-sheet
43
+ return () => clearTimeout(t);
44
+ }, [current]);
45
+ /* the keyboard comes and goes: the body's scroll JUMPS (the Sheet's reveal, the browser's own, ours below, the box growing back).
46
+ 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
47
+ are set back by the jump, then eased to rest) so the eye can follow the pile moving out of the way and back. */
48
+ const glide = useRef(0), last = useRef(0);
49
+ useEffect(() => {
50
+ const el = ref.current, body = el?.closest('.sheet__body');
51
+ if (!el || !body)
52
+ return;
53
+ last.current = body.scrollTop;
54
+ const onScroll = () => {
55
+ const d = body.scrollTop - last.current;
56
+ last.current = body.scrollTop;
57
+ if (!d || performance.now() > glide.current || matchMedia('(prefers-reduced-motion: reduce)').matches)
58
+ return;
59
+ const from = parseFloat(el.style.translate.split(' ')[1] || '0') || 0;
60
+ el.style.transition = 'none';
61
+ el.style.translate = `0 ${from + d}px`;
62
+ void el.offsetHeight; // the set-back lands before the ease
63
+ el.style.transition = `translate ${GLIDE_MS}ms ease-out`;
64
+ el.style.translate = '0 0';
65
+ };
66
+ const clean = () => { el.style.removeProperty('transition'); el.style.removeProperty('translate'); };
67
+ body.addEventListener('scroll', onScroll);
68
+ el.addEventListener('transitionend', clean);
69
+ return () => { body.removeEventListener('scroll', onScroll); el.removeEventListener('transitionend', clean); };
70
+ }, []);
71
+ /* a field in the current step took the focus: once the keyboard is up (the Sheet has climbed), bring the field to EYE LEVEL — its middle
72
+ at the human centre (--screen-centre, 40%) of what shows between the sheet's head and the keyboard; the done pile moves up for it
73
+ (never down past its own top). When the focus
74
+ leaves the fields (the keyboard goes), the pile comes back down. Both moves glide. */
75
+ const typing = (t) => t instanceof HTMLElement && t.matches('input:not([type=checkbox], [type=radio], [type=range]), textarea');
76
+ const focusIn = (e) => {
77
+ onFocus?.(e);
78
+ glide.current = performance.now() + 900;
79
+ const target = e.target, now = target.closest('.sheet-step--now'), body = ref.current?.closest('.sheet__body');
80
+ if (!now || !body || !typing(target))
81
+ return;
82
+ setTimeout(() => {
83
+ const control = (target.closest('.field, .pin') ?? target).getBoundingClientRect(), vv = window.visualViewport;
84
+ const cut = Math.min(body.getBoundingClientRect().bottom - (parseFloat(getComputedStyle(body).scrollPaddingBottom) || 0), vv ? vv.offsetTop + vv.height : Infinity);
85
+ const top = body.getBoundingClientRect().top, eye = top + (cut - top) * ((parseFloat(getComputedStyle(body).getPropertyValue('--screen-centre')) || 40) / 100);
86
+ const by = Math.max((control.top + control.bottom) / 2 - eye, -body.scrollTop); // the field's middle at eye level — the human centre of what shows between the head and the keyboard
87
+ if (Math.abs(by) > 1)
88
+ body.scrollBy({ top: by });
89
+ }, 300); // after the keyboard and the sheet's own climb
90
+ };
91
+ const focusOut = (e) => {
92
+ onBlur?.(e);
93
+ if (!typing(e.target))
94
+ return;
95
+ glide.current = performance.now() + 900;
96
+ const body = ref.current?.closest('.sheet__body');
97
+ setTimeout(() => { if (!typing(document.activeElement) && body)
98
+ body.scrollTo({ top: 0 }); }, 0); // Tab to the next field keeps it up
99
+ };
100
+ /* an up/down swipe turns the wheel one step — unless it started in content that can still scroll that way */
101
+ const down = (e) => {
102
+ onPointerDown?.(e);
103
+ press.current = e.isPrimary && !e.target.closest('input, textarea, select, .pin, .picker, .slider') ? { x: e.clientX, y: e.clientY } : null;
104
+ };
105
+ const up = (e) => {
106
+ onPointerUp?.(e);
107
+ const p = press.current;
108
+ press.current = null;
109
+ if (!p)
110
+ return;
111
+ const dx = e.clientX - p.x, dy = e.clientY - p.y;
112
+ if (Math.abs(dy) < SWIPE_REM * parseFloat(getComputedStyle(document.documentElement).fontSize) || Math.abs(dy) < 2 * Math.abs(dx))
113
+ return;
114
+ const body = e.target.closest('.sheet-step__body');
115
+ if (body && (dy < 0 ? body.scrollTop + body.clientHeight < body.scrollHeight - 1 : body.scrollTop > 0))
116
+ return; // the content's own scroll
117
+ const to = current + (dy < 0 ? 1 : -1);
118
+ if (reversible)
119
+ go(to, 'swipe');
120
+ else if (dy < 0 && ref.current?.querySelector('.sheet-step--now[data-skippable]'))
121
+ go(to, 'skip'); // forward only, and only past a step that may be skipped
122
+ };
123
+ return (_jsxs("div", { ref: ref, className: cx('sheet-steps', !reversible && 'sheet-steps--locked', className), style: style, onFocus: focusIn, onBlur: focusOut, onPointerDown: down, onPointerUp: up, onPointerCancel: (e) => { onPointerCancel?.(e); press.current = null; }, ...rest, children: [_jsx("ol", { className: "sheet-steps__track", children: steps.map((step, i) => (_jsx(StepCtx.Provider, { value: { i, at: current, count, reversible, go }, children: step }, step.key ?? i))) }), finish != null && current === count - 1 && _jsx("div", { className: "sheet-steps__finish", children: finish })] }));
124
+ }
125
+ /** SheetStep — one step of a SheetSteps: its title, then what the member does (a Field, a Pin, a Toggle, the step's own Btn). */
126
+ export function SheetStep({ title, summary, skippable, skipLabel = 'Skip', className, children, ...rest }) {
127
+ const ctx = useContext(StepCtx);
128
+ if (!ctx)
129
+ throw new Error('SheetStep goes inside a SheetSteps');
130
+ const { i, at, count, reversible, go } = ctx;
131
+ const phase = i < at ? 'done' : i === at ? 'now' : 'next';
132
+ const off = phase !== 'now';
133
+ return (_jsxs("li", { className: cx('sheet-step', `sheet-step--${phase}`, className), "aria-current": phase === 'now' ? 'step' : undefined, "data-skippable": skippable || undefined, ...rest, children: [_jsxs("div", { className: "sheet-step__head", inert: off || undefined, children: [_jsx("h3", { className: "sheet-step__title t-title", tabIndex: -1, children: title }), phase === 'done' && summary != null && _jsx("span", { className: "sheet-step__summary t-caption t-muted", children: summary }), phase === 'done' && _jsx(Icon, { name: "check", size: "xs", className: "sheet-step__check" })] }), _jsx("div", { className: "sheet-step__body", children: _jsxs("div", { className: "sheet-step__inner", inert: off || undefined, children: [children, skippable && _jsx(Link, { sm: true, muted: true, className: "sheet-step__skip", onClick: () => go(i + 1, 'skip'), children: skipLabel })] }) }), reversible && off && (_jsx("button", { type: "button", className: "sheet-step__go", "aria-label": typeof title === 'string' ? title : `${i + 1} / ${count}`, onClick: () => go(i, 'tap') }))] }));
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
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.",