@ponchia/ui 0.2.2 → 0.3.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/README.md CHANGED
@@ -4,6 +4,9 @@ Shared UI framework for Bronto personal projects. Nothing-inspired:
4
4
  monochrome surfaces, a single red accent, dot-matrix display type (Doto),
5
5
  flat hairline borders, restrained motion. CSS-first and framework-agnostic.
6
6
 
7
+ **[Live demo →](https://ponchia.github.io/bronto-ui/)** — the kitchen
8
+ sink (every component, light/dark, RTL, theming) deployed from `demo/`.
9
+
7
10
  ## Use
8
11
 
9
12
  Install from npm (public, no registry config):
@@ -18,30 +21,33 @@ npm i @ponchia/ui
18
21
  > deliberately distinct from the package name. See
19
22
  > [`docs/architecture.md`](docs/architecture.md).
20
23
 
21
- Import the full theme (includes responsive breakpoints):
22
-
23
- ```css
24
- @import '@ponchia/ui/css';
25
- ```
26
-
27
- Or the core bundle if the app manages its own responsive layer:
24
+ Import the theme (one bundle — `ui-*` components carry their own
25
+ breakpoints, so there is no separate core/full split as of 0.3.0):
28
26
 
29
27
  ```css
30
- @import '@ponchia/ui/css/core.css';
28
+ @import '@ponchia/ui/css'; /* === @ponchia/ui/css/core.css */
31
29
  ```
32
30
 
33
31
  **Prebuilt single file (recommended for apps without a CSS bundler).**
34
- `@ponchia/ui/css` is a 17-deep `@import` graph fine through a bundler,
35
- a load waterfall over plain HTTP. The package also ships flattened,
36
- minified bundles with no `@import` chain:
32
+ `@ponchia/ui/css` is a wide `@import` fan-out (~14 leaves, one level
33
+ deep) — fine through a bundler, a load waterfall over plain HTTP. The
34
+ package also ships one flattened, minified bundle with no `@import`
35
+ chain:
37
36
 
38
37
  ```css
39
- @import '@ponchia/ui'; /* → dist/bronto.css, full */
40
- @import '@ponchia/ui/dist/bronto-core.css'; /* core, no responsive */
38
+ @import '@ponchia/ui'; /* → dist/bronto.css, the whole framework */
41
39
  ```
42
40
 
43
- ~62 kB raw / ~11 kB gzip, one request, same `@layer bronto`. Source CSS,
44
- tokens/classes/behaviors entrypoints are unchanged use whichever fits.
41
+ ~54 kB raw / ~10 kB gzip, one request, same `@layer bronto`. (The
42
+ enforced ceiling lives in `scripts/check-dist.mjs`, not this prose —
43
+ treat these figures as indicative.) Source CSS, tokens/classes/behaviors
44
+ entrypoints are unchanged — use whichever fits.
45
+
46
+ > **The package root is CSS-only.** `@ponchia/ui` (the `.` export)
47
+ > resolves to a stylesheet — `@import '@ponchia/ui'` in CSS, never
48
+ > `import '@ponchia/ui'` in JS. There is no JS module at the root; the
49
+ > JS entrypoints are the explicit subpaths `@ponchia/ui/tokens`,
50
+ > `/classes`, and `/behaviors` (see [Entrypoints](#entrypoints)).
45
51
 
46
52
  ### Browser support
47
53
 
@@ -59,10 +65,14 @@ font instead, import everything except `fonts.css` and override `--display` /
59
65
 
60
66
  Everything ships inside a single `@layer bronto`, so any un-layered CSS in
61
67
  your app overrides the framework without a specificity fight or `!important`.
62
- The layer is applied by the `css` and `css/core.css` bundles only —
63
- importing an individual leaf such as `@ponchia/ui/css/primitives.css`
64
- directly is **unlayered** (full specificity), which is an intentional
65
- escape hatch, not the default path.
68
+
69
+ > **Leaf imports are layer-safe by default.** Every per-leaf export
70
+ > `@ponchia/ui/css/primitives.css`, etc. is self-wrapped in
71
+ > `@layer bronto`, so mixing the bundle with individual leaves (e.g.
72
+ > per-route CSS splitting in SvelteKit/Astro) is safe: no silent
73
+ > cascade inversion. The deliberate full-specificity escape hatch is
74
+ > the explicit `@ponchia/ui/css/unlayered/<leaf>.css` path — use it
75
+ > only when you *want* an unlayered override, never by accident.
66
76
 
67
77
  Set `data-theme="light"` or `data-theme="dark"` on `<html>`; defaults follow
68
78
  `prefers-color-scheme`.
@@ -90,7 +100,9 @@ themeColor('dark').accent; // → "#ff3b41"
90
100
  ```
91
101
 
92
102
  `behaviors` wires `[data-bronto-theme-toggle]`, `[data-bronto-dismiss]` /
93
- `[data-bronto-dismissible]`, `[data-bronto-disclosure]`, and native
103
+ `[data-bronto-dismissible]`, `[data-bronto-disclosure]`,
104
+ `[data-bronto-menu]` (`initMenu`: Escape / outside-click /
105
+ close-on-activate for a `<details>` `.ui-menu` dropdown), and native
94
106
  `<dialog>` glue (`initDialog`: `[data-bronto-open]` / `[data-bronto-close]`
95
107
  / `[data-bronto-dialog-light]`). `toast(message, { tone, title, duration })`
96
108
  pushes into a shared, body-anchored stack. Each initializer is SSR-safe and
@@ -112,13 +124,10 @@ modules, so it is also a live integration test.
112
124
  | `overlay.css` | modal + drawer (native `<dialog>`), dropdown menu |
113
125
  | `disclosure.css` | tabs, accordion (`<details>`), segmented, breadcrumb, pagination, avatar |
114
126
  | `table.css` | `ui-table` dense / comfortable |
115
- | `app.css` | admin shell: rail, topbar, toolbar, panel, metrics |
116
- | `navigation.css` | site nav, menu, theme toggle (dot indicator) |
117
- | `typography.css` | display headings, eyebrows, legacy `.button` |
118
- | `content.css` | `.ui-prose` Markdown/raw-HTML long-form, zero per-element classes |
119
- | `cards.css` | semantic content cards (token-driven) |
120
- | `layout.css` | site shell, hero, grids |
121
- | `responsive.css` | breakpoint overrides |
127
+ | `app.css` | admin shell: `ui-app-shell`/`-rail`/`-topbar`/`-toolbar`/`-panel`/`-nav`/`-metrics` |
128
+ | `navigation.css` | `ui-themetoggle` (dot-thumb switch) |
129
+ | `site.css` | content-site shell: `ui-container`, `ui-siteheader`/`ui-sitenav` (aria-current), `ui-sitemenu`, `ui-sitefooter`, `ui-skiplink`, `ui-tags`, `ui-meta` |
130
+ | `content.css` | `.ui-prose` Markdown/raw-HTML long-form (zero classes) + `ui-quote` pull-quote |
122
131
 
123
132
  ## Demo
124
133
 
@@ -145,18 +154,52 @@ exports/import-graph integrity, `tokens.css` ⇄ `tokens/index.{js,json}`, the
145
154
  `classes` registry ⇄ the `.ui-*` selectors, `dist/` ⇄ `css/` (fresh + in
146
155
  budget), and that the published tarball ships only the intended files.
147
156
 
148
- Visual + a11y regression is a separate suite (real browser, so not in the
157
+ End-to-end regression is a separate suite (real browsers, so not in the
149
158
  zero-dep core check):
150
159
 
151
160
  ```bash
152
- npx playwright test # demo screenshots (light/dark/RTL) + axe-core WCAG
161
+ npx playwright test # visual snapshots + axe a11y + cross-engine + modes
153
162
  ```
154
163
 
155
- It is pinned to a Playwright container so baselines are byte-stable; the
156
- committed baselines under `test/e2e/__screenshots__` were authored in that
157
- same image. CI (`.github/workflows/ci.yml`) runs both the `check` job and a
158
- containerised `visual` job on every branch push and PR. It never publishes —
159
- a push to `main` ships nothing.
164
+ It covers: visual snapshots light/dark/RTL/modal (chromium); axe-core
165
+ WCAG 2.1 A/AA **plus best-practice** in both themes; behavioural specs
166
+ (`:has()`, `color-mix()`, native `<dialog>`, `:dir()`/logical) on
167
+ **chromium + firefox + webkit**; forced-colors / reduced-motion / print
168
+ mode assertions; and a no-console-error / no-404 / document-structure
169
+ gate. Pinned to a Playwright container so baselines are byte-stable; the
170
+ committed baselines under `test/e2e/__screenshots__` were authored in
171
+ that same image. To (re)generate them after an intentional visual
172
+ change, run the **“Update visual baselines”** workflow
173
+ (`workflow_dispatch`) from your branch — it rebuilds them in the pinned
174
+ container and commits them back (never author them on a dev machine —
175
+ cross-OS rasterisation differs). CI (`.github/workflows/ci.yml`) runs the `check` job and
176
+ a containerised `e2e` job on every branch push and PR (and `release.yml`
177
+ gates publish on it). It never publishes — a push to `main` ships nothing.
178
+
179
+ ## Versioning
180
+
181
+ Pre-1.0 and deliberately so. **Until `1.0.0`, breaking changes ship in
182
+ the _minor_** (`0.x.0`); patches (`0.x.y`) are non-breaking. This is the
183
+ standard 0.x reading of SemVer, stated explicitly because this framework
184
+ dresses several apps:
185
+
186
+ - Because breaking changes bump the **minor**, the protective range is
187
+ the patch range. At `0.x` npm resolves **both** `^0.3.0` and `~0.3.0`
188
+ to `>=0.3.0 <0.4.0` — they are equivalent here, and either gives you
189
+ only non-breaking `0.3.x` patches while holding back the next
190
+ (breaking) `0.4.0`. Pin either; pin an **exact** version if you want
191
+ zero surprise and to adopt each minor deliberately.
192
+ - Every breaking change is called out in [`CHANGELOG.md`](CHANGELOG.md)
193
+ under a **BREAKING** heading with a migration note.
194
+
195
+ **What is contractual** (changes are breaking): the `--accent`
196
+ derivation and token **names** (incl. `--accent-text`, `--focus-ring`);
197
+ the `.ui-*` class names and `cls`/recipe names; the `data-bronto-*`
198
+ behavior attributes; each behavior's return-cleanup contract. **What is
199
+ not** (may change in any release): token _values_ (visual tuning), the
200
+ internal leaf-file boundaries and `@layer` internals, and anything
201
+ explicitly marked legacy/deprecated. Full token contract:
202
+ [`docs/theming.md`](docs/theming.md).
160
203
 
161
204
  ## Release
162
205
 
@@ -168,18 +211,22 @@ git tag vX.Y.Z && git push origin vX.Y.Z
168
211
  ```
169
212
 
170
213
  The tag triggers `.github/workflows/release.yml`: `validate` (read-only
171
- checks + tag↔version match) `publish-npm` (only if validate passes) +
172
- `release-notes`. **The npm publish is the gate** — a failing check means
173
- the version never reaches npm, so consumers never resolve it. GitHub also
174
- serves the raw tag tarball ungated, but that is a legacy/fallback path, not
175
- the documented install. See [`docs/architecture.md`](docs/architecture.md).
176
-
177
- Published: `@ponchia/ui` is live on npm (latest `0.2.1`), released by CI
178
- with provenance. The `@ponchia` scope and the `NPM_TOKEN` repo secret are
179
- in place, so a pushed `vX.Y.Z` tag is all a release needs.
214
+ checks + tag↔version match) **and** `e2e` (Playwright visual + a11y) must
215
+ both pass → `publish-npm` → `release-notes`. **The npm publish is the
216
+ gate** — a failing check or e2e means the version never reaches npm, so
217
+ consumers never resolve it. GitHub also serves the raw tag tarball
218
+ ungated, but that is a legacy/fallback path, not the documented install.
219
+ See [`docs/architecture.md`](docs/architecture.md).
220
+
221
+ Published: `@ponchia/ui` is live on npm, released by CI with provenance.
222
+ The `@ponchia` scope and the `NPM_TOKEN` repo secret are in place, so a
223
+ pushed `vX.Y.Z` tag is all a release needs. (The current published
224
+ version is whatever npm's `latest` dist-tag resolves to — this README
225
+ deliberately does not restate it, so it can't drift from the registry.)
180
226
 
181
227
  ## Consumers
182
228
 
183
- Built for two shapes of app: a content/marketing site (imports
184
- `@ponchia/ui/css/core.css`) and an admin dashboard (imports the full
185
- `@ponchia/ui/css`). Consuming apps depend on it via `@ponchia/ui`.
229
+ Built for two shapes of app: a content/marketing site (`ui-site*`,
230
+ `ui-prose`) and an admin dashboard (`ui-app-*` shell). Both import the
231
+ one bundle `@ponchia/ui` (or `@ponchia/ui/css`); consuming apps depend
232
+ on it via `@ponchia/ui`.
@@ -39,6 +39,13 @@ export declare function dismissible(opts?: DelegateOpts): Cleanup;
39
39
  /** Wire `[data-bronto-disclosure]` triggers. Returns a cleanup function. */
40
40
  export declare function initDisclosure(opts?: DelegateOpts): Cleanup;
41
41
 
42
+ /**
43
+ * Close affordances (Escape, outside-click, close-on-activate) for a
44
+ * native `<details data-bronto-menu>` dropdown holding a `.ui-menu`.
45
+ * Not a full ARIA menu by design. Returns a cleanup function.
46
+ */
47
+ export declare function initMenu(opts?: DelegateOpts): Cleanup;
48
+
42
49
  /**
43
50
  * Wire `[data-bronto-tabs]` groups with the WAI-ARIA Tabs keyboard
44
51
  * pattern (roving tabindex, Arrow/Home/End, aria-selected, panel sync).
@@ -18,6 +18,37 @@ const THEMES = ['light', 'dark'];
18
18
  const noop = () => {};
19
19
  const hasDom = () => typeof document !== 'undefined';
20
20
 
21
+ // Module-global so tab ids stay unique across *every* initTabs() call.
22
+ // A per-call counter makes separate islands/roots all mint `bronto-tab-1`,
23
+ // which collides aria-controls/aria-labelledby across the document.
24
+ let tabUid = 0;
25
+
26
+ // First-toast deferral queue. The very first toast on a brand-new stack
27
+ // is appended next frame so AT observes the empty aria-live region
28
+ // before its first child. Any further toasts created *before* that frame
29
+ // flushes are queued behind it so call order (FIFO) is preserved instead
30
+ // of a synchronous later toast jumping ahead of the deferred first one.
31
+ const toastQueue = [];
32
+ let toastFlushScheduled = false;
33
+
34
+ // Make delegated initializers idempotent. Re-binding the same logical
35
+ // listener on the same host/element tears the previous binding down first,
36
+ // so double-init (HMR, framework re-mount, repeated calls) never stacks
37
+ // duplicate handlers (the "double-toggle" class of bug). The returned
38
+ // cleanup removes the single live binding.
39
+ const BOUND = Symbol('bronto-bound');
40
+ function bindOnce(target, key, add) {
41
+ const reg = target[BOUND] || (target[BOUND] = Object.create(null));
42
+ if (reg[key]) reg[key]();
43
+ const remove = add();
44
+ const cleanup = () => {
45
+ remove();
46
+ if (reg[key] === cleanup) delete reg[key];
47
+ };
48
+ reg[key] = cleanup;
49
+ return cleanup;
50
+ }
51
+
21
52
  /**
22
53
  * Apply the persisted theme to <html data-theme>. Call as early as
23
54
  * possible (an inline module in <head>) to avoid a flash before the
@@ -85,14 +116,16 @@ export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
85
116
  }
86
117
  reflect();
87
118
  docEl.dispatchEvent(
88
- new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true })
119
+ new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true }),
89
120
  );
90
121
  };
91
122
 
92
123
  applyStoredTheme({ storageKey });
93
124
  reflect();
94
- host.addEventListener('click', onClick);
95
- return () => host.removeEventListener('click', onClick);
125
+ return bindOnce(host, 'themeToggle', () => {
126
+ host.addEventListener('click', onClick);
127
+ return () => host.removeEventListener('click', onClick);
128
+ });
96
129
  }
97
130
 
98
131
  /**
@@ -112,8 +145,10 @@ export function dismissible({ root } = {}) {
112
145
  const ev = new CustomEvent('bronto:dismiss', { bubbles: true, cancelable: true });
113
146
  if (target.dispatchEvent(ev)) target.remove();
114
147
  };
115
- host.addEventListener('click', onClick);
116
- return () => host.removeEventListener('click', onClick);
148
+ return bindOnce(host, 'dismissible', () => {
149
+ host.addEventListener('click', onClick);
150
+ return () => host.removeEventListener('click', onClick);
151
+ });
117
152
  }
118
153
 
119
154
  /**
@@ -122,13 +157,19 @@ export function dismissible({ root } = {}) {
122
157
  * Tabs pattern: roving `tabindex`, `aria-selected`, Arrow/Home/End
123
158
  * navigation with automatic activation, and panel `hidden` sync. Tabs are
124
159
  * `.ui-tab[data-tab]`; panels are `.ui-tabs__panel[data-panel]` with
125
- * matching values. SSR-safe; returns a cleanup function.
160
+ * matching values. SSR-safe and idempotent (re-init replaces, never
161
+ * stacks, the per-group listeners); returns a cleanup function.
162
+ *
163
+ * Accessibility caveat: this is what makes tabs operable. Do **not**
164
+ * author `hidden` on `.ui-tabs__panel` in server-rendered markup unless
165
+ * `initTabs` is guaranteed to run client-side — without it the panels
166
+ * stay hidden with no keyboard/pointer way to reveal them. Prefer
167
+ * authoring all panels visible and letting `initTabs` add `hidden`.
126
168
  */
127
169
  export function initTabs({ root } = {}) {
128
170
  if (!hasDom()) return noop;
129
171
  const host = root || document;
130
172
  const cleanups = [];
131
- let uid = 0;
132
173
  // querySelectorAll only matches descendants, so a `root` that *is* a
133
174
  // tab group would be skipped — include it explicitly.
134
175
  const groups = [];
@@ -149,7 +190,7 @@ export function initTabs({ root } = {}) {
149
190
  for (const t of tabs) {
150
191
  const p = panels.find((x) => x.dataset.panel === t.dataset.tab);
151
192
  if (!p) continue;
152
- const n = ++uid;
193
+ const n = ++tabUid;
153
194
  if (!t.id) t.id = `bronto-tab-${n}`;
154
195
  if (!p.id) p.id = `bronto-tabpanel-${n}`;
155
196
  t.setAttribute('aria-controls', p.id);
@@ -183,7 +224,8 @@ export function initTabs({ root } = {}) {
183
224
  if (i < 0) return;
184
225
  let n = i;
185
226
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = (i + 1) % tabs.length;
186
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') n = (i - 1 + tabs.length) % tabs.length;
227
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
228
+ n = (i - 1 + tabs.length) % tabs.length;
187
229
  else if (e.key === 'Home') n = 0;
188
230
  else if (e.key === 'End') n = tabs.length - 1;
189
231
  else return;
@@ -191,13 +233,17 @@ export function initTabs({ root } = {}) {
191
233
  select(tabs[n]);
192
234
  tabs[n].focus();
193
235
  };
194
- group.addEventListener('click', onClick);
195
- group.addEventListener('keydown', onKey);
196
236
  select(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
197
- cleanups.push(() => {
198
- group.removeEventListener('click', onClick);
199
- group.removeEventListener('keydown', onKey);
200
- });
237
+ cleanups.push(
238
+ bindOnce(group, 'tabs', () => {
239
+ group.addEventListener('click', onClick);
240
+ group.addEventListener('keydown', onKey);
241
+ return () => {
242
+ group.removeEventListener('click', onClick);
243
+ group.removeEventListener('keydown', onKey);
244
+ };
245
+ }),
246
+ );
201
247
  }
202
248
  return () => cleanups.forEach((fn) => fn());
203
249
  }
@@ -207,7 +253,11 @@ export function initTabs({ root } = {}) {
207
253
  * declaratively). Click `[data-bronto-open="dialogId"]` calls
208
254
  * `showModal()` on `#dialogId`; click `[data-bronto-close]` closes the
209
255
  * nearest enclosing <dialog>. Clicking the backdrop of a dialog that has
210
- * `[data-bronto-dialog-light]` closes it too. SSR-safe; returns cleanup.
256
+ * `[data-bronto-dialog-light]` closes it too. On open the trigger is
257
+ * remembered and focus is returned to it on *every* close path (Esc,
258
+ * close button, backdrop light-dismiss, programmatic) via the native
259
+ * `close` event, so keyboard/SR users are never dropped at `<body>`.
260
+ * SSR-safe and idempotent; returns cleanup.
211
261
  *
212
262
  * `root` scopes which triggers are delegated (default `document`); the
213
263
  * dialog itself is still resolved by id document-wide, because a modal
@@ -222,7 +272,16 @@ export function initDialog({ root } = {}) {
222
272
  const opener = e.target.closest('[data-bronto-open]');
223
273
  if (opener && host.contains(opener)) {
224
274
  const dlg = document.getElementById(opener.getAttribute('data-bronto-open'));
225
- if (dlg && typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
275
+ if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
276
+ dlg.addEventListener(
277
+ 'close',
278
+ () => {
279
+ if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
280
+ },
281
+ { once: true },
282
+ );
283
+ dlg.showModal();
284
+ }
226
285
  return;
227
286
  }
228
287
  const closer = e.target.closest('[data-bronto-close]');
@@ -243,20 +302,29 @@ export function initDialog({ root } = {}) {
243
302
  dlg.close();
244
303
  }
245
304
  };
246
- host.addEventListener('click', onClick);
247
- return () => host.removeEventListener('click', onClick);
305
+ return bindOnce(host, 'dialog', () => {
306
+ host.addEventListener('click', onClick);
307
+ return () => host.removeEventListener('click', onClick);
308
+ });
248
309
  }
249
310
 
250
311
  /**
251
- * Push a transient toast into a shared, screen-anchored stack (created
252
- * once and appended to <body>, `aria-live="polite"`). `tone` is one of
253
- * accent/success/warning/danger; `title` is an optional uppercase label;
254
- * `duration` ms before auto-dismiss (0 keeps it until dismissed). Returns
255
- * a function that dismisses the toast early. SSR-safe (no-op).
312
+ * Push a transient toast into a shared, screen-anchored stack. The stack
313
+ * is the `aria-live="polite"` region: it is created once, appended to
314
+ * <body>, and **kept resident even when empty** so the live region is
315
+ * always present before content is inserted (a freshly created region
316
+ * that receives its first child in the same tick is not reliably
317
+ * announced by VoiceOver/NVDA). On first creation the empty region is
318
+ * inserted and the toast is appended on the next frame for the same
319
+ * reason. `tone` is accent/success/warning/danger; `title` is an
320
+ * optional uppercase label; `duration` ms before auto-dismiss (0 keeps
321
+ * it until dismissed). Returns a function that dismisses the toast
322
+ * early. SSR-safe (no-op).
256
323
  */
257
324
  export function toast(message, { tone, title, duration = 4000 } = {}) {
258
325
  if (!hasDom()) return noop;
259
326
  let stack = document.querySelector('.ui-toast-stack');
327
+ const freshStack = !stack;
260
328
  if (!stack) {
261
329
  stack = document.createElement('div');
262
330
  stack.className = 'ui-toast-stack';
@@ -276,13 +344,40 @@ export function toast(message, { tone, title, duration = 4000 } = {}) {
276
344
  const body = document.createElement('div');
277
345
  body.textContent = message;
278
346
  el.appendChild(body);
279
- stack.appendChild(el);
347
+ // Append after a frame the *first* time so the empty live region is
348
+ // observed by AT before its first child arrives; once the region has
349
+ // been observed, later toasts append synchronously.
350
+ let dismissed = false;
351
+ // `dismissed` guard: a toast dismissed before its frame (e.g.
352
+ // duration:0 + immediate dismiss) must NOT be resurrected into the
353
+ // persistent aria-live region.
354
+ const place = () => {
355
+ if (!dismissed) stack.appendChild(el);
356
+ };
357
+ const canDefer = typeof requestAnimationFrame === 'function';
358
+ if (freshStack && canDefer) {
359
+ toastQueue.push(place);
360
+ toastFlushScheduled = true;
361
+ requestAnimationFrame(() => {
362
+ toastFlushScheduled = false;
363
+ for (const fn of toastQueue.splice(0)) fn();
364
+ });
365
+ } else if (toastFlushScheduled) {
366
+ // A first-frame deferral is in flight — queue behind it so FIFO
367
+ // order holds and the region still isn't populated synchronously.
368
+ toastQueue.push(place);
369
+ } else {
370
+ place();
371
+ }
280
372
 
281
373
  let timer;
282
374
  const dismiss = () => {
375
+ if (dismissed) return;
376
+ dismissed = true;
283
377
  if (timer) clearTimeout(timer);
284
378
  el.remove();
285
- if (stack && !stack.childElementCount) stack.remove();
379
+ // The stack is a persistent live region — never removed on drain, so
380
+ // the next toast does not recreate (and thus mis-announce) it.
286
381
  };
287
382
  if (duration > 0) timer = setTimeout(dismiss, duration);
288
383
  return dismiss;
@@ -306,6 +401,53 @@ export function initDisclosure({ root } = {}) {
306
401
  trigger.setAttribute('aria-expanded', String(!open));
307
402
  panel.hidden = open;
308
403
  };
309
- host.addEventListener('click', onClick);
310
- return () => host.removeEventListener('click', onClick);
404
+ return bindOnce(host, 'disclosure', () => {
405
+ host.addEventListener('click', onClick);
406
+ return () => host.removeEventListener('click', onClick);
407
+ });
408
+ }
409
+
410
+ /**
411
+ * Dropdown-menu close affordances for a native `<details data-bronto-menu>`
412
+ * holding a `.ui-menu`. `<details>` alone won't close on Escape, on an
413
+ * outside click, or when a `.ui-menu__item` is activated — this adds
414
+ * exactly those, returning focus to the `<summary>` on Esc/activate.
415
+ *
416
+ * Deliberately NOT a full WAI-ARIA menu (no arrow-key roving): the items
417
+ * are real buttons, Tab-reachable; this is a disclosure of actions, and
418
+ * over-claiming `role="menu"` semantics would be worse. SSR-safe,
419
+ * idempotent; returns a cleanup function.
420
+ */
421
+ export function initMenu({ root } = {}) {
422
+ if (!hasDom()) return noop;
423
+ const host = root || document;
424
+ const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
425
+ const shut = (menu) => {
426
+ if (!menu || !menu.open) return;
427
+ menu.open = false;
428
+ menu.querySelector('summary')?.focus();
429
+ };
430
+ const onClick = (e) => {
431
+ const menu = e.target.closest('[data-bronto-menu]');
432
+ // Activate an item → close its menu (and return focus to summary).
433
+ if (menu && e.target.closest('.ui-menu__item')) {
434
+ shut(menu);
435
+ return;
436
+ }
437
+ // Click outside any open menu → close them all (no focus move).
438
+ for (const m of openMenus()) if (!m.contains(e.target)) m.open = false;
439
+ };
440
+ const onKey = (e) => {
441
+ if (e.key !== 'Escape') return;
442
+ const menu = e.target.closest?.('[data-bronto-menu][open]') || openMenus()[0];
443
+ shut(menu);
444
+ };
445
+ return bindOnce(host, 'menu', () => {
446
+ host.addEventListener('click', onClick);
447
+ host.addEventListener('keydown', onKey);
448
+ return () => {
449
+ host.removeEventListener('click', onClick);
450
+ host.removeEventListener('keydown', onKey);
451
+ };
452
+ });
311
453
  }