@ponchia/ui 0.2.1 → 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.
Files changed (48) hide show
  1. package/README.md +128 -54
  2. package/behaviors/index.d.ts +36 -0
  3. package/behaviors/index.js +321 -7
  4. package/classes/index.d.ts +228 -3
  5. package/classes/index.js +139 -4
  6. package/css/app.css +64 -64
  7. package/css/base.css +136 -24
  8. package/css/content.css +324 -0
  9. package/css/core.css +9 -5
  10. package/css/disclosure.css +323 -0
  11. package/css/dots.css +125 -8
  12. package/css/feedback.css +243 -0
  13. package/css/fonts.css +11 -0
  14. package/css/forms.css +41 -14
  15. package/css/motion.css +29 -5
  16. package/css/navigation.css +22 -86
  17. package/css/overlay.css +206 -0
  18. package/css/primitives.css +37 -18
  19. package/css/site.css +295 -0
  20. package/css/table.css +9 -9
  21. package/css/tokens.css +84 -20
  22. package/dist/bronto.css +1 -0
  23. package/dist/css/app.css +1 -0
  24. package/dist/css/base.css +1 -0
  25. package/dist/css/content.css +1 -0
  26. package/dist/css/disclosure.css +1 -0
  27. package/dist/css/dots.css +1 -0
  28. package/dist/css/feedback.css +1 -0
  29. package/dist/css/fonts.css +1 -0
  30. package/dist/css/forms.css +1 -0
  31. package/dist/css/motion.css +1 -0
  32. package/dist/css/navigation.css +1 -0
  33. package/dist/css/overlay.css +1 -0
  34. package/dist/css/primitives.css +1 -0
  35. package/dist/css/site.css +1 -0
  36. package/dist/css/table.css +1 -0
  37. package/dist/css/tokens.css +1 -0
  38. package/package.json +62 -21
  39. package/shiki/nothing.json +83 -0
  40. package/tokens/index.d.ts +18 -10
  41. package/tokens/index.js +16 -14
  42. package/tokens/index.json +32 -28
  43. package/tokens/tokens.dtcg.json +589 -0
  44. package/css/cards.css +0 -336
  45. package/css/index.css +0 -5
  46. package/css/layout.css +0 -219
  47. package/css/responsive.css +0 -157
  48. package/css/typography.css +0 -139
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,31 +21,42 @@ npm i @ponchia/ui
18
21
  > deliberately distinct from the package name. See
19
22
  > [`docs/architecture.md`](docs/architecture.md).
20
23
 
21
- > Not published yetthe only remaining blocker is npm-account side
22
- > (create the `@ponchia` scope + `NPM_TOKEN`; see [Release](#release)).
23
- > Until the first publish, depend on a `file:` link to a checkout or a
24
- > pinned git tag:
25
- >
26
- > ```json
27
- > { "dependencies": { "@ponchia/ui": "file:../bronto-ui" } }
28
- > ```
29
- >
30
- > ```json
31
- > { "dependencies": { "@ponchia/ui": "github:Ponchia/bronto-ui#semver:^0.2.0" } }
32
- > ```
33
-
34
- Import the full theme (includes responsive breakpoints):
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):
35
26
 
36
27
  ```css
37
- @import '@ponchia/ui/css';
28
+ @import '@ponchia/ui/css'; /* === @ponchia/ui/css/core.css */
38
29
  ```
39
30
 
40
- Or the core bundle if the app manages its own responsive layer:
31
+ **Prebuilt single file (recommended for apps without a CSS bundler).**
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:
41
36
 
42
37
  ```css
43
- @import '@ponchia/ui/css/core.css';
38
+ @import '@ponchia/ui'; /* → dist/bronto.css, the whole framework */
44
39
  ```
45
40
 
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)).
51
+
52
+ ### Browser support
53
+
54
+ Evergreen only — the framework relies on cascade layers (`@layer`),
55
+ `:has()`, `color-mix()`, CSS logical properties and native `<dialog>`.
56
+ Floor: **Chrome/Edge 111+, Safari 16.4+, Firefox 121+** (early 2023
57
+ onward). No build-time fallback is shipped; pin an older tag if you must
58
+ support below this.
59
+
46
60
  The Doto `@font-face` ships in `css/fonts.css` (bundled by both `css` and
47
61
  `css/core.css`) with URLs relative to the package, so it resolves through a
48
62
  bundler or static serving with no `/fonts` path assumption. To self-host the
@@ -51,14 +65,23 @@ font instead, import everything except `fonts.css` and override `--display` /
51
65
 
52
66
  Everything ships inside a single `@layer bronto`, so any un-layered CSS in
53
67
  your app overrides the framework without a specificity fight or `!important`.
54
- The layer is applied by the `css` and `css/core.css` bundles only —
55
- importing an individual leaf such as `@ponchia/ui/css/primitives.css`
56
- directly is **unlayered** (full specificity), which is an intentional
57
- 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.
58
76
 
59
77
  Set `data-theme="light"` or `data-theme="dark"` on `<html>`; defaults follow
60
78
  `prefers-color-scheme`.
61
79
 
80
+ **Re-brand with one knob:** `--accent` drives the whole accent family
81
+ (`color-mix`-derived). `:root { --accent: #2f6df6 }` — or scope it to a
82
+ subtree — restyles everything, both themes. Plus `data-density` and
83
+ `data-contrast` presets. Full contract: [`docs/theming.md`](docs/theming.md).
84
+
62
85
  ## Entrypoints
63
86
 
64
87
  The CSS is the framework. These optional sibling entrypoints are thin layers
@@ -77,9 +100,14 @@ themeColor('dark').accent; // → "#ff3b41"
77
100
  ```
78
101
 
79
102
  `behaviors` wires `[data-bronto-theme-toggle]`, `[data-bronto-dismiss]` /
80
- `[data-bronto-dismissible]`, and `[data-bronto-disclosure]`. Each initializer
81
- is SSR-safe and returns a cleanup function. `demo/index.html` drives itself
82
- with these modules, so it is also a live integration test.
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
106
+ `<dialog>` glue (`initDialog`: `[data-bronto-open]` / `[data-bronto-close]`
107
+ / `[data-bronto-dialog-light]`). `toast(message, { tone, title, duration })`
108
+ pushes into a shared, body-anchored stack. Each initializer is SSR-safe and
109
+ returns a cleanup function. `demo/index.html` drives itself with these
110
+ modules, so it is also a live integration test.
83
111
 
84
112
  ## Layout
85
113
 
@@ -89,16 +117,17 @@ with these modules, so it is also a live integration test.
89
117
  | `fonts.css` | Doto `@font-face` (relative URLs; optional if self-hosting) |
90
118
  | `base.css` | reset, element defaults, focus, scrollbars |
91
119
  | `motion.css` | keyframes + animation utilities + reduced-motion |
92
- | `dots.css` | dot-grid, dot rule, status dot, dot loader/bar, matrix reveal |
120
+ | `dots.css` | dot-grid, dot rule, status dot, dot loader, orbital dot spinner, dot bar (+ indeterminate), matrix reveal |
93
121
  | `primitives.css` | `ui-*` buttons, cards, chips, badges, links, key/value |
94
122
  | `forms.css` | inputs, select, textarea, search, switch, checkbox |
123
+ | `feedback.css` | alert / callout, toast, tooltip, linear progress |
124
+ | `overlay.css` | modal + drawer (native `<dialog>`), dropdown menu |
125
+ | `disclosure.css` | tabs, accordion (`<details>`), segmented, breadcrumb, pagination, avatar |
95
126
  | `table.css` | `ui-table` dense / comfortable |
96
- | `app.css` | admin shell: rail, topbar, toolbar, panel, metrics |
97
- | `navigation.css` | site nav, menu, theme toggle (dot indicator) |
98
- | `typography.css` | display headings, eyebrows, legacy `.button` |
99
- | `cards.css` | semantic content cards (token-driven) |
100
- | `layout.css` | site shell, hero, grids |
101
- | `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 |
102
131
 
103
132
  ## Demo
104
133
 
@@ -113,18 +142,64 @@ python3 -m http.server -d . 8080 # then open http://localhost:8080/demo/
113
142
 
114
143
  ```bash
115
144
  npm install # stylelint + jsdom (test only)
116
- npm run check # lint + 4 integrity checks (exports, tokens, classes, pack)
145
+ npm run check # lint + 5 integrity checks (exports, tokens, classes, dist, pack)
117
146
  npm test # node:test — pure modules + jsdom behavior tests
118
- npm run lint:fix # auto-fix the safe stylistic rules
147
+ npm run lint:fix # auto-fix the safe stylistic rules + logical properties
119
148
  npm run tokens:build # regenerate tokens/index.json from tokens/index.js
149
+ npm run dist:build # rebuild the flattened dist/ bundles
120
150
  ```
121
151
 
122
152
  `npm run check` enforces that the data mirrors cannot drift from the CSS:
123
153
  exports/import-graph integrity, `tokens.css` ⇄ `tokens/index.{js,json}`, the
124
- `classes` registry ⇄ the `.ui-*` selectors, and that the published tarball
125
- ships only the intended files. CI
126
- (`.github/workflows/ci.yml`) runs it on every branch push and PR. It never
127
- publishes a push to `main` ships nothing.
154
+ `classes` registry ⇄ the `.ui-*` selectors, `dist/` `css/` (fresh + in
155
+ budget), and that the published tarball ships only the intended files.
156
+
157
+ End-to-end regression is a separate suite (real browsers, so not in the
158
+ zero-dep core check):
159
+
160
+ ```bash
161
+ npx playwright test # visual snapshots + axe a11y + cross-engine + modes
162
+ ```
163
+
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).
128
203
 
129
204
  ## Release
130
205
 
@@ -136,23 +211,22 @@ git tag vX.Y.Z && git push origin vX.Y.Z
136
211
  ```
137
212
 
138
213
  The tag triggers `.github/workflows/release.yml`: `validate` (read-only
139
- checks + tag↔version match) `publish-npm` (only if validate passes) +
140
- `release-notes`. **The npm publish is the gate** — a failing check means
141
- the version never reaches npm, so consumers never resolve it. GitHub also
142
- serves the raw tag tarball ungated, but that is a legacy/fallback path, not
143
- the documented install. See [`docs/architecture.md`](docs/architecture.md).
144
-
145
- **Before the first real publish** — one npm-account step remains:
146
-
147
- - Create the `@ponchia` npm scope and add an `NPM_TOKEN` repo secret
148
- (an npm automation token with publish rights to the scope).
149
-
150
- Done already: MIT `LICENSE`, `version` `0.2.0`, `publishConfig`, the
151
- gated publish workflow. After publishing, switch the consumer repos'
152
- dependency specifier to `@ponchia/ui`.
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.)
153
226
 
154
227
  ## Consumers
155
228
 
156
- Built for two shapes of app: a content/marketing site (imports
157
- `@ponchia/ui/css/core.css`) and an admin dashboard (imports the full
158
- `@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`.
@@ -38,3 +38,39 @@ export declare function dismissible(opts?: DelegateOpts): Cleanup;
38
38
 
39
39
  /** Wire `[data-bronto-disclosure]` triggers. Returns a cleanup function. */
40
40
  export declare function initDisclosure(opts?: DelegateOpts): Cleanup;
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
+
49
+ /**
50
+ * Wire `[data-bronto-tabs]` groups with the WAI-ARIA Tabs keyboard
51
+ * pattern (roving tabindex, Arrow/Home/End, aria-selected, panel sync).
52
+ * Returns a cleanup function.
53
+ */
54
+ export declare function initTabs(opts?: DelegateOpts): Cleanup;
55
+
56
+ /**
57
+ * Wire native <dialog> open/close glue: `[data-bronto-open="id"]`,
58
+ * `[data-bronto-close]`, and backdrop light-dismiss for dialogs marked
59
+ * `[data-bronto-dialog-light]`. Returns a cleanup function.
60
+ */
61
+ export declare function initDialog(opts?: DelegateOpts): Cleanup;
62
+
63
+ export interface ToastOpts {
64
+ /** Status tone — maps to `ui-toast--<tone>`. */
65
+ tone?: 'accent' | 'success' | 'warning' | 'danger';
66
+ /** Optional uppercase label rendered above the message. */
67
+ title?: string;
68
+ /** Auto-dismiss delay in ms. 0 keeps it until dismissed. Default: 4000. */
69
+ duration?: number;
70
+ }
71
+
72
+ /**
73
+ * Push a transient toast into a shared, body-anchored stack. Returns a
74
+ * function that dismisses it early. No-op (returns noop) without a DOM.
75
+ */
76
+ export declare function toast(message: string, opts?: ToastOpts): Cleanup;
@@ -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,242 @@ 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
+ });
152
+ }
153
+
154
+ /**
155
+ * Wire `[data-bronto-tabs]` groups for full keyboard a11y. The framework
156
+ * ships the look + the ARIA/`.is-active` contract; this adds the WAI-ARIA
157
+ * Tabs pattern: roving `tabindex`, `aria-selected`, Arrow/Home/End
158
+ * navigation with automatic activation, and panel `hidden` sync. Tabs are
159
+ * `.ui-tab[data-tab]`; panels are `.ui-tabs__panel[data-panel]` with
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`.
168
+ */
169
+ export function initTabs({ root } = {}) {
170
+ if (!hasDom()) return noop;
171
+ const host = root || document;
172
+ const cleanups = [];
173
+ // querySelectorAll only matches descendants, so a `root` that *is* a
174
+ // tab group would be skipped — include it explicitly.
175
+ const groups = [];
176
+ if (host !== document && host.matches?.('[data-bronto-tabs]')) groups.push(host);
177
+ groups.push(...host.querySelectorAll('[data-bronto-tabs]'));
178
+ for (const group of groups) {
179
+ // Own group only — a tab/panel inside a nested [data-bronto-tabs]
180
+ // belongs to that inner group, not this one.
181
+ const owned = (el) => el.closest('[data-bronto-tabs]') === group;
182
+ const tabs = [...group.querySelectorAll('.ui-tab')].filter(owned);
183
+ const panels = [...group.querySelectorAll('.ui-tabs__panel')].filter(owned);
184
+ if (!tabs.length) continue;
185
+ const list = group.querySelector('.ui-tabs__list');
186
+ if (list) list.setAttribute('role', 'tablist');
187
+
188
+ // APG: bind each tab to its panel (aria-controls) and back
189
+ // (aria-labelledby), minting stable ids only where absent.
190
+ for (const t of tabs) {
191
+ const p = panels.find((x) => x.dataset.panel === t.dataset.tab);
192
+ if (!p) continue;
193
+ const n = ++tabUid;
194
+ if (!t.id) t.id = `bronto-tab-${n}`;
195
+ if (!p.id) p.id = `bronto-tabpanel-${n}`;
196
+ t.setAttribute('aria-controls', p.id);
197
+ p.setAttribute('aria-labelledby', t.id);
198
+ }
199
+
200
+ const select = (tab) => {
201
+ for (const t of tabs) {
202
+ const on = t === tab;
203
+ t.classList.toggle('is-active', on);
204
+ t.setAttribute('role', 'tab');
205
+ t.setAttribute('aria-selected', String(on));
206
+ t.tabIndex = on ? 0 : -1;
207
+ }
208
+ for (const p of panels) {
209
+ p.setAttribute('role', 'tabpanel');
210
+ p.hidden = p.dataset.panel !== tab.dataset.tab;
211
+ }
212
+ };
213
+ const onClick = (e) => {
214
+ // `tabs` is filtered to this group, so membership (not mere DOM
215
+ // containment) is what isolates nested [data-bronto-tabs] groups.
216
+ const tab = e.target.closest('.ui-tab');
217
+ if (tab && tabs.includes(tab)) {
218
+ select(tab);
219
+ tab.focus();
220
+ }
221
+ };
222
+ const onKey = (e) => {
223
+ const i = tabs.indexOf(e.target.closest('.ui-tab'));
224
+ if (i < 0) return;
225
+ let n = i;
226
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = (i + 1) % tabs.length;
227
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
228
+ n = (i - 1 + tabs.length) % tabs.length;
229
+ else if (e.key === 'Home') n = 0;
230
+ else if (e.key === 'End') n = tabs.length - 1;
231
+ else return;
232
+ e.preventDefault();
233
+ select(tabs[n]);
234
+ tabs[n].focus();
235
+ };
236
+ select(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
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
+ );
247
+ }
248
+ return () => cleanups.forEach((fn) => fn());
249
+ }
250
+
251
+ /**
252
+ * Wire native <dialog> open/close glue (the one bit <dialog> can't do
253
+ * declaratively). Click `[data-bronto-open="dialogId"]` calls
254
+ * `showModal()` on `#dialogId`; click `[data-bronto-close]` closes the
255
+ * nearest enclosing <dialog>. Clicking the backdrop of a dialog that has
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.
261
+ *
262
+ * `root` scopes which triggers are delegated (default `document`); the
263
+ * dialog itself is still resolved by id document-wide, because a modal
264
+ * <dialog> is promoted to the top layer and is inherently document-global
265
+ * (same model as `initThemeToggle`, where `root` scopes controls but the
266
+ * theme applies to <html>).
267
+ */
268
+ export function initDialog({ root } = {}) {
269
+ if (!hasDom()) return noop;
270
+ const host = root || document;
271
+ const onClick = (e) => {
272
+ const opener = e.target.closest('[data-bronto-open]');
273
+ if (opener && host.contains(opener)) {
274
+ const dlg = document.getElementById(opener.getAttribute('data-bronto-open'));
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
+ }
285
+ return;
286
+ }
287
+ const closer = e.target.closest('[data-bronto-close]');
288
+ if (closer && host.contains(closer)) {
289
+ const dlg = closer.closest('dialog');
290
+ if (dlg && dlg.open) dlg.close();
291
+ return;
292
+ }
293
+ // Light-dismiss: a click whose target is the <dialog> itself is the
294
+ // backdrop (content sits in child elements).
295
+ const dlg = e.target;
296
+ if (
297
+ dlg.tagName === 'DIALOG' &&
298
+ dlg.open &&
299
+ dlg.hasAttribute('data-bronto-dialog-light') &&
300
+ host.contains(dlg)
301
+ ) {
302
+ dlg.close();
303
+ }
304
+ };
305
+ return bindOnce(host, 'dialog', () => {
306
+ host.addEventListener('click', onClick);
307
+ return () => host.removeEventListener('click', onClick);
308
+ });
309
+ }
310
+
311
+ /**
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).
323
+ */
324
+ export function toast(message, { tone, title, duration = 4000 } = {}) {
325
+ if (!hasDom()) return noop;
326
+ let stack = document.querySelector('.ui-toast-stack');
327
+ const freshStack = !stack;
328
+ if (!stack) {
329
+ stack = document.createElement('div');
330
+ stack.className = 'ui-toast-stack';
331
+ stack.setAttribute('aria-live', 'polite');
332
+ document.body.appendChild(stack);
333
+ }
334
+ const el = document.createElement('div');
335
+ el.className = tone ? `ui-toast ui-toast--${tone}` : 'ui-toast';
336
+ // No per-item role: the stack is already aria-live=polite; a nested
337
+ // status live region risks double announcement in some SRs.
338
+ if (title) {
339
+ const t = document.createElement('p');
340
+ t.className = 'ui-toast__title';
341
+ t.textContent = title;
342
+ el.appendChild(t);
343
+ }
344
+ const body = document.createElement('div');
345
+ body.textContent = message;
346
+ el.appendChild(body);
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
+ }
372
+
373
+ let timer;
374
+ const dismiss = () => {
375
+ if (dismissed) return;
376
+ dismissed = true;
377
+ if (timer) clearTimeout(timer);
378
+ el.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.
381
+ };
382
+ if (duration > 0) timer = setTimeout(dismiss, duration);
383
+ return dismiss;
117
384
  }
118
385
 
119
386
  /**
@@ -134,6 +401,53 @@ export function initDisclosure({ root } = {}) {
134
401
  trigger.setAttribute('aria-expanded', String(!open));
135
402
  panel.hidden = open;
136
403
  };
137
- host.addEventListener('click', onClick);
138
- 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
+ });
139
453
  }