@justai/cuts 0.45.0 → 0.46.0
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/package.json +1 -1
- package/src/Sheet.astro +63 -3
package/package.json
CHANGED
package/src/Sheet.astro
CHANGED
|
@@ -57,6 +57,24 @@ interface Props {
|
|
|
57
57
|
title?: string;
|
|
58
58
|
/** How tall it opens. `medium` is iOS's half-height default; drag up promotes it to large. */
|
|
59
59
|
detent?: "medium" | "large";
|
|
60
|
+
// queries: a sheet opened over another looks like going back · stacked sheet height · floor ·
|
|
61
|
+
// why is the second sheet shorter · going deeper should not look like retreating
|
|
62
|
+
//
|
|
63
|
+
// A STACK MUST NEVER APPEAR TO MOVE BACKWARDS (founder, 2026-07-28).
|
|
64
|
+
//
|
|
65
|
+
// A sheet that opens SHORTER than the one beneath it reads as a retreat: the content area
|
|
66
|
+
// shrinks, the dim jumps, and the feeling is "something closed" rather than "I went deeper".
|
|
67
|
+
// So an opened sheet takes the height of the one below as a FLOOR.
|
|
68
|
+
//
|
|
69
|
+
// It does not make depth visible on its own — two `large` sheets are still the same height —
|
|
70
|
+
// and it is not meant to. Paired with the ceiling of two, it guarantees the only thing that
|
|
71
|
+
// matters at two levels: the movement always reads forward.
|
|
72
|
+
//
|
|
73
|
+
// `false` opts out, and ConfirmSheet does. A question that must be READ is served by being
|
|
74
|
+
// small and focused; stretching an alert to match whatever it was asked from would weaken the
|
|
75
|
+
// one surface whose whole job is to be taken seriously. Apple keeps alerts small regardless of
|
|
76
|
+
// context, for the same reason.
|
|
77
|
+
floorToParent?: boolean;
|
|
60
78
|
// queries: should a sheet have a Done button · how do I close a full height sheet · sheet done ·
|
|
61
79
|
// the exit is not obvious · dismiss a large sheet
|
|
62
80
|
//
|
|
@@ -79,7 +97,7 @@ interface Props {
|
|
|
79
97
|
// hardcoded the English would be a component making its consumer's copy decisions.
|
|
80
98
|
done?: string | false;
|
|
81
99
|
}
|
|
82
|
-
const { id, title, detent = "medium", done } = Astro.props;
|
|
100
|
+
const { id, title, detent = "medium", done, floorToParent = true } = Astro.props;
|
|
83
101
|
// Default: shown at large, absent at medium. `false` removes it, a string replaces the word.
|
|
84
102
|
const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done" : null));
|
|
85
103
|
---
|
|
@@ -89,7 +107,7 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
89
107
|
exposes. A surface that translates at RUNTIME (the vault, the feed — one bundle, thirteen
|
|
90
108
|
languages, no per-locale routes) renders this cut in English and re-labels it from its own
|
|
91
109
|
dictionary. Without the hook such a consumer cannot adopt the cut at all, and writes its own. */}
|
|
92
|
-
<dialog class="sheet" id={id} data-detent={detent} aria-labelledby={title ? `${id}-title` : undefined} aria-label={title ? undefined : "Sheet"}>
|
|
110
|
+
<dialog class="sheet" id={id} data-detent={detent} data-floor={floorToParent ? "" : undefined} aria-labelledby={title ? `${id}-title` : undefined} aria-label={title ? undefined : "Sheet"}>
|
|
93
111
|
{/* tabindex="-1" so the PANEL can take the focus when the sheet opens. Without it, showModal()
|
|
94
112
|
hands focus to the first focusable descendant — which for a picker is the search field, and on
|
|
95
113
|
a phone that throws the software keyboard over the list the visitor opened the sheet to read.
|
|
@@ -295,6 +313,7 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
295
313
|
// `showModal()` themselves (qr does, from its own script). Hooking only the delegated opener
|
|
296
314
|
// would lock for sheets opened one way and not the other — which is worse than not locking at
|
|
297
315
|
// all, because it would work in testing and fail in the persona.
|
|
316
|
+
const locked = new WeakSet<HTMLDialogElement>();
|
|
298
317
|
let openCount = 0;
|
|
299
318
|
const relock = (delta: number) => {
|
|
300
319
|
openCount = Math.max(0, openCount + delta);
|
|
@@ -302,6 +321,24 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
302
321
|
else document.documentElement.removeAttribute("data-sheet-locked");
|
|
303
322
|
};
|
|
304
323
|
|
|
324
|
+
// Measure the tallest sheet ALREADY open and use it as this one's floor. Measured rather than
|
|
325
|
+
// derived from the detent, because a `medium` sheet whose content is short is shorter than its
|
|
326
|
+
// detent allows — the floor has to be what is actually on the screen, not what was permitted.
|
|
327
|
+
function floorOnto(sheet: HTMLDialogElement) {
|
|
328
|
+
if (!sheet.hasAttribute("data-floor")) return;
|
|
329
|
+
const panel = sheet.querySelector<HTMLElement>("[data-panel]");
|
|
330
|
+
if (!panel) return;
|
|
331
|
+
let floor = 0;
|
|
332
|
+
for (const other of document.querySelectorAll<HTMLDialogElement>("dialog.sheet[open]")) {
|
|
333
|
+
if (other === sheet) continue;
|
|
334
|
+
const p = other.querySelector<HTMLElement>("[data-panel]");
|
|
335
|
+
if (p) floor = Math.max(floor, p.getBoundingClientRect().height);
|
|
336
|
+
}
|
|
337
|
+
// Never taller than the screen, and never a floor of zero — this is the first sheet then.
|
|
338
|
+
if (floor > 0) panel.style.minHeight = Math.min(floor, window.innerHeight) + "px";
|
|
339
|
+
else panel.style.removeProperty("min-height");
|
|
340
|
+
}
|
|
341
|
+
|
|
305
342
|
function wire(sheet: HTMLDialogElement) {
|
|
306
343
|
if (wired.has(sheet) || !sheet.showModal) return;
|
|
307
344
|
wired.add(sheet);
|
|
@@ -311,6 +348,8 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
311
348
|
if (sheet.open === wasOpen) return;
|
|
312
349
|
wasOpen = sheet.open;
|
|
313
350
|
relock(wasOpen ? 1 : -1);
|
|
351
|
+
if (wasOpen) floorOnto(sheet);
|
|
352
|
+
else sheet.querySelector<HTMLElement>("[data-panel]")?.style.removeProperty("min-height");
|
|
314
353
|
}).observe(sheet, { attributes: true, attributeFilter: ["open"] });
|
|
315
354
|
const panel = sheet.querySelector<HTMLElement>("[data-panel]");
|
|
316
355
|
const grip = sheet.querySelector<HTMLElement>("[data-grip]");
|
|
@@ -359,7 +398,28 @@ const doneLabel = done === false ? null : (done ?? (detent === "large" ? "Done"
|
|
|
359
398
|
// because its sheet opens when a persona ASKS for a file rather than when a person presses a
|
|
360
399
|
// button. A sheet opened that way had no backdrop-dismiss and no drag: two of the three ways out
|
|
361
400
|
// of it, missing, with nothing to indicate it. The laziness saved nothing worth that.
|
|
362
|
-
|
|
401
|
+
// queries: does the confirm dialog lock the page too · scroll behind ConfirmSheet
|
|
402
|
+
//
|
|
403
|
+
// ConfirmSheet is its own `<dialog class="confirm">` rather than a `<Sheet>` — deliberately, it
|
|
404
|
+
// is an alert and not a surface. But the page scrolling behind it is the same defect on the
|
|
405
|
+
// same person's thumb, so the LOCK covers it while the floor and the drag do not. A rule about
|
|
406
|
+
// how a modal feels belongs to every modal, not to one component.
|
|
407
|
+
function lockOnly(d: HTMLDialogElement) {
|
|
408
|
+
if (locked.has(d)) return;
|
|
409
|
+
locked.add(d);
|
|
410
|
+
let was = d.open;
|
|
411
|
+
if (was) relock(1);
|
|
412
|
+
new MutationObserver(() => {
|
|
413
|
+
if (d.open === was) return;
|
|
414
|
+
was = d.open;
|
|
415
|
+
relock(was ? 1 : -1);
|
|
416
|
+
}).observe(d, { attributes: true, attributeFilter: ["open"] });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const wireAll = () => {
|
|
420
|
+
document.querySelectorAll<HTMLDialogElement>("dialog.sheet").forEach(wire);
|
|
421
|
+
document.querySelectorAll<HTMLDialogElement>("dialog.confirm").forEach(lockOnly);
|
|
422
|
+
};
|
|
363
423
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wireAll);
|
|
364
424
|
else wireAll();
|
|
365
425
|
|