@ponchia/ui 0.3.4 → 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,220 +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-~64kB%20%2F%20~11kB%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) · [Usage](docs/usage.md) ·
16
- [Theming](docs/theming.md) · [Contrast](docs/contrast.md) ·
17
- [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)
18
14
 
19
- > **Editor IntelliSense.** The package ships a VS Code CSS Custom Data
20
- > file so every design token autocompletes inside `var(--…)`. Add to
21
- > your `.vscode/settings.json`:
22
- >
23
- > ```json
24
- > {
25
- > "css.customData": ["node_modules/@ponchia/ui/classes/vscode.css-custom-data.json"]
26
- > }
27
- > ```
15
+ The demo is the kitchen sink every component, light/dark, RTL, live theming.
28
16
 
29
- > **For AI agents / LLMs.** The package ships `llms.txt` at its root —
30
- > point a coding agent at `node_modules/@ponchia/ui/llms.txt` for a
31
- > self-contained orientation. The full class catalog
32
- > (`docs/reference.md`) and the token contract (`docs/theming.md`) also
33
- > ship in the tarball, so an offline agent never has to guess at the API.
17
+ ---
34
18
 
35
- **[Live demo →](https://ponchia.github.io/bronto-ui/)** — the kitchen
36
- sink (every component, light/dark, RTL, theming) deployed from `demo/`.
19
+ ## What it is
37
20
 
38
- ## 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`.
39
22
 
40
- Install from npm (public, no registry config):
23
+ ## Install
41
24
 
42
25
  ```bash
43
26
  npm i @ponchia/ui
44
27
  ```
45
28
 
46
- > Naming: the **npm package** is `@ponchia/ui` (the `@bronto` scope isn't
47
- > ownable). The **CSS layer** and behavior attributes stay `bronto`
48
- > (`@layer bronto`, `data-bronto-*`) — that's the design-system namespace,
49
- > deliberately distinct from the package name. See
50
- > [`docs/architecture.md`](docs/architecture.md).
29
+ Or drop it in with no build step, straight from a CDN:
51
30
 
52
- Import the theme (one bundle — `ui-*` components carry their own
53
- 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):
54
38
 
55
39
  ```css
56
- @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">
57
46
  ```
58
47
 
59
- **Prebuilt single file (recommended for apps without a CSS bundler).**
60
- `@ponchia/ui/css` is a wide `@import` fan-out (~14 leaves, one level
61
- deep) — fine through a bundler, a load waterfall over plain HTTP. The
62
- package also ships one flattened, minified bundle with no `@import`
63
- 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.
64
49
 
65
- ```css
66
- @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>
67
59
  ```
68
60
 
69
- ~64 kB raw / ~11 kB gzip, one request, same `@layer bronto`. (The
70
- enforced ceiling lives in `scripts/check-dist.mjs`, not this prose —
71
- treat these figures as indicative.) Source CSS, tokens/classes/behaviors
72
- entrypoints are unchanged — use whichever fits.
61
+ **3. (Optional) typed recipes build class strings in JS/TS:**
73
62
 
74
- > **The package root is CSS-only.** `@ponchia/ui` (the `.` export)
75
- > resolves to a stylesheet `@import '@ponchia/ui'` in CSS, never
76
- > `import '@ponchia/ui'` in JS. There is no JS module at the root; the
77
- > JS entrypoints are the explicit subpaths `@ponchia/ui/tokens`,
78
- > `/classes`, and `/behaviors` (see [Entrypoints](#entrypoints)).
63
+ ```js
64
+ import { ui, cx } from '@ponchia/ui/classes';
79
65
 
80
- ### Browser support
66
+ ui.button({ variant: 'ghost' }); // → "ui-button ui-button--ghost"
67
+ ui.meter({ tone: 'warning' }); // → "ui-meter ui-meter--warning"
68
+ ```
81
69
 
82
- Evergreen onlythe framework relies on cascade layers (`@layer`),
83
- `:has()`, `color-mix()`, CSS logical properties and native `<dialog>`.
84
- Floor: **Chrome/Edge 111+, Safari 16.4+, Firefox 121+** (early 2023
85
- onward). No build-time fallback is shipped; pin an older tag if you must
86
- support below this.
70
+ **4. (Optional) behaviors SSR-safe vanilla JS, each returns a cleanup function:**
87
71
 
88
- The Doto `@font-face` ships in `css/fonts.css` (bundled by both `css` and
89
- `css/core.css`) with URLs relative to the package, so it resolves through a
90
- bundler or static serving with no `/fonts` path assumption. To self-host the
91
- font instead, import everything except `fonts.css` and override `--display` /
92
- `--dot-font`.
72
+ ```js
73
+ import { initThemeToggle, initDialog, toast } from '@ponchia/ui/behaviors';
93
74
 
94
- Everything ships inside a single `@layer bronto`, so any un-layered CSS in
95
- 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
+ ```
96
79
 
97
- > **Leaf imports are layer-safe by default.** Every per-leaf export
98
- > `@ponchia/ui/css/primitives.css`, etc. — is self-wrapped in
99
- > `@layer bronto`, so mixing the bundle with individual leaves (e.g.
100
- > per-route CSS splitting in SvelteKit/Astro) is safe: no silent
101
- > cascade inversion. The deliberate full-specificity escape hatch is
102
- > the explicit `@ponchia/ui/css/unlayered/<leaf>.css` path — use it
103
- > 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.
104
81
 
105
- Set `data-theme="light"` or `data-theme="dark"` on `<html>`; defaults follow
106
- `prefers-color-scheme`.
82
+ ## What's in the box
107
83
 
108
- **Re-brand with one knob:** `--accent` drives the whole accent family
109
- (`color-mix`-derived). `:root { --accent: #2f6df6 }` or scope it to a
110
- subtreerestyles everything, both themes. Plus `data-density` and
111
- `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.
112
92
 
113
- ## 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)**.
114
94
 
115
- The CSS is the framework. These optional sibling entrypoints are thin layers
116
- on top of it — none pull in a UI framework. See
117
- [`docs/architecture.md`](docs/architecture.md) for the rationale.
95
+ ## Theming: one knob
118
96
 
119
- ```js
120
- import tokens, { cssVars, themeColor } from '@ponchia/ui/tokens'; // tokens as data (+ /tokens.json)
121
- import { ui, cx } from '@ponchia/ui/classes'; // typed class-name recipes
122
- import { initThemeToggle, dismissible } from '@ponchia/ui/behaviors'; // vanilla, SSR-safe
123
- ```
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:
124
98
 
125
- ```js
126
- ui.button({ variant: 'ghost' }); // "ui-button ui-button--ghost"
127
- themeColor('dark').accent; // "#ff3b41"
99
+ ```css
100
+ :root { --accent: #2f6df6; } /* whole app blue */
101
+ .promo { --accent: #16a34a; } /* …or just this section green */
128
102
  ```
129
103
 
130
- `behaviors` wires `[data-bronto-theme-toggle]`, `[data-bronto-dismiss]` /
131
- `[data-bronto-dismissible]`, `[data-bronto-disclosure]`,
132
- `[data-bronto-menu]` (`initMenu`: Escape / outside-click /
133
- close-on-activate for a `<details>` `.ui-menu` dropdown), and native
134
- `<dialog>` glue (`initDialog`: `[data-bronto-open]` / `[data-bronto-close]`
135
- / `[data-bronto-dialog-light]`). `toast(message, { tone, title, duration })`
136
- pushes into a shared, body-anchored stack. Each initializer is SSR-safe and
137
- returns a cleanup function. `demo/index.html` drives itself with these
138
- modules, so it is also a live integration test.
139
-
140
- ## Layout
141
-
142
- | File | Contents |
143
- | ---------------- | ------------------------------------------------------------- |
144
- | `tokens.css` | palette (dual light/dark), spacing, type, motion, dot tokens |
145
- | `fonts.css` | Doto `@font-face` (relative URLs; optional if self-hosting) |
146
- | `base.css` | reset, element defaults, focus, scrollbars |
147
- | `motion.css` | keyframes + animation utilities + reduced-motion |
148
- | `dots.css` | dot-grid, dot rule, status dot, dot loader, orbital dot spinner, dot bar (+ indeterminate), matrix reveal |
149
- | `primitives.css` | `ui-*` buttons, cards, chips, badges, links, key/value |
150
- | `forms.css` | inputs, select, textarea, search, switch, checkbox |
151
- | `feedback.css` | alert / callout, toast, tooltip, linear progress |
152
- | `overlay.css` | modal + drawer (native `<dialog>`), dropdown menu |
153
- | `disclosure.css` | tabs, accordion (`<details>`), segmented, breadcrumb, pagination, avatar |
154
- | `table.css` | `ui-table` dense / comfortable |
155
- | `app.css` | admin shell: `ui-app-shell`/`-rail`/`-topbar`/`-toolbar`/`-panel`/`-nav`/`-metrics` |
156
- | `navigation.css` | `ui-themetoggle` (dot-thumb switch) |
157
- | `site.css` | content-site shell: `ui-container`, `ui-siteheader`/`ui-sitenav` (aria-current), `ui-sitemenu`, `ui-sitefooter`, `ui-skiplink`, `ui-tags`, `ui-meta` |
158
- | `content.css` | `.ui-prose` Markdown/raw-HTML long-form (zero classes) + `ui-quote` pull-quote |
159
-
160
- ## Getting started
161
-
162
- | Consumer | Guide |
163
- | ----------------------- | ------------------------------------------------------------------ |
164
- | Astro | [`docs/getting-started/astro.md`](docs/getting-started/astro.md) |
165
- | SvelteKit | [`docs/getting-started/sveltekit.md`](docs/getting-started/sveltekit.md) |
166
- | Vanilla / Vite / plain | [`docs/getting-started/vanilla.md`](docs/getting-started/vanilla.md) |
167
- | React / Solid (snippet) | [`docs/getting-started/react-solid.md`](docs/getting-started/react-solid.md) |
168
- | Tailwind / cascade-layer interop | [`docs/interop/tailwind.md`](docs/interop/tailwind.md) |
169
-
170
- Each covers the CSS import location, the **no-flash** `applyStoredTheme`
171
- head-script pattern, behavior init/cleanup in that framework's lifecycle,
172
- and SSR caveats. Index: [`docs/integration.md`](docs/integration.md).
173
-
174
- ## Demo
175
-
176
- `demo/index.html` is a kitchen sink covering every primitive in both
177
- themes — it drives itself with the real behavior modules, so it is also a
178
- 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**.
179
105
 
180
- ```bash
181
- python3 -m http.server -d . 8080 # then open http://localhost:8080/demo/
182
- ```
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.
183
130
 
184
- ## 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`.
185
132
 
186
- Contributor setup, the 14 `check` gates, the e2e suite, the visual-
187
- baseline workflow, the deprecation policy and the tag-driven release
188
- flow all live in **[CONTRIBUTING.md](CONTRIBUTING.md)**. Direction and
189
- 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.
190
136
 
191
137
  ## Versioning
192
138
 
193
- Pre-1.0 and deliberately so. **Until `1.0.0`, breaking changes ship in
194
- the _minor_** (`0.x.0`); patches (`0.x.y`) are non-breaking. This is the
195
- standard 0.x reading of SemVer, stated explicitly because this framework
196
- dresses several apps:
197
-
198
- - Because breaking changes bump the **minor**, the protective range is
199
- the patch range. At `0.x` npm resolves **both** `^0.3.0` and `~0.3.0`
200
- to `>=0.3.0 <0.4.0` they are equivalent here, and either gives you
201
- only non-breaking `0.3.x` patches while holding back the next
202
- (breaking) `0.4.0`. Pin either; pin an **exact** version if you want
203
- zero surprise and to adopt each minor deliberately.
204
- - Every breaking change is called out in [`CHANGELOG.md`](CHANGELOG.md)
205
- under a **BREAKING** heading with a migration note.
206
-
207
- **What is contractual** (changes are breaking): the `--accent`
208
- derivation and token **names** (incl. `--accent-text`, `--focus-ring`);
209
- the `.ui-*` class names and `cls`/recipe names; the `data-bronto-*`
210
- behavior attributes; each behavior's return-cleanup contract. **What is
211
- not** (may change in any release): token _values_ (visual tuning), the
212
- internal leaf-file boundaries and `@layer` internals, and anything
213
- explicitly marked legacy/deprecated. Full token contract:
214
- [`docs/theming.md`](docs/theming.md).
215
-
216
- ## Consumers
217
-
218
- Built for two shapes of app: a content/marketing site (`ui-site*`,
219
- `ui-prose`) and an admin dashboard (`ui-app-*` shell). Both import the
220
- one bundle `@ponchia/ui` (or `@ponchia/ui/css`); consuming apps depend
221
- 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,6 +106,22 @@ 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
127
  tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info';
@@ -1044,3 +1044,199 @@ export function initTableSort({ root } = {}) {
1044
1044
 
1045
1045
  return () => cleanups.forEach((fn) => fn());
1046
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
+ }