@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 +93 -46
- package/behaviors/index.d.ts +7 -0
- package/behaviors/index.js +170 -28
- package/classes/index.d.ts +191 -3
- package/classes/index.js +60 -10
- package/css/app.css +42 -42
- package/css/base.css +14 -22
- package/css/content.css +37 -2
- package/css/core.css +5 -5
- package/css/disclosure.css +17 -5
- package/css/forms.css +2 -2
- package/css/navigation.css +16 -85
- package/css/overlay.css +9 -1
- package/css/primitives.css +5 -3
- package/css/site.css +295 -0
- package/css/tokens.css +10 -3
- package/dist/bronto.css +1 -1
- package/dist/css/app.css +1 -0
- package/dist/css/base.css +1 -0
- package/dist/css/content.css +1 -0
- package/dist/css/disclosure.css +1 -0
- package/dist/css/dots.css +1 -0
- package/dist/css/feedback.css +1 -0
- package/dist/css/fonts.css +1 -0
- package/dist/css/forms.css +1 -0
- package/dist/css/motion.css +1 -0
- package/dist/css/navigation.css +1 -0
- package/dist/css/overlay.css +1 -0
- package/dist/css/primitives.css +1 -0
- package/dist/css/site.css +1 -0
- package/dist/css/table.css +1 -0
- package/dist/css/tokens.css +1 -0
- package/package.json +51 -27
- package/shiki/nothing.json +83 -0
- package/tokens/index.d.ts +18 -10
- package/tokens/index.js +4 -2
- package/tokens/index.json +8 -4
- package/tokens/tokens.dtcg.json +22 -8
- package/css/cards.css +0 -336
- package/css/index.css +0 -5
- package/css/layout.css +0 -219
- package/css/responsive.css +0 -157
- package/css/typography.css +0 -139
- package/dist/bronto-core.css +0 -1
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
|
|
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
|
|
35
|
-
a load waterfall over plain HTTP. The
|
|
36
|
-
minified
|
|
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';
|
|
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
|
-
~
|
|
44
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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]`,
|
|
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
|
|
116
|
-
| `navigation.css` |
|
|
117
|
-
| `
|
|
118
|
-
| `content.css` | `.ui-prose`
|
|
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
|
-
|
|
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 #
|
|
161
|
+
npx playwright test # visual snapshots + axe a11y + cross-engine + modes
|
|
153
162
|
```
|
|
154
163
|
|
|
155
|
-
It
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
a
|
|
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)
|
|
172
|
-
`release-notes`. **The npm publish is the
|
|
173
|
-
the version never reaches npm, so
|
|
174
|
-
|
|
175
|
-
the documented install.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 (
|
|
184
|
-
|
|
185
|
-
`@ponchia/ui/css`)
|
|
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`.
|
package/behaviors/index.d.ts
CHANGED
|
@@ -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).
|
package/behaviors/index.js
CHANGED
|
@@ -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
|
|
95
|
-
|
|
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
|
|
116
|
-
|
|
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
|
|
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 = ++
|
|
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')
|
|
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
|
|
199
|
-
|
|
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.
|
|
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)
|
|
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
|
|
247
|
-
|
|
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
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
310
|
-
|
|
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
|
}
|