@webjsdev/ui 0.3.10 → 0.3.12
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 +26 -15
- package/package.json +3 -3
- package/packages/registry/README.md +16 -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 +15 -0
- 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 +208 -14
- package/packages/registry/package.json +1 -1
- package/packages/registry/registry.json +1 -1
- package/packages/registry/themes/base-colors.js +2 -2
- package/src/commands/add.js +52 -2
- package/src/commands/diff.js +2 -2
- package/src/commands/info.js +2 -5
- package/src/commands/init.js +127 -25
- package/src/registry/example.js +7 -4
- package/src/registry/fetcher.js +10 -2
- package/src/registry/local.js +2 -2
- package/src/utils/index.js +0 -1
- package/src/utils/detect-project.js +0 -63
|
@@ -26,6 +26,28 @@
|
|
|
26
26
|
*
|
|
27
27
|
* Programmatic API on <ui-hover-card>: `.show()` · `.hide()`.
|
|
28
28
|
*
|
|
29
|
+
* Keyboard:
|
|
30
|
+
* Tab from the trigger into the card, whose content stays open while it
|
|
31
|
+
* holds focus, so links and buttons inside it are reachable
|
|
32
|
+
* Escape dismiss the card, returning focus to the trigger when the card
|
|
33
|
+
* held it
|
|
34
|
+
*
|
|
35
|
+
* A11y (owned by the element, mostly nothing to supply):
|
|
36
|
+
* The trigger opens on focus as well as hover and carries `aria-haspopup` /
|
|
37
|
+
* `aria-expanded` / `aria-controls`. The panel is a `role="dialog"`, which
|
|
38
|
+
* REQUIRES an accessible name, so the element always gives it one, from the
|
|
39
|
+
* first of: an `aria-label` / `aria-labelledby` you put on
|
|
40
|
+
* `<ui-hover-card-content>`, a `[data-slot="hover-card-title"]` or heading
|
|
41
|
+
* inside the card, or the trigger itself (the card is about whatever the
|
|
42
|
+
* trigger names). Escape dismisses it and hands focus back when the card had
|
|
43
|
+
* it, and the content keeps itself open while focus is inside.
|
|
44
|
+
* What you DO supply: a name for the trigger control if it is icon-only, and
|
|
45
|
+
* ideally a title node in the card so its name describes the card rather
|
|
46
|
+
* than falling back to the trigger's text.
|
|
47
|
+
* Touch: the hover and focus linger paths are gated to pointer devices, so on
|
|
48
|
+
* a no-hover device the card is tap-toggled and dismissed by an outside tap
|
|
49
|
+
* (#745). Do not remove that gate to "fix" keyboard behaviour.
|
|
50
|
+
*
|
|
29
51
|
* Design tokens used: --popover, --popover-foreground, --border.
|
|
30
52
|
*
|
|
31
53
|
* @example
|
|
@@ -38,8 +60,9 @@
|
|
|
38
60
|
* <div class="flex gap-3">
|
|
39
61
|
* <img class="size-10 rounded-full" src="/avatars/vivek.jpg" alt="Vivek Khandelwal">
|
|
40
62
|
* <div>
|
|
41
|
-
* <div class="text-sm font-semibold">Vivek Khandelwal</div>
|
|
63
|
+
* <div class="text-sm font-semibold" data-slot="hover-card-title">Vivek Khandelwal</div>
|
|
42
64
|
* <p class="text-sm text-muted-foreground">Builds the platform, not against it.</p>
|
|
65
|
+
* <a class="text-sm underline" href="/user/vivek/posts">Read the posts</a>
|
|
43
66
|
* </div>
|
|
44
67
|
* </div>
|
|
45
68
|
* </ui-hover-card-content>
|
|
@@ -72,6 +95,8 @@ export class UiHoverCard extends WebComponent({
|
|
|
72
95
|
_showTimer: number | undefined;
|
|
73
96
|
_hideTimer: number | undefined;
|
|
74
97
|
|
|
98
|
+
_keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e);
|
|
99
|
+
|
|
75
100
|
constructor() {
|
|
76
101
|
super();
|
|
77
102
|
this.open = false;
|
|
@@ -95,10 +120,49 @@ export class UiHoverCard extends WebComponent({
|
|
|
95
120
|
}
|
|
96
121
|
|
|
97
122
|
disconnectedCallback(): void {
|
|
123
|
+
this._unbindEscape();
|
|
98
124
|
this._disposeBeforeCache?.();
|
|
99
125
|
super.disconnectedCallback?.();
|
|
100
126
|
}
|
|
101
127
|
|
|
128
|
+
// A hover card can hold interactive content and cover what is under it, so a
|
|
129
|
+
// keyboard user needs a way out that is not "tab through the whole card".
|
|
130
|
+
// Bound only WHILE OPEN so a closed card cannot swallow Escape.
|
|
131
|
+
_onKeyDown(e: KeyboardEvent): void {
|
|
132
|
+
if (e.key !== 'Escape' || !this.open) return;
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
this._dismiss();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Immediate, unlike hide()'s close-delay linger, since a dismissal is
|
|
138
|
+
// deliberate. Focus moves out BEFORE the panel hides: the content is
|
|
139
|
+
// popover="manual", so hiding it while a descendant holds focus drops focus
|
|
140
|
+
// to <body> and the user loses their place on the page.
|
|
141
|
+
_suppressReopen = false;
|
|
142
|
+
|
|
143
|
+
_dismiss(): void {
|
|
144
|
+
clearTimeout(this._showTimer);
|
|
145
|
+
clearTimeout(this._hideTimer);
|
|
146
|
+
this._closeReleasingFocus();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_focusIsInContent(): boolean {
|
|
150
|
+
if (typeof document === 'undefined') return false;
|
|
151
|
+
const content = this.querySelector('ui-hover-card-content');
|
|
152
|
+
const active = document.activeElement;
|
|
153
|
+
return !!content && !!active && content.contains(active);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_bindEscape(): void {
|
|
157
|
+
if (typeof document === 'undefined') return;
|
|
158
|
+
document.addEventListener('keydown', this._keyHandler);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_unbindEscape(): void {
|
|
162
|
+
if (typeof document === 'undefined') return;
|
|
163
|
+
document.removeEventListener('keydown', this._keyHandler);
|
|
164
|
+
}
|
|
165
|
+
|
|
102
166
|
// The trigger also opens on focus (see the @focusin handler), so it is
|
|
103
167
|
// keyboard-reachable: expose the popup relationship on the focusable
|
|
104
168
|
// control. aria-expanded is refreshed on every open transition.
|
|
@@ -117,20 +181,103 @@ export class UiHoverCard extends WebComponent({
|
|
|
117
181
|
control.setAttribute('aria-haspopup', 'dialog');
|
|
118
182
|
control.setAttribute('aria-expanded', String(this.open));
|
|
119
183
|
const content = this.querySelector<HTMLElement>('ui-hover-card-content [role="dialog"]');
|
|
120
|
-
if (content)
|
|
184
|
+
if (!content) return;
|
|
185
|
+
control.setAttribute('aria-controls', ensureId(content, 'ui-hovercard'));
|
|
186
|
+
this._nameContent(content, control);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// role="dialog" REQUIRES an accessible name. Without one a screen reader
|
|
190
|
+
// announces a bare "dialog" with no hint of what it holds, which is an ARIA
|
|
191
|
+
// defect rather than a missing nicety. Three sources, most specific first:
|
|
192
|
+
// 1. what the author put on <ui-hover-card-content>, which always wins
|
|
193
|
+
// 2. a title node inside the card (the dialog.ts idiom)
|
|
194
|
+
// 3. the trigger, since the card is ABOUT whatever the trigger names
|
|
195
|
+
// ("@vivek" names that user's card). Same fallback the dropdown menu
|
|
196
|
+
// already uses to label its panel back to its trigger.
|
|
197
|
+
// Source 3 is what makes the name unconditional: there is no shape of this
|
|
198
|
+
// component that can end up with an unnamed dialog role.
|
|
199
|
+
_nameContent(content: HTMLElement, control: HTMLElement): void {
|
|
200
|
+
const host = this.querySelector('ui-hover-card-content');
|
|
201
|
+
// aria-labelledby is checked FIRST because it beats aria-label per accname,
|
|
202
|
+
// matching dialog / alert-dialog / toggle. Checking aria-label first would
|
|
203
|
+
// name this panel by the attribute that loses everywhere else, so an author
|
|
204
|
+
// writing both would get a different name here than in a dialog.
|
|
205
|
+
const authoredLabelledBy = host?.getAttribute('aria-labelledby');
|
|
206
|
+
if (authoredLabelledBy) {
|
|
207
|
+
content.setAttribute('aria-labelledby', authoredLabelledBy);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const authoredLabel = host?.getAttribute('aria-label');
|
|
211
|
+
if (authoredLabel) {
|
|
212
|
+
content.setAttribute('aria-label', authoredLabel);
|
|
213
|
+
// This method re-runs on every open change, and the fallback below writes
|
|
214
|
+
// aria-labelledby unconditionally. So a card named from its title on an
|
|
215
|
+
// earlier pass still carries that reference, and it would outrank the
|
|
216
|
+
// author's aria-label. Drop it, as dialog / alert-dialog do.
|
|
217
|
+
content.removeAttribute('aria-labelledby');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Already named by an earlier pass (this runs again on every open change).
|
|
221
|
+
if (content.hasAttribute('aria-label') || content.hasAttribute('aria-labelledby')) return;
|
|
222
|
+
const title = content.querySelector<HTMLElement>(
|
|
223
|
+
'[data-slot="hover-card-title"], h1, h2, h3, h4, h5, h6',
|
|
224
|
+
);
|
|
225
|
+
content.setAttribute(
|
|
226
|
+
'aria-labelledby',
|
|
227
|
+
title
|
|
228
|
+
? ensureId(title, 'ui-hovercard-title')
|
|
229
|
+
: ensureId(control, 'ui-hovercard-trigger'),
|
|
230
|
+
);
|
|
121
231
|
}
|
|
122
232
|
|
|
123
233
|
// Back-compat getter.
|
|
124
234
|
get isOpen(): boolean { return this.open; }
|
|
125
235
|
|
|
126
236
|
show(): void {
|
|
237
|
+
// A keyboard dismissal has to stick: _dismiss() moves focus to the trigger,
|
|
238
|
+
// and that focusin lands right back here, which would reopen the card and
|
|
239
|
+
// make Escape look broken. The guard is released once the transfer flushes.
|
|
240
|
+
if (this._suppressReopen) return;
|
|
127
241
|
clearTimeout(this._hideTimer);
|
|
128
242
|
this._showTimer = window.setTimeout(() => { this.open = true; }, this.openDelay);
|
|
129
243
|
}
|
|
130
244
|
|
|
131
245
|
hide(): void {
|
|
132
246
|
clearTimeout(this._showTimer);
|
|
133
|
-
|
|
247
|
+
// Clear the PENDING hide before scheduling the next one. Overwriting the
|
|
248
|
+
// handle alone orphans the old timer, which then fires on its own and
|
|
249
|
+
// closes the card after a later show() thought it had cancelled the close.
|
|
250
|
+
// Reachable whenever two leave paths fire without a show() between them,
|
|
251
|
+
// and the focus linger below doubles how many of those paths exist.
|
|
252
|
+
clearTimeout(this._hideTimer);
|
|
253
|
+
this._hideTimer = window.setTimeout(() => {
|
|
254
|
+
// The card KEEPS ITSELF OPEN while focus is inside it. That is what makes
|
|
255
|
+
// in-card content Tab-reachable and what both doc surfaces promise, and a
|
|
256
|
+
// mouseleave can schedule this close while a keyboard user still holds
|
|
257
|
+
// focus on an in-card link. Closing then would pull the card out from
|
|
258
|
+
// under them, so leave it open: the content's own focusout schedules the
|
|
259
|
+
// close once focus has actually left, and that pass takes this branch.
|
|
260
|
+
if (this._focusIsInContent()) return;
|
|
261
|
+
this.open = false;
|
|
262
|
+
}, this.closeDelay);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Every close of this popover="manual" panel owes the same focus care, not
|
|
266
|
+
// just Escape. The focus linger added here makes in-card content Tab-
|
|
267
|
+
// reachable, so a user CAN be holding focus on a link inside the card when a
|
|
268
|
+
// mouseleave-scheduled close fires; hiding the panel then drops focus to
|
|
269
|
+
// <body>. Guarded, so a close while focus is elsewhere leaves it alone.
|
|
270
|
+
//
|
|
271
|
+
// The reopen suppression is what makes this terminate. Moving focus to the
|
|
272
|
+
// trigger fires the trigger's own focusin, which calls show() and would
|
|
273
|
+
// reopen the card the close just closed. It is released on the next
|
|
274
|
+
// macrotask, once that synchronous focus transfer has been refused, so a
|
|
275
|
+
// genuinely new hover or a fresh Tab back in still opens the card.
|
|
276
|
+
_closeReleasingFocus(): void {
|
|
277
|
+
this._suppressReopen = true;
|
|
278
|
+
if (this._focusIsInContent()) this._control()?.focus();
|
|
279
|
+
this.open = false;
|
|
280
|
+
setTimeout(() => { this._suppressReopen = false; }, 0);
|
|
134
281
|
}
|
|
135
282
|
|
|
136
283
|
// Touch open: there is no hover delay and no mouseleave to close it, so open
|
|
@@ -145,7 +292,8 @@ export class UiHoverCard extends WebComponent({
|
|
|
145
292
|
// Close on an outside tap; also self-remove if the card was already
|
|
146
293
|
// closed by other means (a re-tap toggle), so the listener never lingers.
|
|
147
294
|
if (!this.open || !this.contains(ev.target as Node)) {
|
|
148
|
-
|
|
295
|
+
// Same focus care as every other close path: the card may hold focus.
|
|
296
|
+
this._closeReleasingFocus();
|
|
149
297
|
document.removeEventListener('pointerdown', onOutside, true);
|
|
150
298
|
}
|
|
151
299
|
};
|
|
@@ -162,6 +310,10 @@ export class UiHoverCard extends WebComponent({
|
|
|
162
310
|
updated(changedProperties: Map<string, unknown>): void {
|
|
163
311
|
if (!changedProperties.has('open')) return;
|
|
164
312
|
if (changedProperties.get('open') === undefined) return;
|
|
313
|
+
// Bind synchronously rather than in the microtask below, which bails on
|
|
314
|
+
// engines with no Popover API: Escape has to work regardless of that.
|
|
315
|
+
if (this.open) this._bindEscape();
|
|
316
|
+
else this._unbindEscape();
|
|
165
317
|
// Wait one microtask for <ui-hover-card-content>'s inner [popover]
|
|
166
318
|
// element to commit; we drive its showPopover() / hidePopover() and
|
|
167
319
|
// refresh the trigger's aria-expanded.
|
|
@@ -260,6 +412,10 @@ UiHoverCardTrigger.register('ui-hover-card-trigger');
|
|
|
260
412
|
// The mouseenter/mouseleave handlers keep the card open while the cursor
|
|
261
413
|
// is over the content itself (so it does not close during a brief
|
|
262
414
|
// mouseleave on the trigger if the user is moving toward the card).
|
|
415
|
+
// focusin/focusout mirror that linger for the KEYBOARD: the trigger closes
|
|
416
|
+
// the card on its own focusout, so without these, Tabbing from the trigger
|
|
417
|
+
// toward the card scheduled the close before focus could land inside, and
|
|
418
|
+
// the interactive content the JSDoc example shows was unreachable.
|
|
263
419
|
// --------------------------------------------------------------------------
|
|
264
420
|
|
|
265
421
|
export class UiHoverCardContent extends WebComponent {
|
|
@@ -271,6 +427,8 @@ export class UiHoverCardContent extends WebComponent {
|
|
|
271
427
|
class=${hoverCardContentClass()}
|
|
272
428
|
@mouseenter=${this._onEnter}
|
|
273
429
|
@mouseleave=${this._onLeave}
|
|
430
|
+
@focusin=${this._onEnter}
|
|
431
|
+
@focusout=${this._onLeave}
|
|
274
432
|
><slot></slot></div>`;
|
|
275
433
|
}
|
|
276
434
|
|
|
@@ -15,10 +15,42 @@
|
|
|
15
15
|
* Design tokens used: --input, --background, --primary, --primary-foreground,
|
|
16
16
|
* --muted-foreground, --foreground, --ring, --destructive.
|
|
17
17
|
*
|
|
18
|
+
* A11y (required for accessible output):
|
|
19
|
+
* LABEL IT. A `<label class=${labelClass()} for="<the input's id">` is the
|
|
20
|
+
* whole requirement, and the `for` / `id` pair is what links them. Without it
|
|
21
|
+
* the input has no accessible name and a screen reader announces only "edit
|
|
22
|
+
* text". A `placeholder` is NOT a label: it disappears the moment the user
|
|
23
|
+
* types, and some engines never expose it as a name at all.
|
|
24
|
+
* Every `aria-describedby` must point at an element that EXISTS on the page.
|
|
25
|
+
* A dangling id silently contributes nothing, so the hint or error text is
|
|
26
|
+
* never announced; the example below therefore renders the `#email-hint` node
|
|
27
|
+
* it references rather than assuming it.
|
|
28
|
+
* On a validation failure set `aria-invalid="true"` (the class styles the
|
|
29
|
+
* error ring off it) and point `aria-describedby` at the error text, so the
|
|
30
|
+
* reason is announced along with the field rather than only shown in red.
|
|
31
|
+
* Set the right `type` and `autocomplete`. Both are accessibility features:
|
|
32
|
+
* `type` picks the mobile keyboard and the native validation, and
|
|
33
|
+
* `autocomplete` is what lets a password manager or an autofill user avoid
|
|
34
|
+
* retyping. `type="search"` / `type="email"` also announce as such.
|
|
35
|
+
* A required field wants the native `required` attribute, not just an
|
|
36
|
+
* asterisk in the label, so the state reaches assistive tech.
|
|
37
|
+
*
|
|
18
38
|
* @example
|
|
19
39
|
* ```html
|
|
20
|
-
* <
|
|
21
|
-
*
|
|
40
|
+
* <div class=${fieldClass()}>
|
|
41
|
+
* <label class=${labelClass()} for="email">Email</label>
|
|
42
|
+
* <input class=${inputClass()} type="email" name="email" id="email" required
|
|
43
|
+
* autocomplete="email" aria-describedby="email-hint">
|
|
44
|
+
* <p class=${hintClass()} id="email-hint">We never share it.</p>
|
|
45
|
+
* </div>
|
|
46
|
+
*
|
|
47
|
+
* <!-- Failed validation: invalid state plus the reason, both announced. -->
|
|
48
|
+
* <div class=${fieldClass()}>
|
|
49
|
+
* <label class=${labelClass()} for="email-2">Email</label>
|
|
50
|
+
* <input class=${inputClass()} type="email" name="email" id="email-2"
|
|
51
|
+
* aria-invalid="true" aria-describedby="email-2-error" value="nope">
|
|
52
|
+
* <p class=${errorClass()} id="email-2-error">Enter a valid email address.</p>
|
|
53
|
+
* </div>
|
|
22
54
|
* ```
|
|
23
55
|
*/
|
|
24
56
|
import { cn } from '../lib/utils.ts';
|
|
@@ -8,12 +8,45 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Design tokens used: --muted, --muted-foreground, --background.
|
|
10
10
|
*
|
|
11
|
+
* A11y (required for accessible output):
|
|
12
|
+
* SYMBOL-ONLY KEYS NEED A SPOKEN NAME. A screen reader reads a bare glyph
|
|
13
|
+
* however its speech engine happens to handle that codepoint, so a key shown
|
|
14
|
+
* as a symbol can be announced as a meaningless character name or skipped
|
|
15
|
+
* entirely. Spell the name the way a person says it: `⌘` is "Command", `⇧`
|
|
16
|
+
* "Shift", `⌥` "Option", `⌃` "Control", `⏎` "Enter", `⌫` "Backspace", `␣`
|
|
17
|
+
* "Space", `⎋` "Escape", and the arrows are "Up arrow" and friends. A key
|
|
18
|
+
* already spelled in words ("Shift", "K") reads correctly and needs nothing.
|
|
19
|
+
* DO NOT put `aria-label` on the `<kbd>` itself. `<kbd>` maps to
|
|
20
|
+
* `role=generic`, where a name is prohibited and browsers ignore it, so it
|
|
21
|
+
* would silently do nothing. Supply the name as TEXT instead: hide the glyph
|
|
22
|
+
* with `aria-hidden="true"` and put the spoken form in a visually-hidden
|
|
23
|
+
* sibling (`sr-only`), which every screen reader reads.
|
|
24
|
+
* NAME THE CHORD, not just the keys, since three `<kbd>` elements are
|
|
25
|
+
* otherwise announced as three separate things with no hint they are pressed
|
|
26
|
+
* together. Wrap the group in an element with `role="img"` and an
|
|
27
|
+
* `aria-label` of the whole chord: `role="img"` both SUPPORTS a name (unlike
|
|
28
|
+
* generic) and makes its children presentational, so the chord is announced
|
|
29
|
+
* once as one thing. Do not use `role="group"` + `aria-hidden` children for
|
|
30
|
+
* this: that leaves the group with nothing to announce.
|
|
31
|
+
* Keep the native `<kbd>` element. The class is styling only; `<kbd>` is what
|
|
32
|
+
* carries the "this is keyboard input" semantic.
|
|
33
|
+
* `<kbd>` DOCUMENTS a shortcut, it does not create one. If the shortcut is
|
|
34
|
+
* real, it also needs a keyboard handler, must not clash with an assistive-tech
|
|
35
|
+
* or browser binding, and per WCAG 2.1.4 a single-character shortcut has to be
|
|
36
|
+
* remappable or disableable.
|
|
37
|
+
*
|
|
11
38
|
* @example
|
|
12
39
|
* ```html
|
|
13
|
-
*
|
|
40
|
+
* <!-- A symbol key: hide the glyph, supply the spoken form as text. An
|
|
41
|
+
* aria-label on the <kbd> itself would be ignored (role=generic). -->
|
|
42
|
+
* <kbd class=${kbdClass()} aria-hidden="true">⌘</kbd><span class="sr-only">Command</span>
|
|
43
|
+
*
|
|
44
|
+
* <!-- A spelled-out key needs nothing. -->
|
|
14
45
|
* <kbd class=${kbdClass()}>K</kbd>
|
|
15
46
|
*
|
|
16
|
-
*
|
|
47
|
+
* <!-- A chord, announced once as one thing. role="img" supports a name AND
|
|
48
|
+
* makes its children presentational, so the glyphs are not read twice. -->
|
|
49
|
+
* <div class=${kbdGroupClass()} role="img" aria-label="Command Shift P">
|
|
17
50
|
* <kbd class=${kbdClass()}>⌘</kbd>
|
|
18
51
|
* <kbd class=${kbdClass()}>Shift</kbd>
|
|
19
52
|
* <kbd class=${kbdClass()}>P</kbd>
|
|
@@ -12,10 +12,31 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Design tokens used: none (typography only).
|
|
14
14
|
*
|
|
15
|
+
* A11y (required for accessible output):
|
|
16
|
+
* Put this on a REAL `<label>`, and link it to its control, either with `for`
|
|
17
|
+
* pointing at the control's `id` (below) or by nesting the control inside the
|
|
18
|
+
* `<label>`. That link is the entire point of the element: it supplies the
|
|
19
|
+
* control's accessible name and makes clicking the text focus the control.
|
|
20
|
+
* The same classes on a `<span>` or `<div>` look identical and name nothing.
|
|
21
|
+
* One label, one control. `for` takes a single id, so a label cannot name a
|
|
22
|
+
* group; use `<fieldset>` + `<legend>`, or a heading referenced by
|
|
23
|
+
* `aria-labelledby`, for that.
|
|
24
|
+
* Do not use a label as a heading for a non-control region. A label with no
|
|
25
|
+
* control is inert; if it is really a section title, use a heading element.
|
|
26
|
+
* The dimmed disabled look is presentational only (it keys on a parent's
|
|
27
|
+
* `data-disabled` or a `peer-disabled:` sibling). Actually disable the
|
|
28
|
+
* CONTROL: styling the label alone leaves a control that still takes input.
|
|
29
|
+
*
|
|
15
30
|
* @example
|
|
16
31
|
* ```html
|
|
17
32
|
* <label class=${labelClass()} for="email">Email</label>
|
|
18
33
|
* <input class=${inputClass()} id="email" name="email" type="email">
|
|
34
|
+
*
|
|
35
|
+
* <!-- Nesting works too, and needs no id / for pair. -->
|
|
36
|
+
* <label class=${labelClass()}>
|
|
37
|
+
* <input type="checkbox" data-slot="checkbox" class=${checkboxClass()}>
|
|
38
|
+
* Email me a receipt
|
|
39
|
+
* </label>
|
|
19
40
|
* ```
|
|
20
41
|
*/
|
|
21
42
|
|
|
@@ -21,15 +21,36 @@
|
|
|
21
21
|
* Design tokens used: --input, --background, --primary, --primary-foreground,
|
|
22
22
|
* --muted-foreground, --ring, --destructive.
|
|
23
23
|
*
|
|
24
|
+
* A11y (required for accessible output):
|
|
25
|
+
* LABEL IT. A `<label class=${labelClass()} for="<the select's id">` linked by
|
|
26
|
+
* the `for` / `id` pair is the whole requirement. A `<select>` with no label
|
|
27
|
+
* announces only its current value, so the user hears "Basic" with no idea it
|
|
28
|
+
* is the billing plan.
|
|
29
|
+
* The chevron is DECORATIVE and must stay `aria-hidden="true"`. It duplicates
|
|
30
|
+
* information the select's own role already conveys, and it sits outside the
|
|
31
|
+
* control, so without the attribute it is announced as a stray graphic.
|
|
32
|
+
* There is no placeholder option in a `<select>`. If you need one, use a
|
|
33
|
+
* `<option value="" disabled selected>` prompt, and keep `required` on the
|
|
34
|
+
* select so an empty submit is caught natively.
|
|
35
|
+
* Group long option lists with `<optgroup label="...">`; the label is
|
|
36
|
+
* announced, so it is real structure rather than only a visual break.
|
|
37
|
+
* Being native is the accessibility win here: the platform picker, the
|
|
38
|
+
* keyboard behaviour, and the mobile UX come for free. Do not rebuild it as a
|
|
39
|
+
* div-based listbox to gain styling.
|
|
40
|
+
*
|
|
24
41
|
* @example
|
|
25
42
|
* ```html
|
|
26
|
-
* <div class=${
|
|
27
|
-
* <
|
|
28
|
-
*
|
|
29
|
-
* <
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
43
|
+
* <div class=${fieldClass()}>
|
|
44
|
+
* <label class=${labelClass()} for="plan">Billing plan</label>
|
|
45
|
+
* <div class=${nativeSelectWrapperClass()}>
|
|
46
|
+
* <select class=${nativeSelectClass()} name="plan" id="plan" required>
|
|
47
|
+
* <option value="" disabled selected>Choose a plan</option>
|
|
48
|
+
* <option value="basic">Basic</option>
|
|
49
|
+
* <option value="pro">Pro</option>
|
|
50
|
+
* </select>
|
|
51
|
+
* <!-- chevron icon, decorative -->
|
|
52
|
+
* <svg class="${nativeSelectIconClass()}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
53
|
+
* </div>
|
|
33
54
|
* </div>
|
|
34
55
|
* ```
|
|
35
56
|
*/
|
|
@@ -25,25 +25,61 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Design tokens used: --popover, --popover-foreground, --border.
|
|
27
27
|
*
|
|
28
|
+
* A11y (required for accessible output):
|
|
29
|
+
* This is the Tier-1 component with the most for you to supply, because
|
|
30
|
+
* `popoverContentClass()` returns only classes: the panel it styles is a bare
|
|
31
|
+
* `<div popover>` with NO role and NO accessible name unless you add them.
|
|
32
|
+
* GIVE THE PANEL `role="dialog"`, then NAME it with `aria-labelledby`
|
|
33
|
+
* pointing at the `popoverTitleClass()` heading's `id`. Both halves matter: a
|
|
34
|
+
* role with no name announces as a bare "dialog", and a name with no role is
|
|
35
|
+
* never announced as a grouped thing at all. This is what shadcn's
|
|
36
|
+
* `PopoverContent` does for you in React, and here it is yours.
|
|
37
|
+
* Point `aria-describedby` at the `popoverDescriptionClass()` paragraph when
|
|
38
|
+
* there is one, so the supporting line is announced with the panel.
|
|
39
|
+
* TELL THE TRIGGER what it does: `aria-expanded` tracking the open state, and
|
|
40
|
+
* `aria-haspopup="dialog"`. The browser does not derive either from
|
|
41
|
+
* `popovertarget`, so without them the button announces as a plain button that
|
|
42
|
+
* gives no hint a panel will appear. Keep `aria-expanded` in sync from the
|
|
43
|
+
* panel's `toggle` event, which is the native signal for this.
|
|
44
|
+
* Use `popover` (auto), not `popover="manual"`, unless you have a reason.
|
|
45
|
+
* The auto mode is where the platform gives you light-dismiss, Escape-to-close,
|
|
46
|
+
* and FOCUS RESTORATION back to the invoker; manual mode gives up all three
|
|
47
|
+
* and makes them your problem.
|
|
48
|
+
* Make the title a REAL heading element (`<h2>` / `<h3>`) at the right level
|
|
49
|
+
* for its place in the page, not a styled `<div>`, so it appears in the
|
|
50
|
+
* heading outline a screen reader user navigates by.
|
|
51
|
+
*
|
|
28
52
|
* @example
|
|
29
53
|
* ```html
|
|
30
54
|
* <!-- Single invoker, implicit anchor, zero inline style. -->
|
|
31
|
-
* <button popovertarget="filter"
|
|
32
|
-
*
|
|
55
|
+
* <button popovertarget="filter" aria-expanded="false" aria-haspopup="dialog"
|
|
56
|
+
* class=${buttonClass({ variant: 'outline' })}>Filter</button>
|
|
57
|
+
* <div id="filter" popover role="dialog" aria-labelledby="filter-title"
|
|
58
|
+
* aria-describedby="filter-desc"
|
|
33
59
|
* class=${popoverContentClass({ side: 'bottom', align: 'start', sideOffset: 4 })}>
|
|
34
60
|
* <div class=${popoverHeaderClass()}>
|
|
35
|
-
* <h3 class=${popoverTitleClass()}>Filter posts</h3>
|
|
36
|
-
* <p class=${popoverDescriptionClass()}>By tag and status.</p>
|
|
61
|
+
* <h3 class=${popoverTitleClass()} id="filter-title">Filter posts</h3>
|
|
62
|
+
* <p class=${popoverDescriptionClass()} id="filter-desc">By tag and status.</p>
|
|
37
63
|
* </div>
|
|
38
64
|
* </div>
|
|
39
65
|
*
|
|
66
|
+
* <!-- Keep the trigger's aria-expanded in sync from the native toggle event. -->
|
|
67
|
+
* <script type="module">
|
|
68
|
+
* const panel = document.getElementById('filter');
|
|
69
|
+
* const trigger = document.querySelector('[popovertarget="filter"]');
|
|
70
|
+
* panel.addEventListener('toggle', (e) => {
|
|
71
|
+
* trigger.setAttribute('aria-expanded', String(e.newState === 'open'));
|
|
72
|
+
* });
|
|
73
|
+
* </script>
|
|
74
|
+
*
|
|
40
75
|
* <!-- Explicit anchor, for multiple invokers or anchoring to a different element. -->
|
|
41
76
|
* <span style="anchor-name: --picker">@vivek</span>
|
|
42
|
-
* <button popovertarget="profile">Show</button>
|
|
43
|
-
* <div id="profile" popover
|
|
77
|
+
* <button popovertarget="profile" aria-expanded="false" aria-haspopup="dialog">Show</button>
|
|
78
|
+
* <div id="profile" popover role="dialog" aria-labelledby="profile-title"
|
|
79
|
+
* style="position-anchor: --picker"
|
|
44
80
|
* class=${popoverContentClass({ side: 'bottom' })}>
|
|
45
81
|
* <div class=${popoverHeaderClass()}>
|
|
46
|
-
* <h3 class=${popoverTitleClass()}>Vivek Khandelwal</h3>
|
|
82
|
+
* <h3 class=${popoverTitleClass()} id="profile-title">Vivek Khandelwal</h3>
|
|
47
83
|
* <p class=${popoverDescriptionClass()}>@vivek</p>
|
|
48
84
|
* </div>
|
|
49
85
|
* </div>
|
|
@@ -11,16 +11,37 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Design tokens used: --input, --primary, --ring, --destructive.
|
|
13
13
|
*
|
|
14
|
+
* A11y (required for accessible output):
|
|
15
|
+
* `data-slot="radio"` is REQUIRED, not decoration. The injected stylesheet
|
|
16
|
+
* keys the indicator dot on it, and `radioClass()` carries no checked
|
|
17
|
+
* background fill, so without the attribute a SELECTED radio differs from an
|
|
18
|
+
* unselected one by border colour alone. That is both easy to miss and a
|
|
19
|
+
* WCAG 1.4.1 problem (state by colour only). Every example here carries it.
|
|
20
|
+
* NAME THE GROUP. A bare `<div role="radiogroup">` has no accessible name, so
|
|
21
|
+
* a screen reader announces "radio group" with no idea what is being chosen.
|
|
22
|
+
* Point `aria-labelledby` at the visible group label, as below, or use a
|
|
23
|
+
* `<fieldset>` + `<legend>` instead of the `role="radiogroup"` div.
|
|
24
|
+
* Give every input the SAME `name`, which is what makes the browser treat
|
|
25
|
+
* them as one set: it supplies single-selection, Arrow-key navigation, and a
|
|
26
|
+
* single tab stop for free. Different names produce N independent radios that
|
|
27
|
+
* can all be selected at once.
|
|
28
|
+
* Label each input with `<label for>` pointing at its `id`, and mark the
|
|
29
|
+
* group `aria-invalid="true"` plus an `aria-describedby` error element when
|
|
30
|
+
* the selection fails validation.
|
|
31
|
+
*
|
|
14
32
|
* @example
|
|
15
33
|
* ```html
|
|
16
|
-
* <div
|
|
17
|
-
* <
|
|
18
|
-
*
|
|
19
|
-
* <
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* <
|
|
34
|
+
* <div class=${fieldClass()}>
|
|
35
|
+
* <span class=${fieldLabelClass()} id="plan-label">Billing plan</span>
|
|
36
|
+
* <div role="radiogroup" aria-labelledby="plan-label" class=${radioGroupClass()}>
|
|
37
|
+
* <div class="flex items-center gap-2">
|
|
38
|
+
* <input type="radio" data-slot="radio" name="plan" value="basic" id="plan-basic" class=${radioClass()}>
|
|
39
|
+
* <label class=${labelClass()} for="plan-basic">Basic</label>
|
|
40
|
+
* </div>
|
|
41
|
+
* <div class="flex items-center gap-2">
|
|
42
|
+
* <input type="radio" data-slot="radio" name="plan" value="pro" id="plan-pro" class=${radioClass()}>
|
|
43
|
+
* <label class=${labelClass()} for="plan-pro">Pro</label>
|
|
44
|
+
* </div>
|
|
24
45
|
* </div>
|
|
25
46
|
* </div>
|
|
26
47
|
* ```
|
|
@@ -109,7 +130,14 @@ export function radioGroupClass(opts: { orientation?: RadioGroupOrientation } =
|
|
|
109
130
|
: 'grid gap-3';
|
|
110
131
|
}
|
|
111
132
|
|
|
112
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Tailwind classes for a styled `<input type="radio">`.
|
|
135
|
+
*
|
|
136
|
+
* PAIR THIS WITH `data-slot="radio"` ON THE SAME INPUT. The injected stylesheet
|
|
137
|
+
* keys the indicator dot on that attribute, and this class deliberately carries
|
|
138
|
+
* no checked background fill, so the class alone leaves a selected radio
|
|
139
|
+
* differing from an unselected one by border colour and nothing else.
|
|
140
|
+
*/
|
|
113
141
|
export function radioClass(): string {
|
|
114
142
|
return cn(RADIO_CLASS);
|
|
115
143
|
}
|