@usableapp/cardds 0.7.48 → 0.7.50
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 +18 -0
- package/cardds.js +6 -1
- package/css/base.css +10 -0
- package/css/choice.css +12 -3
- package/css/forms.css +3 -2
- package/css/sheet.css +15 -3
- package/css/tokens.css +1 -1
- package/dist/cardds.css +41 -9
- package/dist/forms/Pin.js +25 -3
- package/dist/sheets/Sheet.d.ts +1 -3
- package/dist/sheets/Sheet.js +14 -187
- package/dist/sheets/SheetSteps.js +70 -79
- package/dist/sheets/sheetDrag.d.ts +9 -0
- package/dist/sheets/sheetDrag.js +49 -0
- package/dist/sheets/sheetMeasure.d.ts +10 -0
- package/dist/sheets/sheetMeasure.js +84 -0
- package/dist/sheets/sheetSlide.d.ts +9 -0
- package/dist/sheets/sheetSlide.js +96 -0
- package/dist/sheets/sheetStepsPile.d.ts +8 -0
- package/dist/sheets/sheetStepsPile.js +70 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
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.50 — 2026-10-04
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **With the keyboard up, the screen's top stays in sight** (Lh 2026-10-04, timebank on an iPhone): when its keyboard opens, an iPhone slides the whole page up to show the field — a sheet's title went over the clock, a `Track` under the camera. cardds now measures that slide (`--vv-top`) and adds it to the screen's top reserve (`--safe-top`), so the top of a fill screen, an `AppBar`, a `Track`, a `Snackbar` stay where they were. Nothing to change in a screen. If your own CSS reads `env(safe-area-inset-top)` directly for something at the top of a cardds screen, read `--safe-top` instead.
|
|
9
|
+
- **`SheetSteps`: the phone's keyboard no longer drops when a step changes** (Lh 2026-10-04, timebank as a home-screen app on an iPhone): the pad went down at every step change and the member had to tap a cell to get it back. On a touch screen the cursor now waits in the steps' hidden holder from the moment the step's button is pressed, and no script moves it after that; the open step's first field shows the cursor (a `Pin`'s caret, a `Field`'s full border) and takes the first key typed. A desktop is unchanged. Nothing to change in a screen — but `document.activeElement` is the holder (`.sheet-steps__holder`) until the first key, not the field: do not test for the field's focus right after a step change on a phone. Arriving on a NEW screen (a route change) still shows no keyboard until a tap — an iPhone allows none.
|
|
10
|
+
- **`Field`: the words keep 12px from the lead icon** — 0.7.49 took the browser's padding off inputs, and the words stood against the icon. The gap is the Field's own now (`--field-gap`, 12px; it was 8px plus the browser's padding: 2px on a desktop, about 13px on an iPhone). If your theme sets `--field-gap`, it still wins.
|
|
11
|
+
|
|
12
|
+
## 0.7.49 — 2026-10-03
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **`Pin` on an iPhone: the digits can be read** (Lh 2026-10-03, timebank): iOS gives a text field half an em of padding each side — more than a Pin cell is wide — so a typed digit had no room, started at the cell's middle and ran out of it (a few slivers of ink). The cell now has no padding and no platform look; it is also 2:3 on iOS as everywhere (it stood taller). Nothing to change in a screen.
|
|
16
|
+
- **`Pin`: a cell that cannot be typed into yet does not look ready** — a phone opens its keyboard on a tap, not when a script sets the cursor (a sheet arriving, a step opening). On a touch screen, a cell that got the cursor that way with no keyboard up shows the resting border and no caret, until a finger taps a cell, the keyboard is up, or a digit arrives. A desktop is unchanged.
|
|
17
|
+
- **`SheetSteps`: a digit typed while a step is opening is not lost** — it used to be dropped (the cursor waits in a hidden holder while the step folds, and on an iPhone the keyboard could stay there); it now lands in the open step's first field, and the cursor with it.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **One look on every phone** (Lh 2026-10-03): the browser's own padding, margin, corners, field look and button colour are taken off every `button`, text `input` and `textarea` once, at zero specificity — iOS, Android and a desktop each gave their own numbers (iOS: 0.5em a side on a field, 1em on a button, system-blue words on an unstyled button). cardds's own rules are untouched; on a desktop nothing changes size (the words in a `Field` start 2px further left). **If your app has its own `<button>` or `<input>` with no styling inside a cardds page, it loses the browser's padding and look too — give it a cardds component or its own rule.** `html` also gets `text-size-adjust: 100%` (an iPhone turned sideways no longer enlarges the words).
|
|
21
|
+
- **A `Sheet`'s long title runs sideways** like a song title, instead of being cut with `…` (Lh 2026-10-03): still one line; it waits, runs to its end, waits, starts again. With reduced motion it is cut with `…` as before. Knob `--sheet-title-run-time`. Nothing to change in a screen. Story `Sheet · LongTitle`.
|
|
22
|
+
|
|
5
23
|
## 0.7.48 — 2026-10-03
|
|
6
24
|
|
|
7
25
|
### Changed
|
package/cardds.js
CHANGED
|
@@ -11,12 +11,17 @@
|
|
|
11
11
|
bar and the sheet's control disappear behind the keyboard unless they
|
|
12
12
|
read this; Android with interactive-widget=resizes-content (set it in the
|
|
13
13
|
viewport meta) shrinks the layout viewport, and this stays 0. A pinch zoom
|
|
14
|
-
also shrinks the visual viewport, and is not a keyboard — ignored.
|
|
14
|
+
also shrinks the visual viewport, and is not a keyboard — ignored.
|
|
15
|
+
--vv-top: how far the phone has PANNED the page up to show a focused field (iOS does it when the keyboard opens, even on
|
|
16
|
+
a page that cannot scroll): everything on the screen slides up by that much, its top under the status bar and the camera
|
|
17
|
+
(timebank on an iPhone, Lh 2026-10-04 — a sheet's title over the clock, a Track under the island). The screen's top
|
|
18
|
+
reserve (--safe-top, tokens.css) adds it, so the top stays where the eye is; --kb already counts it out at the foot. */
|
|
15
19
|
if (window.visualViewport) {
|
|
16
20
|
const vv = window.visualViewport;
|
|
17
21
|
const publish = () => {
|
|
18
22
|
const covered = vv.scale > 1.01 ? 0 : Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
|
|
19
23
|
document.documentElement.style.setProperty('--kb', covered + 'px');
|
|
24
|
+
document.documentElement.style.setProperty('--vv-top', (vv.scale > 1.01 ? 0 : Math.max(0, Math.round(vv.offsetTop))) + 'px');
|
|
20
25
|
};
|
|
21
26
|
vv.addEventListener('resize', publish);
|
|
22
27
|
vv.addEventListener('scroll', publish);
|
package/css/base.css
CHANGED
|
@@ -39,6 +39,16 @@ body {
|
|
|
39
39
|
|
|
40
40
|
img, svg { display: block; max-width: 100%; }
|
|
41
41
|
button { font: inherit; }
|
|
42
|
+
/* ONE LOOK ON EVERY PHONE (Lh 2026-10-03, after the Pin on an iPhone): a browser dresses a button and a text field in its
|
|
43
|
+
platform's own clothes — iOS gives a field 0.5em of padding a side, rounded corners, its own ground; a button 1em a side and
|
|
44
|
+
the system's blue words; Android and a desktop give other numbers. cardds draws every control WHOLE, so none of that may show
|
|
45
|
+
through: the platform's padding, margin, corners, look and colour are taken off here, once, at zero specificity — a
|
|
46
|
+
component's own rule always wins, this only fills in where it says nothing. What a control looks like is then cardds's
|
|
47
|
+
alone, the same on iOS, Android and a desktop. (Checkboxes, radios, ranges and file inputs are drawn by their own rules.) */
|
|
48
|
+
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* an iPhone turned on its side must not blow the words up */
|
|
49
|
+
:where(button, input, textarea, select) { margin: 0; border-radius: 0; }
|
|
50
|
+
:where(button, input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), textarea) { padding: 0; -webkit-appearance: none; appearance: none; }
|
|
51
|
+
:where(button) { color: inherit; }
|
|
42
52
|
button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
43
53
|
|
|
44
54
|
/* zero-specificity reset for controls that draw nothing of their own (2026-09-27 review):
|
package/css/choice.css
CHANGED
|
@@ -186,6 +186,10 @@
|
|
|
186
186
|
min-width: 0;
|
|
187
187
|
max-width: var(--pin-cell-max-w, var(--fab));
|
|
188
188
|
aspect-ratio: 2 / 3;
|
|
189
|
+
padding: 0; /* iPhone (Lh 2026-10-03): WebKit on iOS gives a text field 0.5em of padding each side — of a 40px digit that is more
|
|
190
|
+
than the pill is wide, so the field had NO room for its text: the digit started at the pill's middle and ran out of it, a few
|
|
191
|
+
slivers of ink at the edge. The pill centres its digit itself; it needs no padding on any platform. */
|
|
192
|
+
-webkit-appearance: none; appearance: none; /* the pill is drawn here, whole — no platform field look under it */
|
|
189
193
|
border: var(--border-w) solid var(--pin-cell-border, var(--outline));
|
|
190
194
|
border-radius: var(--pin-cell-radius, var(--r-chip));
|
|
191
195
|
background: var(--pin-cell-bg, var(--action-bg));
|
|
@@ -214,15 +218,20 @@
|
|
|
214
218
|
.pin--secret .pin__cell.is-fresh:not(:placeholder-shown) { animation: pin-flash var(--pin-flash-time, 0.5s) steps(1); }
|
|
215
219
|
@keyframes pin-flash { 0% { -webkit-text-fill-color: currentColor; background-size: 0 0; } }
|
|
216
220
|
.pin--secret .pin__cell::selection { background: transparent; } /* its digit is selected when the cell takes the cursor: nothing to show of it */
|
|
217
|
-
.pin__cell:focus { outline: none;
|
|
218
|
-
.pin__cell:focus:
|
|
221
|
+
.pin__cell:focus { outline: none; }
|
|
222
|
+
.pin__cell:is(:focus, [data-cursor]) { border-color: var(--pin-cell-active-border, currentColor); } /* [data-cursor]: SheetSteps holds the phone's keyboard for this cell — it shows the cursor it is about to get */
|
|
223
|
+
/* asleep (Pin sets it): the cursor was put here by a script on a phone and no keyboard is up — nothing can be typed until a tap, so
|
|
224
|
+
the cell does not say "ready": the resting border, no caret */
|
|
225
|
+
.pin--asleep .pin__cell:focus { border-color: var(--pin-cell-border, var(--outline)); }
|
|
226
|
+
.pin--asleep .pin__cell:focus:placeholder-shown { background-image: none; animation: none; }
|
|
227
|
+
.pin__cell:is(:focus, [data-cursor]):placeholder-shown {
|
|
219
228
|
background-image: linear-gradient(currentColor, currentColor);
|
|
220
229
|
background-size: var(--pin-caret-w, 2px) var(--pin-caret-h, 1em);
|
|
221
230
|
background-position: center;
|
|
222
231
|
background-repeat: no-repeat;
|
|
223
232
|
}
|
|
224
233
|
@media (prefers-reduced-motion: no-preference) {
|
|
225
|
-
.pin__cell:focus:placeholder-shown { animation: pin-caret 1.1s steps(1) infinite; }
|
|
234
|
+
.pin__cell:is(:focus, [data-cursor]):placeholder-shown { animation: pin-caret 1.1s steps(1) infinite; }
|
|
226
235
|
@keyframes pin-caret { 50% { background-size: 0 0; } }
|
|
227
236
|
}
|
|
228
237
|
|
package/css/forms.css
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
.field {
|
|
8
8
|
display: flex;
|
|
9
9
|
align-items: center;
|
|
10
|
-
gap: var(--field-gap, var(--sp-
|
|
10
|
+
gap: var(--field-gap, var(--sp-3)); /* 12 between the lead icon and the words: the field's OWN room (Lh 2026-10-04) — it was sp-2 plus whatever padding the
|
|
11
|
+
browser gave the input (2px on a desktop, 0.5em on an iPhone); with that padding taken off (base.css) the words stood against the icon */
|
|
11
12
|
border: var(--border-w) solid var(--field-border, var(--action-border));
|
|
12
13
|
border-radius: var(--field-radius, var(--r-control));
|
|
13
14
|
background: var(--field-bg, var(--action-bg));
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
}
|
|
31
32
|
.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
33
|
/* 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
|
-
.field:has(:focus) { border-color: var(--field-active-border, currentColor); }
|
|
34
|
+
.field:has(:focus, [data-cursor]) { border-color: var(--field-active-border, currentColor); } /* [data-cursor]: SheetSteps holds the phone's keyboard for this field */
|
|
34
35
|
|
|
35
36
|
/* field with its action inside: date pick, send, go — the button sits flush right */
|
|
36
37
|
.field--action { padding-right: var(--sp-1); min-height: var(--field-action-min-h, var(--tap-lg)); }
|
package/css/sheet.css
CHANGED
|
@@ -211,10 +211,22 @@
|
|
|
211
211
|
whole ask is that switch: peek shows it (a row is --tap tall plus its own
|
|
212
212
|
padding; the strip cuts in that padding) — card #67's "ไม่อยู่บ้าน" */
|
|
213
213
|
.sheet > .card__head { min-height: var(--sheet-head-h); }
|
|
214
|
-
/* a sheet's title is ONE line,
|
|
215
|
-
|
|
214
|
+
/* a sheet's title is ONE line, always (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน" wrapped to two lines at 390px) — the
|
|
215
|
+
thing at its end keeps its place beside it; a caption still takes its own row. A title too long for the line RUNS sideways
|
|
216
|
+
like a song title (Lh 2026-10-03, as the StackSheet's — it was cut with …, and the end of it could never be read): the Sheet
|
|
217
|
+
measures --_over, the width it overflows by, and sets .is-long; one way — it waits, runs to its end, waits, and starts again
|
|
218
|
+
from the first letter. The move is text-indent: the heading is the app's own element, there is no inner box to slide.
|
|
219
|
+
Before the Sheet has measured, and where motion is off, it is cut with …. */
|
|
216
220
|
.sheet > .card__head:not(:has(> :is(.t-caption, .t-body))) { flex-wrap: nowrap; }
|
|
217
|
-
.sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow:
|
|
221
|
+
.sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow-x: clip; overflow-y: visible; text-overflow: ellipsis; min-width: 0; } /* clip SIDEWAYS only: hidden cut the Thai tone marks over the line */
|
|
222
|
+
.sheet > .card__head > :is(h1, h2, h3):first-child.is-long, .sheet__title.is-long {
|
|
223
|
+
text-overflow: clip;
|
|
224
|
+
animation: sheet-title-run var(--sheet-title-run-time, var(--_run, 6s)) ease-in-out infinite; /* --_run: from the overflow, 30px a second + the pauses */
|
|
225
|
+
}
|
|
226
|
+
@keyframes sheet-title-run { 0%, 20% { text-indent: 0; } 85%, 100% { text-indent: calc(-1 * var(--_over)); } }
|
|
227
|
+
@media (prefers-reduced-motion: reduce) {
|
|
228
|
+
.sheet > .card__head > :is(h1, h2, h3):first-child.is-long, .sheet__title.is-long { animation: none; text-overflow: ellipsis; }
|
|
229
|
+
}
|
|
218
230
|
.sheet__head {
|
|
219
231
|
display: grid;
|
|
220
232
|
grid-template-columns: var(--tap-sm) 1fr var(--tap-sm);
|
package/css/tokens.css
CHANGED
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
--signal-error-soft: color-mix(in srgb, var(--signal-error) 14%, var(--surface));
|
|
257
257
|
--signal-info-soft: color-mix(in srgb, var(--signal-info) 14%, var(--surface));
|
|
258
258
|
}
|
|
259
|
-
:root { --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); }
|
|
259
|
+
:root { --safe-top: calc(env(safe-area-inset-top, 0px) + var(--vv-top, 0px)); /* + the phone's pan while a keyboard is up (cardds.js) */ --safe-bottom: env(safe-area-inset-bottom, 0px); }
|
|
260
260
|
* { --_safe-b-open: max(0px, var(--safe-bottom) - var(--kb, 0px)); }
|
|
261
261
|
|
|
262
262
|
@property --sheet-peek { syntax: "<length>"; inherits: true; initial-value: 0px; }
|
package/dist/cardds.css
CHANGED
|
@@ -297,7 +297,7 @@
|
|
|
297
297
|
--signal-error-soft: color-mix(in srgb, var(--signal-error) 14%, var(--surface));
|
|
298
298
|
--signal-info-soft: color-mix(in srgb, var(--signal-info) 14%, var(--surface));
|
|
299
299
|
}
|
|
300
|
-
:root { --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); }
|
|
300
|
+
:root { --safe-top: calc(env(safe-area-inset-top, 0px) + var(--vv-top, 0px)); /* + the phone's pan while a keyboard is up (cardds.js) */ --safe-bottom: env(safe-area-inset-bottom, 0px); }
|
|
301
301
|
* { --_safe-b-open: max(0px, var(--safe-bottom) - var(--kb, 0px)); }
|
|
302
302
|
|
|
303
303
|
@property --sheet-peek { syntax: "<length>"; inherits: true; initial-value: 0px; }
|
|
@@ -508,6 +508,16 @@ body {
|
|
|
508
508
|
|
|
509
509
|
img, svg { display: block; max-width: 100%; }
|
|
510
510
|
button { font: inherit; }
|
|
511
|
+
/* ONE LOOK ON EVERY PHONE (Lh 2026-10-03, after the Pin on an iPhone): a browser dresses a button and a text field in its
|
|
512
|
+
platform's own clothes — iOS gives a field 0.5em of padding a side, rounded corners, its own ground; a button 1em a side and
|
|
513
|
+
the system's blue words; Android and a desktop give other numbers. cardds draws every control WHOLE, so none of that may show
|
|
514
|
+
through: the platform's padding, margin, corners, look and colour are taken off here, once, at zero specificity — a
|
|
515
|
+
component's own rule always wins, this only fills in where it says nothing. What a control looks like is then cardds's
|
|
516
|
+
alone, the same on iOS, Android and a desktop. (Checkboxes, radios, ranges and file inputs are drawn by their own rules.) */
|
|
517
|
+
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* an iPhone turned on its side must not blow the words up */
|
|
518
|
+
:where(button, input, textarea, select) { margin: 0; border-radius: 0; }
|
|
519
|
+
:where(button, input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"]), textarea) { padding: 0; -webkit-appearance: none; appearance: none; }
|
|
520
|
+
:where(button) { color: inherit; }
|
|
511
521
|
button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
512
522
|
|
|
513
523
|
/* zero-specificity reset for controls that draw nothing of their own (2026-09-27 review):
|
|
@@ -1536,10 +1546,22 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
1536
1546
|
whole ask is that switch: peek shows it (a row is --tap tall plus its own
|
|
1537
1547
|
padding; the strip cuts in that padding) — card #67's "ไม่อยู่บ้าน" */
|
|
1538
1548
|
.sheet > .card__head { min-height: var(--sheet-head-h); }
|
|
1539
|
-
/* a sheet's title is ONE line,
|
|
1540
|
-
|
|
1549
|
+
/* a sheet's title is ONE line, always (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน" wrapped to two lines at 390px) — the
|
|
1550
|
+
thing at its end keeps its place beside it; a caption still takes its own row. A title too long for the line RUNS sideways
|
|
1551
|
+
like a song title (Lh 2026-10-03, as the StackSheet's — it was cut with …, and the end of it could never be read): the Sheet
|
|
1552
|
+
measures --_over, the width it overflows by, and sets .is-long; one way — it waits, runs to its end, waits, and starts again
|
|
1553
|
+
from the first letter. The move is text-indent: the heading is the app's own element, there is no inner box to slide.
|
|
1554
|
+
Before the Sheet has measured, and where motion is off, it is cut with …. */
|
|
1541
1555
|
.sheet > .card__head:not(:has(> :is(.t-caption, .t-body))) { flex-wrap: nowrap; }
|
|
1542
|
-
.sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow:
|
|
1556
|
+
.sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow-x: clip; overflow-y: visible; text-overflow: ellipsis; min-width: 0; } /* clip SIDEWAYS only: hidden cut the Thai tone marks over the line */
|
|
1557
|
+
.sheet > .card__head > :is(h1, h2, h3):first-child.is-long, .sheet__title.is-long {
|
|
1558
|
+
text-overflow: clip;
|
|
1559
|
+
animation: sheet-title-run var(--sheet-title-run-time, var(--_run, 6s)) ease-in-out infinite; /* --_run: from the overflow, 30px a second + the pauses */
|
|
1560
|
+
}
|
|
1561
|
+
@keyframes sheet-title-run { 0%, 20% { text-indent: 0; } 85%, 100% { text-indent: calc(-1 * var(--_over)); } }
|
|
1562
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1563
|
+
.sheet > .card__head > :is(h1, h2, h3):first-child.is-long, .sheet__title.is-long { animation: none; text-overflow: ellipsis; }
|
|
1564
|
+
}
|
|
1543
1565
|
.sheet__head {
|
|
1544
1566
|
display: grid;
|
|
1545
1567
|
grid-template-columns: var(--tap-sm) 1fr var(--tap-sm);
|
|
@@ -1952,7 +1974,8 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
1952
1974
|
.field {
|
|
1953
1975
|
display: flex;
|
|
1954
1976
|
align-items: center;
|
|
1955
|
-
gap: var(--field-gap, var(--sp-
|
|
1977
|
+
gap: var(--field-gap, var(--sp-3)); /* 12 between the lead icon and the words: the field's OWN room (Lh 2026-10-04) — it was sp-2 plus whatever padding the
|
|
1978
|
+
browser gave the input (2px on a desktop, 0.5em on an iPhone); with that padding taken off (base.css) the words stood against the icon */
|
|
1956
1979
|
border: var(--border-w) solid var(--field-border, var(--action-border));
|
|
1957
1980
|
border-radius: var(--field-radius, var(--r-control));
|
|
1958
1981
|
background: var(--field-bg, var(--action-bg));
|
|
@@ -1975,7 +1998,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
1975
1998
|
}
|
|
1976
1999
|
.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) */
|
|
1977
2000
|
/* the field the cursor is in: its own border goes full ink, in every palette (the coloured ones fade it to 25% at rest) */
|
|
1978
|
-
.field:has(:focus) { border-color: var(--field-active-border, currentColor); }
|
|
2001
|
+
.field:has(:focus, [data-cursor]) { border-color: var(--field-active-border, currentColor); } /* [data-cursor]: SheetSteps holds the phone's keyboard for this field */
|
|
1979
2002
|
|
|
1980
2003
|
/* field with its action inside: date pick, send, go — the button sits flush right */
|
|
1981
2004
|
.field--action { padding-right: var(--sp-1); min-height: var(--field-action-min-h, var(--tap-lg)); }
|
|
@@ -3040,6 +3063,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3040
3063
|
min-width: 0;
|
|
3041
3064
|
max-width: var(--pin-cell-max-w, var(--fab));
|
|
3042
3065
|
aspect-ratio: 2 / 3;
|
|
3066
|
+
padding: 0; /* iPhone (Lh 2026-10-03): WebKit on iOS gives a text field 0.5em of padding each side — of a 40px digit that is more
|
|
3067
|
+
than the pill is wide, so the field had NO room for its text: the digit started at the pill's middle and ran out of it, a few
|
|
3068
|
+
slivers of ink at the edge. The pill centres its digit itself; it needs no padding on any platform. */
|
|
3069
|
+
-webkit-appearance: none; appearance: none; /* the pill is drawn here, whole — no platform field look under it */
|
|
3043
3070
|
border: var(--border-w) solid var(--pin-cell-border, var(--outline));
|
|
3044
3071
|
border-radius: var(--pin-cell-radius, var(--r-chip));
|
|
3045
3072
|
background: var(--pin-cell-bg, var(--action-bg));
|
|
@@ -3068,15 +3095,20 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
3068
3095
|
.pin--secret .pin__cell.is-fresh:not(:placeholder-shown) { animation: pin-flash var(--pin-flash-time, 0.5s) steps(1); }
|
|
3069
3096
|
@keyframes pin-flash { 0% { -webkit-text-fill-color: currentColor; background-size: 0 0; } }
|
|
3070
3097
|
.pin--secret .pin__cell::selection { background: transparent; } /* its digit is selected when the cell takes the cursor: nothing to show of it */
|
|
3071
|
-
.pin__cell:focus { outline: none;
|
|
3072
|
-
.pin__cell:focus:
|
|
3098
|
+
.pin__cell:focus { outline: none; }
|
|
3099
|
+
.pin__cell:is(:focus, [data-cursor]) { border-color: var(--pin-cell-active-border, currentColor); } /* [data-cursor]: SheetSteps holds the phone's keyboard for this cell — it shows the cursor it is about to get */
|
|
3100
|
+
/* asleep (Pin sets it): the cursor was put here by a script on a phone and no keyboard is up — nothing can be typed until a tap, so
|
|
3101
|
+
the cell does not say "ready": the resting border, no caret */
|
|
3102
|
+
.pin--asleep .pin__cell:focus { border-color: var(--pin-cell-border, var(--outline)); }
|
|
3103
|
+
.pin--asleep .pin__cell:focus:placeholder-shown { background-image: none; animation: none; }
|
|
3104
|
+
.pin__cell:is(:focus, [data-cursor]):placeholder-shown {
|
|
3073
3105
|
background-image: linear-gradient(currentColor, currentColor);
|
|
3074
3106
|
background-size: var(--pin-caret-w, 2px) var(--pin-caret-h, 1em);
|
|
3075
3107
|
background-position: center;
|
|
3076
3108
|
background-repeat: no-repeat;
|
|
3077
3109
|
}
|
|
3078
3110
|
@media (prefers-reduced-motion: no-preference) {
|
|
3079
|
-
.pin__cell:focus:placeholder-shown { animation: pin-caret 1.1s steps(1) infinite; }
|
|
3111
|
+
.pin__cell:is(:focus, [data-cursor]):placeholder-shown { animation: pin-caret 1.1s steps(1) infinite; }
|
|
3080
3112
|
@keyframes pin-caret { 50% { background-size: 0 0; } }
|
|
3081
3113
|
}
|
|
3082
3114
|
|
package/dist/forms/Pin.js
CHANGED
|
@@ -73,6 +73,21 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
|
|
|
73
73
|
const [cleared, setCleared] = useState(undefined); // the app's error whose miss has played out (its cells emptied)
|
|
74
74
|
const wrong = (errOn && error !== cleared) || bad;
|
|
75
75
|
const restart = useRef(false);
|
|
76
|
+
/* ASLEEP (iPhone, Lh 2026-10-03): a phone opens its keyboard on a TAP, not when a script sets the cursor (a sheet arriving, a step
|
|
77
|
+
opening). A cell that got the cursor that way with no keyboard up cannot be typed into yet — it must not look ready: no full
|
|
78
|
+
border, no caret, until a finger taps a cell, the keyboard is up, or a digit arrives. Never on a desktop (a key is at hand). */
|
|
79
|
+
const [asleep, setAsleep] = useState(false);
|
|
80
|
+
const tapped = useRef(0);
|
|
81
|
+
const keyboardUp = () => { const vv = window.visualViewport; return !!vv && window.innerHeight - vv.height > 120; };
|
|
82
|
+
const wake = () => { tapped.current = performance.now(); setAsleep(false); };
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!asleep)
|
|
85
|
+
return;
|
|
86
|
+
const vv = window.visualViewport, check = () => { if (keyboardUp())
|
|
87
|
+
setAsleep(false); };
|
|
88
|
+
vv?.addEventListener('resize', check);
|
|
89
|
+
return () => vv?.removeEventListener('resize', check);
|
|
90
|
+
}, [asleep]);
|
|
76
91
|
const [fresh, setFresh] = useState(null); // the cell whose digit was just typed: a secret Pin shows it for a moment
|
|
77
92
|
useEffect(() => {
|
|
78
93
|
if (!errOn) {
|
|
@@ -95,6 +110,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
|
|
|
95
110
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- when the code is emptied from outside
|
|
96
111
|
}, [code]);
|
|
97
112
|
const input = (i) => (e) => {
|
|
113
|
+
wake(); // a digit arrived: the keyboard is there — and the cell the Pin moves on to is ready too
|
|
98
114
|
const digits = e.target.value.replace(/\D/g, '');
|
|
99
115
|
if (restart.current) { // a new try after a wrong code: what arrives starts a new code from the first cell
|
|
100
116
|
restart.current = false;
|
|
@@ -133,6 +149,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
|
|
|
133
149
|
cell(Math.min(digits.length, length - 1))?.focus();
|
|
134
150
|
};
|
|
135
151
|
const key = (i) => (e) => {
|
|
152
|
+
wake();
|
|
136
153
|
if (e.key === 'Backspace' && !e.currentTarget.value && i > 0) {
|
|
137
154
|
e.preventDefault();
|
|
138
155
|
setFresh(null);
|
|
@@ -148,11 +165,16 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
|
|
|
148
165
|
cell(i + 1)?.focus();
|
|
149
166
|
}
|
|
150
167
|
};
|
|
151
|
-
return (_jsx("div", { ref: ref, className: cx('pin', secret && 'pin--secret', wrong && 'pin--error', shake && 'pin--shake', className), ...rest,
|
|
168
|
+
return (_jsx("div", { ref: ref, className: cx('pin', secret && 'pin--secret', wrong && 'pin--error', shake && 'pin--shake', asleep && 'pin--asleep', className), ...rest, onPointerDown: (e) => { wake(); rest.onPointerDown?.(e); }, onBlur: (e) => { if (!e.currentTarget.contains(e.relatedTarget))
|
|
169
|
+
setAsleep(false); rest.onBlur?.(e); }, onAnimationEnd: (e) => { if (e.animationName === 'pin-shake')
|
|
152
170
|
setShake(false); if (e.animationName === 'pin-flash')
|
|
153
171
|
setFresh(null); onAnimationEnd?.(e); }, children: Array.from({ length }, (_, i) => (
|
|
154
172
|
// no maxLength: the browser would cut a pasted code before this ever saw it. A cell holds one digit — typing into a full one
|
|
155
173
|
// replaces it, so its digit is selected whenever the cell takes the focus
|
|
156
|
-
_jsx("input", { className: cx('pin__cell', secret && fresh === i && 'is-fresh'), inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => {
|
|
157
|
-
|
|
174
|
+
_jsx("input", { className: cx('pin__cell', secret && fresh === i && 'is-fresh'), inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => {
|
|
175
|
+
if (!restart.current)
|
|
176
|
+
e.currentTarget.select();
|
|
177
|
+
if (performance.now() - tapped.current > 700 && window.matchMedia('(pointer: coarse)').matches && !keyboardUp())
|
|
178
|
+
setAsleep(true); // set by a script, on a phone, no keyboard: not ready
|
|
179
|
+
} }, i))) }));
|
|
158
180
|
}
|
package/dist/sheets/Sheet.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { ComponentProps } from 'react';
|
|
2
|
+
import { type Resting } from './sheetMeasure.js';
|
|
2
3
|
/** how far the sheet slid. `away` = not on the screen (slid out, inert) — a state the app sets, never one a drag or a tap lands on */
|
|
3
4
|
export type SheetState = 'peek' | 'half' | '3q' | 'full' | 'away';
|
|
4
5
|
/** what asked for the change: the handle's `tap`, a `drag` released, `focus` in a peeked sheet, the `keyboard` (or anything else) moving the cut */
|
|
5
6
|
export type SheetChangeReason = 'tap' | 'drag' | 'focus' | 'keyboard';
|
|
6
|
-
declare const ORDER: readonly ["peek", "half", "3q", "full"];
|
|
7
|
-
type Resting = (typeof ORDER)[number];
|
|
8
7
|
export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'> {
|
|
9
8
|
/** CONTROLLED: where the sheet is. The app owns it — pair it with `onStateChange`, or the sheet cannot move (like `value` without `onChange`).
|
|
10
9
|
* `peek` (handle + title row) · `half` · `3q` · `full` (its top edge at the top bar) · `away` (not on the screen). A class, never a height. */
|
|
@@ -45,4 +44,3 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
|
|
|
45
44
|
* first's `onSettled`) — the app adds no timer.
|
|
46
45
|
*/
|
|
47
46
|
export declare function Sheet({ state, defaultState, onStateChange, onSettled, states, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
|
|
48
|
-
export {};
|
package/dist/sheets/Sheet.js
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect,
|
|
3
|
-
import { focusWhenActive,
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { focusWhenActive, useControlled, watchSize } from '../state.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
import { word } from '../words.js';
|
|
6
|
-
|
|
6
|
+
import { ORDER, slid, stopsOf, useFootRoom, usePeekStrip, useTitleRun } from './sheetMeasure.js';
|
|
7
|
+
import { useSheetSlide } from './sheetSlide.js';
|
|
8
|
+
import { useSheetDrag } from './sheetDrag.js';
|
|
7
9
|
const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
|
|
8
|
-
const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
|
|
9
|
-
/* A SHEET THAT GOES AWAY IS NOT DONE WHEN IT LANDS (Lh 2026-10-03): it holds its box for a beat more (--motion-sheet-rest) — the base
|
|
10
|
-
is seen — and only then is it settled. `out` = the sheets still on their way out, per box; a sheet asked up in that box waits for it to empty. */
|
|
11
|
-
const out = new WeakMap();
|
|
12
|
-
const waiting = new WeakMap();
|
|
13
|
-
const leave = (box, key) => { if (!out.has(box))
|
|
14
|
-
out.set(box, new Set()); out.get(box).add(key); };
|
|
15
|
-
const gone = (box, key) => { const set = out.get(box); if (set?.delete(key) && !set.size)
|
|
16
|
-
[...(waiting.get(box) ?? [])].forEach((go) => go()); };
|
|
17
|
-
const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim().endsWith('ms') ? 1 : 1000) || 0)); // a computed `0.35s, 0s` list → its longest, in ms
|
|
18
10
|
/**
|
|
19
11
|
* Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
|
|
20
12
|
* box — a Step or a SheetStage — a state is only how far it slid (translate; the material never changes size).
|
|
@@ -42,90 +34,12 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
|
|
|
42
34
|
const handleRef = useRef(null);
|
|
43
35
|
const home = useRef(current !== 'peek' && current !== 'away' ? current : 'half'); // where a tap from peek returns to
|
|
44
36
|
const was = useRef('away'); // mounted up = it came onto the screen: it rises from away like any arrival
|
|
45
|
-
const skipClick = useRef(false);
|
|
46
|
-
const [entered, setEntered] = useState(false); // false for the first frame: the class says `away`, so a sheet mounted up has somewhere to slide from
|
|
47
|
-
const me = useRef({}).current; // this sheet, in its box's list of sheets on their way out
|
|
48
|
-
const [held, setHeld] = useState(false); // true while it waits for the sheet going away before it to be settled
|
|
49
|
-
const [prev, setPrev] = useState('away'); // asked up from `away` (or mounted up): it holds first, and rises once its box is free
|
|
50
|
-
if (prev !== current) {
|
|
51
|
-
setPrev(current);
|
|
52
|
-
setHeld(prev === 'away' && current !== 'away');
|
|
53
|
-
}
|
|
54
|
-
const shown = entered && !held ? current : 'away';
|
|
55
|
-
const live = held ? 'away' : current; // where it is as far as its own habits go: a held sheet has not come up yet
|
|
56
|
-
const latest = useLatest({ current, onSettled });
|
|
57
|
-
const last = useRef('away'); // where it was last SHOWN (the settle effect keeps it)
|
|
58
|
-
const drag = useRef(null);
|
|
59
37
|
const allowed = useCallback(() => {
|
|
60
38
|
const list = (typeof states === 'string' ? states.split(/\s+/) : [...(states ?? [])]).filter((s) => ORDER.includes(s));
|
|
61
39
|
return list.length ? ORDER.filter((s) => list.includes(s)) : [...ORDER];
|
|
62
40
|
}, [states]);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
setEntered(true);
|
|
66
|
-
}, []);
|
|
67
|
-
/* it starts on its way out: said in the commit itself, before any sheet of the box decides to rise */
|
|
68
|
-
useLayoutEffect(() => {
|
|
69
|
-
const box = ref.current?.parentElement;
|
|
70
|
-
if (box && current === 'away' && last.current !== 'away' && !reducedMotion())
|
|
71
|
-
leave(box, me);
|
|
72
|
-
}, [current, me]);
|
|
73
|
-
/* held: it rises when no sheet of its box is on its way out — at once if none is */
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
const box = ref.current?.parentElement;
|
|
76
|
-
if (!held)
|
|
77
|
-
return;
|
|
78
|
-
if (!box || !out.get(box)?.size) {
|
|
79
|
-
setHeld(false);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
const go = () => setHeld(false);
|
|
83
|
-
if (!waiting.has(box))
|
|
84
|
-
waiting.set(box, new Set());
|
|
85
|
-
waiting.get(box).add(go);
|
|
86
|
-
return () => { waiting.get(box)?.delete(go); };
|
|
87
|
-
}, [held]);
|
|
88
|
-
/* it has finished moving: the transition ended (or at once where there is none) — say so once per move; a move cut short by another says nothing */
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
const el = ref.current, box = el?.parentElement;
|
|
91
|
-
if (!el || last.current === shown)
|
|
92
|
-
return;
|
|
93
|
-
last.current = shown;
|
|
94
|
-
const cs = getComputedStyle(el);
|
|
95
|
-
let done = false, rest = 0;
|
|
96
|
-
const settled = () => { if (box)
|
|
97
|
-
gone(box, me); latest.current.onSettled?.(latest.current.current); };
|
|
98
|
-
const end = () => {
|
|
99
|
-
if (done)
|
|
100
|
-
return;
|
|
101
|
-
done = true;
|
|
102
|
-
clearTimeout(t);
|
|
103
|
-
el.removeEventListener('transitionend', onEnd);
|
|
104
|
-
if (shown !== 'away' || reducedMotion())
|
|
105
|
-
return settled();
|
|
106
|
-
rest = window.setTimeout(settled, ms(cs.getPropertyValue('--motion-sheet-rest') || '0s')); // landed: the base is seen for the rest, THEN it is settled
|
|
107
|
-
};
|
|
108
|
-
const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
|
|
109
|
-
end(); };
|
|
110
|
-
el.addEventListener('transitionend', onEnd);
|
|
111
|
-
const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100); // no transition (motion off) or none came: settled anyway
|
|
112
|
-
return () => { done = true; clearTimeout(t); clearTimeout(rest); el.removeEventListener('transitionend', onEnd); if (box && shown === 'away')
|
|
113
|
-
gone(box, me); }; // cut short on its way out (raised again, unmounted): the box is free
|
|
114
|
-
}, [shown, latest, me]);
|
|
115
|
-
/* taken out of the tree while it is still up — the box stays, the sheet goes: nothing to slide, so it vanishes. Said once in dev.
|
|
116
|
-
(A whole screen leaving takes the box too; StrictMode's trial unmount is over by the time the timer looks.) */
|
|
117
|
-
const alive = useRef(false);
|
|
118
|
-
useEffect(() => {
|
|
119
|
-
alive.current = true;
|
|
120
|
-
const box = ref.current?.parentElement;
|
|
121
|
-
return () => {
|
|
122
|
-
alive.current = false;
|
|
123
|
-
setTimeout(() => {
|
|
124
|
-
if (!alive.current && box?.isConnected && latest.current.current !== 'away')
|
|
125
|
-
warnOnce('sheet-unmount', 'cardds Sheet: unmounted while on the screen, so it vanished instead of sliding out. Set state="away" and unmount it in onSettled (state === "away").');
|
|
126
|
-
});
|
|
127
|
-
};
|
|
128
|
-
}, [latest]);
|
|
41
|
+
/* it arrives and leaves by sliding, with a rest after it went away — sheetSlide.ts */
|
|
42
|
+
const { shown, live, held } = useSheetSlide(ref, current, onSettled);
|
|
129
43
|
/* THE SHEET COMES UP → ITS FIELD HAS THE CURSOR, always (Lh 2026-09-29): whenever it rises — onto the screen (mounted up, or from `away`),
|
|
130
44
|
or from peek to any open state, by a button, the handle, a drag or the app — once it has finished sliding (Lh 2026-09-24: the keyboard
|
|
131
45
|
must not come up while it still moves) the cursor goes to the field marked `data-autofocus` (a SheetSteps marks its open step's first
|
|
@@ -143,20 +57,6 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
|
|
|
143
57
|
return () => { if (!cancel())
|
|
144
58
|
was.current = from; }; // cut short (StrictMode's trial run, or a new state mid-slide): the rise still counts from where it began
|
|
145
59
|
}, [live]);
|
|
146
|
-
/* geometry, read once per gesture — never stored: how far it has slid, and where each state rests, in px from its top at full */
|
|
147
|
-
const slid = (el) => {
|
|
148
|
-
const box = (el.offsetParent ?? el.parentElement);
|
|
149
|
-
return el.getBoundingClientRect().top - (box.getBoundingClientRect().top + box.clientTop + el.offsetTop);
|
|
150
|
-
};
|
|
151
|
-
const stopsOf = (el) => {
|
|
152
|
-
const cs = getComputedStyle(el), height = el.getBoundingClientRect().height;
|
|
153
|
-
return {
|
|
154
|
-
full: 0,
|
|
155
|
-
half: height * (parseFloat(cs.getPropertyValue('--sheet-half')) || 0),
|
|
156
|
-
'3q': height * (parseFloat(cs.getPropertyValue('--sheet-3q')) || 0),
|
|
157
|
-
peek: height - (parseFloat(cs.getPropertyValue('--sheet-peek')) || 0), // registered in tokens.css: reads back resolved
|
|
158
|
-
};
|
|
159
|
-
};
|
|
160
60
|
/* bring a control into the part of the body that shows — between the pinned head and the foot's strip on the cut. The sheet asks
|
|
161
61
|
for the lowest of its states, from the current one (or `atLeast`) upward, that has room; if none has, its highest. */
|
|
162
62
|
const reveal = useCallback((target, reason, atLeast) => {
|
|
@@ -201,45 +101,11 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
|
|
|
201
101
|
reveal(at, 'keyboard');
|
|
202
102
|
});
|
|
203
103
|
}, [reveal]);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const el = ref.current, box = el?.parentElement, head = el?.querySelector(':scope > .card__head, :scope > .sheet__head');
|
|
210
|
-
if (!el || !box || !head)
|
|
211
|
-
return;
|
|
212
|
-
const publish = () => {
|
|
213
|
-
box.style.removeProperty('--sheet-peek'); // read the CSS strip, not our own
|
|
214
|
-
const cs = getComputedStyle(el);
|
|
215
|
-
const strip = parseFloat(cs.getPropertyValue('--sheet-peek')) || 0, floor = parseFloat(cs.getPropertyValue('--sheet-head-h')) || 0;
|
|
216
|
-
const over = head.getBoundingClientRect().height - floor;
|
|
217
|
-
if (over > 0.5)
|
|
218
|
-
box.style.setProperty('--sheet-peek', `${strip + over}px`);
|
|
219
|
-
};
|
|
220
|
-
const stop = watchSize([head], publish);
|
|
221
|
-
return () => { stop(); box.style.removeProperty('--sheet-peek'); };
|
|
222
|
-
}, []);
|
|
223
|
-
/* the FOOT's room follows what the foot really holds (timebank, Lh 2026-10-01): a button grows with the member's text size, a
|
|
224
|
-
line may stand over it (an error) — the CSS strip was a fixed sum (--btn-h), so the body's words ran under the button. The
|
|
225
|
-
sheet measures its foot's rows and publishes --sheet-foot-h; the strip, its ground and the body's stop all read it. */
|
|
226
|
-
useEffect(() => {
|
|
227
|
-
const el = ref.current, foot = el?.querySelector(':scope > .card__foot');
|
|
228
|
-
if (!el || !foot) {
|
|
229
|
-
el?.style.removeProperty('--sheet-foot-h');
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
const publish = () => {
|
|
233
|
-
const rows = [...foot.children].map((c) => c.getBoundingClientRect()).filter((r) => r.height > 0);
|
|
234
|
-
if (!rows.length)
|
|
235
|
-
return el.style.removeProperty('--sheet-foot-h');
|
|
236
|
-
el.style.setProperty('--sheet-foot-h', `${Math.max(...rows.map((r) => r.bottom)) - Math.min(...rows.map((r) => r.top))}px`);
|
|
237
|
-
};
|
|
238
|
-
const stop = watchSize([...foot.children], publish);
|
|
239
|
-
const mo = new MutationObserver(publish);
|
|
240
|
-
mo.observe(foot, { childList: true });
|
|
241
|
-
return () => { stop(); mo.disconnect(); };
|
|
242
|
-
}); // every render: the foot's rows are the app's, they come and go
|
|
104
|
+
usePeekStrip(ref);
|
|
105
|
+
useTitleRun(ref);
|
|
106
|
+
useFootRoom(ref);
|
|
107
|
+
/* the handle held and moved: the sheet follows, and asks for the nearest state on release — sheetDrag.ts */
|
|
108
|
+
const { down, move, up, afterDrag } = useSheetDrag(ref, allowed, (to) => request(to, 'drag'));
|
|
243
109
|
const focus = (e) => {
|
|
244
110
|
onFocus?.(e);
|
|
245
111
|
if (current !== 'peek' || e.target.closest('.sheet__handle'))
|
|
@@ -250,48 +116,9 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
|
|
|
250
116
|
reveal(e.target, 'focus', home.current);
|
|
251
117
|
};
|
|
252
118
|
const tap = () => {
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
return;
|
|
256
|
-
} // the click that follows a drag is not a tap
|
|
119
|
+
if (afterDrag())
|
|
120
|
+
return; // the click that follows a drag is not a tap
|
|
257
121
|
request(current === 'peek' ? (allowed().includes(home.current) ? home.current : allowed().find((s) => s !== 'peek') ?? 'half') : 'peek', 'tap');
|
|
258
122
|
};
|
|
259
|
-
const down = (e) => {
|
|
260
|
-
const el = ref.current;
|
|
261
|
-
if (!el || !e.isPrimary || e.button !== 0)
|
|
262
|
-
return;
|
|
263
|
-
const list = allowed(), stops = stopsOf(el), y = slid(el); // where it is now — mid-slide too, so a grab never jumps
|
|
264
|
-
drag.current = { startY: e.clientY, y0: y, y, stops, allowed: list, moved: false,
|
|
265
|
-
min: Math.min(...list.map((s) => stops[s])), max: Math.max(...list.map((s) => stops[s])),
|
|
266
|
-
tap: TAP_REM * parseFloat(getComputedStyle(document.documentElement).fontSize) };
|
|
267
|
-
e.currentTarget.setPointerCapture(e.pointerId);
|
|
268
|
-
};
|
|
269
|
-
const move = (e) => {
|
|
270
|
-
const d = drag.current, el = ref.current;
|
|
271
|
-
if (!d || !el || !e.isPrimary)
|
|
272
|
-
return;
|
|
273
|
-
const dy = e.clientY - d.startY;
|
|
274
|
-
if (!d.moved) {
|
|
275
|
-
if (Math.abs(dy) < d.tap)
|
|
276
|
-
return;
|
|
277
|
-
d.moved = true;
|
|
278
|
-
el.classList.add('is-dragging');
|
|
279
|
-
}
|
|
280
|
-
d.y = Math.min(Math.max(d.y0 + dy, d.min), d.max); // never above full, never below its lowest state
|
|
281
|
-
el.style.setProperty('--_drag', `${d.y - d.y0}px`); // the one thing written while held — the CSS adds it to the state's translate
|
|
282
|
-
};
|
|
283
|
-
const up = () => {
|
|
284
|
-
const d = drag.current, el = ref.current;
|
|
285
|
-
drag.current = null;
|
|
286
|
-
if (!d || !el)
|
|
287
|
-
return;
|
|
288
|
-
el.classList.remove('is-dragging');
|
|
289
|
-
if (!d.moved)
|
|
290
|
-
return; // a tap: the click that follows toggles
|
|
291
|
-
el.style.removeProperty('--_drag'); // same state, or the app says no: it eases back on its own
|
|
292
|
-
request(d.allowed.reduce((a, b) => (Math.abs(d.stops[b] - d.y) < Math.abs(d.stops[a] - d.y) ? b : a)), 'drag');
|
|
293
|
-
skipClick.current = true;
|
|
294
|
-
requestAnimationFrame(() => { skipClick.current = false; });
|
|
295
|
-
};
|
|
296
123
|
return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${shown}`, className), inert: current === 'away' || held || 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] }));
|
|
297
124
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
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 { focusWhenActive, useControlled
|
|
3
|
+
import { focusWhenActive, useControlled } 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
9
|
import { word } from '../words.js';
|
|
10
|
+
import { usePile } from './sheetStepsPile.js';
|
|
10
11
|
const MOVE_MS = 350; // = --motion-sheet: the accordion's beat
|
|
11
|
-
const GLIDE_MS = 220; // the pile's slide out of the keyboard's way and back: quick, but the eye follows it
|
|
12
12
|
const SWIPE_REM = 3; // an up/down move longer than this (and mostly up/down) is a swipe
|
|
13
|
+
/* the open step's first field — the one that takes the cursor; NOW_INPUT: the one a typed character can be handed to */
|
|
14
|
+
const NOW_FIELD = '.sheet-step--now .sheet-step__inner :is(input:not([type=checkbox], [type=radio], [type=hidden]), textarea)';
|
|
15
|
+
const NOW_INPUT = '.sheet-step--now .sheet-step__inner input:not([type=checkbox], [type=radio], [type=hidden])';
|
|
16
|
+
const coarse = () => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches; // a phone: its keyboard follows a finger, not a script
|
|
13
17
|
const StepCtx = createContext(null);
|
|
14
18
|
/**
|
|
15
19
|
* SheetSteps — one task taken in steps INSIDE one sheet (phone · code · PIN · PIN again · face · notices). The done steps pile up
|
|
@@ -39,40 +43,25 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
39
43
|
moving.current = performance.now() + MOVE_MS + 100;
|
|
40
44
|
} // from the very render that changes it: the old field's blur and the keyboard's jump come in that same commit
|
|
41
45
|
const press = useRef(null);
|
|
46
|
+
/* the pile: where the body is scrolled (the two latest done rows in sight), and the glide of its jumps — sheetStepsPile.ts */
|
|
47
|
+
const { settle, glideNow } = usePile(ref, moving);
|
|
42
48
|
const go = (i, reason) => {
|
|
43
49
|
if (i >= 0 && i <= count)
|
|
44
50
|
request(i, reason);
|
|
45
51
|
};
|
|
46
|
-
/*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
above.unshift(el);
|
|
56
|
-
const bTop = body.getBoundingClientRect().top, at = (el) => body.scrollTop + el.getBoundingClientRect().top - bTop;
|
|
57
|
-
const third = above.length > 2 ? above[above.length - 3] : null; // 2½ rows show (Lh 2026-09-24): the two latest whole, the one before cut in half — a sign there is more above
|
|
58
|
-
let to = third ? at(third) + third.getBoundingClientRect().height / 2 : 0;
|
|
59
|
-
/* …unless the field with the focus would still be under the cut (a short phone, a big text size): then up as far as it needs —
|
|
60
|
-
but never past the LATEST done row, which always stays in sight */
|
|
61
|
-
const focused = document.activeElement instanceof HTMLElement && now.contains(document.activeElement) ? document.activeElement : null;
|
|
62
|
-
if (focused) {
|
|
63
|
-
const vv = window.visualViewport;
|
|
64
|
-
const cut = Math.min(body.getBoundingClientRect().bottom - (parseFloat(getComputedStyle(body).scrollPaddingBottom) || 0), vv ? vv.offsetTop + vv.height : Infinity);
|
|
65
|
-
const under = (focused.closest('.field, .pin') ?? focused).getBoundingClientRect().bottom + (body.scrollTop - to) - cut; // where its bottom will be once scrolled to `to`
|
|
66
|
-
const most = above.length ? at(above[above.length - 1]) : 0;
|
|
67
|
-
if (under > 0)
|
|
68
|
-
to = Math.min(to + under, Math.max(most, to));
|
|
69
|
-
}
|
|
70
|
-
if (Math.abs(to - body.scrollTop) <= 1)
|
|
52
|
+
/* ON A PHONE THE KEYBOARD STAYS WITH ONE ELEMENT (Lh 2026-10-04, timebank as a home-screen app on an iPhone: the pad went down at
|
|
53
|
+
every step change — there a cursor moved by a SCRIPT, after the server's answer, does not take the keyboard along). So the
|
|
54
|
+
cursor is parked in the holder INSIDE the press on the step's button (a finger's own event, which every phone follows), and it
|
|
55
|
+
STAYS there through the step change — no script moves it again. The open step's first field only SHOWS the cursor
|
|
56
|
+
(`data-cursor`); the first key typed is handed to it, and the cursor with it, from inside that key's event (handOver). */
|
|
57
|
+
const [parked, setParked] = useState(false);
|
|
58
|
+
const park = () => {
|
|
59
|
+
const at = document.activeElement, holder = holderRef.current;
|
|
60
|
+
if (!coarse() || !holder || at === holder || !(at instanceof HTMLInputElement || at instanceof HTMLTextAreaElement) || !ref.current?.contains(at))
|
|
71
61
|
return;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
body.scrollTo({ top: to, behavior: smooth ? 'smooth' : 'auto' });
|
|
62
|
+
holder.inputMode = at.inputMode || 'text';
|
|
63
|
+
holder.value = '';
|
|
64
|
+
holder.focus({ preventScroll: true });
|
|
76
65
|
};
|
|
77
66
|
/* the step changed, in TWO beats one right after the other: (1) the old step folds while the new one unfolds — nothing else
|
|
78
67
|
moves; (2) then the pile settles (the two latest done rows in sight), gliding. The cursor goes into the new step at once
|
|
@@ -80,7 +69,11 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
80
69
|
/* the open step's first field is the one to take the cursor when the sheet comes onto the screen (Sheet reads `data-autofocus`) */
|
|
81
70
|
useLayoutEffect(() => {
|
|
82
71
|
ref.current?.querySelectorAll('[data-autofocus]').forEach((el) => el.removeAttribute('data-autofocus'));
|
|
83
|
-
ref.current?.querySelector(
|
|
72
|
+
const field = ref.current?.querySelector(NOW_FIELD);
|
|
73
|
+
field?.setAttribute('data-autofocus', '');
|
|
74
|
+
ref.current?.querySelectorAll('[data-cursor]').forEach((el) => el.removeAttribute('data-cursor'));
|
|
75
|
+
if (parked && coarse())
|
|
76
|
+
field?.setAttribute('data-cursor', ''); // the keyboard is on the holder, for this field: it shows the cursor
|
|
84
77
|
});
|
|
85
78
|
useLayoutEffect(() => {
|
|
86
79
|
if (first.current) {
|
|
@@ -93,7 +86,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
93
86
|
// The cursor waits in a HOLDER while the fold plays — an invisible text field of the steps' own: the phone's keyboard stays up
|
|
94
87
|
// (a blur would drop it and the next focus bring it back — Lh 2026-09-24), and it takes the keyboard type of the field to come
|
|
95
88
|
const at = document.activeElement, holder = holderRef.current;
|
|
96
|
-
const nextField = ref.current?.querySelector(
|
|
89
|
+
const nextField = ref.current?.querySelector(NOW_FIELD);
|
|
97
90
|
if (at instanceof HTMLElement && ref.current?.contains(at)) {
|
|
98
91
|
if (holder && nextField && at.matches('input, textarea')) {
|
|
99
92
|
holder.inputMode = nextField.inputMode || 'text';
|
|
@@ -105,44 +98,21 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
105
98
|
}
|
|
106
99
|
// the cursor stands by: the step's first field takes the focus (the keyboard comes with it); a step with no field — its
|
|
107
100
|
// title, so a screen reader hears where it is (the one rule of every part that becomes active: focusWhenActive)
|
|
101
|
+
if (coarse() && document.activeElement === holder) { // a phone, the keyboard up on the holder: it stays there — only the pile settles
|
|
102
|
+
const t = setTimeout(() => { glideNow(); settle(); }, MOVE_MS);
|
|
103
|
+
return () => clearTimeout(t);
|
|
104
|
+
}
|
|
108
105
|
const cancel = focusWhenActive(ref.current, { wait: MOVE_MS, scope: '.sheet-step--now .sheet-step__inner',
|
|
109
106
|
fallback: () => ref.current?.querySelector('.sheet-step--now .sheet-step__title'),
|
|
110
|
-
then: () => {
|
|
107
|
+
then: () => { glideNow(); settle(); } });
|
|
111
108
|
return () => { cancel(); };
|
|
112
109
|
}, [current]);
|
|
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).
|
|
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
|
|
115
|
-
are set back by the jump, then eased to rest) so the eye can follow the pile moving out of the way and back. */
|
|
116
|
-
const glide = useRef(0), last = useRef(0), own = useRef(0); // own: our smooth scroll is playing — not a jump to replay
|
|
117
|
-
useEffect(() => {
|
|
118
|
-
const el = ref.current, body = el?.closest('.sheet__body');
|
|
119
|
-
if (!el || !body)
|
|
120
|
-
return;
|
|
121
|
-
last.current = body.scrollTop;
|
|
122
|
-
const onScroll = () => {
|
|
123
|
-
const d = body.scrollTop - last.current;
|
|
124
|
-
last.current = body.scrollTop;
|
|
125
|
-
if (!d || performance.now() > glide.current || performance.now() < own.current || reducedMotion())
|
|
126
|
-
return;
|
|
127
|
-
const from = parseFloat(el.style.translate.split(' ')[1] || '0') || 0;
|
|
128
|
-
el.style.transition = 'none';
|
|
129
|
-
el.style.translate = `0 ${from + d}px`;
|
|
130
|
-
void el.offsetHeight; // the set-back lands before the ease
|
|
131
|
-
const hold = Math.max(0, moving.current - 100 - performance.now()); // during a step change: held until the fold is done (beat 2)
|
|
132
|
-
el.style.transition = `translate ${GLIDE_MS}ms ease-out ${hold}ms`;
|
|
133
|
-
el.style.translate = '0 0';
|
|
134
|
-
};
|
|
135
|
-
const clean = () => { el.style.removeProperty('transition'); el.style.removeProperty('translate'); };
|
|
136
|
-
body.addEventListener('scroll', onScroll);
|
|
137
|
-
el.addEventListener('transitionend', clean);
|
|
138
|
-
return () => { body.removeEventListener('scroll', onScroll); el.removeEventListener('transitionend', clean); };
|
|
139
|
-
}, []);
|
|
140
110
|
/* a field in the current step took the focus: once the keyboard is up (the Sheet has climbed), settle the pile — the two latest
|
|
141
111
|
done rows in sight. When the focus leaves the fields (the keyboard goes), the pile comes back down. Both moves glide. */
|
|
142
112
|
const typing = (t) => t instanceof HTMLElement && t.matches('input:not([type=checkbox], [type=radio], [type=range]), textarea');
|
|
143
113
|
const focusIn = (e) => {
|
|
144
114
|
onFocus?.(e);
|
|
145
|
-
|
|
115
|
+
glideNow();
|
|
146
116
|
const target = e.target;
|
|
147
117
|
if (!target.closest('.sheet-step--now') || !typing(target) || performance.now() < moving.current - 100)
|
|
148
118
|
return; // a step change settles on its own beat
|
|
@@ -152,7 +122,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
152
122
|
onBlur?.(e);
|
|
153
123
|
if (!typing(e.target) || performance.now() < moving.current)
|
|
154
124
|
return; // the old step's field losing the cursor to the new one
|
|
155
|
-
|
|
125
|
+
glideNow();
|
|
156
126
|
const body = ref.current?.closest('.sheet__body');
|
|
157
127
|
setTimeout(() => { if (!typing(document.activeElement) && body)
|
|
158
128
|
body.scrollTo({ top: 0 }); }, 0); // Tab to the next field keeps it up
|
|
@@ -180,17 +150,25 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
|
|
|
180
150
|
else if (dy < 0 && ref.current?.querySelector('.sheet-step--now[data-skippable]'))
|
|
181
151
|
go(to, 'skip'); // forward only, and only past a step that may be skipped
|
|
182
152
|
};
|
|
183
|
-
|
|
153
|
+
/* typed while the cursor waits in the holder (the fold is playing — or, on an iPhone, the keyboard never followed the cursor into the
|
|
154
|
+
new step: a focus set by script does not always take the keyboard with it, and every digit landed here and was dropped —
|
|
155
|
+
timebank, Lh 2026-10-03): what was typed belongs to the open step's first field. Hand it over, and the cursor with it —
|
|
156
|
+
from inside the key's own event, which the phone does follow. */
|
|
157
|
+
const handOver = (e) => {
|
|
158
|
+
const typed = e.currentTarget.value;
|
|
159
|
+
e.currentTarget.value = '';
|
|
160
|
+
const field = ref.current?.querySelector(NOW_INPUT);
|
|
161
|
+
if (!typed || !field)
|
|
162
|
+
return;
|
|
163
|
+
field.focus({ preventScroll: true });
|
|
164
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(field, field.value + typed);
|
|
165
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
166
|
+
};
|
|
167
|
+
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("input", { ref: holderRef, className: "sheet-steps__holder", "aria-hidden": true, tabIndex: -1, autoComplete: "off", onFocus: () => setParked(true), onBlur: () => setParked(false), onInput: handOver }), _jsx("ol", { className: "sheet-steps__track", children: steps.map((step, i) => (_jsx(StepCtx.Provider, { value: { numbered, i, at: current, furthest: reach, count, reversible, go, park }, children: step }, step.key ?? i))) }), finish != null && ( // always in sight; it takes taps only once every step is done (or skipped)
|
|
184
168
|
_jsx("div", { className: "sheet-steps__finish", children: isValidElement(finish) ? cloneElement(finish, { disabled: reach < count || finish.props.disabled }) : finish }))] }));
|
|
185
169
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const ctx = useContext(StepCtx);
|
|
189
|
-
if (!ctx)
|
|
190
|
-
throw new Error('SheetStep goes inside a SheetSteps');
|
|
191
|
-
const { numbered, i, at, furthest, count, reversible, go } = ctx;
|
|
192
|
-
const phase = i === at ? 'now' : i < furthest ? 'done' : 'next';
|
|
193
|
-
const off = phase !== 'now';
|
|
170
|
+
/* the step's format checks, as one group — see SheetStep */
|
|
171
|
+
function useStepChecks(off, park) {
|
|
194
172
|
/* the step is the FORM GROUP of the format checks inside it (Field format/digits/required, Pin match): the words of the first
|
|
195
173
|
control that fails show under the button; a press on a button of the step checks them all first and holds the press back
|
|
196
174
|
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 */
|
|
@@ -208,13 +186,6 @@ export function SheetStep({ title, summary, skippable, skipLabel = word('skip'),
|
|
|
208
186
|
checks.current.set(c, null);
|
|
209
187
|
setNote(null);
|
|
210
188
|
} }, [off]);
|
|
211
|
-
/* 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
|
|
212
|
-
phone's keyboard does not drop and come back (the pile jumped up and down with it — Lh 2026-09-24) */
|
|
213
|
-
const keepCursor = (e) => {
|
|
214
|
-
const at = document.activeElement;
|
|
215
|
-
if (e.target.closest('.btn, .link') && at instanceof HTMLElement && at.matches('input, textarea') && e.currentTarget.contains(at))
|
|
216
|
-
e.preventDefault();
|
|
217
|
-
};
|
|
218
189
|
const press = (e) => {
|
|
219
190
|
const btn = e.target.closest('button.btn'); // a FileBtn is a label.btn: picking a file is not the step's press
|
|
220
191
|
if (!btn || btn.disabled)
|
|
@@ -232,6 +203,26 @@ export function SheetStep({ title, summary, skippable, skipLabel = word('skip'),
|
|
|
232
203
|
e.preventDefault();
|
|
233
204
|
bad.focus();
|
|
234
205
|
}
|
|
206
|
+
else
|
|
207
|
+
park(); // the press goes through: on a phone the cursor waits in the holder from HERE — inside the finger's own event
|
|
208
|
+
};
|
|
209
|
+
return { note, group, press };
|
|
210
|
+
}
|
|
211
|
+
/** SheetStep — one step of a SheetSteps: its title, then what the member does (a Field, a Pin, a Toggle, the step's own Btn). */
|
|
212
|
+
export function SheetStep({ title, summary, skippable, skipLabel = word('skip'), error, busy, className, children, ...rest }) {
|
|
213
|
+
const ctx = useContext(StepCtx);
|
|
214
|
+
if (!ctx)
|
|
215
|
+
throw new Error('SheetStep goes inside a SheetSteps');
|
|
216
|
+
const { numbered, i, at, furthest, count, reversible, go, park } = ctx;
|
|
217
|
+
const phase = i === at ? 'now' : i < furthest ? 'done' : 'next';
|
|
218
|
+
const off = phase !== 'now';
|
|
219
|
+
const { note, group, press } = useStepChecks(off, park);
|
|
220
|
+
/* 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
|
|
221
|
+
phone's keyboard does not drop and come back (the pile jumped up and down with it — Lh 2026-09-24) */
|
|
222
|
+
const keepCursor = (e) => {
|
|
223
|
+
const at = document.activeElement;
|
|
224
|
+
if (e.target.closest('.btn, .link') && at instanceof HTMLElement && at.matches('input, textarea') && e.currentTarget.contains(at))
|
|
225
|
+
e.preventDefault();
|
|
235
226
|
};
|
|
236
227
|
/* Enter on the keyboard in a field of the step = a press on the step's button (the phone's "go"/"next" key, a desktop's Enter);
|
|
237
228
|
the press goes through the step's checks like a tap (Lh 2026-09-25) */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PointerEvent as ReactPointerEvent, type RefObject } from 'react';
|
|
2
|
+
import { type Resting } from './sheetMeasure.js';
|
|
3
|
+
/** the handle's pointer handlers, and `afterDrag()` — true once for the click that follows a drag (it is not a tap) */
|
|
4
|
+
export declare function useSheetDrag(ref: RefObject<HTMLElement | null>, allowed: () => Resting[], snap: (to: Resting) => void): {
|
|
5
|
+
down: (e: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
6
|
+
move: (e: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
7
|
+
up: () => void;
|
|
8
|
+
afterDrag: () => boolean;
|
|
9
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* the Sheet's handle held and moved — its own behaviour, not exported from the package: the sheet follows the finger (`--_drag`),
|
|
2
|
+
and on release asks for the nearest of its states. A move shorter than a tap is left to the click. */
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
import { slid, stopsOf } from './sheetMeasure.js';
|
|
5
|
+
const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
|
|
6
|
+
/** the handle's pointer handlers, and `afterDrag()` — true once for the click that follows a drag (it is not a tap) */
|
|
7
|
+
export function useSheetDrag(ref, allowed, snap) {
|
|
8
|
+
const skipClick = useRef(false);
|
|
9
|
+
const drag = useRef(null);
|
|
10
|
+
const down = (e) => {
|
|
11
|
+
const el = ref.current;
|
|
12
|
+
if (!el || !e.isPrimary || e.button !== 0)
|
|
13
|
+
return;
|
|
14
|
+
const list = allowed(), stops = stopsOf(el), y = slid(el); // where it is now — mid-slide too, so a grab never jumps
|
|
15
|
+
drag.current = { startY: e.clientY, y0: y, y, stops, allowed: list, moved: false,
|
|
16
|
+
min: Math.min(...list.map((s) => stops[s])), max: Math.max(...list.map((s) => stops[s])),
|
|
17
|
+
tap: TAP_REM * parseFloat(getComputedStyle(document.documentElement).fontSize) };
|
|
18
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
19
|
+
};
|
|
20
|
+
const move = (e) => {
|
|
21
|
+
const d = drag.current, el = ref.current;
|
|
22
|
+
if (!d || !el || !e.isPrimary)
|
|
23
|
+
return;
|
|
24
|
+
const dy = e.clientY - d.startY;
|
|
25
|
+
if (!d.moved) {
|
|
26
|
+
if (Math.abs(dy) < d.tap)
|
|
27
|
+
return;
|
|
28
|
+
d.moved = true;
|
|
29
|
+
el.classList.add('is-dragging');
|
|
30
|
+
}
|
|
31
|
+
d.y = Math.min(Math.max(d.y0 + dy, d.min), d.max); // never above full, never below its lowest state
|
|
32
|
+
el.style.setProperty('--_drag', `${d.y - d.y0}px`); // the one thing written while held — the CSS adds it to the state's translate
|
|
33
|
+
};
|
|
34
|
+
const up = () => {
|
|
35
|
+
const d = drag.current, el = ref.current;
|
|
36
|
+
drag.current = null;
|
|
37
|
+
if (!d || !el)
|
|
38
|
+
return;
|
|
39
|
+
el.classList.remove('is-dragging');
|
|
40
|
+
if (!d.moved)
|
|
41
|
+
return; // a tap: the click that follows toggles
|
|
42
|
+
el.style.removeProperty('--_drag'); // same state, or the app says no: it eases back on its own
|
|
43
|
+
snap(d.allowed.reduce((a, b) => (Math.abs(d.stops[b] - d.y) < Math.abs(d.stops[a] - d.y) ? b : a)));
|
|
44
|
+
skipClick.current = true;
|
|
45
|
+
requestAnimationFrame(() => { skipClick.current = false; });
|
|
46
|
+
};
|
|
47
|
+
const afterDrag = () => { const skip = skipClick.current; skipClick.current = false; return skip; };
|
|
48
|
+
return { down, move, up, afterDrag };
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
export declare const ORDER: readonly ["peek", "half", "3q", "full"];
|
|
3
|
+
export type Resting = (typeof ORDER)[number];
|
|
4
|
+
type SheetRef = RefObject<HTMLElement | null>;
|
|
5
|
+
export declare const slid: (el: HTMLElement) => number;
|
|
6
|
+
export declare const stopsOf: (el: HTMLElement) => Record<Resting, number>;
|
|
7
|
+
export declare function usePeekStrip(ref: SheetRef): void;
|
|
8
|
+
export declare function useTitleRun(ref: SheetRef): void;
|
|
9
|
+
export declare function useFootRoom(ref: SheetRef): void;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* what a Sheet MEASURES — its own behaviour, not exported from the package: where it is and where each state rests (read once per
|
|
2
|
+
gesture, never stored), and the three numbers it publishes for its CSS: the peek strip, the title's run, the foot's room. */
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { watchSize } from '../state.js';
|
|
5
|
+
export const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
|
|
6
|
+
/* geometry, read once per gesture — never stored: how far it has slid, and where each state rests, in px from its top at full */
|
|
7
|
+
export const slid = (el) => {
|
|
8
|
+
const box = (el.offsetParent ?? el.parentElement);
|
|
9
|
+
return el.getBoundingClientRect().top - (box.getBoundingClientRect().top + box.clientTop + el.offsetTop);
|
|
10
|
+
};
|
|
11
|
+
export const stopsOf = (el) => {
|
|
12
|
+
const cs = getComputedStyle(el), height = el.getBoundingClientRect().height;
|
|
13
|
+
return {
|
|
14
|
+
full: 0,
|
|
15
|
+
half: height * (parseFloat(cs.getPropertyValue('--sheet-half')) || 0),
|
|
16
|
+
'3q': height * (parseFloat(cs.getPropertyValue('--sheet-3q')) || 0),
|
|
17
|
+
peek: height - (parseFloat(cs.getPropertyValue('--sheet-peek')) || 0), // registered in tokens.css: reads back resolved
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/* the peek strip holds the handle and the WHOLE title row. CSS gives the box the strip for a one-line head (--sheet-peek in
|
|
21
|
+
sheet.css); when the head is taller than that floor — a title wrapped on a narrow phone at a large text size — the sheet
|
|
22
|
+
measures its head and publishes the real strip on its box, so nothing of the title is under the cut (2026-09-22, with the
|
|
23
|
+
22-base type scale). Removed when the head fits the floor again, so the CSS number is the one in force. */
|
|
24
|
+
export function usePeekStrip(ref) {
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const el = ref.current, box = el?.parentElement, head = el?.querySelector(':scope > .card__head, :scope > .sheet__head');
|
|
27
|
+
if (!el || !box || !head)
|
|
28
|
+
return;
|
|
29
|
+
const publish = () => {
|
|
30
|
+
box.style.removeProperty('--sheet-peek'); // read the CSS strip, not our own
|
|
31
|
+
const cs = getComputedStyle(el);
|
|
32
|
+
const strip = parseFloat(cs.getPropertyValue('--sheet-peek')) || 0, floor = parseFloat(cs.getPropertyValue('--sheet-head-h')) || 0;
|
|
33
|
+
const over = head.getBoundingClientRect().height - floor;
|
|
34
|
+
if (over > 0.5)
|
|
35
|
+
box.style.setProperty('--sheet-peek', `${strip + over}px`);
|
|
36
|
+
};
|
|
37
|
+
const stop = watchSize([head], publish);
|
|
38
|
+
return () => { stop(); box.style.removeProperty('--sheet-peek'); };
|
|
39
|
+
}, []);
|
|
40
|
+
}
|
|
41
|
+
/* the TITLE is one line; too long for it, it runs sideways like a song title (Lh 2026-10-03): the sheet measures how far its
|
|
42
|
+
words overflow the line and hands it to the CSS (--_over, --_run, .is-long — sheet.css). Measured on the words themselves
|
|
43
|
+
(a range), so the running indent never feeds back into the number. */
|
|
44
|
+
export function useTitleRun(ref) {
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const el = ref.current, title = el?.querySelector(':scope > .card__head > :is(h1, h2, h3):first-child, :scope > .sheet__head .sheet__title');
|
|
47
|
+
if (!el || !title)
|
|
48
|
+
return;
|
|
49
|
+
const measure = () => {
|
|
50
|
+
const words = document.createRange();
|
|
51
|
+
words.selectNodeContents(title);
|
|
52
|
+
const over = Math.max(0, Math.ceil(words.getBoundingClientRect().width - title.clientWidth));
|
|
53
|
+
title.style.setProperty('--_over', `${over}px`);
|
|
54
|
+
title.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
|
|
55
|
+
title.classList.toggle('is-long', over > 0);
|
|
56
|
+
};
|
|
57
|
+
const stop = watchSize([title], measure);
|
|
58
|
+
const mo = new MutationObserver(measure);
|
|
59
|
+
mo.observe(title, { childList: true, characterData: true, subtree: true }); // the app changed the words
|
|
60
|
+
return () => { stop(); mo.disconnect(); title.classList.remove('is-long'); title.style.removeProperty('--_over'); title.style.removeProperty('--_run'); };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/* the FOOT's room follows what the foot really holds (timebank, Lh 2026-10-01): a button grows with the member's text size, a
|
|
64
|
+
line may stand over it (an error) — the CSS strip was a fixed sum (--btn-h), so the body's words ran under the button. The
|
|
65
|
+
sheet measures its foot's rows and publishes --sheet-foot-h; the strip, its ground and the body's stop all read it. */
|
|
66
|
+
export function useFootRoom(ref) {
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const el = ref.current, foot = el?.querySelector(':scope > .card__foot');
|
|
69
|
+
if (!el || !foot) {
|
|
70
|
+
el?.style.removeProperty('--sheet-foot-h');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const publish = () => {
|
|
74
|
+
const rows = [...foot.children].map((c) => c.getBoundingClientRect()).filter((r) => r.height > 0);
|
|
75
|
+
if (!rows.length)
|
|
76
|
+
return el.style.removeProperty('--sheet-foot-h');
|
|
77
|
+
el.style.setProperty('--sheet-foot-h', `${Math.max(...rows.map((r) => r.bottom)) - Math.min(...rows.map((r) => r.top))}px`);
|
|
78
|
+
};
|
|
79
|
+
const stop = watchSize([...foot.children], publish);
|
|
80
|
+
const mo = new MutationObserver(publish);
|
|
81
|
+
mo.observe(foot, { childList: true });
|
|
82
|
+
return () => { stop(); mo.disconnect(); };
|
|
83
|
+
}); // every render: the foot's rows are the app's, they come and go
|
|
84
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import type { SheetState } from './Sheet.js';
|
|
3
|
+
/** `shown` = the state its class says · `live` = where it is as far as its own habits go (a held sheet has not come up yet) ·
|
|
4
|
+
* `held` = it waits for the sheet going away before it to be settled */
|
|
5
|
+
export declare function useSheetSlide(ref: RefObject<HTMLElement | null>, current: SheetState, onSettled?: (state: SheetState) => void): {
|
|
6
|
+
shown: SheetState;
|
|
7
|
+
live: SheetState;
|
|
8
|
+
held: boolean;
|
|
9
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* how a Sheet comes and goes — its own behaviour, not exported from the package: the first frame `away` (so it slides in), the rest
|
|
2
|
+
after it went away, the wait of a sheet asked up meanwhile, `onSettled`, and the warning when it is unmounted while up. */
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { reducedMotion, useLatest, warnOnce } from '../state.js';
|
|
5
|
+
/* A SHEET THAT GOES AWAY IS NOT DONE WHEN IT LANDS (Lh 2026-10-03): it holds its box for a beat more (--motion-sheet-rest) — the base
|
|
6
|
+
is seen — and only then is it settled. `out` = the sheets still on their way out, per box; a sheet asked up in that box waits for it to empty. */
|
|
7
|
+
const out = new WeakMap();
|
|
8
|
+
const waiting = new WeakMap();
|
|
9
|
+
const leave = (box, key) => { if (!out.has(box))
|
|
10
|
+
out.set(box, new Set()); out.get(box).add(key); };
|
|
11
|
+
const gone = (box, key) => { const set = out.get(box); if (set?.delete(key) && !set.size)
|
|
12
|
+
[...(waiting.get(box) ?? [])].forEach((go) => go()); };
|
|
13
|
+
const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim().endsWith('ms') ? 1 : 1000) || 0)); // a computed `0.35s, 0s` list → its longest, in ms
|
|
14
|
+
/** `shown` = the state its class says · `live` = where it is as far as its own habits go (a held sheet has not come up yet) ·
|
|
15
|
+
* `held` = it waits for the sheet going away before it to be settled */
|
|
16
|
+
export function useSheetSlide(ref, current, onSettled) {
|
|
17
|
+
const [entered, setEntered] = useState(false); // false for the first frame: the class says `away`, so a sheet mounted up has somewhere to slide from
|
|
18
|
+
const me = useRef({}).current; // this sheet, in its box's list of sheets on their way out
|
|
19
|
+
const [held, setHeld] = useState(false); // true while it waits for the sheet going away before it to be settled
|
|
20
|
+
const [prev, setPrev] = useState('away'); // asked up from `away` (or mounted up): it holds first, and rises once its box is free
|
|
21
|
+
if (prev !== current) {
|
|
22
|
+
setPrev(current);
|
|
23
|
+
setHeld(prev === 'away' && current !== 'away');
|
|
24
|
+
}
|
|
25
|
+
const shown = entered && !held ? current : 'away';
|
|
26
|
+
const live = held ? 'away' : current; // where it is as far as its own habits go: a held sheet has not come up yet
|
|
27
|
+
const latest = useLatest({ current, onSettled });
|
|
28
|
+
const last = useRef('away'); // where it was last SHOWN (the settle effect keeps it)
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
void ref.current?.offsetHeight; // the browser styles the `away` frame first, or there is no transition to run
|
|
31
|
+
setEntered(true);
|
|
32
|
+
}, []);
|
|
33
|
+
/* it starts on its way out: said in the commit itself, before any sheet of the box decides to rise */
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const box = ref.current?.parentElement;
|
|
36
|
+
if (box && current === 'away' && last.current !== 'away' && !reducedMotion())
|
|
37
|
+
leave(box, me);
|
|
38
|
+
}, [current, me]);
|
|
39
|
+
/* held: it rises when no sheet of its box is on its way out — at once if none is */
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const box = ref.current?.parentElement;
|
|
42
|
+
if (!held)
|
|
43
|
+
return;
|
|
44
|
+
if (!box || !out.get(box)?.size) {
|
|
45
|
+
setHeld(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const go = () => setHeld(false);
|
|
49
|
+
if (!waiting.has(box))
|
|
50
|
+
waiting.set(box, new Set());
|
|
51
|
+
waiting.get(box).add(go);
|
|
52
|
+
return () => { waiting.get(box)?.delete(go); };
|
|
53
|
+
}, [held]);
|
|
54
|
+
/* it has finished moving: the transition ended (or at once where there is none) — say so once per move; a move cut short by another says nothing */
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const el = ref.current, box = el?.parentElement;
|
|
57
|
+
if (!el || last.current === shown)
|
|
58
|
+
return;
|
|
59
|
+
last.current = shown;
|
|
60
|
+
const cs = getComputedStyle(el);
|
|
61
|
+
let done = false, rest = 0;
|
|
62
|
+
const settled = () => { if (box)
|
|
63
|
+
gone(box, me); latest.current.onSettled?.(latest.current.current); };
|
|
64
|
+
const end = () => {
|
|
65
|
+
if (done)
|
|
66
|
+
return;
|
|
67
|
+
done = true;
|
|
68
|
+
clearTimeout(t);
|
|
69
|
+
el.removeEventListener('transitionend', onEnd);
|
|
70
|
+
if (shown !== 'away' || reducedMotion())
|
|
71
|
+
return settled();
|
|
72
|
+
rest = window.setTimeout(settled, ms(cs.getPropertyValue('--motion-sheet-rest') || '0s')); // landed: the base is seen for the rest, THEN it is settled
|
|
73
|
+
};
|
|
74
|
+
const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
|
|
75
|
+
end(); };
|
|
76
|
+
el.addEventListener('transitionend', onEnd);
|
|
77
|
+
const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100); // no transition (motion off) or none came: settled anyway
|
|
78
|
+
return () => { done = true; clearTimeout(t); clearTimeout(rest); el.removeEventListener('transitionend', onEnd); if (box && shown === 'away')
|
|
79
|
+
gone(box, me); }; // cut short on its way out (raised again, unmounted): the box is free
|
|
80
|
+
}, [shown, latest, me]);
|
|
81
|
+
/* taken out of the tree while it is still up — the box stays, the sheet goes: nothing to slide, so it vanishes. Said once in dev.
|
|
82
|
+
(A whole screen leaving takes the box too; StrictMode's trial unmount is over by the time the timer looks.) */
|
|
83
|
+
const alive = useRef(false);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
alive.current = true;
|
|
86
|
+
const box = ref.current?.parentElement;
|
|
87
|
+
return () => {
|
|
88
|
+
alive.current = false;
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
if (!alive.current && box?.isConnected && latest.current.current !== 'away')
|
|
91
|
+
warnOnce('sheet-unmount', 'cardds Sheet: unmounted while on the screen, so it vanished instead of sliding out. Set state="away" and unmount it in onSettled (state === "away").');
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
}, [latest]);
|
|
95
|
+
return { shown, live, held };
|
|
96
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
export declare const GLIDE_MS = 220;
|
|
3
|
+
/** `settle()` scrolls the pile to its place · `glideNow()` = the next jumps are played as slides. `moving` = until when a step
|
|
4
|
+
* change is playing its beats (the SheetSteps keeps it). */
|
|
5
|
+
export declare function usePile(ref: RefObject<HTMLElement | null>, moving: RefObject<number>): {
|
|
6
|
+
settle: () => void;
|
|
7
|
+
glideNow: () => void;
|
|
8
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* the PILE of a SheetSteps — its own behaviour, not exported from the package: where the body is scrolled when a step opens or a
|
|
2
|
+
field takes the keyboard, and the quick slide that replays every jump of that scroll so the eye can follow it. */
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { reducedMotion } from '../state.js';
|
|
5
|
+
export const GLIDE_MS = 220; // the pile's slide out of the keyboard's way and back: quick, but the eye follows it
|
|
6
|
+
const GLIDE_FOR = 900; // how long after a focus move (or a step change) a jump of the scroll is replayed as a slide
|
|
7
|
+
/** `settle()` scrolls the pile to its place · `glideNow()` = the next jumps are played as slides. `moving` = until when a step
|
|
8
|
+
* change is playing its beats (the SheetSteps keeps it). */
|
|
9
|
+
export function usePile(ref, moving) {
|
|
10
|
+
const glide = useRef(0), last = useRef(0), own = useRef(0); // own: our smooth scroll is playing — not a jump to replay
|
|
11
|
+
const glideNow = () => { glide.current = performance.now() + GLIDE_FOR; };
|
|
12
|
+
/* how far the body is scrolled when a step opens or a field in it takes the keyboard: the TWO latest done rows always show
|
|
13
|
+
above the open step, and half of the one before — older ones go up out of sight, one row at a time, so what was done never seems to vanish (Lh
|
|
14
|
+
2026-09-24: moving the pile further startled; with two rows or fewer nothing moves) */
|
|
15
|
+
const settle = () => {
|
|
16
|
+
const list = ref.current, body = list?.closest('.sheet__body'), now = list?.querySelector('.sheet-step--now');
|
|
17
|
+
if (!list || !body || !now)
|
|
18
|
+
return;
|
|
19
|
+
const above = [];
|
|
20
|
+
for (let el = now.previousElementSibling; el; el = el.previousElementSibling)
|
|
21
|
+
above.unshift(el);
|
|
22
|
+
const bTop = body.getBoundingClientRect().top, at = (el) => body.scrollTop + el.getBoundingClientRect().top - bTop;
|
|
23
|
+
const third = above.length > 2 ? above[above.length - 3] : null; // 2½ rows show (Lh 2026-09-24): the two latest whole, the one before cut in half — a sign there is more above
|
|
24
|
+
let to = third ? at(third) + third.getBoundingClientRect().height / 2 : 0;
|
|
25
|
+
/* …unless the field with the focus would still be under the cut (a short phone, a big text size): then up as far as it needs —
|
|
26
|
+
but never past the LATEST done row, which always stays in sight */
|
|
27
|
+
const focused = document.activeElement instanceof HTMLElement && now.contains(document.activeElement) ? document.activeElement : null;
|
|
28
|
+
if (focused) {
|
|
29
|
+
const vv = window.visualViewport;
|
|
30
|
+
const cut = Math.min(body.getBoundingClientRect().bottom - (parseFloat(getComputedStyle(body).scrollPaddingBottom) || 0), vv ? vv.offsetTop + vv.height : Infinity);
|
|
31
|
+
const under = (focused.closest('.field, .pin') ?? focused).getBoundingClientRect().bottom + (body.scrollTop - to) - cut; // where its bottom will be once scrolled to `to`
|
|
32
|
+
const most = above.length ? at(above[above.length - 1]) : 0;
|
|
33
|
+
if (under > 0)
|
|
34
|
+
to = Math.min(to + under, Math.max(most, to));
|
|
35
|
+
}
|
|
36
|
+
if (Math.abs(to - body.scrollTop) <= 1)
|
|
37
|
+
return;
|
|
38
|
+
// the pile SCROLLS there, animated (Lh 2026-09-24) — the browser's own smooth scroll; its frames are not replayed as jumps
|
|
39
|
+
const smooth = !reducedMotion();
|
|
40
|
+
own.current = performance.now() + (smooth ? 700 : 0);
|
|
41
|
+
body.scrollTo({ top: to, behavior: smooth ? 'smooth' : 'auto' });
|
|
42
|
+
};
|
|
43
|
+
/* the keyboard comes and goes: the body's scroll JUMPS (the Sheet's reveal, the browser's own, ours below, the box growing back).
|
|
44
|
+
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
|
|
45
|
+
are set back by the jump, then eased to rest) so the eye can follow the pile moving out of the way and back. */
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const el = ref.current, body = el?.closest('.sheet__body');
|
|
48
|
+
if (!el || !body)
|
|
49
|
+
return;
|
|
50
|
+
last.current = body.scrollTop;
|
|
51
|
+
const onScroll = () => {
|
|
52
|
+
const d = body.scrollTop - last.current;
|
|
53
|
+
last.current = body.scrollTop;
|
|
54
|
+
if (!d || performance.now() > glide.current || performance.now() < own.current || reducedMotion())
|
|
55
|
+
return;
|
|
56
|
+
const from = parseFloat(el.style.translate.split(' ')[1] || '0') || 0;
|
|
57
|
+
el.style.transition = 'none';
|
|
58
|
+
el.style.translate = `0 ${from + d}px`;
|
|
59
|
+
void el.offsetHeight; // the set-back lands before the ease
|
|
60
|
+
const hold = Math.max(0, moving.current - 100 - performance.now()); // during a step change: held until the fold is done (beat 2)
|
|
61
|
+
el.style.transition = `translate ${GLIDE_MS}ms ease-out ${hold}ms`;
|
|
62
|
+
el.style.translate = '0 0';
|
|
63
|
+
};
|
|
64
|
+
const clean = () => { el.style.removeProperty('transition'); el.style.removeProperty('translate'); };
|
|
65
|
+
body.addEventListener('scroll', onScroll);
|
|
66
|
+
el.addEventListener('transitionend', clean);
|
|
67
|
+
return () => { body.removeEventListener('scroll', onScroll); el.removeEventListener('transitionend', clean); };
|
|
68
|
+
}, []);
|
|
69
|
+
return { settle, glideNow };
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.50",
|
|
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.",
|