@webjsdev/ui 0.3.9 → 0.3.11
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 +21 -13
- package/package.json +3 -3
- package/packages/registry/README.md +14 -10
- package/packages/registry/components/accordion.ts +18 -0
- package/packages/registry/components/alert-dialog.ts +203 -22
- package/packages/registry/components/aspect-ratio.ts +12 -0
- package/packages/registry/components/card.ts +20 -1
- package/packages/registry/components/checkbox.ts +45 -3
- package/packages/registry/components/collapsible.ts +15 -0
- package/packages/registry/components/dialog.ts +205 -22
- package/packages/registry/components/dropdown-menu.ts +390 -22
- package/packages/registry/components/hover-card.ts +162 -4
- package/packages/registry/components/input.ts +34 -2
- package/packages/registry/components/kbd.ts +35 -2
- package/packages/registry/components/label.ts +21 -0
- package/packages/registry/components/native-select.ts +28 -7
- package/packages/registry/components/popover.ts +43 -7
- package/packages/registry/components/radio-group.ts +37 -9
- package/packages/registry/components/sonner.ts +53 -5
- package/packages/registry/components/switch.ts +23 -1
- package/packages/registry/components/tabs.ts +29 -2
- package/packages/registry/components/textarea.ts +21 -2
- package/packages/registry/components/toggle-group.ts +81 -8
- package/packages/registry/components/toggle.ts +82 -3
- package/packages/registry/components/tooltip.ts +57 -0
- package/packages/registry/lib/utils.ts +178 -13
- package/packages/registry/registry.json +1 -1
- package/packages/registry/themes/base-colors.js +1 -1
- package/packages/registry/themes/index.css +11 -0
- package/src/commands/add.js +50 -0
- package/src/commands/info.js +1 -4
- package/src/commands/init.js +126 -24
- package/src/registry/extract.js +1 -7
- package/src/registry/fetcher.js +9 -1
- package/src/registry/local.js +1 -1
- package/src/utils/index.js +0 -1
- package/src/utils/detect-project.js +0 -63
package/README.md
CHANGED
|
@@ -24,10 +24,12 @@ function calls, not for a layered React abstraction over every primitive:
|
|
|
24
24
|
Escape, and backdrop overlay all come from the platform. Light DOM
|
|
25
25
|
throughout (no shadow DOM); authored children project through `<slot>`.
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
webjs
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
**This is the component library for WebJs apps.** It is what `webjs ui init`
|
|
28
|
+
and `webjs ui add` install, and WebJs is the only host it is tested and
|
|
29
|
+
supported on. The output is plain Tailwind CSS v4 classes and standard custom
|
|
30
|
+
elements, so nothing stops it rendering elsewhere, but no other framework is a
|
|
31
|
+
supported target and none is detected or defaulted for. Variant names, sizes,
|
|
32
|
+
and data-attribute conventions mirror shadcn's so an AI agent's existing
|
|
31
33
|
knowledge of shadcn maps directly.
|
|
32
34
|
|
|
33
35
|
Tier-2 elements extend the `WebComponent({ ... })` factory from
|
|
@@ -58,7 +60,7 @@ so on. Follow that block and the markup is fully accessible.
|
|
|
58
60
|
|
|
59
61
|
## Install
|
|
60
62
|
|
|
61
|
-
### Option A :
|
|
63
|
+
### Option A : through `@webjsdev/cli` (the normal path)
|
|
62
64
|
|
|
63
65
|
Nothing to install. `@webjsdev/ui` is a hard dependency of `@webjsdev/cli`,
|
|
64
66
|
so a global webjs install already includes it. Apps scaffolded with
|
|
@@ -69,9 +71,11 @@ webjs ui init
|
|
|
69
71
|
webjs ui add button card dialog
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
### Option B :
|
|
74
|
+
### Option B : the standalone binary
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
The `webjsui` binary does not require `@webjsdev/cli`, so a WebJs app that
|
|
77
|
+
skipped the global install can reach the kit with two npm installs, the CLI
|
|
78
|
+
and the runtime base class:
|
|
75
79
|
|
|
76
80
|
```sh
|
|
77
81
|
npm install -D @webjsdev/ui
|
|
@@ -80,15 +84,19 @@ npx webjsui init
|
|
|
80
84
|
npx webjsui add button card dialog
|
|
81
85
|
```
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
Both paths write the same `components.json`. `init` takes no reading of the
|
|
88
|
+
host project: the defaults below are fixed, and `--css <path>` overrides the
|
|
89
|
+
stylesheet it appends the tokens to. Re-running it preserves what an existing
|
|
90
|
+
project already declared (its aliases, stylesheet path, and base color) and
|
|
91
|
+
leaves an existing helper file as you edited it, so it is safe as a repair
|
|
92
|
+
step. Pass `--overwrite` to reset those instead.
|
|
86
93
|
|
|
87
94
|
## What `init` writes
|
|
88
95
|
|
|
89
96
|
- `components.json`, your project's UI config (aliases, base color, Tailwind path)
|
|
90
|
-
- `lib/utils.ts`, the `cn()` class-merge helper
|
|
91
|
-
|
|
97
|
+
- `lib/utils/cn.ts`, the `cn()` class-merge helper, and `lib/utils/dom.ts`
|
|
98
|
+
beside it for the client-only `onBeforeCache()` helper
|
|
99
|
+
- Tailwind tokens + CSS variables appended to `styles/globals.css`
|
|
92
100
|
|
|
93
101
|
## What `add` does
|
|
94
102
|
|
|
@@ -109,7 +117,7 @@ always compares against the live upstream.
|
|
|
109
117
|
|
|
110
118
|
| Command | Effect |
|
|
111
119
|
|---|---|
|
|
112
|
-
| `webjsui init` | Initialize a project (writes `components.json`, `lib/utils.ts`, the theme tokens). Exits non-zero if the tokens cannot be written. |
|
|
120
|
+
| `webjsui init` | Initialize a project (writes `components.json`, `lib/utils/cn.ts`, the theme tokens). Preserves an existing project's settings and helper files; `-o, --overwrite` resets them. Exits non-zero if the tokens cannot be written. |
|
|
113
121
|
| `webjsui add <names...>` | Add components (copies helpers + a pointer for Tier-1, self-heals theme tokens) |
|
|
114
122
|
| `webjsui list` | List all available components |
|
|
115
123
|
| `webjsui view <name>` | Print a component's projected view (helpers + paste-ready example) and full source |
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "The AI-first component library for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Class-helper functions for visuals, custom elements only where state matters, source-copied into your repo so you own it.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"webjsui": "bin/webjsui.js"
|
|
8
8
|
},
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"url": "git+https://github.com/webjsdev/webjs.git",
|
|
38
38
|
"directory": "packages/ui"
|
|
39
39
|
},
|
|
40
|
-
"homepage": "https://
|
|
40
|
+
"homepage": "https://webjs.dev/ui",
|
|
41
41
|
"bugs": "https://github.com/webjsdev/webjs/issues",
|
|
42
42
|
"license": "MIT",
|
|
43
43
|
"keywords": [
|
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
Internal: sources for the `@webjsdev/ui` component registry.
|
|
4
4
|
|
|
5
5
|
Not published. `registry.json` is the manifest, and the files it points at
|
|
6
|
-
are the source of truth. The
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
are the source of truth. The marketing site composes shadcn-compatible JSON on
|
|
7
|
+
demand from these sources and serves it at
|
|
8
|
+
`https://webjs.dev/ui/registry/<name>.json`, which the `@webjsdev/ui` CLI
|
|
9
|
+
fetches. There is no build step, no generated output, no `prestart` hook.
|
|
10
|
+
|
|
11
|
+
The composer and the routes moved out of this package with #1099, when the
|
|
12
|
+
gallery merged into the marketing site. The old `ui.webjs.dev/registry/**`
|
|
13
|
+
URLs still resolve, permanently redirecting to the new ones, because
|
|
14
|
+
already-published CLI versions fetch from them.
|
|
11
15
|
|
|
12
16
|
## Layout
|
|
13
17
|
|
|
@@ -26,10 +30,10 @@ overrides → merged CSS, same `files[]` shape.
|
|
|
26
30
|
|
|
27
31
|
## Wire endpoints
|
|
28
32
|
|
|
29
|
-
Served by
|
|
30
|
-
`app/registry/**`):
|
|
33
|
+
Served by the marketing site (`website/modules/ui/queries/registry.server.ts`
|
|
34
|
+
composes, `website/app/ui/registry/**` routes):
|
|
31
35
|
|
|
32
|
-
- `GET /registry/<name>.json`: single registry item (`type: registry:ui` /
|
|
36
|
+
- `GET /ui/registry/<name>.json`: single registry item (`type: registry:ui` /
|
|
33
37
|
`registry:theme` / `registry:lib`), with file contents inlined.
|
|
34
|
-
- `GET /registry/index.json`: flat metadata-only list.
|
|
35
|
-
- `GET /registry`: full manifest with every item's content inlined.
|
|
38
|
+
- `GET /ui/registry/index.json`: flat metadata-only list.
|
|
39
|
+
- `GET /ui/registry`: full manifest with every item's content inlined.
|
|
@@ -24,6 +24,24 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Design tokens used: --border, --ring, --foreground.
|
|
26
26
|
*
|
|
27
|
+
* A11y (mostly handled by the native primitives):
|
|
28
|
+
* Build it on `<details>` + `<summary>`, which is where nearly all of this
|
|
29
|
+
* comes from: the browser supplies the button semantics, the expanded state,
|
|
30
|
+
* Enter / Space activation, and focus, with no ARIA to hand-write. Do not
|
|
31
|
+
* reimplement it with a `<div>` and `aria-expanded`.
|
|
32
|
+
* Put the trigger classes on the `<summary>` ITSELF, not on a `<button>` or
|
|
33
|
+
* `<div>` nested inside it. A nested interactive element inside a `<summary>`
|
|
34
|
+
* is the #1078-class bug: the focusable thing and the labelled thing come
|
|
35
|
+
* apart, and the summary's own name goes empty.
|
|
36
|
+
* The chevron is decorative, so keep it `aria-hidden="true"`.
|
|
37
|
+
* `name` on `<details>` is what gives exclusive-open behaviour natively, so
|
|
38
|
+
* prefer it over JS that closes siblings.
|
|
39
|
+
* Use a real heading inside the `<summary>` when the section title belongs in
|
|
40
|
+
* the document outline, and pick its level from the surrounding page.
|
|
41
|
+
* Never rely on the collapsed state to HIDE something that must stay
|
|
42
|
+
* available: collapsed content is not reachable by find-in-page on every
|
|
43
|
+
* engine, so nothing essential should live only there.
|
|
44
|
+
*
|
|
27
45
|
* @example
|
|
28
46
|
* ```html
|
|
29
47
|
* <div class=${accordionClass()}>
|
|
@@ -44,8 +44,29 @@
|
|
|
44
44
|
* Keyboard: Escape is blocked (alert dialogs require explicit choice);
|
|
45
45
|
* Tab cycles trapped within the dialog (native focus trap).
|
|
46
46
|
*
|
|
47
|
+
* A11y (owned by the element, but SUPPLY A TITLE):
|
|
48
|
+
* On open the element names and describes the panel from the
|
|
49
|
+
* `data-slot="alert-dialog-title"` / `alert-dialog-description` nodes,
|
|
50
|
+
* falling back to the first heading / paragraph. An `aria-label` or
|
|
51
|
+
* `aria-labelledby` you put on `<ui-alert-dialog-content>` is forwarded onto
|
|
52
|
+
* the panel and wins over both.
|
|
53
|
+
* A modal MUST have an accessible name, so with no title node and no name of
|
|
54
|
+
* your own the panel falls back to a generic `aria-label="Alert dialog"`.
|
|
55
|
+
* Treat that as a bug in your markup rather than a feature: this dialog
|
|
56
|
+
* interrupts the user to demand an explicit choice and blocks Escape, so
|
|
57
|
+
* naming it "Alert dialog" tells them nothing about what they are deciding.
|
|
58
|
+
*
|
|
47
59
|
* Design tokens used: --background, --border, --muted-foreground.
|
|
48
60
|
*
|
|
61
|
+
* Scroll lock (#1144): opening the dialog locks body scroll, which hides the
|
|
62
|
+
* page scrollbar and would widen the viewport. The lock reserves the scrollbar
|
|
63
|
+
* gutter so the width never changes and a `position: fixed` header stays put.
|
|
64
|
+
* Where the engine ignores `scrollbar-gutter` (WebKit today) it publishes the
|
|
65
|
+
* leftover width on <html> as `--wj-scrollbar-compensation`, so a fixed header
|
|
66
|
+
* opts in with `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`
|
|
67
|
+
* (a transparent border composes with existing padding, and a background paints
|
|
68
|
+
* across it, so a header keeps its own chrome full bleed).
|
|
69
|
+
*
|
|
49
70
|
* @example
|
|
50
71
|
* ```html
|
|
51
72
|
* <ui-alert-dialog>
|
|
@@ -104,29 +125,130 @@ function installStyles(): void {
|
|
|
104
125
|
document.head.appendChild(style);
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
//
|
|
108
|
-
// lockstep with dialog.ts (not imported) so `webjs ui add alert-dialog`
|
|
128
|
+
// Page scroll lock, refcounted so nested dialogs release safely in ANY order.
|
|
129
|
+
// Kept in lockstep with dialog.ts (not imported) so `webjs ui add alert-dialog`
|
|
109
130
|
// doesn't pull in the full dialog component.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
131
|
+
//
|
|
132
|
+
// Hiding the page scrollbar widens the viewport by the scrollbar's width, so
|
|
133
|
+
// anything laid out against the viewport moves. Padding the page holds in-flow
|
|
134
|
+
// content still, but a `position: fixed` header lays out against the initial
|
|
135
|
+
// containing block, never against any padding box, so padding alone leaves it
|
|
136
|
+
// sliding right by half the scrollbar width. Two mechanisms fix it:
|
|
137
|
+
//
|
|
138
|
+
// 1. Reserve the scrollbar gutter for the duration of the lock, so the
|
|
139
|
+
// viewport width never changes and NOTHING moves, in flow or fixed. This
|
|
140
|
+
// needs no cooperation from the page. Measured honoured on Chromium.
|
|
141
|
+
// 2. Where the engine ignores it (measured on WebKit), fall back to
|
|
142
|
+
// padding <html> and publish the leftover width as
|
|
143
|
+
// `--wj-scrollbar-compensation` on it, so a fixed element can opt in with
|
|
144
|
+
// `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`.
|
|
145
|
+
//
|
|
146
|
+
// Everything below is MEASURED rather than assumed, because engines disagree
|
|
147
|
+
// about both scrollbar geometry and gutter support. When mechanism 1 works the
|
|
148
|
+
// measured residual is zero, so no padding is applied and the custom property is
|
|
149
|
+
// never set: the two mechanisms cannot double-compensate.
|
|
150
|
+
//
|
|
151
|
+
// The lock's state is DOCUMENT level, so it is keyed on globalThis rather than
|
|
152
|
+
// module scope. dialog.ts and alert-dialog.ts ship as separate copies (so
|
|
153
|
+
// `webjs ui add alert-dialog` stays self contained), and two independent
|
|
154
|
+
// counters mutating the same <html> can only be released safely in LIFO order.
|
|
155
|
+
// They are not: `disconnectedCallback` fires in tree order and the #766
|
|
156
|
+
// before-cache close runs in registration order, so a confirm inside a dialog
|
|
157
|
+
// releases OUTER first, and the inner unlock would then re-apply the values it
|
|
158
|
+
// captured with nothing left to clear them, leaving <html> padded for good. One
|
|
159
|
+
// shared counter makes release order irrelevant.
|
|
160
|
+
interface ScrollLockState {
|
|
161
|
+
count: number;
|
|
162
|
+
overflow: string;
|
|
163
|
+
rootPaddingRight: string;
|
|
164
|
+
scrollbarGutter: string;
|
|
165
|
+
compensation: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
|
|
169
|
+
|
|
170
|
+
function scrollLockState(): ScrollLockState {
|
|
171
|
+
// The key follows the framework's global-key prefix (see `__webjsSonnerBus` in
|
|
172
|
+
// sonner.ts, the same pattern for the same reason), and the cast is typed
|
|
173
|
+
// rather than a string-keyed bag, so a change to the shape is a type error in
|
|
174
|
+
// both copies instead of a silent undefined at runtime.
|
|
175
|
+
const store = globalThis as unknown as { __webjsScrollLock?: ScrollLockState };
|
|
176
|
+
let state = store.__webjsScrollLock;
|
|
177
|
+
if (!state) {
|
|
178
|
+
state = { count: 0, overflow: '', rootPaddingRight: '', scrollbarGutter: '', compensation: '' };
|
|
179
|
+
store.__webjsScrollLock = state;
|
|
180
|
+
}
|
|
181
|
+
return state;
|
|
182
|
+
}
|
|
113
183
|
|
|
114
184
|
function lockScroll(): void {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
185
|
+
const state = scrollLockState();
|
|
186
|
+
if (state.count === 0) {
|
|
187
|
+
const root = document.documentElement;
|
|
188
|
+
const body = document.body;
|
|
189
|
+
const rootStyle = getComputedStyle(root);
|
|
190
|
+
|
|
191
|
+
state.overflow = body.style.overflow;
|
|
192
|
+
state.rootPaddingRight = root.style.paddingRight;
|
|
193
|
+
state.scrollbarGutter = root.style.scrollbarGutter;
|
|
194
|
+
state.compensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
|
|
195
|
+
|
|
196
|
+
// <html> is an in-flow block filling the initial containing block, so its
|
|
197
|
+
// border box IS the viewport width, and it is re-laid-out synchronously.
|
|
198
|
+
// A `position: fixed` probe is NOT a substitute: WebKit keeps reporting its
|
|
199
|
+
// old box until the next rendering update, so it reads a zero delta and the
|
|
200
|
+
// compensation below never fires.
|
|
201
|
+
const widthBefore = root.getBoundingClientRect().width;
|
|
202
|
+
const padBefore = parseFloat(rootStyle.paddingRight) || 0;
|
|
203
|
+
// An engine with no `scrollbar-gutter` at all reads back undefined, which
|
|
204
|
+
// must be treated as "the page has not chosen" so the gutter is still
|
|
205
|
+
// attempted (setting an unsupported property is a harmless no-op, and the
|
|
206
|
+
// measured residual below is what actually decides the fallback).
|
|
207
|
+
const chosenGutter = rootStyle.scrollbarGutter || 'auto';
|
|
208
|
+
|
|
209
|
+
// Reserve the gutter, but only when the page has not already made its own
|
|
210
|
+
// choice. An app running `stable both-edges` keeps both gutters through the
|
|
211
|
+
// lock, and overwriting that with the single-edge value would drop one and
|
|
212
|
+
// introduce the very shift this exists to prevent.
|
|
213
|
+
if (chosenGutter === 'auto' && window.innerWidth > root.clientWidth) {
|
|
214
|
+
root.style.scrollbarGutter = 'stable';
|
|
215
|
+
}
|
|
216
|
+
body.style.overflow = 'hidden';
|
|
217
|
+
|
|
218
|
+
const grew = root.getBoundingClientRect().width - widthBefore;
|
|
219
|
+
if (grew > 0) {
|
|
220
|
+
// Padding the ROOT holds in-flow content still whatever the body's own
|
|
221
|
+
// width is (a `max-width` body does not widen with the viewport, so
|
|
222
|
+
// padding the body would miss it), and it leaves the page's own body
|
|
223
|
+
// padding untouched.
|
|
224
|
+
root.style.paddingRight = `${padBefore + grew}px`;
|
|
225
|
+
// A fixed box lays out against the viewport, so no padding here can reach
|
|
226
|
+
// it. Publish what it moved by and let it opt in.
|
|
227
|
+
root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`);
|
|
228
|
+
}
|
|
121
229
|
}
|
|
122
|
-
|
|
230
|
+
state.count++;
|
|
123
231
|
}
|
|
124
232
|
|
|
125
233
|
function unlockScroll(): void {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
234
|
+
const state = scrollLockState();
|
|
235
|
+
// An unlock with no matching lock is a no-op, NOT the last release: clamping to
|
|
236
|
+
// zero and restoring would replay a stale snapshot onto whatever the page owns
|
|
237
|
+
// now. Belt-and-braces as things stand, because _teardown() only unlocks what
|
|
238
|
+
// its own element locked, so no path through the components can reach here with
|
|
239
|
+
// a zero count. It is kept because the cost is one comparison and the failure
|
|
240
|
+
// it prevents is silent.
|
|
241
|
+
if (state.count === 0) return;
|
|
242
|
+
state.count--;
|
|
243
|
+
if (state.count === 0) {
|
|
244
|
+
const root = document.documentElement;
|
|
245
|
+
document.body.style.overflow = state.overflow;
|
|
246
|
+
root.style.paddingRight = state.rootPaddingRight;
|
|
247
|
+
root.style.scrollbarGutter = state.scrollbarGutter;
|
|
248
|
+
// Restored rather than removed, so a value the PAGE set before any dialog
|
|
249
|
+
// opened survives the lock.
|
|
250
|
+
if (state.compensation) root.style.setProperty(SCROLLBAR_COMPENSATION, state.compensation);
|
|
251
|
+
else root.style.removeProperty(SCROLLBAR_COMPENSATION);
|
|
130
252
|
}
|
|
131
253
|
}
|
|
132
254
|
|
|
@@ -147,6 +269,7 @@ export class UiAlertDialog extends WebComponent({
|
|
|
147
269
|
}
|
|
148
270
|
|
|
149
271
|
_disposeBeforeCache?: () => void;
|
|
272
|
+
_scrollLocked?: boolean;
|
|
150
273
|
|
|
151
274
|
connectedCallback(): void {
|
|
152
275
|
installStyles();
|
|
@@ -195,14 +318,33 @@ export class UiAlertDialog extends WebComponent({
|
|
|
195
318
|
}
|
|
196
319
|
|
|
197
320
|
_setup(): void {
|
|
321
|
+
// A detached element must not lock: `updated()` defers this to a microtask,
|
|
322
|
+
// and a removal in between already ran _teardown() as a no-op, so locking
|
|
323
|
+
// here would never be released (nothing disconnects twice).
|
|
324
|
+
if (!this.isConnected) return;
|
|
198
325
|
const content = this._content;
|
|
199
326
|
if (!content) return;
|
|
200
|
-
|
|
327
|
+
// Idempotent, because the count is an integer and the flag is a boolean. A
|
|
328
|
+
// coalesced hide() then show() in one task produces ONE update whose
|
|
329
|
+
// changedProperties still reports open, so _setup() can run twice with no
|
|
330
|
+
// _teardown() between; double-counting there would leave the page locked for
|
|
331
|
+
// the rest of the session.
|
|
332
|
+
if (!this._scrollLocked) {
|
|
333
|
+
lockScroll();
|
|
334
|
+
this._scrollLocked = true;
|
|
335
|
+
}
|
|
201
336
|
content.showModal();
|
|
202
337
|
}
|
|
203
338
|
|
|
204
339
|
_teardown(): void {
|
|
205
|
-
|
|
340
|
+
// Release only what THIS element locked. _setup() returns before locking
|
|
341
|
+
// when there is no content child, so an unconditional unlock here would
|
|
342
|
+
// consume ANOTHER open dialog's count and restore the page out from under
|
|
343
|
+
// it, dropping its compensation while it is still open.
|
|
344
|
+
if (this._scrollLocked) {
|
|
345
|
+
this._scrollLocked = false;
|
|
346
|
+
unlockScroll();
|
|
347
|
+
}
|
|
206
348
|
this._content?.close();
|
|
207
349
|
}
|
|
208
350
|
}
|
|
@@ -267,18 +409,57 @@ export class UiAlertDialogContent extends WebComponent({
|
|
|
267
409
|
// description is data-slot="alert-dialog-description" (falling back to the
|
|
268
410
|
// first paragraph). Author-set ARIA always wins. Inlined rather than shared
|
|
269
411
|
// with dialog.ts so `webjs ui add alert-dialog` stays self-contained.
|
|
412
|
+
// The description is independent of the NAME, so it is wired on every path,
|
|
413
|
+
// including the two authored-name early returns.
|
|
414
|
+
_wireDescription(panel: Element): void {
|
|
415
|
+
const authored = this.getAttribute('aria-describedby');
|
|
416
|
+
if (authored) {
|
|
417
|
+
panel.setAttribute('aria-describedby', authored);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const desc =
|
|
421
|
+
this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
|
|
422
|
+
if (desc && !panel.hasAttribute('aria-describedby')) {
|
|
423
|
+
panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-alert-desc'));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
270
427
|
_wireLabels(): void {
|
|
271
428
|
const panel = this.querySelector('[data-slot="alert-dialog-content"]');
|
|
272
429
|
if (!panel) return;
|
|
430
|
+
// A name the author put on <ui-alert-dialog-content> is where they
|
|
431
|
+
// naturally write it, but role="alertdialog" lives on the inner panel.
|
|
432
|
+
//
|
|
433
|
+
// Each authored-name branch RETURNS rather than falling through to the
|
|
434
|
+
// title wiring. Falling through would set aria-labelledby from the title
|
|
435
|
+
// alongside the forwarded aria-label, and aria-labelledby beats aria-label
|
|
436
|
+
// per accname, so the title would silently win over the author's name.
|
|
437
|
+
const authoredLabelledBy = this.getAttribute('aria-labelledby');
|
|
438
|
+
if (authoredLabelledBy) {
|
|
439
|
+
panel.setAttribute('aria-labelledby', authoredLabelledBy);
|
|
440
|
+
this._wireDescription(panel);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const authoredLabel = this.getAttribute('aria-label');
|
|
444
|
+
if (authoredLabel) {
|
|
445
|
+
panel.setAttribute('aria-label', authoredLabel);
|
|
446
|
+
// An authored name replaces the title as the NAME, so a stale
|
|
447
|
+
// aria-labelledby from an earlier open must go or it would win over it.
|
|
448
|
+
panel.removeAttribute('aria-labelledby');
|
|
449
|
+
this._wireDescription(panel);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
273
452
|
const title =
|
|
274
453
|
this.querySelector('[data-slot="alert-dialog-title"]') ?? this.querySelector('h1, h2, h3');
|
|
275
|
-
const desc =
|
|
276
|
-
this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
|
|
277
454
|
if (title && !panel.hasAttribute('aria-labelledby')) {
|
|
278
455
|
panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-alert-title'));
|
|
279
456
|
}
|
|
280
|
-
|
|
281
|
-
|
|
457
|
+
this._wireDescription(panel);
|
|
458
|
+
// APG: a modal MUST have an accessible name, and this one interrupts the
|
|
459
|
+
// user to demand a decision, so an unnamed one is worse here than
|
|
460
|
+
// anywhere. A floor, not a substitute for a real title; a title wins.
|
|
461
|
+
if (!panel.hasAttribute('aria-labelledby') && !panel.hasAttribute('aria-label')) {
|
|
462
|
+
panel.setAttribute('aria-label', 'Alert dialog');
|
|
282
463
|
}
|
|
283
464
|
}
|
|
284
465
|
|
|
@@ -8,6 +8,18 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Design tokens used: none (layout only).
|
|
10
10
|
*
|
|
11
|
+
* A11y (required for accessible output):
|
|
12
|
+
* Layout only, so it adds no semantics and takes none away. What matters is
|
|
13
|
+
* the CONTENT you put inside it: an `<img>` still needs an `alt` (empty
|
|
14
|
+
* `alt=""` when it is purely decorative), an `<iframe>` still needs a `title`,
|
|
15
|
+
* and a `<video>` still needs its captions.
|
|
16
|
+
* Reserving the ratio IS the accessibility win: the box keeps its space before
|
|
17
|
+
* the media loads, so the page does not shift under someone who is already
|
|
18
|
+
* reading or aiming at a target (WCAG 2.4.x-adjacent).
|
|
19
|
+
* Do not clip meaningful content to make a ratio fit. If a crop would hide
|
|
20
|
+
* part of an image that carries information, put that information in the
|
|
21
|
+
* surrounding text too.
|
|
22
|
+
*
|
|
11
23
|
* @example
|
|
12
24
|
* ```html
|
|
13
25
|
* <div style="aspect-ratio: 16/9;" class="${aspectRatioClass()}">
|
|
@@ -13,11 +13,30 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Design tokens used: --card, --card-foreground, --muted-foreground, --border.
|
|
15
15
|
*
|
|
16
|
+
* A11y (required for accessible output):
|
|
17
|
+
* Use a REAL HEADING for `cardTitleClass()` whenever the card has a
|
|
18
|
+
* meaningful title. The helper only styles text, so on a `<div>` the title is
|
|
19
|
+
* invisible to the heading outline a screen reader user navigates by, and on a
|
|
20
|
+
* page of cards they get an unstructured wall. Pick the level that fits the
|
|
21
|
+
* surrounding document (`<h2>` under the page `<h1>`, `<h3>` inside a
|
|
22
|
+
* sectioned region); do not pick it for the font size, which the class sets.
|
|
23
|
+
* The card root is a plain `<div>` with no role, which is correct: it is a
|
|
24
|
+
* visual container. Give it a role only when it really is one thing, and then
|
|
25
|
+
* name it, e.g. `<section aria-labelledby="<the title's id">` for a landmark,
|
|
26
|
+
* or `role="listitem"` inside a `role="list"` for a card grid.
|
|
27
|
+
* A WHOLE-CARD LINK is the trap. Do not wrap the card in an `<a>` around a
|
|
28
|
+
* header, body, and buttons: it swallows the nested controls and gives a
|
|
29
|
+
* screen reader user one enormous link whose name is the entire card text.
|
|
30
|
+
* Put the link on the title instead and let the card be a container.
|
|
31
|
+
* Keep the reading order of the DOM matching the visual order.
|
|
32
|
+
* `cardActionClass()` places an action at the top right, but it stays after
|
|
33
|
+
* the title and description in the markup, which is what you want announced.
|
|
34
|
+
*
|
|
16
35
|
* @example
|
|
17
36
|
* ```html
|
|
18
37
|
* <div class=${cardClass()}>
|
|
19
38
|
* <div class=${cardHeaderClass()}>
|
|
20
|
-
* <
|
|
39
|
+
* <h3 class=${cardTitleClass()}>Notifications</h3>
|
|
21
40
|
* <div class=${cardDescriptionClass()}>You have 3 unread messages.</div>
|
|
22
41
|
* <div class=${cardActionClass()}>
|
|
23
42
|
* <button class=${buttonClass({ variant: 'ghost', size: 'sm' })}>Mark all read</button>
|
|
@@ -12,10 +12,45 @@
|
|
|
12
12
|
* Design tokens used: --input, --primary, --primary-foreground, --background,
|
|
13
13
|
* --ring, --destructive.
|
|
14
14
|
*
|
|
15
|
+
* A11y (required for accessible output):
|
|
16
|
+
* `data-slot="checkbox"` is REQUIRED, not decoration. The injected stylesheet
|
|
17
|
+
* keys the checkmark on it, so without it a checked box renders as a filled
|
|
18
|
+
* square with no tick: the checked and unchecked states then differ by colour
|
|
19
|
+
* alone, which is exactly what WCAG 1.4.1 rules out. Every example here
|
|
20
|
+
* carries it; keep it when you copy one.
|
|
21
|
+
* Associate a label with the input, either `<label for>` pointing at the
|
|
22
|
+
* input's `id` (as below) or by nesting the input inside the `<label>`. A
|
|
23
|
+
* checkbox with no label has no accessible name.
|
|
24
|
+
* Group related checkboxes in a `<fieldset>` with a `<legend>` naming the
|
|
25
|
+
* group, so the set is announced as one thing rather than N loose controls.
|
|
26
|
+
* Set `aria-invalid="true"` on a failed checkbox (the class styles it) and
|
|
27
|
+
* point `aria-describedby` at the element holding the error text.
|
|
28
|
+
* The indeterminate state is a PROPERTY, not an attribute
|
|
29
|
+
* (`el.indeterminate = true`). Do NOT add `aria-checked="mixed"` alongside it:
|
|
30
|
+
* the browser already maps a native checkbox's `indeterminate` to a mixed
|
|
31
|
+
* checked state, and hand-writing the ARIA duplicates a state the host
|
|
32
|
+
* language owns, which is discouraged precisely because the two can then
|
|
33
|
+
* disagree. Set the property and leave the ARIA alone.
|
|
34
|
+
*
|
|
15
35
|
* @example
|
|
16
36
|
* ```html
|
|
17
|
-
* <
|
|
18
|
-
*
|
|
37
|
+
* <div class="flex items-center gap-2">
|
|
38
|
+
* <input type="checkbox" data-slot="checkbox" name="terms" id="terms" class=${checkboxClass()}>
|
|
39
|
+
* <label class=${labelClass()} for="terms">I accept the terms</label>
|
|
40
|
+
* </div>
|
|
41
|
+
*
|
|
42
|
+
* <!-- A group of related checkboxes is named by its legend. -->
|
|
43
|
+
* <fieldset class=${stackClass({ gap: 'sm' })}>
|
|
44
|
+
* <legend class=${fieldLabelClass()}>Email preferences</legend>
|
|
45
|
+
* <div class="flex items-center gap-2">
|
|
46
|
+
* <input type="checkbox" data-slot="checkbox" name="digest" id="pref-digest" class=${checkboxClass()}>
|
|
47
|
+
* <label class=${labelClass()} for="pref-digest">Weekly digest</label>
|
|
48
|
+
* </div>
|
|
49
|
+
* <div class="flex items-center gap-2">
|
|
50
|
+
* <input type="checkbox" data-slot="checkbox" name="security" id="pref-security" class=${checkboxClass()}>
|
|
51
|
+
* <label class=${labelClass()} for="pref-security">Security alerts</label>
|
|
52
|
+
* </div>
|
|
53
|
+
* </fieldset>
|
|
19
54
|
* ```
|
|
20
55
|
*/
|
|
21
56
|
import { cn } from '../lib/utils.ts';
|
|
@@ -93,7 +128,14 @@ export function installCheckboxStyles(): void {
|
|
|
93
128
|
|
|
94
129
|
if (typeof document !== 'undefined') installCheckboxStyles();
|
|
95
130
|
|
|
96
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Tailwind classes for a styled native `<input type="checkbox">`.
|
|
133
|
+
*
|
|
134
|
+
* PAIR THIS WITH `data-slot="checkbox"` ON THE SAME INPUT. The injected
|
|
135
|
+
* stylesheet keys the checkmark and the indeterminate dash on that attribute,
|
|
136
|
+
* so the class alone gives you a box that fills with colour when checked but
|
|
137
|
+
* never draws a tick, leaving the two states distinguishable by colour only.
|
|
138
|
+
*/
|
|
97
139
|
export function checkboxClass(): string {
|
|
98
140
|
return cn(CHECKBOX_CLASS);
|
|
99
141
|
}
|
|
@@ -17,6 +17,21 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Design tokens used: --border, --ring, --foreground.
|
|
19
19
|
*
|
|
20
|
+
* A11y (mostly handled by the native primitives):
|
|
21
|
+
* Build it on `<details>` + `<summary>`. The browser gives you the button
|
|
22
|
+
* semantics, the expanded state, Enter / Space activation, and focus for free,
|
|
23
|
+
* with no ARIA to hand-write. Do not rebuild it from a `<div>` plus
|
|
24
|
+
* `aria-expanded`.
|
|
25
|
+
* Put the trigger classes on the `<summary>` ITSELF, never on a `<button>` or
|
|
26
|
+
* `<div>` nested inside it: nesting an interactive element in a `<summary>`
|
|
27
|
+
* splits the focusable element from the labelled one and empties the summary's
|
|
28
|
+
* accessible name.
|
|
29
|
+
* Give the `<summary>` real text. A summary whose only content is an icon has
|
|
30
|
+
* no accessible name, so an icon-only trigger needs an `aria-label` and an
|
|
31
|
+
* `aria-hidden="true"` icon.
|
|
32
|
+
* Do not put anything essential ONLY inside collapsed content: it is not
|
|
33
|
+
* reachable by find-in-page on every engine.
|
|
34
|
+
*
|
|
20
35
|
* @example
|
|
21
36
|
* ```html
|
|
22
37
|
* <details class=${collapsibleClass()}>
|