@justai/cuts 0.29.0 → 0.30.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 +2 -3
- package/src/LangPicker.astro +15 -9
- package/src/Sheet.astro +54 -98
- package/src/Menu.astro +0 -216
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -21,11 +21,10 @@
|
|
|
21
21
|
"./Profile.astro": "./src/Profile.astro",
|
|
22
22
|
"./ConfirmSheet.astro": "./src/ConfirmSheet.astro",
|
|
23
23
|
"./Sheet.astro": "./src/Sheet.astro",
|
|
24
|
-
"./Menu.astro": "./src/Menu.astro",
|
|
25
24
|
"./ground.css": "./src/ground.css"
|
|
26
25
|
},
|
|
27
26
|
"peerDependencies": {
|
|
28
|
-
"@justai/ui": ">=0.
|
|
27
|
+
"@justai/ui": ">=0.3.0",
|
|
29
28
|
"astro": ">=5"
|
|
30
29
|
},
|
|
31
30
|
"publishConfig": {
|
package/src/LangPicker.astro
CHANGED
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
// 2026-07-26 — WHY THIS IS A SHEET AND NO LONGER A DROPDOWN. It used to be a panel absolutely
|
|
14
14
|
// positioned under its button: a desktop metaphor, in an ecosystem whose personas are born
|
|
15
15
|
// mobile/app-souled. Thirteen rows with a search field is not a menu, it is a list you choose from,
|
|
16
|
-
// and iOS presents that from the bottom edge — grabber, drag-to-dismiss
|
|
17
|
-
//
|
|
18
|
-
// being only what it always claimed to be: the persona's list, in the ecosystem's shape.
|
|
16
|
+
// and iOS presents that from the bottom edge — grabber, drag-to-dismiss. <Sheet> owns all of that at
|
|
17
|
+
// EVERY screen width (there is no wide-screen variant, deliberately — see its header); this file is
|
|
18
|
+
// back to being only what it always claimed to be: the persona's list, in the ecosystem's shape.
|
|
19
19
|
import Sheet from "./Sheet.astro";
|
|
20
|
+
// The persona owns the LIST; the framework owns the ORDER. See @justai/ui/locale — one sequence
|
|
21
|
+
// across the fleet, so a language sits in the same place on every persona a visitor meets.
|
|
22
|
+
import { orderLocales } from "@justai/ui/locale";
|
|
20
23
|
interface Props {
|
|
21
24
|
current: string;
|
|
22
25
|
locales: readonly string[];
|
|
@@ -26,6 +29,7 @@ interface Props {
|
|
|
26
29
|
searchLabel: string; // the persona's t.chromeLangSearch
|
|
27
30
|
}
|
|
28
31
|
const { current, locales, localeNames, localePath, label, searchLabel } = Astro.props;
|
|
32
|
+
const ordered = orderLocales(locales);
|
|
29
33
|
---
|
|
30
34
|
|
|
31
35
|
<div class="picker" data-picker>
|
|
@@ -40,7 +44,7 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
|
|
|
40
44
|
aria-label={`${label}: ${current.toUpperCase()}`}
|
|
41
45
|
title={label}>{current.toUpperCase()}</button>
|
|
42
46
|
|
|
43
|
-
<Sheet id="lang-sheet" title={label} detent="large"
|
|
47
|
+
<Sheet id="lang-sheet" title={label} detent="large">
|
|
44
48
|
{/* `name` and not `id`: a form field with neither makes the browser complain that it cannot
|
|
45
49
|
autofill (Chrome's Issues panel flags it on every page of the fleet, since this cut is on
|
|
46
50
|
every page). `name` answers it without the one risk an `id` would carry — an id must be
|
|
@@ -48,7 +52,7 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
|
|
|
48
52
|
the list client-side. */}
|
|
49
53
|
<input type="search" class="search" name="lang-search" placeholder={searchLabel} autocomplete="off" spellcheck="false" data-search aria-label={searchLabel} />
|
|
50
54
|
<div class="list" role="listbox" aria-label={label} data-list>
|
|
51
|
-
{
|
|
55
|
+
{ordered.map((l) => (
|
|
52
56
|
<a
|
|
53
57
|
href={localePath(l)}
|
|
54
58
|
role="option"
|
|
@@ -117,7 +121,7 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
|
|
|
117
121
|
|
|
118
122
|
<script>
|
|
119
123
|
// What is left here is only what the LIST does: filter, and remember the choice. Opening,
|
|
120
|
-
// dismissing
|
|
124
|
+
// dismissing and dragging all belong to <Sheet>.
|
|
121
125
|
const picker = document.querySelector<HTMLElement>("[data-picker]");
|
|
122
126
|
if (picker) {
|
|
123
127
|
const trigger = picker.querySelector<HTMLButtonElement>("[data-trigger]")!;
|
|
@@ -137,9 +141,11 @@ const { current, locales, localeNames, localePath, label, searchLabel } = Astro.
|
|
|
137
141
|
trigger.setAttribute("aria-expanded", "true");
|
|
138
142
|
search.value = "";
|
|
139
143
|
filter();
|
|
140
|
-
// Focus the search ONLY where a keyboard is already present. On a
|
|
141
|
-
// the software keyboard over the list you opened the sheet to read — iOS
|
|
142
|
-
// and lets you reach for search.
|
|
144
|
+
// Focus the search ONLY where a hardware keyboard is already present. On a touch device,
|
|
145
|
+
// focusing would throw the software keyboard over the list you opened the sheet to read — iOS
|
|
146
|
+
// shows the list first and lets you reach for search. This is NOT a desktop/mobile branch of
|
|
147
|
+
// the EXPERIENCE (the sheet is identical everywhere); it is a fact about input hardware, which
|
|
148
|
+
// is why it asks about the pointer rather than about the viewport.
|
|
143
149
|
if (matchMedia("(pointer: fine)").matches) setTimeout(() => search.focus(), 60);
|
|
144
150
|
});
|
|
145
151
|
sheet?.addEventListener("close", () => trigger.setAttribute("aria-expanded", "false"));
|
package/src/Sheet.astro
CHANGED
|
@@ -14,16 +14,30 @@ import "./ground.css";
|
|
|
14
14
|
* positioned under its button is a DESKTOP metaphor: it is what the language picker and the profile
|
|
15
15
|
* menu both were, and on a phone it reads as a website, not an app.
|
|
16
16
|
*
|
|
17
|
-
* WHAT APPLE DOES
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* actions are a Menu.
|
|
17
|
+
* WHAT APPLE DOES: a sheet is what a screen presents ON TOP of itself — a list to choose from, a
|
|
18
|
+
* short set of actions, a form. It arrives from the bottom edge with a grabber you can pull. An
|
|
19
|
+
* anchored menu was tried alongside this for one day and removed; see the catalogue in
|
|
20
|
+
* .justai/expert/the-components-the-framework-offers.md for why the ecosystem has ONE shape here.
|
|
22
21
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* ── ONE BEHAVIOUR AT EVERY WIDTH ─────────────────────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The first version of this file carried a `@media (min-width: 768px)` branch that turned the sheet
|
|
25
|
+
* into a panel anchored to its control, on the reasoning that UIKit adapts a sheet into a popover on
|
|
26
|
+
* regular width. The founder rejected it on sight, and he was right — it contradicted the very
|
|
27
|
+
* thesis written six lines above:
|
|
28
|
+
*
|
|
29
|
+
* "neden desktop da tavrini degistiriyorsun. Mobile neyse desktop tavri da ayni olacak? …
|
|
30
|
+
* default behaviour mobile. yani ekran cozunurlugu yuksek olsa bile ux deneyimi mobile."
|
|
31
|
+
*
|
|
32
|
+
* "Desktop is the EXTENSION" cannot mean "desktop gets a different component". A visitor on a large
|
|
33
|
+
* screen is not a different visitor. So there is NO width branch here, and there must not be one
|
|
34
|
+
* added: the sheet rises from the bottom edge at 390px and at 3840px alike.
|
|
35
|
+
*
|
|
36
|
+
* What a wide screen changes is only how much room surrounds it. **The persona's COLUMN is the
|
|
37
|
+
* active view** — that is the founder's frame, and it is what the panel is sized to: 480px, which is
|
|
38
|
+
* not a chosen number but the fleet's measured one (every persona's page column is `max-width:
|
|
39
|
+
* 480px`). On a phone `min(100%, 480px)` is the whole screen; on a desk it is the persona's own
|
|
40
|
+
* width, centred, with the page dimmed around it. One rule, both cases, no query.
|
|
27
41
|
*
|
|
28
42
|
* NATIVE <dialog>, not a scrim div: the top layer, ::backdrop, Escape and focus containment are the
|
|
29
43
|
* platform's already, and the fleet's ConfirmSheet set that precedent.
|
|
@@ -43,16 +57,11 @@ interface Props {
|
|
|
43
57
|
title?: string;
|
|
44
58
|
/** How tall it opens. `medium` is iOS's half-height default; drag up promotes it to large. */
|
|
45
59
|
detent?: "medium" | "large";
|
|
46
|
-
/**
|
|
47
|
-
* Selector for the control this sheet belongs to. On regular width the panel anchors to it
|
|
48
|
-
* instead of centering — UIKit's sheet→popover adaptation. Omit to centre on large screens.
|
|
49
|
-
*/
|
|
50
|
-
anchor?: string;
|
|
51
60
|
}
|
|
52
|
-
const { id, title, detent = "medium"
|
|
61
|
+
const { id, title, detent = "medium" } = Astro.props;
|
|
53
62
|
---
|
|
54
63
|
|
|
55
|
-
<dialog class="sheet" id={id} data-detent={detent}
|
|
64
|
+
<dialog class="sheet" id={id} data-detent={detent} aria-label={title}>
|
|
56
65
|
{/* tabindex="-1" so the PANEL can take the focus when the sheet opens. Without it, showModal()
|
|
57
66
|
hands focus to the first focusable descendant — which for a picker is the search field, and on
|
|
58
67
|
a phone that throws the software keyboard over the list the visitor opened the sheet to read.
|
|
@@ -73,9 +82,8 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
73
82
|
/* The dialog is the full-screen STAGE; the PANEL is the sheet.
|
|
74
83
|
`position: fixed` is written out rather than inherited: the UA gives a dialog
|
|
75
84
|
`position: absolute`, and an absolutely-positioned top-layer element resolves against the
|
|
76
|
-
initial containing block — which is document-relative, so on a scrolled page the
|
|
77
|
-
|
|
78
|
-
makes both branches mean the same thing by the viewport. */
|
|
85
|
+
initial containing block — which is document-relative, so on a scrolled page the sheet would
|
|
86
|
+
be placed at coordinates the visitor is no longer looking at. */
|
|
79
87
|
position: fixed;
|
|
80
88
|
inset: 0;
|
|
81
89
|
width: 100%;
|
|
@@ -87,7 +95,7 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
87
95
|
border: 0;
|
|
88
96
|
background: none;
|
|
89
97
|
overflow: visible;
|
|
90
|
-
/* The stage
|
|
98
|
+
/* The stage seats the panel on the BOTTOM edge, centred. It has to be written as a
|
|
91
99
|
display:none/display:flex pair, because a `display` of our own on a <dialog> would defeat the
|
|
92
100
|
UA rule that hides it while closed — a sheet permanently visible on every page. */
|
|
93
101
|
display: none;
|
|
@@ -97,6 +105,7 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
97
105
|
align-items: flex-end;
|
|
98
106
|
justify-content: center;
|
|
99
107
|
}
|
|
108
|
+
|
|
100
109
|
/* Entry motion is written with @starting-style, not with an [open] toggle. A dialog enters from
|
|
101
110
|
display:none, and a property that had no previous computed value does not transition — the
|
|
102
111
|
"from" state has to be declared, or the sheet simply appears where it belongs with no travel at
|
|
@@ -115,10 +124,10 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
115
124
|
.sheet-panel {
|
|
116
125
|
display: flex;
|
|
117
126
|
flex-direction: column;
|
|
118
|
-
/*
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
width: 100
|
|
127
|
+
/* THE ONE SIZING RULE, and there is no media query anywhere in this file. The persona's column
|
|
128
|
+
is the active view: 480px is the fleet's measured page width, not a chosen breakpoint. On a
|
|
129
|
+
phone this is the whole screen; on a desk it is the persona's own width, centred. */
|
|
130
|
+
width: min(100%, var(--sheet-width, 480px));
|
|
122
131
|
max-height: min(92dvh, 100dvh - 24px);
|
|
123
132
|
/* The material the fleet already agreed reads as legible glass (ConfirmSheet, 93%). */
|
|
124
133
|
background: color-mix(in srgb, var(--bg) 93%, transparent);
|
|
@@ -127,8 +136,8 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
127
136
|
border-top: 1px solid var(--border);
|
|
128
137
|
border-radius: 20px 20px 0 0;
|
|
129
138
|
/* Sliding UP with an overshoot spring would bounce the panel past the screen edge and back —
|
|
130
|
-
iOS decelerates into place instead
|
|
131
|
-
travels. */
|
|
139
|
+
iOS decelerates into place instead — an overshoot spring belongs to something that scales,
|
|
140
|
+
not to something that travels to an edge. */
|
|
132
141
|
transform: translateY(0);
|
|
133
142
|
transition: transform 0.34s var(--ease-out);
|
|
134
143
|
will-change: transform;
|
|
@@ -177,48 +186,17 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
177
186
|
/* A scroll that reaches its end must not become the page's scroll behind the sheet. */
|
|
178
187
|
overscroll-behavior: contain;
|
|
179
188
|
-webkit-overflow-scrolling: touch;
|
|
180
|
-
padding
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
@media (max-width: 767.98px) {
|
|
187
|
-
.sheet[data-detent="medium"]:not([data-expanded]) .sheet-panel { max-height: min(56dvh, 100dvh - 24px); }
|
|
189
|
+
/* The top padding is not spacing, it is CLEARANCE. This is a scroll container, so it clips at
|
|
190
|
+
its own edge — and a focused first child draws its ring OUTSIDE its border box, which lands
|
|
191
|
+
exactly on that edge and gets cut. The founder saw it on the picker's search field: the ring
|
|
192
|
+
reads as sliced along the top. 6px is a focus ring's width plus its offset, which is the only
|
|
193
|
+
number that makes the clipping impossible rather than unlikely. */
|
|
194
|
+
padding: 6px 12px max(14px, env(safe-area-inset-bottom));
|
|
188
195
|
}
|
|
189
196
|
|
|
190
|
-
/*
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
/* no longer a full-viewport stage — the box IS the panel's position */
|
|
194
|
-
inset: auto;
|
|
195
|
-
width: auto;
|
|
196
|
-
height: auto;
|
|
197
|
-
margin: 0;
|
|
198
|
-
/* written by the script from the anchor's rect; these are the fallbacks when centring */
|
|
199
|
-
top: var(--sheet-top, 50%);
|
|
200
|
-
left: var(--sheet-left, 50%);
|
|
201
|
-
translate: var(--sheet-translate, -50% -50%);
|
|
202
|
-
}
|
|
203
|
-
.sheet[open] { display: block; }
|
|
204
|
-
.sheet-panel {
|
|
205
|
-
width: min(360px, calc(100vw - 32px));
|
|
206
|
-
max-height: min(70dvh, 560px);
|
|
207
|
-
border: 1px solid var(--border);
|
|
208
|
-
border-radius: var(--radius-lg);
|
|
209
|
-
box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.45);
|
|
210
|
-
transform: none;
|
|
211
|
-
opacity: 1;
|
|
212
|
-
transition: transform 0.2s var(--ease-out), opacity 0.2s var(--ease-out);
|
|
213
|
-
}
|
|
214
|
-
@starting-style {
|
|
215
|
-
.sheet[open] .sheet-panel { transform: translateY(-8px) scale(0.98); opacity: 0; }
|
|
216
|
-
}
|
|
217
|
-
.sheet::backdrop { background: rgba(0, 0, 0, 0.18); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
|
218
|
-
.sheet-grip { display: none; } /* nothing to drag when it is a popover */
|
|
219
|
-
/* — see .sheet-panel:focus below — */
|
|
220
|
-
.sheet-title { text-align: start; padding-top: 14px; }
|
|
221
|
-
}
|
|
197
|
+
/* The medium detent — half height, promoted to large by a pull upwards. It applies at EVERY width
|
|
198
|
+
now, because the gesture that changes it does too: the grabber is never hidden. */
|
|
199
|
+
.sheet[data-detent="medium"]:not([data-expanded]) .sheet-panel { max-height: min(56dvh, 100dvh - 24px); }
|
|
222
200
|
|
|
223
201
|
@media (prefers-reduced-motion: reduce) {
|
|
224
202
|
.sheet-panel, .sheet::backdrop { transition: none; }
|
|
@@ -229,27 +207,15 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
229
207
|
<script>
|
|
230
208
|
// The sheet's behaviour, once, for every persona that mounts one. Consumers write no JavaScript:
|
|
231
209
|
// an element with data-sheet-open="<id>" is a trigger, and that is the whole contract.
|
|
210
|
+
//
|
|
211
|
+
// Note what is NOT here any more: a matchMedia guard. The drag worked only below 768px while this
|
|
212
|
+
// component still had a desktop branch; now that there is one behaviour, there is one code path,
|
|
213
|
+
// and a mouse can pull the grabber exactly as a finger does.
|
|
232
214
|
(function () {
|
|
233
215
|
const DRAG_CLOSE = 0.28; // fraction of the panel's height that dismisses on release
|
|
234
216
|
const FLICK = 0.55; // px/ms — a fast flick dismisses regardless of distance
|
|
235
217
|
const wired = new WeakSet<HTMLDialogElement>();
|
|
236
218
|
|
|
237
|
-
function place(sheet: HTMLDialogElement) {
|
|
238
|
-
// Anchored presentation is a regular-width affair; on compact the CSS owns the position.
|
|
239
|
-
if (!window.matchMedia("(min-width: 768px)").matches) return;
|
|
240
|
-
const sel = sheet.getAttribute("data-anchor");
|
|
241
|
-
const el = sel ? (document.querySelector(sel) as HTMLElement | null) : null;
|
|
242
|
-
if (!el) return;
|
|
243
|
-
const r = el.getBoundingClientRect();
|
|
244
|
-
const panel = sheet.querySelector<HTMLElement>("[data-panel]");
|
|
245
|
-
const w = panel ? panel.getBoundingClientRect().width || 360 : 360;
|
|
246
|
-
// Keep the panel on screen: prefer aligning its trailing edge to the control's, then clamp.
|
|
247
|
-
const left = Math.min(Math.max(12, r.right - w), window.innerWidth - w - 12);
|
|
248
|
-
sheet.style.setProperty("--sheet-top", `${r.bottom + 10}px`);
|
|
249
|
-
sheet.style.setProperty("--sheet-left", `${left}px`);
|
|
250
|
-
sheet.style.setProperty("--sheet-translate", "0 0");
|
|
251
|
-
}
|
|
252
|
-
|
|
253
219
|
function wire(sheet: HTMLDialogElement) {
|
|
254
220
|
if (wired.has(sheet) || !sheet.showModal) return;
|
|
255
221
|
wired.add(sheet);
|
|
@@ -264,10 +230,9 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
264
230
|
panel.style.transform = "";
|
|
265
231
|
});
|
|
266
232
|
|
|
267
|
-
// ── drag: the grabber follows the
|
|
233
|
+
// ── drag: the grabber follows the pointer, and the release decides ──
|
|
268
234
|
let id: number | null = null, y0 = 0, t0 = 0, dy = 0;
|
|
269
235
|
grip?.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
270
|
-
if (window.matchMedia("(min-width: 768px)").matches) return;
|
|
271
236
|
id = e.pointerId; y0 = e.clientY; t0 = e.timeStamp; dy = 0;
|
|
272
237
|
grip.setPointerCapture(id);
|
|
273
238
|
sheet.setAttribute("data-dragging", "");
|
|
@@ -294,26 +259,17 @@ const { id, title, detent = "medium", anchor } = Astro.props;
|
|
|
294
259
|
grip?.addEventListener("pointercancel", release);
|
|
295
260
|
}
|
|
296
261
|
|
|
297
|
-
function open(sheet: HTMLDialogElement) {
|
|
298
|
-
wire(sheet);
|
|
299
|
-
place(sheet);
|
|
300
|
-
if (!sheet.open) sheet.showModal();
|
|
301
|
-
// Land the focus on the PANEL, not on whatever control happens to be first inside it — the
|
|
302
|
-
// keyboard-over-the-list failure described on the tabindex attribute above.
|
|
303
|
-
sheet.querySelector<HTMLElement>("[data-panel]")?.focus({ preventScroll: true });
|
|
304
|
-
}
|
|
305
|
-
|
|
306
262
|
document.addEventListener("click", (e) => {
|
|
307
263
|
const t = (e.target as HTMLElement)?.closest?.("[data-sheet-open]");
|
|
308
264
|
if (!t) return;
|
|
309
265
|
const sheet = document.getElementById(t.getAttribute("data-sheet-open") || "") as HTMLDialogElement | null;
|
|
310
266
|
if (!sheet) return;
|
|
311
267
|
e.preventDefault();
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
268
|
+
wire(sheet);
|
|
269
|
+
if (!sheet.open) sheet.showModal();
|
|
270
|
+
// Land the focus on the PANEL, not on whatever control happens to be first inside it — the
|
|
271
|
+
// keyboard-over-the-list failure described on the tabindex attribute above.
|
|
272
|
+
sheet.querySelector<HTMLElement>("[data-panel]")?.focus({ preventScroll: true });
|
|
317
273
|
});
|
|
318
274
|
})();
|
|
319
275
|
</script>
|
package/src/Menu.astro
DELETED
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
// THE GROUND rides with the menu — see <Sheet> for the failure this line prevents. Law 1 is not a
|
|
3
|
-
// promise a component makes, it is a file it imports.
|
|
4
|
-
import "./ground.css";
|
|
5
|
-
/**
|
|
6
|
-
* <Menu> — the ecosystem's ACTION MENU, in iOS grammar.
|
|
7
|
-
*
|
|
8
|
-
* THE LINE BETWEEN THIS AND <Sheet>, which is Apple's line and not ours: a short list of ACTIONS is a
|
|
9
|
-
* menu, anchored to the control that opened it — that is what iOS shows when you tap an avatar or a
|
|
10
|
-
* toolbar button. A list you CHOOSE from, long enough to scroll or carrying a search field, is a
|
|
11
|
-
* sheet from the bottom edge. Account actions are the first thing; thirteen languages are the second.
|
|
12
|
-
*
|
|
13
|
-
* A DESKTOP DROPDOWN IS NOT AN iOS MENU, and the difference is not the position — both are anchored.
|
|
14
|
-
* It is everything else, and each line below is one of them:
|
|
15
|
-
* • it SPRINGS FROM THE CONTROL. transform-origin is set to the corner the menu grew out of, so the
|
|
16
|
-
* thing that opened it is visibly where it came from. A dropdown fades in place.
|
|
17
|
-
* • the screen behind it DIMS. A menu is modal on iOS; a dropdown is a layer.
|
|
18
|
-
* • rows are 44px. That is the platform's touch minimum, and it is why an iOS menu reads roomy
|
|
19
|
-
* next to a web dropdown built for a mouse.
|
|
20
|
-
* • the icon sits AFTER the label. iOS menus are trailing-icon; web menus are leading-icon. This is
|
|
21
|
-
* the single most recognisable tell of the two grammars.
|
|
22
|
-
* • hover is a bonus, never the affordance — :active carries the feedback so a finger gets the same
|
|
23
|
-
* answer as a cursor.
|
|
24
|
-
*
|
|
25
|
-
* THE PLATFORM PRIMITIVE IS `popover`, not <dialog>: a menu must light-dismiss and must NOT trap
|
|
26
|
-
* focus (a modal dialog would fight the page behind it, and Apple's menus do not trap either). The
|
|
27
|
-
* top layer, Escape, and dismiss-on-outside-click all come free and correct.
|
|
28
|
-
*
|
|
29
|
-
* NO HOMEWORK (law 1): `data-menu-open="<id>"` on any control opens it, positioning included; every
|
|
30
|
-
* token consumed is grounded in ground.css.
|
|
31
|
-
*/
|
|
32
|
-
interface Props {
|
|
33
|
-
/** DOM id — the handle `data-menu-open` points at. */
|
|
34
|
-
id: string;
|
|
35
|
-
/** Accessible name for the menu itself, e.g. "Account". */
|
|
36
|
-
label?: string;
|
|
37
|
-
}
|
|
38
|
-
const { id, label } = Astro.props;
|
|
39
|
-
---
|
|
40
|
-
|
|
41
|
-
<div class="menu" id={id} popover="auto" role="menu" aria-label={label} data-menu>
|
|
42
|
-
<slot />
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
<style is:global>
|
|
46
|
-
/* GLOBAL, not scoped, and deliberately: a menu's rows are almost always written by the CONSUMER
|
|
47
|
-
(the persona knows what its actions are; the framework knows what a menu looks like). Scoped
|
|
48
|
-
styles would reach the panel and miss every row inside it — the same class of failure as a cut
|
|
49
|
-
that keeps its selectors but loses the ground it fed on. The class names are prefixed so this
|
|
50
|
-
can never be mistaken for a page's own vocabulary. */
|
|
51
|
-
|
|
52
|
-
.menu[popover] {
|
|
53
|
-
position: fixed;
|
|
54
|
-
inset: auto;
|
|
55
|
-
top: var(--menu-top, 50%);
|
|
56
|
-
left: var(--menu-left, 50%);
|
|
57
|
-
margin: 0;
|
|
58
|
-
padding: 6px;
|
|
59
|
-
width: max-content;
|
|
60
|
-
min-width: 232px;
|
|
61
|
-
max-width: min(300px, calc(100vw - 24px));
|
|
62
|
-
border: 1px solid var(--border);
|
|
63
|
-
border-radius: var(--radius-md);
|
|
64
|
-
background: color-mix(in srgb, var(--bg) 93%, transparent);
|
|
65
|
-
-webkit-backdrop-filter: blur(30px) saturate(1.5);
|
|
66
|
-
backdrop-filter: blur(30px) saturate(1.5);
|
|
67
|
-
box-shadow: 0 22px 60px -18px rgba(0, 0, 0, 0.5);
|
|
68
|
-
color: var(--text);
|
|
69
|
-
font-family: var(--font);
|
|
70
|
-
overflow: visible;
|
|
71
|
-
/* It grows OUT OF the control: the script writes the origin to the corner it was summoned from. */
|
|
72
|
-
transform-origin: var(--menu-origin, top right);
|
|
73
|
-
transform: scale(0.92);
|
|
74
|
-
opacity: 0;
|
|
75
|
-
transition: transform 0.22s var(--ease-spring), opacity 0.16s var(--ease-out), overlay 0.22s allow-discrete, display 0.22s allow-discrete;
|
|
76
|
-
}
|
|
77
|
-
.menu[popover]:popover-open { transform: scale(1); opacity: 1; }
|
|
78
|
-
@starting-style {
|
|
79
|
-
.menu[popover]:popover-open { transform: scale(0.92); opacity: 0; }
|
|
80
|
-
}
|
|
81
|
-
.menu[popover]::backdrop {
|
|
82
|
-
background: rgba(0, 0, 0, 0.28);
|
|
83
|
-
opacity: 0;
|
|
84
|
-
transition: opacity 0.22s var(--ease-out), overlay 0.22s allow-discrete, display 0.22s allow-discrete;
|
|
85
|
-
}
|
|
86
|
-
.menu[popover]:popover-open::backdrop { opacity: 1; }
|
|
87
|
-
@starting-style {
|
|
88
|
-
.menu[popover]:popover-open::backdrop { opacity: 0; }
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/* ── a row ── */
|
|
92
|
-
.menu-item {
|
|
93
|
-
display: flex;
|
|
94
|
-
align-items: center;
|
|
95
|
-
justify-content: space-between;
|
|
96
|
-
gap: 12px;
|
|
97
|
-
width: 100%;
|
|
98
|
-
min-height: 44px; /* the platform's touch target, and the reason this reads as an app */
|
|
99
|
-
padding: 0 12px;
|
|
100
|
-
border: 0;
|
|
101
|
-
border-radius: 10px;
|
|
102
|
-
background: none;
|
|
103
|
-
color: inherit;
|
|
104
|
-
font: 600 var(--fs-sm) / 1.3 var(--font);
|
|
105
|
-
text-align: start;
|
|
106
|
-
text-decoration: none;
|
|
107
|
-
cursor: pointer;
|
|
108
|
-
transition: background 0.12s var(--ease-out);
|
|
109
|
-
}
|
|
110
|
-
/* :active first — a finger never hovers, and the feedback must not be a desktop-only privilege. */
|
|
111
|
-
.menu-item:active { background: color-mix(in srgb, var(--text) 12%, transparent); }
|
|
112
|
-
@media (hover: hover) {
|
|
113
|
-
.menu-item:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
|
114
|
-
}
|
|
115
|
-
.menu-item:focus-visible {
|
|
116
|
-
outline: none;
|
|
117
|
-
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
|
118
|
-
}
|
|
119
|
-
/* The icon FOLLOWS the label — the iOS tell. */
|
|
120
|
-
.menu-item svg { flex: none; width: 17px; height: 17px; color: var(--text-dim); }
|
|
121
|
-
.menu-item .menu-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
122
|
-
|
|
123
|
-
.menu-item.destructive { color: var(--danger); }
|
|
124
|
-
.menu-item.destructive svg { color: currentColor; }
|
|
125
|
-
.menu-item[aria-checked="true"] { color: var(--confirm-accent, var(--accent)); }
|
|
126
|
-
.menu-item[aria-checked="true"] svg { color: currentColor; }
|
|
127
|
-
|
|
128
|
-
/* A hairline between GROUPS — inset nowhere, because iOS separates menu sections edge to edge. */
|
|
129
|
-
.menu-sep { height: 1px; margin: 5px -6px; background: var(--border); }
|
|
130
|
-
|
|
131
|
-
/* An identity block at the head of a menu: avatar, name, secondary line. Not a row — it does
|
|
132
|
-
nothing when tapped, so it must not look tappable. */
|
|
133
|
-
.menu-head {
|
|
134
|
-
display: flex;
|
|
135
|
-
align-items: center;
|
|
136
|
-
gap: 10px;
|
|
137
|
-
padding: 8px 12px 10px;
|
|
138
|
-
}
|
|
139
|
-
.menu-head .menu-name { font: 700 var(--fs-sm) / 1.25 var(--font); color: var(--text); }
|
|
140
|
-
.menu-head .menu-sub { font: 400 var(--fs-xs) / 1.3 var(--font); color: var(--text-dim); }
|
|
141
|
-
|
|
142
|
-
@media (prefers-reduced-motion: reduce) {
|
|
143
|
-
.menu[popover], .menu[popover]::backdrop { transition: none; }
|
|
144
|
-
.menu[popover]:popover-open { transform: none; }
|
|
145
|
-
}
|
|
146
|
-
</style>
|
|
147
|
-
|
|
148
|
-
<script>
|
|
149
|
-
// Positioning and the trigger contract, once for every menu in the ecosystem.
|
|
150
|
-
(function () {
|
|
151
|
-
const GAP = 8;
|
|
152
|
-
const triggerOf = new WeakMap<HTMLElement, HTMLElement>();
|
|
153
|
-
const wired = new WeakSet<HTMLElement>();
|
|
154
|
-
|
|
155
|
-
// aria-expanded is mirrored from the popover's OWN toggle event, once per menu and permanently.
|
|
156
|
-
// The first version set it to "true" by hand on click and attached a {once: true} toggle listener
|
|
157
|
-
// for the close — which never fired: showPopover() QUEUES its toggle event, so the listener
|
|
158
|
-
// registered a line later was consumed by the OPENING toggle and removed before any close
|
|
159
|
-
// happened. The trigger then announced an expanded menu forever, to exactly the visitors who
|
|
160
|
-
// depend on that announcement. It passed on the desk and failed in CI, where one worker changed
|
|
161
|
-
// the timing — a scheduling race, not a slow machine.
|
|
162
|
-
function wire(menu: HTMLElement) {
|
|
163
|
-
if (wired.has(menu)) return;
|
|
164
|
-
wired.add(menu);
|
|
165
|
-
menu.addEventListener("toggle", (ev) => {
|
|
166
|
-
const t = triggerOf.get(menu);
|
|
167
|
-
if (t) t.setAttribute("aria-expanded", (ev as ToggleEvent).newState === "open" ? "true" : "false");
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function place(menu: HTMLElement, trigger: HTMLElement) {
|
|
172
|
-
const r = trigger.getBoundingClientRect();
|
|
173
|
-
const w = menu.offsetWidth || 232;
|
|
174
|
-
const h = menu.offsetHeight || 200;
|
|
175
|
-
// Trailing-aligned by default (the control is usually at the end of a toolbar), then clamped
|
|
176
|
-
// into the viewport — a menu that runs off the screen edge is the desktop bug we are leaving.
|
|
177
|
-
let left = Math.min(Math.max(12, r.right - w), window.innerWidth - w - 12);
|
|
178
|
-
let top = r.bottom + GAP;
|
|
179
|
-
let originY = "top";
|
|
180
|
-
// Not enough room below → flip above, which is what iOS does rather than shrinking the menu.
|
|
181
|
-
if (top + h > window.innerHeight - 12 && r.top - GAP - h > 12) {
|
|
182
|
-
top = r.top - GAP - h;
|
|
183
|
-
originY = "bottom";
|
|
184
|
-
}
|
|
185
|
-
const originX = left + w / 2 > r.left + r.width / 2 ? "left" : "right";
|
|
186
|
-
menu.style.setProperty("--menu-top", `${Math.round(top)}px`);
|
|
187
|
-
menu.style.setProperty("--menu-left", `${Math.round(left)}px`);
|
|
188
|
-
menu.style.setProperty("--menu-origin", `${originY} ${originX}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
document.addEventListener("click", (e) => {
|
|
192
|
-
const t = (e.target as HTMLElement)?.closest?.("[data-menu-open]") as HTMLElement | null;
|
|
193
|
-
if (!t) return;
|
|
194
|
-
const menu = document.getElementById(t.getAttribute("data-menu-open") || "");
|
|
195
|
-
if (!menu || !("showPopover" in menu)) return;
|
|
196
|
-
e.preventDefault();
|
|
197
|
-
wire(menu);
|
|
198
|
-
triggerOf.set(menu, t);
|
|
199
|
-
if (menu.matches(":popover-open")) { menu.hidePopover(); return; }
|
|
200
|
-
// Measure BEFORE showing: an unpositioned popover would paint at its fallback spot for one
|
|
201
|
-
// frame and then jump — the flash is small and it is exactly what makes a UI feel unfinished.
|
|
202
|
-
menu.style.visibility = "hidden";
|
|
203
|
-
menu.showPopover();
|
|
204
|
-
place(menu, t);
|
|
205
|
-
menu.style.visibility = "";
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// Choosing an item closes the menu — every menu, without each consumer remembering to.
|
|
209
|
-
document.addEventListener("click", (e) => {
|
|
210
|
-
const item = (e.target as HTMLElement)?.closest?.(".menu-item") as HTMLElement | null;
|
|
211
|
-
if (!item) return;
|
|
212
|
-
const menu = item.closest<HTMLElement>("[data-menu]");
|
|
213
|
-
if (menu?.matches(":popover-open")) menu.hidePopover();
|
|
214
|
-
});
|
|
215
|
-
})();
|
|
216
|
-
</script>
|