@ponchia/ui 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,219 +2,152 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@ponchia/ui?logo=npm)](https://www.npmjs.com/package/@ponchia/ui)
4
4
  [![npm provenance](https://img.shields.io/badge/npm-provenance-blue?logo=npm)](https://www.npmjs.com/package/@ponchia/ui#provenance)
5
- [![runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)](package.json)
6
- [![bundle](https://img.shields.io/badge/dist-~54kB%20%2F%20~10kB%20gzip-informational)](scripts/check-dist.mjs)
5
+ [![runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)](https://github.com/Ponchia/bronto-ui/blob/main/package.json)
6
+ [![dist](https://img.shields.io/badge/dist-~73kB%20%2F%20~13kB%20gzip-informational)](https://github.com/Ponchia/bronto-ui/blob/main/scripts/check-dist.mjs)
7
7
  [![CI](https://github.com/Ponchia/bronto-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/Ponchia/bronto-ui/actions/workflows/ci.yml)
8
- [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
8
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Ponchia/bronto-ui/badge)](https://scorecard.dev/viewer/?uri=github.com/Ponchia/bronto-ui)
9
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/Ponchia/bronto-ui/blob/main/LICENSE)
9
10
 
10
- Shared UI framework for Bronto personal projects. Nothing-inspired:
11
- monochrome surfaces, a single red accent, dot-matrix display type (Doto),
12
- flat hairline borders, restrained motion. CSS-first and framework-agnostic.
11
+ **A CSS-first, framework-agnostic UI framework with a "Nothing"-inspired look — monochrome surfaces, one rationed red accent, dot-matrix display type, hairline borders, restrained motion. Zero runtime dependencies. Re-brand the whole thing with one CSS variable.**
13
12
 
14
- **New here?** → [Getting started](#getting-started) ·
15
- [Reference](docs/reference.md) · [Theming](docs/theming.md) ·
16
- [Roadmap](ROADMAP.md) · [Contributing](CONTRIBUTING.md)
13
+ ### [Live demo ](https://ponchia.github.io/bronto-ui/)  ·  [Theme playground →](https://ponchia.github.io/bronto-ui/demo/theme-playground.html)
17
14
 
18
- > **Editor IntelliSense.** The package ships a VS Code CSS Custom Data
19
- > file so every design token autocompletes inside `var(--…)`. Add to
20
- > your `.vscode/settings.json`:
21
- >
22
- > ```json
23
- > {
24
- > "css.customData": ["node_modules/@ponchia/ui/classes/vscode.css-custom-data.json"]
25
- > }
26
- > ```
15
+ The demo is the kitchen sink every component, light/dark, RTL, live theming.
27
16
 
28
- > **For AI agents / LLMs.** The package ships `llms.txt` at its root —
29
- > point a coding agent at `node_modules/@ponchia/ui/llms.txt` for a
30
- > self-contained orientation. The full class catalog
31
- > (`docs/reference.md`) and the token contract (`docs/theming.md`) also
32
- > ship in the tarball, so an offline agent never has to guess at the API.
17
+ ---
33
18
 
34
- **[Live demo →](https://ponchia.github.io/bronto-ui/)** — the kitchen
35
- sink (every component, light/dark, RTL, theming) deployed from `demo/`.
19
+ ## What it is
36
20
 
37
- ## Use
21
+ `@ponchia/ui` ships its design as **CSS**, not components. You drop in one stylesheet and style with semantic `ui-*` classes; an optional thin layer of typed class-name recipes and SSR-safe vanilla behaviors sits on top for the few things that genuinely need JS (theme persistence, dialogs, toasts, disclosure). The guiding principle is **color is rationed, structure carries meaning** — layout, type weight and the hairline do the work before a hue does, and the accent is a spotlight, not a paint bucket. Because everything lives in a single `@layer bronto`, your own un-layered CSS overrides the framework with no specificity fight and no `!important`.
38
22
 
39
- Install from npm (public, no registry config):
23
+ ## Install
40
24
 
41
25
  ```bash
42
26
  npm i @ponchia/ui
43
27
  ```
44
28
 
45
- > Naming: the **npm package** is `@ponchia/ui` (the `@bronto` scope isn't
46
- > ownable). The **CSS layer** and behavior attributes stay `bronto`
47
- > (`@layer bronto`, `data-bronto-*`) — that's the design-system namespace,
48
- > deliberately distinct from the package name. See
49
- > [`docs/architecture.md`](docs/architecture.md).
29
+ Or drop it in with no build step, straight from a CDN:
50
30
 
51
- Import the theme (one bundle — `ui-*` components carry their own
52
- breakpoints, so there is no separate core/full split as of 0.3.0):
31
+ ```html
32
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ponchia/ui/dist/bronto.css">
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ **1. Load the CSS.** One flattened, minified bundle — the whole framework, one request (~73 kB raw / ~13 kB gzip):
53
38
 
54
39
  ```css
55
- @import '@ponchia/ui/css'; /* === @ponchia/ui/css/core.css */
40
+ @import '@ponchia/ui'; /* via a bundler */
41
+ ```
42
+
43
+ ```html
44
+ <!-- or a plain <link>, no bundler -->
45
+ <link rel="stylesheet" href="/node_modules/@ponchia/ui/dist/bronto.css">
56
46
  ```
57
47
 
58
- **Prebuilt single file (recommended for apps without a CSS bundler).**
59
- `@ponchia/ui/css` is a wide `@import` fan-out (~14 leaves, one level
60
- deep) — fine through a bundler, a load waterfall over plain HTTP. The
61
- package also ships one flattened, minified bundle with no `@import`
62
- chain:
48
+ > Prefer source leaves through a bundler? Use `@import '@ponchia/ui/css'` (a thin `@import` fan-out) instead. Both resolve the Doto `@font-face` with relative URLs, so there's no `/fonts` path assumption.
63
49
 
64
- ```css
65
- @import '@ponchia/ui'; /* → dist/bronto.css, the whole framework */
50
+ **2. Write markup with `ui-*` classes** (primary is the default button; modifiers are opt-in):
51
+
52
+ ```html
53
+ <button class="ui-button">Save</button>
54
+ <button class="ui-button ui-button--ghost">Cancel</button>
55
+ <div class="ui-card">
56
+ <span class="ui-eyebrow">Status</span>
57
+ <span class="ui-badge ui-badge--success">Online</span>
58
+ </div>
66
59
  ```
67
60
 
68
- ~54 kB raw / ~10 kB gzip, one request, same `@layer bronto`. (The
69
- enforced ceiling lives in `scripts/check-dist.mjs`, not this prose —
70
- treat these figures as indicative.) Source CSS, tokens/classes/behaviors
71
- entrypoints are unchanged — use whichever fits.
61
+ **3. (Optional) typed recipes build class strings in JS/TS:**
72
62
 
73
- > **The package root is CSS-only.** `@ponchia/ui` (the `.` export)
74
- > resolves to a stylesheet `@import '@ponchia/ui'` in CSS, never
75
- > `import '@ponchia/ui'` in JS. There is no JS module at the root; the
76
- > JS entrypoints are the explicit subpaths `@ponchia/ui/tokens`,
77
- > `/classes`, and `/behaviors` (see [Entrypoints](#entrypoints)).
63
+ ```js
64
+ import { ui, cx } from '@ponchia/ui/classes';
78
65
 
79
- ### Browser support
66
+ ui.button({ variant: 'ghost' }); // → "ui-button ui-button--ghost"
67
+ ui.meter({ tone: 'warning' }); // → "ui-meter ui-meter--warning"
68
+ ```
80
69
 
81
- Evergreen onlythe framework relies on cascade layers (`@layer`),
82
- `:has()`, `color-mix()`, CSS logical properties and native `<dialog>`.
83
- Floor: **Chrome/Edge 111+, Safari 16.4+, Firefox 121+** (early 2023
84
- onward). No build-time fallback is shipped; pin an older tag if you must
85
- support below this.
70
+ **4. (Optional) behaviors SSR-safe vanilla JS, each returns a cleanup function:**
86
71
 
87
- The Doto `@font-face` ships in `css/fonts.css` (bundled by both `css` and
88
- `css/core.css`) with URLs relative to the package, so it resolves through a
89
- bundler or static serving with no `/fonts` path assumption. To self-host the
90
- font instead, import everything except `fonts.css` and override `--display` /
91
- `--dot-font`.
72
+ ```js
73
+ import { initThemeToggle, initDialog, toast } from '@ponchia/ui/behaviors';
92
74
 
93
- Everything ships inside a single `@layer bronto`, so any un-layered CSS in
94
- your app overrides the framework without a specificity fight or `!important`.
75
+ initThemeToggle(); // wires [data-bronto-theme-toggle] + localStorage
76
+ initDialog(); // native <dialog> glue: [data-bronto-open] / [data-bronto-close]
77
+ toast('Saved', { tone: 'success' }); // body-anchored stack, no markup needed
78
+ ```
95
79
 
96
- > **Leaf imports are layer-safe by default.** Every per-leaf export
97
- > `@ponchia/ui/css/primitives.css`, etc. — is self-wrapped in
98
- > `@layer bronto`, so mixing the bundle with individual leaves (e.g.
99
- > per-route CSS splitting in SvelteKit/Astro) is safe: no silent
100
- > cascade inversion. The deliberate full-specificity escape hatch is
101
- > the explicit `@ponchia/ui/css/unlayered/<leaf>.css` path — use it
102
- > only when you *want* an unlayered override, never by accident.
80
+ Behaviors cover theme persistence, disclosure, dropdown menus, native-`<dialog>` modals/drawers, tabs, combobox, form validation, table sort, carousel and toasts wired by `data-bronto-*` attributes.
103
81
 
104
- Set `data-theme="light"` or `data-theme="dark"` on `<html>`; defaults follow
105
- `prefers-color-scheme`.
82
+ ## What's in the box
106
83
 
107
- **Re-brand with one knob:** `--accent` drives the whole accent family
108
- (`color-mix`-derived). `:root { --accent: #2f6df6 }` or scope it to a
109
- subtreerestyles everything, both themes. Plus `data-density` and
110
- `data-contrast` presets. Full contract: [`docs/theming.md`](docs/theming.md).
84
+ - **Primitives** buttons, cards, chips, badges, links, key/value, `ui-num`, avatars.
85
+ - **Forms** inputs, select, textarea, search, switch, checkbox, `ui-input-icon`, `ui-input-group`.
86
+ - **Feedback** alert/callout, toast, tooltip, `ui-progress` (task) and `ui-meter` (measured value).
87
+ - **Overlay** modal + drawer on native `<dialog>`, dropdown menu, `ui-carousel` + `ui-lightbox` (gallery, user-driven — not an auto-slider).
88
+ - **Disclosure & nav** — tabs, accordion, segmented, breadcrumb, pagination, `ui-steps`, `ui-timeline`, `ui-pagehead`, `ui-kbd`.
89
+ - **Shells** — an admin dashboard shell (`ui-app-*`) and a content/marketing site shell (`ui-site*`, `ui-container`).
90
+ - **Prose** — `.ui-prose` styles raw, unclassed semantic HTML (Markdown / CMS / LLM output) with zero classes.
91
+ - **Motion & dots** — the dot-matrix motif kit: dot grid, status dots, dot loaders, the orbital spinner, matrix reveal — all reduced-motion aware.
111
92
 
112
- ## Entrypoints
93
+ Full generated catalog of every class: **[docs/reference.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/reference.md)**. The decision guide (which primitive when): **[docs/usage.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/usage.md)**.
113
94
 
114
- The CSS is the framework. These optional sibling entrypoints are thin layers
115
- on top of it — none pull in a UI framework. See
116
- [`docs/architecture.md`](docs/architecture.md) for the rationale.
95
+ ## Theming: one knob
117
96
 
118
- ```js
119
- import tokens, { cssVars, themeColor } from '@ponchia/ui/tokens'; // tokens as data (+ /tokens.json)
120
- import { ui, cx } from '@ponchia/ui/classes'; // typed class-name recipes
121
- import { initThemeToggle, dismissible } from '@ponchia/ui/behaviors'; // vanilla, SSR-safe
122
- ```
97
+ Everything accent-colored derives from a single `--accent` variable via `color-mix()`. Re-brand the entire app — both light and dark — with one declaration, globally or scoped to any subtree:
123
98
 
124
- ```js
125
- ui.button({ variant: 'ghost' }); // "ui-button ui-button--ghost"
126
- themeColor('dark').accent; // "#ff3b41"
99
+ ```css
100
+ :root { --accent: #2f6df6; } /* whole app blue */
101
+ .promo { --accent: #16a34a; } /* …or just this section green */
127
102
  ```
128
103
 
129
- `behaviors` wires `[data-bronto-theme-toggle]`, `[data-bronto-dismiss]` /
130
- `[data-bronto-dismissible]`, `[data-bronto-disclosure]`,
131
- `[data-bronto-menu]` (`initMenu`: Escape / outside-click /
132
- close-on-activate for a `<details>` `.ui-menu` dropdown), and native
133
- `<dialog>` glue (`initDialog`: `[data-bronto-open]` / `[data-bronto-close]`
134
- / `[data-bronto-dialog-light]`). `toast(message, { tone, title, duration })`
135
- pushes into a shared, body-anchored stack. Each initializer is SSR-safe and
136
- returns a cleanup function. `demo/index.html` drives itself with these
137
- modules, so it is also a live integration test.
138
-
139
- ## Layout
140
-
141
- | File | Contents |
142
- | ---------------- | ------------------------------------------------------------- |
143
- | `tokens.css` | palette (dual light/dark), spacing, type, motion, dot tokens |
144
- | `fonts.css` | Doto `@font-face` (relative URLs; optional if self-hosting) |
145
- | `base.css` | reset, element defaults, focus, scrollbars |
146
- | `motion.css` | keyframes + animation utilities + reduced-motion |
147
- | `dots.css` | dot-grid, dot rule, status dot, dot loader, orbital dot spinner, dot bar (+ indeterminate), matrix reveal |
148
- | `primitives.css` | `ui-*` buttons, cards, chips, badges, links, key/value |
149
- | `forms.css` | inputs, select, textarea, search, switch, checkbox |
150
- | `feedback.css` | alert / callout, toast, tooltip, linear progress |
151
- | `overlay.css` | modal + drawer (native `<dialog>`), dropdown menu |
152
- | `disclosure.css` | tabs, accordion (`<details>`), segmented, breadcrumb, pagination, avatar |
153
- | `table.css` | `ui-table` dense / comfortable |
154
- | `app.css` | admin shell: `ui-app-shell`/`-rail`/`-topbar`/`-toolbar`/`-panel`/`-nav`/`-metrics` |
155
- | `navigation.css` | `ui-themetoggle` (dot-thumb switch) |
156
- | `site.css` | content-site shell: `ui-container`, `ui-siteheader`/`ui-sitenav` (aria-current), `ui-sitemenu`, `ui-sitefooter`, `ui-skiplink`, `ui-tags`, `ui-meta` |
157
- | `content.css` | `.ui-prose` Markdown/raw-HTML long-form (zero classes) + `ui-quote` pull-quote |
158
-
159
- ## Getting started
160
-
161
- | Consumer | Guide |
162
- | ----------------------- | ------------------------------------------------------------------ |
163
- | Astro | [`docs/getting-started/astro.md`](docs/getting-started/astro.md) |
164
- | SvelteKit | [`docs/getting-started/sveltekit.md`](docs/getting-started/sveltekit.md) |
165
- | Vanilla / Vite / plain | [`docs/getting-started/vanilla.md`](docs/getting-started/vanilla.md) |
166
- | React / Solid (snippet) | [`docs/getting-started/react-solid.md`](docs/getting-started/react-solid.md) |
167
- | Tailwind / cascade-layer interop | [`docs/interop/tailwind.md`](docs/interop/tailwind.md) |
168
-
169
- Each covers the CSS import location, the **no-flash** `applyStoredTheme`
170
- head-script pattern, behavior init/cleanup in that framework's lifecycle,
171
- and SSR caveats. Index: [`docs/integration.md`](docs/integration.md).
172
-
173
- ## Demo
174
-
175
- `demo/index.html` is a kitchen sink covering every primitive in both
176
- themes — it drives itself with the real behavior modules, so it is also a
177
- live integration test. Serve the package root and open `/demo/`:
104
+ Buttons, focus rings, dot motifs, accent borders and soft fills all follow automatically. Light/dark is `data-theme="light"` / `"dark"` on `<html>` (defaults to `prefers-color-scheme`); `data-density` and `data-contrast` give density and contrast presets. A full re-skin (radius, display face, dot density, surfaces) is a handful more token overrides — the "Nothing" look is the **default skin, not the architecture**.
178
105
 
179
- ```bash
180
- python3 -m http.server -d . 8080 # then open http://localhost:8080/demo/
181
- ```
106
+ > When you change `--accent`, contrast becomes yours: verify your hue in the **[theme playground](https://ponchia.github.io/bronto-ui/demo/theme-playground.html)** (it shows the derived family and computed WCAG ratios). Full contract: **[docs/theming.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/theming.md)**.
107
+
108
+ ## Works with anything
109
+
110
+ The CSS is the framework, so it works with React, Svelte/SvelteKit, Astro, Vue, Solid or plain HTML — there's no component runtime to adopt. The optional `classes` and `behaviors` entrypoints pull in **no** UI framework and are SSR-safe.
111
+
112
+ Per-framework getting-started guides + runnable example apps live in the repo:
113
+
114
+ | Stack | Guide | Example |
115
+ | ----- | ----- | ------- |
116
+ | Vanilla / Vite / plain HTML | [vanilla.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/vanilla.md) | [`examples/vanilla-vite`](https://github.com/Ponchia/bronto-ui/tree/main/examples/vanilla-vite) |
117
+ | Astro | [astro.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/astro.md) | [`examples/astro`](https://github.com/Ponchia/bronto-ui/tree/main/examples/astro) |
118
+ | SvelteKit | [sveltekit.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/sveltekit.md) | [`examples/sveltekit`](https://github.com/Ponchia/bronto-ui/tree/main/examples/sveltekit) |
119
+ | React / Solid (snippet) | [react-solid.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/react-solid.md) | — |
120
+ | Tailwind / cascade-layer interop | [tailwind.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/interop/tailwind.md) | — |
121
+
122
+ ## Extras
123
+
124
+ - **Tokens as data** — `import tokens, { themeColor, cssVars } from '@ponchia/ui/tokens'` (plus `tokens.json`, W3C DTCG `tokens.dtcg.json`, and `tokens/resolved.json` for charts/data-viz).
125
+ - **Editor IntelliSense** — point VS Code at the shipped custom-data file so every token autocompletes in `var(--…)`:
126
+ ```json
127
+ { "css.customData": ["node_modules/@ponchia/ui/classes/vscode.css-custom-data.json"] }
128
+ ```
129
+ - **For AI coding agents** — the package ships `llms.txt` at its root plus `docs/reference.md`, `docs/usage.md` and `docs/theming.md` inside the tarball, so an offline agent has the full API without guessing.
182
130
 
183
- ## Develop & release
131
+ > The package root is **CSS-only**: `@import '@ponchia/ui'` in CSS, never `import '@ponchia/ui'` in JS. The JS entrypoints are the explicit subpaths `/tokens`, `/classes`, `/behaviors`.
184
132
 
185
- Contributor setup, the 14 `check` gates, the e2e suite, the visual-
186
- baseline workflow, the deprecation policy and the tag-driven release
187
- flow all live in **[CONTRIBUTING.md](CONTRIBUTING.md)**. Direction and
188
- scope: **[ROADMAP.md](ROADMAP.md)**.
133
+ ## Browser support
134
+
135
+ Evergreen only. The framework relies on cascade layers, `:has()`, `color-mix()`, CSS logical properties and native `<dialog>`. Floor: **Chrome/Edge 111+, Safari 16.4+, Firefox 121+** (early 2023 onward). No build-time fallback ships; pin an older tag if you need below this.
189
136
 
190
137
  ## Versioning
191
138
 
192
- Pre-1.0 and deliberately so. **Until `1.0.0`, breaking changes ship in
193
- the _minor_** (`0.x.0`); patches (`0.x.y`) are non-breaking. This is the
194
- standard 0.x reading of SemVer, stated explicitly because this framework
195
- dresses several apps:
196
-
197
- - Because breaking changes bump the **minor**, the protective range is
198
- the patch range. At `0.x` npm resolves **both** `^0.3.0` and `~0.3.0`
199
- to `>=0.3.0 <0.4.0` they are equivalent here, and either gives you
200
- only non-breaking `0.3.x` patches while holding back the next
201
- (breaking) `0.4.0`. Pin either; pin an **exact** version if you want
202
- zero surprise and to adopt each minor deliberately.
203
- - Every breaking change is called out in [`CHANGELOG.md`](CHANGELOG.md)
204
- under a **BREAKING** heading with a migration note.
205
-
206
- **What is contractual** (changes are breaking): the `--accent`
207
- derivation and token **names** (incl. `--accent-text`, `--focus-ring`);
208
- the `.ui-*` class names and `cls`/recipe names; the `data-bronto-*`
209
- behavior attributes; each behavior's return-cleanup contract. **What is
210
- not** (may change in any release): token _values_ (visual tuning), the
211
- internal leaf-file boundaries and `@layer` internals, and anything
212
- explicitly marked legacy/deprecated. Full token contract:
213
- [`docs/theming.md`](docs/theming.md).
214
-
215
- ## Consumers
216
-
217
- Built for two shapes of app: a content/marketing site (`ui-site*`,
218
- `ui-prose`) and an admin dashboard (`ui-app-*` shell). Both import the
219
- one bundle `@ponchia/ui` (or `@ponchia/ui/css`); consuming apps depend
220
- on it via `@ponchia/ui`.
139
+ Pre-1.0 and deliberately so. **Until `1.0.0`, breaking changes ship in the _minor_** (`0.x.0`); patches (`0.x.y`) are always non-breaking. Pin with the patch range — at `0.x`, `~0.3.0` (and equivalently `^0.3.0`) resolves to `>=0.3.0 <0.4.0`, giving you safe patches while holding back the next breaking minor. Every breaking change is called out under a **BREAKING** heading in the **[CHANGELOG](https://github.com/Ponchia/bronto-ui/blob/main/CHANGELOG.md)** with a migration note.
140
+
141
+ Contractual (changes are breaking): the `--accent` derivation and token **names**, the `.ui-*` class and recipe names, the `data-bronto-*` attributes, and each behavior's cleanup contract. Not contractual (may change any release): token **values** (visual tuning) and internal leaf-file / `@layer` boundaries.
142
+
143
+ ## Links
144
+
145
+ - **[Live demo](https://ponchia.github.io/bronto-ui/)** · **[Theme playground](https://ponchia.github.io/bronto-ui/demo/theme-playground.html)**
146
+ - **[Class reference](https://github.com/Ponchia/bronto-ui/blob/main/docs/reference.md)** · **[Usage guide](https://github.com/Ponchia/bronto-ui/blob/main/docs/usage.md)** · **[Theming](https://github.com/Ponchia/bronto-ui/blob/main/docs/theming.md)** · **[Contrast](https://github.com/Ponchia/bronto-ui/blob/main/docs/contrast.md)**
147
+ - **[CHANGELOG](https://github.com/Ponchia/bronto-ui/blob/main/CHANGELOG.md)** · **[Roadmap](https://github.com/Ponchia/bronto-ui/blob/main/ROADMAP.md)** · **[Contributing](https://github.com/Ponchia/bronto-ui/blob/main/CONTRIBUTING.md)**
148
+
149
+ ## License
150
+
151
+ [MIT](https://github.com/Ponchia/bronto-ui/blob/main/LICENSE) © Ponchia.
152
+
153
+ The bundled **Doto** font (`fonts/*.ttf`) is © 2024 The Doto Project Authors and licensed separately under the [SIL Open Font License 1.1](https://github.com/Ponchia/bronto-ui/blob/main/fonts/OFL.txt) — see `fonts/OFL.txt`.
@@ -106,13 +106,36 @@ export declare function initTabs(opts?: DelegateOpts): Cleanup;
106
106
  */
107
107
  export declare function initDialog(opts?: DelegateOpts): Cleanup;
108
108
 
109
+ /**
110
+ * Image carousel / gallery built on CSS scroll-snap (touch/trackpad swipe
111
+ * is the browser's). Wires `[data-bronto-carousel]`: prev/next
112
+ * (`[data-bronto-carousel-prev|next]`), keyboard (Arrow/Home/End on the
113
+ * focused `.ui-carousel__viewport`), a `.ui-carousel__thumb` strip with
114
+ * `aria-current` sync, the `.ui-carousel__status` counter, and ARIA. Keeps
115
+ * a JS index in sync with the scroll position both ways (via
116
+ * IntersectionObserver where available). `data-bronto-carousel-loop` wraps
117
+ * at the ends; `data-bronto-carousel-label` names the region. A
118
+ * full-screen lightbox is the same markup inside a native
119
+ * `<dialog class="ui-lightbox">` opened by `initDialog` (focus-trap/Escape
120
+ * come from the dialog). Emits `bronto:change` ({ detail: { index } }).
121
+ * SSR-safe, idempotent per carousel. Returns a cleanup function.
122
+ */
123
+ export declare function initCarousel(opts?: DelegateOpts): Cleanup;
124
+
109
125
  export interface ToastOpts {
110
126
  /** Status tone — maps to `ui-toast--<tone>`. */
111
- tone?: 'accent' | 'success' | 'warning' | 'danger';
127
+ tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info';
112
128
  /** Optional uppercase label rendered above the message. */
113
129
  title?: string;
114
130
  /** Auto-dismiss delay in ms. 0 keeps it until dismissed. Default: 4000. */
115
131
  duration?: number;
132
+ /**
133
+ * Route to the assertive live region so AT interrupts immediately.
134
+ * Defaults to `true` when `tone === 'danger'`.
135
+ */
136
+ assertive?: boolean;
137
+ /** Render a dismiss button on the toast. */
138
+ closable?: boolean;
116
139
  }
117
140
 
118
141
  /**
@@ -319,7 +319,7 @@ export function initDialog({ root } = {}) {
319
319
  * that receives its first child in the same tick is not reliably
320
320
  * announced by VoiceOver/NVDA). On first creation the empty region is
321
321
  * inserted and the toast is appended on the next frame for the same
322
- * reason. `tone` is accent/success/warning/danger; `title` is an
322
+ * reason. `tone` is accent/success/warning/danger/info; `title` is an
323
323
  * optional uppercase label; `duration` ms before auto-dismiss (0 keeps
324
324
  * it until dismissed). Returns a function that dismisses the toast
325
325
  * early. SSR-safe (no-op).
@@ -607,11 +607,29 @@ export function initFormValidation({ root } = {}) {
607
607
  };
608
608
 
609
609
  return bindOnce(host, 'formValidation', () => {
610
+ // Suppress native bubbles UP FRONT for forms present at init. The
611
+ // in-handler `noValidate = true` only fires after the first
612
+ // submit/blur, so the very first invalid real-browser submit would
613
+ // otherwise show the native UA bubble instead of the Bronto
614
+ // summary — contradicting the documented contract. (Forms added
615
+ // after init are still covered by the in-handler set.)
616
+ // Feature-detect rather than `instanceof Element` — `Element` is not
617
+ // a guaranteed global (SSR / the no-DOM test env), and `host` is
618
+ // either `document` (no `.matches`) or a root Element.
619
+ const selfForm =
620
+ typeof host.matches === 'function' && host.matches('[data-bronto-validate]') ? [host] : [];
621
+ const forms = [...selfForm, ...(host.querySelectorAll?.('[data-bronto-validate]') ?? [])];
622
+ const priorNoValidate = new Map();
623
+ for (const f of forms) {
624
+ priorNoValidate.set(f, f.noValidate);
625
+ f.noValidate = true;
626
+ }
610
627
  host.addEventListener('submit', onSubmit, true);
611
628
  host.addEventListener('focusout', onBlur);
612
629
  return () => {
613
630
  host.removeEventListener('submit', onSubmit, true);
614
631
  host.removeEventListener('focusout', onBlur);
632
+ for (const [f, v] of priorNoValidate) f.noValidate = v;
615
633
  };
616
634
  });
617
635
  }
@@ -702,6 +720,12 @@ export function initCombobox({ root } = {}) {
702
720
  if (match) any = true;
703
721
  }
704
722
  if (empty) empty.hidden = any;
723
+ // The active option may have just been filtered out — drop the
724
+ // stale activedescendant so Enter can't select a hidden option.
725
+ if (active >= 0 && options[active]?.hidden) {
726
+ active = -1;
727
+ setActive(null);
728
+ }
705
729
  open();
706
730
  };
707
731
 
@@ -762,7 +786,7 @@ export function initCombobox({ root } = {}) {
762
786
  }
763
787
  break;
764
788
  case 'Enter':
765
- if (!list.hidden && active >= 0) {
789
+ if (!list.hidden && active >= 0 && !options[active].hidden) {
766
790
  e.preventDefault();
767
791
  select(options[active]);
768
792
  }
@@ -1020,3 +1044,199 @@ export function initTableSort({ root } = {}) {
1020
1044
 
1021
1045
  return () => cleanups.forEach((fn) => fn());
1022
1046
  }
1047
+
1048
+ /**
1049
+ * Image carousel / gallery, built on CSS scroll-snap so touch + trackpad
1050
+ * swipe (and momentum) are the browser's, not hand-rolled. This wires the
1051
+ * parts scroll-snap can't do alone: prev/next buttons, keyboard nav, a
1052
+ * thumbnail strip, the position counter, and ARIA — keeping a JS index in
1053
+ * sync with the scroll position both ways.
1054
+ *
1055
+ * Markup: `[data-bronto-carousel]` containing a `.ui-carousel__viewport`
1056
+ * of `.ui-carousel__slide` children; optionally
1057
+ * `[data-bronto-carousel-prev]` / `[data-bronto-carousel-next]` controls,
1058
+ * a `.ui-carousel__thumbs` list of `.ui-carousel__thumb` buttons, and a
1059
+ * `.ui-carousel__status` counter slot. Add `data-bronto-carousel-loop` to
1060
+ * wrap at the ends, `data-bronto-carousel-label` to name the region.
1061
+ *
1062
+ * A full-screen **lightbox** is the same markup inside a native
1063
+ * `<dialog class="ui-lightbox">` opened by {@link initDialog}: the
1064
+ * `<dialog>` provides the top layer, focus-trap, Escape and focus-return,
1065
+ * so this behavior never touches focus management.
1066
+ *
1067
+ * Emits `bronto:change` ({ detail: { index } }) on every index change
1068
+ * (button, key, thumbnail, or swipe). SSR-safe, idempotent per carousel;
1069
+ * returns a cleanup function.
1070
+ */
1071
+ export function initCarousel({ root } = {}) {
1072
+ if (!hasDom()) return noop;
1073
+ const host = root || document;
1074
+ const boxes = [];
1075
+ if (host !== document && host.matches?.('[data-bronto-carousel]')) boxes.push(host);
1076
+ boxes.push(...(host.querySelectorAll?.('[data-bronto-carousel]') ?? []));
1077
+ const cleanups = [];
1078
+
1079
+ for (const box of boxes) {
1080
+ const viewport = box.querySelector('.ui-carousel__viewport');
1081
+ if (!viewport) continue;
1082
+ const slides = [...viewport.children].filter((el) =>
1083
+ el.classList.contains('ui-carousel__slide'),
1084
+ );
1085
+ if (!slides.length) continue;
1086
+ const n = slides.length;
1087
+ const thumbs = [...box.querySelectorAll('.ui-carousel__thumb')];
1088
+ const status = box.querySelector('.ui-carousel__status');
1089
+ const prevBtn = box.querySelector('[data-bronto-carousel-prev]');
1090
+ const nextBtn = box.querySelector('[data-bronto-carousel-next]');
1091
+ const loop = box.hasAttribute('data-bronto-carousel-loop');
1092
+
1093
+ // ARIA scaffolding — pragmatic carousel semantics (not the full APG
1094
+ // tablist), the same restraint initMenu takes.
1095
+ viewport.setAttribute('role', 'group');
1096
+ viewport.setAttribute('aria-roledescription', 'carousel');
1097
+ if (!viewport.hasAttribute('aria-label'))
1098
+ viewport.setAttribute(
1099
+ 'aria-label',
1100
+ box.getAttribute('data-bronto-carousel-label') || 'Carousel',
1101
+ );
1102
+ if (!viewport.hasAttribute('tabindex')) viewport.tabIndex = 0;
1103
+ slides.forEach((s, i) => {
1104
+ s.setAttribute('role', 'group');
1105
+ s.setAttribute('aria-roledescription', 'slide');
1106
+ if (!s.hasAttribute('aria-label')) s.setAttribute('aria-label', `${i + 1} of ${n}`);
1107
+ });
1108
+ if (status) status.setAttribute('aria-live', 'polite');
1109
+ for (const b of [prevBtn, nextBtn]) {
1110
+ if (!b) continue;
1111
+ if (b.tagName === 'BUTTON' && !b.hasAttribute('type')) b.type = 'button';
1112
+ }
1113
+ if (prevBtn && !prevBtn.hasAttribute('aria-label'))
1114
+ prevBtn.setAttribute('aria-label', 'Previous');
1115
+ if (nextBtn && !nextBtn.hasAttribute('aria-label')) nextBtn.setAttribute('aria-label', 'Next');
1116
+
1117
+ let index = Math.max(
1118
+ 0,
1119
+ slides.findIndex((s) => s.hasAttribute('data-bronto-carousel-current')),
1120
+ );
1121
+
1122
+ // While a button/keyboard nav is smooth-scrolling, the IntersectionObserver
1123
+ // would observe the intermediate slides crossing its threshold and re-fire
1124
+ // `bronto:change` for each — a feedback burst on a single Home→End jump.
1125
+ // This flag makes the IO drive the index on *user* swipes only; a timeout
1126
+ // (not the patchy `scrollend` event) releases it once the scroll settles.
1127
+ let programmatic = false;
1128
+ let progTimer = null;
1129
+ const holdProgrammatic = () => {
1130
+ programmatic = true;
1131
+ if (progTimer) clearTimeout(progTimer);
1132
+ progTimer = setTimeout(() => {
1133
+ programmatic = false;
1134
+ }, 500);
1135
+ progTimer?.unref?.(); // don't keep a Node test process alive
1136
+ };
1137
+
1138
+ const render = () => {
1139
+ if (status) status.textContent = `${index + 1} / ${n}`;
1140
+ thumbs.forEach((t, i) => {
1141
+ if (i === index) t.setAttribute('aria-current', 'true');
1142
+ else t.removeAttribute('aria-current');
1143
+ });
1144
+ if (prevBtn && !loop) prevBtn.disabled = index === 0;
1145
+ if (nextBtn && !loop) nextBtn.disabled = index === n - 1;
1146
+ };
1147
+
1148
+ const emit = () =>
1149
+ box.dispatchEvent(new CustomEvent('bronto:change', { detail: { index }, bubbles: true }));
1150
+
1151
+ // jsdom (and any layout-less env) has no scrollIntoView; it's a pure
1152
+ // affordance, so never let it break index/aria sync — same guard as
1153
+ // initCombobox.
1154
+ const reveal = (el) => {
1155
+ try {
1156
+ el?.scrollIntoView({ block: 'nearest', inline: 'center' });
1157
+ } catch {
1158
+ /* no layout — ignore */
1159
+ }
1160
+ };
1161
+
1162
+ const goTo = (i, { emitChange = true } = {}) => {
1163
+ const next = loop ? (i + n) % n : Math.max(0, Math.min(n - 1, i));
1164
+ const changed = next !== index;
1165
+ index = next;
1166
+ holdProgrammatic(); // suppress IO echo from the smooth-scroll this triggers
1167
+ reveal(slides[index]);
1168
+ reveal(thumbs[index]);
1169
+ render();
1170
+ if (changed && emitChange) emit();
1171
+ };
1172
+
1173
+ const onKey = (e) => {
1174
+ let target = null;
1175
+ if (e.key === 'ArrowRight') target = index + 1;
1176
+ else if (e.key === 'ArrowLeft') target = index - 1;
1177
+ else if (e.key === 'Home') target = 0;
1178
+ else if (e.key === 'End') target = n - 1;
1179
+ else return;
1180
+ e.preventDefault();
1181
+ goTo(target);
1182
+ };
1183
+ const onClick = (e) => {
1184
+ if (prevBtn && e.target.closest('[data-bronto-carousel-prev]')) {
1185
+ goTo(index - 1);
1186
+ return;
1187
+ }
1188
+ if (nextBtn && e.target.closest('[data-bronto-carousel-next]')) {
1189
+ goTo(index + 1);
1190
+ return;
1191
+ }
1192
+ const thumb = e.target.closest('.ui-carousel__thumb');
1193
+ if (thumb) {
1194
+ const i = thumbs.indexOf(thumb);
1195
+ if (i >= 0) goTo(i);
1196
+ }
1197
+ };
1198
+
1199
+ // Swipe sync (enhancement): when the user scrolls the viewport, snap
1200
+ // the JS index to the slide that's settled into view. Feature-detected
1201
+ // so the buttons/keyboard still work where IntersectionObserver is
1202
+ // absent (jsdom, older engines).
1203
+ let io = null;
1204
+ if (typeof IntersectionObserver === 'function') {
1205
+ io = new IntersectionObserver(
1206
+ (entries) => {
1207
+ if (programmatic) return; // ignore the echo of a button/key-driven scroll
1208
+ let best = null;
1209
+ for (const ent of entries) {
1210
+ if (ent.isIntersecting && (!best || ent.intersectionRatio > best.intersectionRatio))
1211
+ best = ent;
1212
+ }
1213
+ if (!best) return;
1214
+ const i = slides.indexOf(best.target);
1215
+ if (i >= 0 && i !== index) {
1216
+ index = i;
1217
+ render();
1218
+ reveal(thumbs[index]);
1219
+ emit();
1220
+ }
1221
+ },
1222
+ { root: viewport, threshold: 0.6 },
1223
+ );
1224
+ slides.forEach((s) => io.observe(s));
1225
+ }
1226
+
1227
+ render();
1228
+ const bound = bindOnce(box, 'carousel', () => {
1229
+ viewport.addEventListener('keydown', onKey);
1230
+ box.addEventListener('click', onClick);
1231
+ return () => {
1232
+ viewport.removeEventListener('keydown', onKey);
1233
+ box.removeEventListener('click', onClick);
1234
+ io?.disconnect();
1235
+ if (progTimer) clearTimeout(progTimer);
1236
+ };
1237
+ });
1238
+ cleanups.push(bound);
1239
+ }
1240
+
1241
+ return () => cleanups.forEach((fn) => fn());
1242
+ }