@usableapp/cardds 0.7.5 → 0.7.7
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 +24 -0
- package/README.md +8 -0
- package/css/forms.css +6 -1
- package/css/sheet.css +3 -3
- package/css/step.css +5 -1
- package/dist/cardds.css +14 -5
- package/dist/forms/Field.d.ts +7 -1
- package/dist/forms/Field.js +60 -3
- package/dist/forms/Pin.d.ts +4 -3
- package/dist/forms/Pin.js +45 -12
- package/dist/forms/check.d.ts +0 -4
- package/dist/forms/check.js +1 -1
- package/dist/sheets/SheetSteps.d.ts +2 -3
- package/dist/sheets/SheetSteps.js +4 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
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.7 — 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Changed (breaking for a confirm PIN)
|
|
8
|
+
- `Pin match` that does not match: before — red for 3 s, then the steps went back one on their own (`onAtChange(at - 1,
|
|
9
|
+
'mismatch')`). After — the step STAYS: the Pin shakes with red edges, the step shows `formatError`, then the Pin empties
|
|
10
|
+
itself (`onChange('', false)`) ready for a new try; the words go with the next digit. Going back is the member's choice:
|
|
11
|
+
put a `Link` under the Pin that sets `at` back (and clears both codes) — story `SheetSteps · Errors` ("ตั้ง PIN ใหม่").
|
|
12
|
+
The reason `'mismatch'` is gone from `SheetStepsReason` — drop its branch from `onAtChange`. Knobs `--pin-shake-x`, `--pin-shake-time`.
|
|
13
|
+
- `SheetStep`: a tap on a `Link` in the step keeps the cursor in its field, like its buttons — before, the field lost the
|
|
14
|
+
focus, the pile scrolled back and the link's click was lost (a "change number" link under an OTP too).
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `Field mask` — `"###-###-####"`: `#` = a digit, anything else is written in for the member as they type (081-234-5678).
|
|
18
|
+
Only digits stay; a mark comes only once a digit follows it. `onChange` reads the shaped value.
|
|
19
|
+
- `Field`: a placeholder longer than the box shrinks to show whole (measured in the input's font; the typed value keeps its size).
|
|
20
|
+
|
|
21
|
+
## 0.7.6 — 2026-09-24
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- The bottom layer of a `Screen fill` (the `ActionBar` — a Walkthrough's dots, controls, CTA row and link) now lies on
|
|
25
|
+
the screen's surface. Before: it had no ground, so long words scrolling under it mid-scroll showed through and could
|
|
26
|
+
not be read. Knob `--action-bar-bg` (default `var(--surface)`; `transparent` = the old see-through).
|
|
27
|
+
- `SheetSteps`: the cursor holder field's size is `1rem` (was 16px); no raw px left in sheet.css.
|
|
28
|
+
|
|
5
29
|
## 0.7.5 — 2026-09-24
|
|
6
30
|
|
|
7
31
|
### Errors in a form (new)
|
package/README.md
CHANGED
|
@@ -57,6 +57,14 @@ story live in a 375×812 phone cell, grouped by `src/<group>/`, searchable; swit
|
|
|
57
57
|
it comes up over the phone cell and publishes `--kb` there — to SEE what a keyboard leaves of the screen; it
|
|
58
58
|
types nothing), text size, and a slider per size base. Nothing is hand-listed: add a preview file or a story and it shows.
|
|
59
59
|
|
|
60
|
+
**On a real phone** — the mock keyboard shows the room a keyboard takes; the REAL one (its kind by `inputMode`: `numeric` /
|
|
61
|
+
`tel` = digits, `email`, `text`; its language is the member's own setting) only shows on a device. The gallery's server is
|
|
62
|
+
open on the Wi-Fi (`server.host: true`): `npm run dev` prints a `Network:` address — open it on a phone on the same
|
|
63
|
+
Wi-Fi. A project using cardds does the same in its own `vite.config` (`server: { host: true }`, or `vite --host`).
|
|
64
|
+
The iOS Simulator's Safari shows the iOS keyboard too (⌘K if it hides); desktop device modes show none. Installing as a
|
|
65
|
+
PWA needs https — a tunnel (`cloudflared tunnel --url http://localhost:<port>`), then Add to Home Screen. `host: true`
|
|
66
|
+
lets anyone on the same network open the dev server: use it on a network you trust.
|
|
67
|
+
|
|
60
68
|
`tests/fixtures/*.html` are the pages the geometry tests measure — not a demo. Nothing here is verified by eye.
|
|
61
69
|
|
|
62
70
|
### In another project
|
package/css/forms.css
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
outline: none; /* the browser's ring drew a second box inside the field (Lh 2026-09-22) — the field itself tells focus */
|
|
29
29
|
caret-color: currentColor;
|
|
30
30
|
}
|
|
31
|
-
.field input::placeholder { color: inherit; opacity: 0.55; }
|
|
31
|
+
.field input::placeholder { color: inherit; opacity: 0.55; font-size: calc(1em * var(--_ph-fit, 1)); } /* --_ph-fit: Field's measure — a placeholder longer than the box shrinks to show whole (Lh 2026-09-25) */
|
|
32
32
|
/* the field the cursor is in: its own border goes full ink, in every palette (the coloured ones fade it to 25% at rest) */
|
|
33
33
|
.field:has(:focus) { border-color: var(--field-active-border, currentColor); }
|
|
34
34
|
|
|
@@ -72,6 +72,11 @@
|
|
|
72
72
|
<p class="form-note form-note--busy" role="status"><svg …/>Sending the code…</p> (the ring turns) */
|
|
73
73
|
.field.field--error, [data-palette] .field.field--error { border-color: var(--field-error-border, var(--signal-error)); }
|
|
74
74
|
.pin.pin--error .pin__cell, .pin.pin--error .pin__cell:focus { border-color: var(--pin-error-border, var(--signal-error)); color: var(--pin-error-ink, var(--signal-error)); } /* ALL the cells, edge and digit — the one the cursor waits in too (Lh 2026-09-24); the wrong digits stay in red until the member types again */
|
|
75
|
+
/* a code that does not match (Pin match): the row shakes once, sideways, then the Pin empties itself for a new try (Lh 2026-09-25) */
|
|
76
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
77
|
+
.pin--shake { animation: pin-shake var(--pin-shake-time, 0.4s) ease-in-out; }
|
|
78
|
+
}
|
|
79
|
+
@keyframes pin-shake { 0%, 100% { translate: 0; } 20%, 60% { translate: calc(-1 * var(--pin-shake-x, var(--sp-2))); } 40%, 80% { translate: var(--pin-shake-x, var(--sp-2)); } }
|
|
75
80
|
.form-note { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); margin: 0; font: var(--form-note-font, var(--type-caption)); color: var(--form-note-ink, var(--card-muted)); text-align: center; }
|
|
76
81
|
.form-note > .icon { flex: none; }
|
|
77
82
|
.form-note--error { color: var(--form-note-error-ink, var(--signal-error)); }
|
package/css/sheet.css
CHANGED
|
@@ -380,8 +380,8 @@
|
|
|
380
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
381
|
falls — no height is written. Nothing fades: done and next read at full ink. */
|
|
382
382
|
.sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
|
|
383
|
-
.sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb,
|
|
384
|
-
.sheet-steps__holder { position: absolute; top: 0; left: 0; width:
|
|
383
|
+
.sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0rem)); /* 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__holder { position: absolute; top: 0; left: 0; width: 1rem; height: 1rem; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; font 1rem = the browser's own size, so iOS does not zoom to it */
|
|
385
385
|
.sheet-steps { position: relative; }
|
|
386
386
|
.sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
|
|
387
387
|
|
|
@@ -403,7 +403,7 @@
|
|
|
403
403
|
.sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
|
|
404
404
|
.sheet-step__inner { display: grid; gap: var(--card-gap); padding-bottom: var(--card-gap); }
|
|
405
405
|
.sheet-step__inner > * { min-width: 0; }
|
|
406
|
-
.sheet-
|
|
406
|
+
.sheet-step__inner > .link { justify-self: center; } /* a link of its own row in a step (skip, "change number", "set PIN again"): centred under the step, as the skip (Lh 2026-09-25) */
|
|
407
407
|
.sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
|
|
408
408
|
.sheet-step__go { position: absolute; inset: 0; margin: 0; padding: 0; border: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
409
409
|
/* the last step's finish, when the app gives one: under the steps */
|
package/css/step.css
CHANGED
|
@@ -120,7 +120,11 @@
|
|
|
120
120
|
position: absolute; inset-inline: 0; top: auto; bottom: var(--kb, 0rem);
|
|
121
121
|
max-width: none;
|
|
122
122
|
margin: 0;
|
|
123
|
-
padding-top: 0;
|
|
123
|
+
padding-top: 0;
|
|
124
|
+
/* its GROUND (Lh 2026-09-24, timebank card 141 — was "no ground of its own"): words that run under the layer mid-scroll
|
|
125
|
+
showed through the dots, the controls and the buttons. The layer lies on the screen's own surface now; a project sets
|
|
126
|
+
another (or `transparent`, the old see-through) with the knob */
|
|
127
|
+
background: var(--action-bar-bg, var(--surface));
|
|
124
128
|
}
|
|
125
129
|
/* the walk's dots are the BOTTOM layer's too (Lh 2026-09-23): a row of the bar, over its buttons, centred */
|
|
126
130
|
.action-bar > .pager__at, .action-bar__tier > .pager__at { align-self: center; }
|
package/dist/cardds.css
CHANGED
|
@@ -1557,8 +1557,8 @@ button { font: inherit; cursor: pointer; }
|
|
|
1557
1557
|
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
|
|
1558
1558
|
falls — no height is written. Nothing fades: done and next read at full ink. */
|
|
1559
1559
|
.sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
|
|
1560
|
-
.sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb,
|
|
1561
|
-
.sheet-steps__holder { position: absolute; top: 0; left: 0; width:
|
|
1560
|
+
.sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0rem)); /* 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; }
|
|
1561
|
+
.sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1rem; height: 1rem; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; font 1rem = the browser's own size, so iOS does not zoom to it */
|
|
1562
1562
|
.sheet-steps { position: relative; }
|
|
1563
1563
|
.sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
|
|
1564
1564
|
|
|
@@ -1580,7 +1580,7 @@ button { font: inherit; cursor: pointer; }
|
|
|
1580
1580
|
.sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
|
|
1581
1581
|
.sheet-step__inner { display: grid; gap: var(--card-gap); padding-bottom: var(--card-gap); }
|
|
1582
1582
|
.sheet-step__inner > * { min-width: 0; }
|
|
1583
|
-
.sheet-
|
|
1583
|
+
.sheet-step__inner > .link { justify-self: center; } /* a link of its own row in a step (skip, "change number", "set PIN again"): centred under the step, as the skip (Lh 2026-09-25) */
|
|
1584
1584
|
.sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
|
|
1585
1585
|
.sheet-step__go { position: absolute; inset: 0; margin: 0; padding: 0; border: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
1586
1586
|
/* the last step's finish, when the app gives one: under the steps */
|
|
@@ -1712,7 +1712,11 @@ button { font: inherit; cursor: pointer; }
|
|
|
1712
1712
|
position: absolute; inset-inline: 0; top: auto; bottom: var(--kb, 0rem);
|
|
1713
1713
|
max-width: none;
|
|
1714
1714
|
margin: 0;
|
|
1715
|
-
padding-top: 0;
|
|
1715
|
+
padding-top: 0;
|
|
1716
|
+
/* its GROUND (Lh 2026-09-24, timebank card 141 — was "no ground of its own"): words that run under the layer mid-scroll
|
|
1717
|
+
showed through the dots, the controls and the buttons. The layer lies on the screen's own surface now; a project sets
|
|
1718
|
+
another (or `transparent`, the old see-through) with the knob */
|
|
1719
|
+
background: var(--action-bar-bg, var(--surface));
|
|
1716
1720
|
}
|
|
1717
1721
|
/* the walk's dots are the BOTTOM layer's too (Lh 2026-09-23): a row of the bar, over its buttons, centred */
|
|
1718
1722
|
.action-bar > .pager__at, .action-bar__tier > .pager__at { align-self: center; }
|
|
@@ -1781,7 +1785,7 @@ button { font: inherit; cursor: pointer; }
|
|
|
1781
1785
|
outline: none; /* the browser's ring drew a second box inside the field (Lh 2026-09-22) — the field itself tells focus */
|
|
1782
1786
|
caret-color: currentColor;
|
|
1783
1787
|
}
|
|
1784
|
-
.field input::placeholder { color: inherit; opacity: 0.55; }
|
|
1788
|
+
.field input::placeholder { color: inherit; opacity: 0.55; font-size: calc(1em * var(--_ph-fit, 1)); } /* --_ph-fit: Field's measure — a placeholder longer than the box shrinks to show whole (Lh 2026-09-25) */
|
|
1785
1789
|
/* the field the cursor is in: its own border goes full ink, in every palette (the coloured ones fade it to 25% at rest) */
|
|
1786
1790
|
.field:has(:focus) { border-color: var(--field-active-border, currentColor); }
|
|
1787
1791
|
|
|
@@ -1825,6 +1829,11 @@ button { font: inherit; cursor: pointer; }
|
|
|
1825
1829
|
<p class="form-note form-note--busy" role="status"><svg …/>Sending the code…</p> (the ring turns) */
|
|
1826
1830
|
.field.field--error, [data-palette] .field.field--error { border-color: var(--field-error-border, var(--signal-error)); }
|
|
1827
1831
|
.pin.pin--error .pin__cell, .pin.pin--error .pin__cell:focus { border-color: var(--pin-error-border, var(--signal-error)); color: var(--pin-error-ink, var(--signal-error)); } /* ALL the cells, edge and digit — the one the cursor waits in too (Lh 2026-09-24); the wrong digits stay in red until the member types again */
|
|
1832
|
+
/* a code that does not match (Pin match): the row shakes once, sideways, then the Pin empties itself for a new try (Lh 2026-09-25) */
|
|
1833
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1834
|
+
.pin--shake { animation: pin-shake var(--pin-shake-time, 0.4s) ease-in-out; }
|
|
1835
|
+
}
|
|
1836
|
+
@keyframes pin-shake { 0%, 100% { translate: 0; } 20%, 60% { translate: calc(-1 * var(--pin-shake-x, var(--sp-2))); } 40%, 80% { translate: var(--pin-shake-x, var(--sp-2)); } }
|
|
1828
1837
|
.form-note { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); margin: 0; font: var(--form-note-font, var(--type-caption)); color: var(--form-note-ink, var(--card-muted)); text-align: center; }
|
|
1829
1838
|
.form-note > .icon { flex: none; }
|
|
1830
1839
|
.form-note--error { color: var(--form-note-error-ink, var(--signal-error)); }
|
package/dist/forms/Field.d.ts
CHANGED
|
@@ -20,11 +20,17 @@ export interface FieldProps extends Omit<ComponentProps<'input'>, 'className' |
|
|
|
20
20
|
/** the words when the format check fails — shown under the step's button (the app's words: cardds has none). `required`
|
|
21
21
|
* (the attribute) makes an empty field fail when the button is pressed. */
|
|
22
22
|
formatError?: ReactNode;
|
|
23
|
+
/** the shape the digits are written in, AS THE MEMBER TYPES: `#` = one digit, anything else is put in for them ("###-###-####"
|
|
24
|
+
* → 081-234-5678). Only digits are kept; a mark comes only once a digit follows it, so Backspace never sticks on a dash. The
|
|
25
|
+
* app's `onChange` reads the shaped value (the digits alone: `value.replace(/\D/g, '')`). Lh 2026-09-25 */
|
|
26
|
+
mask?: string;
|
|
23
27
|
className?: string;
|
|
24
28
|
style?: React.CSSProperties;
|
|
25
29
|
}
|
|
30
|
+
/** the digits of `v`, written into `mask`; a mark is written only when a digit comes after it */
|
|
31
|
+
export declare function applyMask(v: string, mask: string): string;
|
|
26
32
|
/**
|
|
27
33
|
* Field — the flat outlined input: the LABEL IS THE PLACEHOLDER. 48px tall (56px with an action inside).
|
|
28
34
|
* Fields belong in a Sheet (what the member acts on), 1–3 per step, never in a reading card.
|
|
29
35
|
*/
|
|
30
|
-
export declare function Field({ icon, action, label, error, format, digits, formatError, className, style, onBlur, onInput, ...input }: FieldProps): import("react").JSX.Element;
|
|
36
|
+
export declare function Field({ icon, action, label, error, format, digits, formatError, mask, className, style, onBlur, onInput, onChange, ...input }: FieldProps): import("react").JSX.Element;
|
package/dist/forms/Field.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
3
|
import { formatOk, useCheck } from './check.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
import { Icon } from '../type/Icon.js';
|
|
6
|
+
/** the digits of `v`, written into `mask`; a mark is written only when a digit comes after it */
|
|
7
|
+
export function applyMask(v, mask) {
|
|
8
|
+
const d = v.replace(/\D/g, '');
|
|
9
|
+
let out = '', k = 0;
|
|
10
|
+
for (const m of mask) {
|
|
11
|
+
if (k >= d.length)
|
|
12
|
+
break;
|
|
13
|
+
if (m === '#')
|
|
14
|
+
out += d[k++];
|
|
15
|
+
else
|
|
16
|
+
out += m;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
6
20
|
/**
|
|
7
21
|
* Field — the flat outlined input: the LABEL IS THE PLACEHOLDER. 48px tall (56px with an action inside).
|
|
8
22
|
* Fields belong in a Sheet (what the member acts on), 1–3 per step, never in a reading card.
|
|
9
23
|
*/
|
|
10
|
-
export function Field({ icon, action, label, error, format, digits, formatError, className, style, onBlur, onInput, ...input }) {
|
|
24
|
+
export function Field({ icon, action, label, error, format, digits, formatError, mask, className, style, onBlur, onInput, onChange, ...input }) {
|
|
11
25
|
const ref = useRef(null);
|
|
12
26
|
const [bad, setBad] = useState(false); // the format check failed, and has not been fixed since
|
|
13
27
|
const wrong = (error != null && error !== false && error !== '') || bad;
|
|
@@ -21,6 +35,35 @@ export function Field({ icon, action, label, error, format, digits, formatError,
|
|
|
21
35
|
};
|
|
22
36
|
const { say } = useCheck((strict) => { const w = verdict(strict); if (w != null)
|
|
23
37
|
setBad(true); return w; }, () => { ref.current?.focus(); ref.current?.select(); });
|
|
38
|
+
/* a placeholder longer than the box SHRINKS to show whole (Lh 2026-09-25): its width is measured in the input's own font,
|
|
39
|
+
and `--_ph-fit` (the share that fits, ≤ 1) scales the placeholder's size — again whenever the box or the font changes */
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
const el = ref.current, text = input.placeholder;
|
|
42
|
+
if (!el)
|
|
43
|
+
return;
|
|
44
|
+
if (!text) {
|
|
45
|
+
el.style.removeProperty('--_ph-fit');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
49
|
+
const fit = () => {
|
|
50
|
+
if (!ctx)
|
|
51
|
+
return;
|
|
52
|
+
const cs = getComputedStyle(el);
|
|
53
|
+
ctx.font = cs.font;
|
|
54
|
+
const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), need = ctx.measureText(text).width;
|
|
55
|
+
const share = need > room && room > 0 ? Math.floor((room / need) * 1000) / 1000 : 1;
|
|
56
|
+
if (share < 1)
|
|
57
|
+
el.style.setProperty('--_ph-fit', String(share));
|
|
58
|
+
else
|
|
59
|
+
el.style.removeProperty('--_ph-fit');
|
|
60
|
+
};
|
|
61
|
+
fit();
|
|
62
|
+
const ro = new ResizeObserver(fit);
|
|
63
|
+
ro.observe(el);
|
|
64
|
+
document.fonts?.ready.then(fit);
|
|
65
|
+
return () => ro.disconnect();
|
|
66
|
+
}, [input.placeholder]);
|
|
24
67
|
useEffect(() => {
|
|
25
68
|
if (wrong) {
|
|
26
69
|
ref.current?.focus();
|
|
@@ -29,7 +72,21 @@ export function Field({ icon, action, label, error, format, digits, formatError,
|
|
|
29
72
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- when the app's error comes or changes
|
|
30
73
|
}, [error]);
|
|
31
74
|
return (_jsxs("label", { className: cx('field', action != null && 'field--action', wrong && 'field--error', className), style: style, children: [icon && _jsx(Icon, { name: icon, size: "sm" }), _jsx("input", { ref: ref, "aria-label": label, "aria-invalid": wrong || undefined, ...input, onBlur: (e) => { onBlur?.(e); if (!checked)
|
|
32
|
-
return; const w = verdict(false); setBad(w != null); say(w); },
|
|
75
|
+
return; const w = verdict(false); setBad(w != null); say(w); }, onChange: (e) => {
|
|
76
|
+
if (mask) {
|
|
77
|
+
const el = e.currentTarget, before = el.value.slice(0, el.selectionStart ?? el.value.length).replace(/\D/g, '').length;
|
|
78
|
+
const shaped = applyMask(el.value, mask);
|
|
79
|
+
if (shaped !== el.value) {
|
|
80
|
+
el.value = shaped;
|
|
81
|
+
let at = 0;
|
|
82
|
+
for (let n = 0; at < shaped.length && n < before; at++)
|
|
83
|
+
if (/\d/.test(shaped[at]))
|
|
84
|
+
n++; // the caret stays after the same digit
|
|
85
|
+
el.setSelectionRange(at, at);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
onChange?.(e);
|
|
89
|
+
}, onInput: (e) => { onInput?.(e); if (bad && verdict(false) == null) {
|
|
33
90
|
setBad(false);
|
|
34
91
|
say(null);
|
|
35
92
|
} } }), action] }));
|
package/dist/forms/Pin.d.ts
CHANGED
|
@@ -14,8 +14,9 @@ export interface PinProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defa
|
|
|
14
14
|
* first cell, and the next digit typed starts a new code. The words are the step's (`SheetStep error`). */
|
|
15
15
|
error?: unknown;
|
|
16
16
|
/** a FORMAT check on the client: the code it must equal (the first PIN, when this one confirms it). Once every cell is filled
|
|
17
|
-
* and it differs, the Pin
|
|
18
|
-
*
|
|
17
|
+
* and it differs, the Pin SHAKES with red edges and its digits in sight, the step shows `formatError` under it, then the Pin
|
|
18
|
+
* empties itself (`onChange('', false)`), ready for a new try — it stays on its step. Going back to set the first code again
|
|
19
|
+
* is the member's choice: the app puts a `Link` under it (Lh 2026-09-25; it used to step back on its own after 3 s). */
|
|
19
20
|
match?: string;
|
|
20
21
|
/** the words when a check fails: the code does not match, or the step's button was pressed with cells still empty */
|
|
21
22
|
formatError?: ReactNode;
|
|
@@ -28,4 +29,4 @@ export interface PinProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defa
|
|
|
28
29
|
* arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
|
|
29
30
|
* one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
|
|
30
31
|
*/
|
|
31
|
-
export declare function Pin({ length, value, defaultValue, label, onChange, autoFocus, error, match, formatError, className, ...rest }: PinProps): import("react").JSX.Element;
|
|
32
|
+
export declare function Pin({ length, value, defaultValue, label, onChange, autoFocus, error, match, formatError, className, onAnimationEnd, ...rest }: PinProps): import("react").JSX.Element;
|
package/dist/forms/Pin.js
CHANGED
|
@@ -2,28 +2,40 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { cx } from '../cx.js';
|
|
4
4
|
import { useCheck } from './check.js';
|
|
5
|
+
const RESET_MS = 900; // a code that does not match: red + shake this long, then the cells empty for a new try
|
|
5
6
|
/**
|
|
6
7
|
* Pin — big code entry, one digit per pill; a filled cell turns accent. Lives in a Sheet.
|
|
7
8
|
* Its own behaviour, inside it: typing a digit moves to the next cell, Backspace in an empty cell moves back and clears, the
|
|
8
9
|
* arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
|
|
9
10
|
* one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
|
|
10
11
|
*/
|
|
11
|
-
export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, error, match, formatError, className, ...rest }) {
|
|
12
|
+
export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, error, match, formatError, className, onAnimationEnd, ...rest }) {
|
|
12
13
|
const ref = useRef(null);
|
|
13
14
|
const [inner, setInner] = useState(defaultValue);
|
|
14
15
|
const code = (value ?? inner).replace(/\D/g, '').slice(0, length);
|
|
15
16
|
const cell = (i) => ref.current?.querySelectorAll('.pin__cell')[i];
|
|
16
17
|
const set = (next) => {
|
|
17
18
|
next = next.slice(0, length);
|
|
18
|
-
if (bad) {
|
|
19
|
+
if (bad || said.current) {
|
|
19
20
|
setBad(false);
|
|
20
21
|
say(null);
|
|
22
|
+
said.current = false;
|
|
21
23
|
}
|
|
24
|
+
clearTimeout(reset.current);
|
|
22
25
|
if (match != null && next.length === length && next !== match) {
|
|
23
26
|
setBad(true);
|
|
24
27
|
say(words);
|
|
25
|
-
|
|
28
|
+
said.current = true;
|
|
26
29
|
restart.current = true;
|
|
30
|
+
setShake(true);
|
|
31
|
+
reset.current = setTimeout(() => {
|
|
32
|
+
setBad(false);
|
|
33
|
+
restart.current = false;
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
setInner('');
|
|
36
|
+
onChange?.('', false);
|
|
37
|
+
cell(0)?.focus();
|
|
38
|
+
}, RESET_MS);
|
|
27
39
|
}
|
|
28
40
|
if (value === undefined)
|
|
29
41
|
setInner(next);
|
|
@@ -36,8 +48,12 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
|
|
|
36
48
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- on mount only
|
|
37
49
|
}, [autoFocus]);
|
|
38
50
|
const [bad, setBad] = useState(false); // a format check failed, and nothing was typed since
|
|
51
|
+
const [shake, setShake] = useState(false); // plays once per miss; off again at its animationend
|
|
52
|
+
const said = useRef(false); // the mismatch words are still shown (the cells were emptied, the words wait for the next digit)
|
|
53
|
+
const reset = useRef(undefined);
|
|
54
|
+
useEffect(() => () => clearTimeout(reset.current), []);
|
|
39
55
|
const words = formatError ?? '';
|
|
40
|
-
const { say
|
|
56
|
+
const { say } = useCheck((strict) => {
|
|
41
57
|
if (strict && code.length < length) {
|
|
42
58
|
setBad(true);
|
|
43
59
|
return words;
|
|
@@ -68,14 +84,18 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
|
|
|
68
84
|
}, [code]);
|
|
69
85
|
const input = (i) => (e) => {
|
|
70
86
|
const digits = e.target.value.replace(/\D/g, '');
|
|
71
|
-
if (restart.current) {
|
|
87
|
+
if (restart.current) { // a new try after a wrong code: what arrives starts a new code from the first cell
|
|
72
88
|
restart.current = false;
|
|
73
|
-
if (digits)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
if (!digits)
|
|
90
|
+
return;
|
|
91
|
+
const old = code[i] ?? '';
|
|
92
|
+
// the cell still held its red digit (not selected, so it stays visible): what came in is the value minus that digit — one
|
|
93
|
+
// typed digit, or a whole pasted / SMS-autofilled code (then it fills every cell, never just the last digit: timebank 142)
|
|
94
|
+
const fresh = digits.length >= length ? digits.slice(-length) : old && digits.length > 1 && digits.startsWith(old) ? digits.slice(old.length) : old && digits.length > 1 && digits.endsWith(old) ? digits.slice(0, -old.length) : digits;
|
|
95
|
+
set(fresh);
|
|
96
|
+
cell(Math.min(fresh.length, length - 1))?.focus();
|
|
77
97
|
return;
|
|
78
|
-
}
|
|
98
|
+
}
|
|
79
99
|
const at = Math.min(i, code.length); // a code has no gaps: a digit typed further along lands in the first empty cell
|
|
80
100
|
if (!digits) {
|
|
81
101
|
set(code.slice(0, at) + code.slice(at + 1));
|
|
@@ -84,6 +104,18 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
|
|
|
84
104
|
set(code.slice(0, at) + digits + code.slice(at + digits.length)); // a paste (or a typed run) spills over the cells that follow
|
|
85
105
|
cell(Math.min(at + digits.length, length - 1))?.focus();
|
|
86
106
|
};
|
|
107
|
+
/* a paste after a wrong code: the clipboard's digits ARE the new code — read them straight, whatever the cell held */
|
|
108
|
+
const paste = (e) => {
|
|
109
|
+
if (!restart.current)
|
|
110
|
+
return; // otherwise the change handler spills it over the cells as always
|
|
111
|
+
const digits = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length);
|
|
112
|
+
if (!digits)
|
|
113
|
+
return;
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
restart.current = false;
|
|
116
|
+
set(digits);
|
|
117
|
+
cell(Math.min(digits.length, length - 1))?.focus();
|
|
118
|
+
};
|
|
87
119
|
const key = (i) => (e) => {
|
|
88
120
|
if (e.key === 'Backspace' && !e.currentTarget.value && i > 0) {
|
|
89
121
|
e.preventDefault();
|
|
@@ -99,9 +131,10 @@ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onC
|
|
|
99
131
|
cell(i + 1)?.focus();
|
|
100
132
|
}
|
|
101
133
|
};
|
|
102
|
-
return (_jsx("div", { ref: ref, className: cx('pin', wrong && 'pin--error', className), ...rest,
|
|
134
|
+
return (_jsx("div", { ref: ref, className: cx('pin', wrong && 'pin--error', shake && 'pin--shake', className), ...rest, onAnimationEnd: (e) => { if (e.animationName === 'pin-shake')
|
|
135
|
+
setShake(false); onAnimationEnd?.(e); }, children: Array.from({ length }, (_, i) => (
|
|
103
136
|
// no maxLength: the browser would cut a pasted code before this ever saw it. A cell holds one digit — typing into a full one
|
|
104
137
|
// replaces it, so its digit is selected whenever the cell takes the focus
|
|
105
|
-
_jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 ? 'one-time-code' : 'off', onChange: input(i), onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
|
|
138
|
+
_jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => { if (!restart.current)
|
|
106
139
|
e.currentTarget.select(); } }, i))) }));
|
|
107
140
|
}
|
package/dist/forms/check.d.ts
CHANGED
|
@@ -9,15 +9,11 @@ export type CheckGroup = {
|
|
|
9
9
|
add: (c: Check) => () => void;
|
|
10
10
|
/** a control's own verdict changed (it was left, or it was fixed): its words, or null */
|
|
11
11
|
say: (c: Check, words: ReactNode | null) => void;
|
|
12
|
-
/** a check that can only be failed by going back (the second PIN does not match the first): the group shows the words for a
|
|
13
|
-
* moment, then steps back */
|
|
14
|
-
back: () => void;
|
|
15
12
|
};
|
|
16
13
|
export declare const CheckContext: import("react").Context<CheckGroup | null>;
|
|
17
14
|
/** join the group around (if any) with this control's check; the latest check function is always the one asked */
|
|
18
15
|
export declare function useCheck(check: Check['check'], focus: Check['focus']): {
|
|
19
16
|
say: (words: ReactNode | null) => void | undefined;
|
|
20
|
-
back: () => void | undefined;
|
|
21
17
|
};
|
|
22
18
|
/** a Field's format: `email`, `tel` (digits, spaces, - + ( ) only) and/or an exact count of `digits` */
|
|
23
19
|
export declare function formatOk(value: string, format?: 'email' | 'tel', digits?: number): boolean;
|
package/dist/forms/check.js
CHANGED
|
@@ -8,7 +8,7 @@ export function useCheck(check, focus) {
|
|
|
8
8
|
latest.current.focus = focus;
|
|
9
9
|
const self = useRef({ check: (s) => latest.current.check(s), focus: () => latest.current.focus() }).current;
|
|
10
10
|
useEffect(() => group?.add(self), [group, self]);
|
|
11
|
-
return { say: (words) => group?.say(self, words)
|
|
11
|
+
return { say: (words) => group?.say(self, words) };
|
|
12
12
|
}
|
|
13
13
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
14
14
|
/** a Field's format: `email`, `tel` (digits, spaces, - + ( ) only) and/or an exact count of `digits` */
|
|
@@ -1,7 +1,6 @@
|
|
|
1
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
|
-
|
|
4
|
-
export type SheetStepsReason = 'tap' | 'swipe' | 'skip' | 'mismatch';
|
|
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';
|
|
5
4
|
export interface SheetStepsProps extends ComponentProps<'div'> {
|
|
6
5
|
/** optional: a `Btn` that finishes, under the steps — always in sight, `disabled` until EVERY step is done (its work, or
|
|
7
6
|
* skipped: `at` has reached `count`). None = the task ends when `at` reaches `count` — the last step's own action, or its skip. */
|
|
@@ -5,7 +5,6 @@ import { Icon } from '../type/Icon.js';
|
|
|
5
5
|
import { Link } from '../actions/Link.js';
|
|
6
6
|
import { FormNote } from '../forms/FormNote.js';
|
|
7
7
|
import { CheckContext } from '../forms/check.js';
|
|
8
|
-
const BACK_MS = 3000; // a PIN that does not match: its red digits and words stay this long, then the step goes back
|
|
9
8
|
const MOVE_MS = 350; // = --motion-sheet: the accordion's beat
|
|
10
9
|
const GLIDE_MS = 220; // the pile's slide out of the keyboard's way and back: quick, but the eye follows it
|
|
11
10
|
const SWIPE_REM = 3; // an up/down move longer than this (and mostly up/down) is a swipe
|
|
@@ -197,9 +196,7 @@ export function SheetStep({ title, summary, skippable, skipLabel = 'Skip', error
|
|
|
197
196
|
const off = phase !== 'now';
|
|
198
197
|
/* the step is the FORM GROUP of the format checks inside it (Field format/digits/required, Pin match): the words of the first
|
|
199
198
|
control that fails show under the button; a press on a button of the step checks them all first and holds the press back
|
|
200
|
-
when one fails (the app's onClick never hears it); a Pin that does not match shows its words
|
|
201
|
-
const goRef = useRef(go);
|
|
202
|
-
goRef.current = go;
|
|
199
|
+
when one fails (the app's onClick never hears it); a Pin that does not match shakes, shows its words and empties itself — the step stays */
|
|
203
200
|
const [note, setNote] = useState(null);
|
|
204
201
|
const checks = useRef(new Map());
|
|
205
202
|
const first = () => { for (const w of checks.current.values())
|
|
@@ -208,18 +205,17 @@ export function SheetStep({ title, summary, skippable, skipLabel = 'Skip', error
|
|
|
208
205
|
const group = useMemo(() => ({
|
|
209
206
|
add: (c) => { checks.current.set(c, null); return () => { checks.current.delete(c); setNote(first()); }; },
|
|
210
207
|
say: (c, w) => { checks.current.set(c, w); setNote(first()); },
|
|
211
|
-
|
|
212
|
-
}), [i]);
|
|
208
|
+
}), []);
|
|
213
209
|
useEffect(() => { if (off) {
|
|
214
210
|
for (const c of checks.current.keys())
|
|
215
211
|
checks.current.set(c, null);
|
|
216
212
|
setNote(null);
|
|
217
213
|
} }, [off]);
|
|
218
|
-
/* a press on the step's button keeps the cursor where it is: the button still clicks, but the field keeps the focus, so a
|
|
214
|
+
/* a press on the step's button or link keeps the cursor where it is: the button still clicks, but the field keeps the focus, so a
|
|
219
215
|
phone's keyboard does not drop and come back (the pile jumped up and down with it — Lh 2026-09-24) */
|
|
220
216
|
const keepCursor = (e) => {
|
|
221
217
|
const at = document.activeElement;
|
|
222
|
-
if (e.target.closest('.btn') && at instanceof HTMLElement && at.matches('input, textarea') && e.currentTarget.contains(at))
|
|
218
|
+
if (e.target.closest('.btn, .link') && at instanceof HTMLElement && at.matches('input, textarea') && e.currentTarget.contains(at))
|
|
223
219
|
e.preventDefault();
|
|
224
220
|
};
|
|
225
221
|
const press = (e) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
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.",
|