@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.
- package/README.md +128 -54
- package/behaviors/index.d.ts +36 -0
- package/behaviors/index.js +321 -7
- package/classes/index.d.ts +228 -3
- package/classes/index.js +139 -4
- package/css/app.css +64 -64
- package/css/base.css +136 -24
- package/css/content.css +324 -0
- package/css/core.css +9 -5
- package/css/disclosure.css +323 -0
- package/css/dots.css +125 -8
- package/css/feedback.css +243 -0
- package/css/fonts.css +11 -0
- package/css/forms.css +41 -14
- package/css/motion.css +29 -5
- package/css/navigation.css +22 -86
- package/css/overlay.css +206 -0
- package/css/primitives.css +37 -18
- package/css/site.css +295 -0
- package/css/table.css +9 -9
- package/css/tokens.css +84 -20
- package/dist/bronto.css +1 -0
- 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 +62 -21
- package/shiki/nothing.json +83 -0
- package/tokens/index.d.ts +18 -10
- package/tokens/index.js +16 -14
- package/tokens/index.json +32 -28
- package/tokens/tokens.dtcg.json +589 -0
- 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/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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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]`,
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
97
|
-
| `navigation.css` |
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
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 +
|
|
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,
|
|
125
|
-
ships only the intended files.
|
|
126
|
-
|
|
127
|
-
|
|
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)
|
|
140
|
-
`release-notes`. **The npm publish is the
|
|
141
|
-
the version never reaches npm, so
|
|
142
|
-
|
|
143
|
-
the documented install.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
157
|
-
|
|
158
|
-
`@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
|
@@ -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;
|
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,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
|
|
116
|
-
|
|
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
|
|
138
|
-
|
|
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
|
}
|