@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
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* `duration`: ms, default 4000 (loading toasts default to 0, no auto-dismiss).
|
|
23
23
|
* `action`: { label, onClick } | undefined. Renders an action button.
|
|
24
24
|
* `cancel`: { label, onClick } | undefined. Renders a cancel button.
|
|
25
|
+
* Both buttons dismiss the toast after running their onClick.
|
|
25
26
|
*
|
|
26
27
|
* Events: none dispatched (consumers act on the id returned by `toast()`).
|
|
27
28
|
*
|
|
@@ -29,6 +30,17 @@
|
|
|
29
30
|
* per-instance dispatch (bypasses the singleton router that `toast()`
|
|
30
31
|
* uses); typically only needed when mounting multiple viewports.
|
|
31
32
|
*
|
|
33
|
+
* A11y (owned by the element, nothing to supply):
|
|
34
|
+
* The viewport is a persistent `aria-live="polite"` region labelled
|
|
35
|
+
* "Notifications", so a toast is announced as it is inserted; an `error`
|
|
36
|
+
* toast carries `role="alert"` so it is announced assertively. Every toast
|
|
37
|
+
* also ships a labelled close button, so a toast can always be dismissed by
|
|
38
|
+
* hand rather than only by its timer or a programmatic `toast.dismiss(id)`.
|
|
39
|
+
* That matters most for a toast that never auto-dismisses, which
|
|
40
|
+
* `toast.loading()` is by default (`duration: 0`) and which was otherwise
|
|
41
|
+
* impossible to get rid of from the UI. Each toast's icon is decorative and
|
|
42
|
+
* aria-hidden; the meaning is in the text and the role.
|
|
43
|
+
*
|
|
32
44
|
* Design tokens used: --popover, --popover-foreground, --border, --radius.
|
|
33
45
|
*
|
|
34
46
|
* @example
|
|
@@ -44,6 +56,12 @@
|
|
|
44
56
|
* toast.error('Failed to save', { description: 'Try again' });
|
|
45
57
|
* toast.promise(savePost(), { loading: 'Saving', success: 'Saved', error: 'Failed' });
|
|
46
58
|
* toast.dismiss(id);
|
|
59
|
+
*
|
|
60
|
+
* // action and cancel buttons; each dismisses the toast after its onClick.
|
|
61
|
+
* toast('Post deleted', {
|
|
62
|
+
* action: { label: 'Undo', onClick: () => restorePost() },
|
|
63
|
+
* cancel: { label: 'Dismiss', onClick: () => {} },
|
|
64
|
+
* });
|
|
47
65
|
* </script>
|
|
48
66
|
* ```
|
|
49
67
|
*/
|
|
@@ -57,6 +75,7 @@ interface ToastOptions {
|
|
|
57
75
|
description?: string;
|
|
58
76
|
duration?: number;
|
|
59
77
|
action?: { label: string; onClick: () => void };
|
|
78
|
+
cancel?: { label: string; onClick: () => void };
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
interface ToastItem extends ToastOptions {
|
|
@@ -99,6 +118,7 @@ function makeToast(message: string, opts: ToastOptions = {}, type: ToastType = '
|
|
|
99
118
|
description: opts.description,
|
|
100
119
|
duration: opts.duration ?? (type === 'loading' ? 0 : 4000),
|
|
101
120
|
action: opts.action,
|
|
121
|
+
cancel: opts.cancel,
|
|
102
122
|
});
|
|
103
123
|
}
|
|
104
124
|
|
|
@@ -207,6 +227,7 @@ export class UiSonner extends WebComponent({
|
|
|
207
227
|
description: opts.description,
|
|
208
228
|
duration: opts.duration ?? (type === 'loading' ? 0 : 4000),
|
|
209
229
|
action: opts.action,
|
|
230
|
+
cancel: opts.cancel,
|
|
210
231
|
});
|
|
211
232
|
return id;
|
|
212
233
|
}
|
|
@@ -242,6 +263,7 @@ export class UiSonner extends WebComponent({
|
|
|
242
263
|
this.items.get(),
|
|
243
264
|
(item) => item.id,
|
|
244
265
|
(item) => html`<div
|
|
266
|
+
data-slot="sonner-toast"
|
|
245
267
|
class=${TOAST_ITEM_BASE}
|
|
246
268
|
data-type=${item.type}
|
|
247
269
|
role=${item.type === 'error' ? 'alert' : 'status'}
|
|
@@ -253,9 +275,21 @@ export class UiSonner extends WebComponent({
|
|
|
253
275
|
? html`<div class="mt-1 text-xs text-muted-foreground">${item.description}</div>`
|
|
254
276
|
: ''}
|
|
255
277
|
</div>
|
|
278
|
+
${item.cancel
|
|
279
|
+
? html`<button
|
|
280
|
+
type="button"
|
|
281
|
+
data-slot="sonner-cancel"
|
|
282
|
+
class="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
|
|
283
|
+
@click=${() => {
|
|
284
|
+
item.cancel!.onClick();
|
|
285
|
+
this._remove(item.id);
|
|
286
|
+
}}
|
|
287
|
+
>${item.cancel.label}</button>`
|
|
288
|
+
: ''}
|
|
256
289
|
${item.action
|
|
257
290
|
? html`<button
|
|
258
291
|
type="button"
|
|
292
|
+
data-slot="sonner-action"
|
|
259
293
|
class="rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
|
|
260
294
|
@click=${() => {
|
|
261
295
|
item.action!.onClick();
|
|
@@ -263,6 +297,13 @@ export class UiSonner extends WebComponent({
|
|
|
263
297
|
}}
|
|
264
298
|
>${item.action.label}</button>`
|
|
265
299
|
: ''}
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
data-slot="sonner-close"
|
|
303
|
+
aria-label="Close notification"
|
|
304
|
+
class="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
305
|
+
@click=${() => this._remove(item.id)}
|
|
306
|
+
>${unsafeHTML(CLOSE_SVG)}</button>
|
|
266
307
|
</div>`,
|
|
267
308
|
)}
|
|
268
309
|
</div>`;
|
|
@@ -270,15 +311,22 @@ export class UiSonner extends WebComponent({
|
|
|
270
311
|
}
|
|
271
312
|
UiSonner.register('ui-sonner');
|
|
272
313
|
|
|
314
|
+
// The close affordance's own glyph. Decorative: the button's accessible name
|
|
315
|
+
// comes from its aria-label, so the icon must not be walked for a name.
|
|
316
|
+
const CLOSE_SVG =
|
|
317
|
+
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
318
|
+
|
|
319
|
+
// Type glyphs, all decorative: the toast's text carries the meaning, and the
|
|
320
|
+
// type is conveyed by role="alert" vs role="status", not by the picture.
|
|
273
321
|
const ICONS: Record<ToastType, string> = {
|
|
274
322
|
default: '',
|
|
275
323
|
success:
|
|
276
|
-
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>',
|
|
324
|
+
'<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>',
|
|
277
325
|
error:
|
|
278
|
-
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
|
279
|
-
info: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
|
326
|
+
'<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
|
327
|
+
info: '<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
|
280
328
|
warning:
|
|
281
|
-
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
|
329
|
+
'<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
|
282
330
|
loading:
|
|
283
|
-
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/></svg>',
|
|
331
|
+
'<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/></svg>',
|
|
284
332
|
};
|
|
@@ -11,6 +11,22 @@
|
|
|
11
11
|
* Design tokens used: --primary, --input, --background, --foreground, --ring,
|
|
12
12
|
* --primary-foreground.
|
|
13
13
|
*
|
|
14
|
+
* A11y (required for accessible output):
|
|
15
|
+
* Keep `role="switch"` on the native checkbox. That is what makes a screen
|
|
16
|
+
* reader announce "on / off" rather than "checked / unchecked", and the
|
|
17
|
+
* browser maps the input's checked state to it for free.
|
|
18
|
+
* NAME IT. The visible track is a `<span>` and the real control is `sr-only`,
|
|
19
|
+
* so there is no text on the input at all. Wrapping the whole thing in a
|
|
20
|
+
* `<label>` (the first example) is the simplest way, since the label's text
|
|
21
|
+
* then names the input. A STANDALONE switch, one with no wrapping `<label>`
|
|
22
|
+
* and no `<label for>` pointing at it, has NO accessible name and needs an
|
|
23
|
+
* explicit `aria-label`, as the second example shows.
|
|
24
|
+
* The visual track and thumb are presentation only. They carry no text and no
|
|
25
|
+
* role, so nothing about the state reaches assistive tech through them: it all
|
|
26
|
+
* comes from the input. Do not move the name onto the track.
|
|
27
|
+
* Use `disabled` on the input, not on the track: the track styles itself from
|
|
28
|
+
* the `peer-disabled:` variant, and only the real control can refuse input.
|
|
29
|
+
*
|
|
14
30
|
* @example
|
|
15
31
|
* ```html
|
|
16
32
|
* <label class="inline-flex items-center gap-2">
|
|
@@ -19,8 +35,14 @@
|
|
|
19
35
|
* <span class=${labelClass()}>Notifications</span>
|
|
20
36
|
* </label>
|
|
21
37
|
*
|
|
38
|
+
* <!-- Standalone (no wrapping label), so the name has to be explicit. -->
|
|
39
|
+
* <input type="checkbox" role="switch" name="wifi" aria-label="Wi-Fi"
|
|
40
|
+
* class=${switchInputClass()}>
|
|
41
|
+
* <span class=${switchTrackClass()}></span>
|
|
42
|
+
*
|
|
22
43
|
* <!-- Small size. -->
|
|
23
|
-
* <input type="checkbox" role="switch" name="x"
|
|
44
|
+
* <input type="checkbox" role="switch" name="x" aria-label="Compact mode"
|
|
45
|
+
* class=${cn(switchInputClass(), 'peer/sm')}>
|
|
24
46
|
* <span class=${switchTrackClass({ size: 'sm' })}></span>
|
|
25
47
|
* ```
|
|
26
48
|
*/
|
|
@@ -30,6 +30,21 @@
|
|
|
30
30
|
* Home / End first / last
|
|
31
31
|
* Enter / Space activate (native button activation)
|
|
32
32
|
*
|
|
33
|
+
* A11y (owned by the element, nothing to supply beyond trigger names):
|
|
34
|
+
* The element wires the whole APG Tabs pattern: the list is a
|
|
35
|
+
* `role="tablist"` reporting `aria-orientation`, each trigger is a
|
|
36
|
+
* `role="tab"` cross-linked to its panel with `aria-controls` while the panel
|
|
37
|
+
* points back with `aria-labelledby`, ids stay unique across groups on one
|
|
38
|
+
* page, and an inactive panel is both `hidden` and `inert`. Arrow / Home / End
|
|
39
|
+
* move focus AND selection (APG automatic activation) via a roving tabindex,
|
|
40
|
+
* so the tablist is a single Tab stop and nav stays inside its own group when
|
|
41
|
+
* tabs are nested. Do not hand-add any of these; the element already has.
|
|
42
|
+
* What you DO supply: a name for each trigger. A trigger's name comes from its
|
|
43
|
+
* own content, so an icon-only tab needs an `aria-label` on the
|
|
44
|
+
* `<ui-tabs-trigger>` and an `aria-hidden="true"` icon inside it.
|
|
45
|
+
* Keep the panel's content after the tablist in the DOM, which the example
|
|
46
|
+
* shape already does, so reading order follows the tab that was activated.
|
|
47
|
+
*
|
|
33
48
|
* Design tokens used: --muted, --muted-foreground, --foreground, --background,
|
|
34
49
|
* --input, --ring.
|
|
35
50
|
*
|
|
@@ -12,10 +12,29 @@
|
|
|
12
12
|
* Design tokens used: --input, --background, --muted-foreground, --ring,
|
|
13
13
|
* --destructive.
|
|
14
14
|
*
|
|
15
|
+
* A11y (required for accessible output):
|
|
16
|
+
* LABEL IT. A `<label class=${labelClass()} for="<the textarea's id">` linked
|
|
17
|
+
* by the `for` / `id` pair is the whole requirement. The `placeholder` in the
|
|
18
|
+
* old example was doing that job and cannot: it vanishes as soon as the user
|
|
19
|
+
* types, and it is not reliably exposed as a name.
|
|
20
|
+
* Point `aria-describedby` at a hint or a character counter that EXISTS on the
|
|
21
|
+
* page, and on a validation failure set `aria-invalid="true"` plus an
|
|
22
|
+
* `aria-describedby` error element so the reason is announced with the field.
|
|
23
|
+
* If you show a live character count, put it in an `aria-live="polite"`
|
|
24
|
+
* region, otherwise the number updates silently for a screen reader user.
|
|
25
|
+
* Leave the textarea RESIZABLE. The class autosizes via `field-sizing:
|
|
26
|
+
* content`, which is fine, but do not add `resize-none` on top: a user who
|
|
27
|
+
* needs more room to read their own text has no other way to get it (WCAG
|
|
28
|
+
* 1.4.4-adjacent).
|
|
29
|
+
*
|
|
15
30
|
* @example
|
|
16
31
|
* ```html
|
|
17
|
-
* <
|
|
18
|
-
* </
|
|
32
|
+
* <div class=${fieldClass()}>
|
|
33
|
+
* <label class=${labelClass()} for="message">Message</label>
|
|
34
|
+
* <textarea class=${textareaClass()} name="message" id="message" rows="4"
|
|
35
|
+
* aria-describedby="message-hint"></textarea>
|
|
36
|
+
* <p class=${hintClass()} id="message-hint">Markdown is supported.</p>
|
|
37
|
+
* </div>
|
|
19
38
|
* ```
|
|
20
39
|
*/
|
|
21
40
|
import { cn } from '../lib/utils.ts';
|
|
@@ -22,15 +22,30 @@
|
|
|
22
22
|
* `orientation`: "horizontal" (default) | "vertical".
|
|
23
23
|
*
|
|
24
24
|
* Attributes on <ui-toggle-group-item>:
|
|
25
|
-
* `value`:
|
|
26
|
-
* `pressed`:
|
|
25
|
+
* `value`: string. Identifier this item contributes when selected.
|
|
26
|
+
* `pressed`: boolean (reflected). Mirrors the group's selection for this item.
|
|
27
|
+
* `disabled`: boolean (reflected). Refuses click + Enter / Space, is skipped by
|
|
28
|
+
* Arrow / Home / End, and never holds the group's tab stop.
|
|
27
29
|
*
|
|
28
30
|
* Events:
|
|
29
31
|
* `ui-value-change` on <ui-toggle-group>: `{ detail: { value } }` after selection changes.
|
|
30
32
|
*
|
|
31
33
|
* Keyboard: Arrow keys move focus between items (roving tabindex, so the
|
|
32
34
|
* group is a single Tab stop), Home / End jump to the first / last item, and
|
|
33
|
-
* Enter / Space toggles the focused item.
|
|
35
|
+
* Enter / Space toggles the focused item. A `disabled` item is skipped by all
|
|
36
|
+
* of these.
|
|
37
|
+
*
|
|
38
|
+
* A11y (owned by the element, nothing to supply beyond item names):
|
|
39
|
+
* The group is a `role="group"` whose items carry `aria-pressed`, navigated by
|
|
40
|
+
* a roving tabindex so the whole group is one Tab stop. A `disabled` item
|
|
41
|
+
* reports `aria-disabled` and is skipped by navigation: since the group is a
|
|
42
|
+
* single tab stop, focus landing on a disabled item could not be tabbed past,
|
|
43
|
+
* so skipping it is what keeps the group usable rather than a nicety.
|
|
44
|
+
* Note `disabled` on the item is deliberately exposed as `aria-disabled`, not
|
|
45
|
+
* a `disabled` attribute. The item's host IS the button and it is a custom
|
|
46
|
+
* element, so a `disabled` attribute there is inert: no `:disabled` CSS, no
|
|
47
|
+
* click suppression, no removal from the tab order.
|
|
48
|
+
* What you DO supply: an `aria-label` on any icon-only item, as below.
|
|
34
49
|
*
|
|
35
50
|
* Design tokens used: inherited from toggleClass (--muted, --accent, --ring,
|
|
36
51
|
* --input, --destructive).
|
|
@@ -48,6 +63,13 @@
|
|
|
48
63
|
* <ui-toggle-group-item value="bold">B</ui-toggle-group-item>
|
|
49
64
|
* <ui-toggle-group-item value="italic">I</ui-toggle-group-item>
|
|
50
65
|
* </ui-toggle-group>
|
|
66
|
+
*
|
|
67
|
+
* <!-- A disabled item: unclickable, and skipped by Arrow / Home / End. -->
|
|
68
|
+
* <ui-toggle-group type="single" value="left">
|
|
69
|
+
* <ui-toggle-group-item value="left" aria-label="Align left">L</ui-toggle-group-item>
|
|
70
|
+
* <ui-toggle-group-item value="justify" aria-label="Justify" disabled>J</ui-toggle-group-item>
|
|
71
|
+
* <ui-toggle-group-item value="right" aria-label="Align right">R</ui-toggle-group-item>
|
|
72
|
+
* </ui-toggle-group>
|
|
51
73
|
* ```
|
|
52
74
|
*/
|
|
53
75
|
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
@@ -57,8 +79,13 @@ import { toggleClass, type ToggleVariant, type ToggleSize } from './toggle.ts';
|
|
|
57
79
|
const ROOT_BASE =
|
|
58
80
|
'group/toggle-group flex w-fit items-center rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch';
|
|
59
81
|
|
|
82
|
+
// The `disabled:` variants inside toggleClass() key on the :disabled pseudo,
|
|
83
|
+
// which only ever matches a real form control. An item's host IS the button
|
|
84
|
+
// here (a custom element), so `disabled` on it is inert as far as CSS and the
|
|
85
|
+
// browser are concerned, and the disabled look has to come from the
|
|
86
|
+
// aria-disabled variant instead.
|
|
60
87
|
const ITEM_EXTRA =
|
|
61
|
-
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l';
|
|
88
|
+
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l';
|
|
62
89
|
|
|
63
90
|
// --------------------------------------------------------------------------
|
|
64
91
|
// <ui-toggle-group>
|
|
@@ -117,6 +144,14 @@ export class UiToggleGroup extends WebComponent({
|
|
|
117
144
|
return Array.from(this.querySelectorAll<UiToggleGroupItem>('ui-toggle-group-item'));
|
|
118
145
|
}
|
|
119
146
|
|
|
147
|
+
// Arrow / Home / End navigate among ENABLED items only. Landing focus on a
|
|
148
|
+
// disabled item would strand the user on a control that refuses every
|
|
149
|
+
// interaction, and since this group is a single tab stop they cannot simply
|
|
150
|
+
// Tab past it.
|
|
151
|
+
_enabledItems(): UiToggleGroupItem[] {
|
|
152
|
+
return this._items().filter((i) => !i.disabled);
|
|
153
|
+
}
|
|
154
|
+
|
|
120
155
|
_reflectItems(): void {
|
|
121
156
|
const values = this._values;
|
|
122
157
|
const items = this._items();
|
|
@@ -138,18 +173,29 @@ export class UiToggleGroup extends WebComponent({
|
|
|
138
173
|
// single tabbable slot via `focusItem`.
|
|
139
174
|
_roving(items: UiToggleGroupItem[], current?: UiToggleGroupItem): void {
|
|
140
175
|
if (!items.length) return;
|
|
176
|
+
// The tab stop must land on an ENABLED item: promoting a disabled one makes
|
|
177
|
+
// the group's single Tab stop a dead end.
|
|
178
|
+
const enabled = items.filter((i) => !i.disabled);
|
|
179
|
+
if (!enabled.length) {
|
|
180
|
+
items.forEach((item) => {
|
|
181
|
+
item.tabIndex = -1;
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
141
185
|
const values = this._values;
|
|
142
186
|
const active =
|
|
143
187
|
typeof document !== 'undefined' ? (document.activeElement as Element | null) : null;
|
|
144
|
-
const focused = active ?
|
|
145
|
-
const selected =
|
|
146
|
-
const
|
|
188
|
+
const focused = active ? enabled.find((i) => i === active) : undefined;
|
|
189
|
+
const selected = enabled.find((i) => !!i.value && values.has(i.value));
|
|
190
|
+
const held = current && !current.disabled ? current : undefined;
|
|
191
|
+
const tabbable = focused ?? held ?? selected ?? enabled[0];
|
|
147
192
|
items.forEach((item) => {
|
|
148
193
|
item.tabIndex = item === tabbable ? 0 : -1;
|
|
149
194
|
});
|
|
150
195
|
}
|
|
151
196
|
|
|
152
197
|
focusItem(item: UiToggleGroupItem): void {
|
|
198
|
+
if (item.disabled) return;
|
|
153
199
|
this._items().forEach((el) => {
|
|
154
200
|
el.tabIndex = el === item ? 0 : -1;
|
|
155
201
|
});
|
|
@@ -189,11 +235,13 @@ UiToggleGroup.register('ui-toggle-group');
|
|
|
189
235
|
export class UiToggleGroupItem extends WebComponent({
|
|
190
236
|
value: prop(String, { reflect: true }),
|
|
191
237
|
pressed: prop(Boolean, { reflect: true }),
|
|
238
|
+
disabled: prop(Boolean, { reflect: true }),
|
|
192
239
|
}) {
|
|
193
240
|
constructor() {
|
|
194
241
|
super();
|
|
195
242
|
this.value = '';
|
|
196
243
|
this.pressed = false;
|
|
244
|
+
this.disabled = false;
|
|
197
245
|
}
|
|
198
246
|
|
|
199
247
|
// render() runs server-side too. webjs resolves closest() at SSR against
|
|
@@ -240,12 +288,36 @@ export class UiToggleGroupItem extends WebComponent({
|
|
|
240
288
|
this.dataset.size = size;
|
|
241
289
|
this.dataset.spacing = spacing;
|
|
242
290
|
this.dataset.state = this.pressed ? 'on' : 'off';
|
|
291
|
+
// role and data-slot are set HERE as well as in connectedCallback, because
|
|
292
|
+
// SSR runs render() but NOT connectedCallback. Without this the served item
|
|
293
|
+
// carried aria-pressed / aria-disabled with no role at all, and neither is a
|
|
294
|
+
// global ARIA attribute, so the first paint shipped attributes that are not
|
|
295
|
+
// allowed on a generic-role element. (`this.role` is shimmed server-side too,
|
|
296
|
+
// like the ariaPressed / dataset / className writes around it; setAttribute
|
|
297
|
+
// is simply the more explicit spelling.)
|
|
298
|
+
this.setAttribute('role', 'button');
|
|
299
|
+
this.setAttribute('data-slot', 'toggle-group-item');
|
|
243
300
|
this.ariaPressed = String(this.pressed);
|
|
301
|
+
// aria-disabled rather than a `disabled` attribute: the host is a custom
|
|
302
|
+
// element, so `disabled` would be inert (no CSS pseudo, no click blocking,
|
|
303
|
+
// no removal from the tab order). aria-disabled is what actually reaches
|
|
304
|
+
// assistive tech, and the group skips these in its roving tabindex.
|
|
305
|
+
this.ariaDisabled = String(this.disabled);
|
|
244
306
|
this.className = cn(toggleClass({ variant, size }), ITEM_EXTRA);
|
|
245
307
|
return html`<slot></slot>`;
|
|
246
308
|
}
|
|
247
309
|
|
|
310
|
+
// Disabling the item that happens to hold the group's only tab stop would
|
|
311
|
+
// leave the group unreachable by keyboard, and the group cannot see an item's
|
|
312
|
+
// prop change on its own, so ask it to recompute which item is tabbable.
|
|
313
|
+
updated(changedProperties: Map<string, unknown>): void {
|
|
314
|
+
if (!changedProperties.has('disabled')) return;
|
|
315
|
+
if (changedProperties.get('disabled') === undefined) return;
|
|
316
|
+
queueMicrotask(() => this._group?._reflectItems());
|
|
317
|
+
}
|
|
318
|
+
|
|
248
319
|
_onClick = (): void => {
|
|
320
|
+
if (this.disabled) return;
|
|
249
321
|
if (!this.value) return;
|
|
250
322
|
this.dispatchEvent(
|
|
251
323
|
new CustomEvent('ui-toggle-item-click', { detail: { value: this.value }, bubbles: true }),
|
|
@@ -253,6 +325,7 @@ export class UiToggleGroupItem extends WebComponent({
|
|
|
253
325
|
};
|
|
254
326
|
|
|
255
327
|
_onKeyDown = (e: KeyboardEvent): void => {
|
|
328
|
+
if (this.disabled) return;
|
|
256
329
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
257
330
|
e.preventDefault();
|
|
258
331
|
this._onClick();
|
|
@@ -263,7 +336,7 @@ export class UiToggleGroupItem extends WebComponent({
|
|
|
263
336
|
const horizontal = group.orientation !== 'vertical';
|
|
264
337
|
const nextKey = horizontal ? 'ArrowRight' : 'ArrowDown';
|
|
265
338
|
const prevKey = horizontal ? 'ArrowLeft' : 'ArrowUp';
|
|
266
|
-
const items = group.
|
|
339
|
+
const items = group._enabledItems();
|
|
267
340
|
const idx = items.indexOf(this);
|
|
268
341
|
if (idx === -1) return;
|
|
269
342
|
let target: UiToggleGroupItem | null = null;
|
|
@@ -21,6 +21,32 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Keyboard: native button. Enter / Space activates (via the inner <button>).
|
|
23
23
|
*
|
|
24
|
+
* A11y (required for accessible output):
|
|
25
|
+
* `<ui-toggle>` renders a native `<button>` inside itself, and that button is
|
|
26
|
+
* the focusable control whose accessible name a screen reader announces. An
|
|
27
|
+
* `aria-label` on the host would not reach it (a name on a generic-role
|
|
28
|
+
* element does not contribute to a descendant's name), so the element
|
|
29
|
+
* FORWARDS the host's `aria-label` / `aria-labelledby` onto the inner button.
|
|
30
|
+
* Put the name on the host, as the icon-only example below does, and the
|
|
31
|
+
* element wires the rest. An icon-only toggle needs one; a text toggle takes
|
|
32
|
+
* its name from the slotted text and needs nothing.
|
|
33
|
+
* Set the name as an ATTRIBUTE on the host in your markup
|
|
34
|
+
* (`aria-label="Toggle bold"`), not as a property.
|
|
35
|
+
* LIMITATION, and it is a real one: `aria-label` / `aria-labelledby` are not
|
|
36
|
+
* reactive properties of this element, so they are not observed attributes.
|
|
37
|
+
* The name is read during render(), which means markup present at first paint
|
|
38
|
+
* works, but a LATER `el.setAttribute('aria-label', ...)` does not re-render
|
|
39
|
+
* and so does not reach the inner button until something else happens to
|
|
40
|
+
* trigger a render. If your name is computed after mount, set it on the inner
|
|
41
|
+
* button yourself (`el.querySelector('button').setAttribute('aria-label',
|
|
42
|
+
* name)`). Do NOT reach for "flip a reactive prop to force a re-render":
|
|
43
|
+
* adding or removing the name switches which of the three templates below is
|
|
44
|
+
* used, and a template swap REBUILDS the button, so doing it on a click would
|
|
45
|
+
* destroy the control the user is focused on and drop focus to <body>.
|
|
46
|
+
* Using the Tier-1 `toggleClass()` helper on your own `<button>` instead? Then
|
|
47
|
+
* the name is entirely yours: give an icon-only button an `aria-label`, and
|
|
48
|
+
* carry the pressed state on `aria-pressed` + `data-state` as the example does.
|
|
49
|
+
*
|
|
24
50
|
* Design tokens used: --muted, --muted-foreground, --accent, --accent-foreground,
|
|
25
51
|
* --input, --background, --ring, --destructive.
|
|
26
52
|
*
|
|
@@ -93,13 +119,66 @@ export class UiToggle extends WebComponent({
|
|
|
93
119
|
this.disabled = false;
|
|
94
120
|
}
|
|
95
121
|
|
|
122
|
+
// Read a host attribute defensively. render() runs server-side too, where
|
|
123
|
+
// webjs shims the attribute methods; the typeof guard stays for any other
|
|
124
|
+
// renderer that does not.
|
|
125
|
+
_hostAttr(name: string): string | null {
|
|
126
|
+
if (typeof this.getAttribute !== 'function') return null;
|
|
127
|
+
return this.getAttribute(name);
|
|
128
|
+
}
|
|
129
|
+
|
|
96
130
|
render() {
|
|
131
|
+
// The focusable control is the inner <button>, so its accessible name is
|
|
132
|
+
// what a screen reader announces. `aria-label` on the host cannot supply
|
|
133
|
+
// that (the host has a generic role, and a name there does not contribute
|
|
134
|
+
// to a descendant's name), and the documented icon-only shape slots an
|
|
135
|
+
// aria-hidden SVG, which contributes nothing either. Forward the host's
|
|
136
|
+
// name source onto the button so the documented shape ships named.
|
|
137
|
+
//
|
|
138
|
+
// The name is BRANCHED into three templates rather than carried in one
|
|
139
|
+
// hole, because a null hole does NOT omit an attribute on the server: the
|
|
140
|
+
// server renderer stringifies the value, so `aria-label=${null}` ships
|
|
141
|
+
// `aria-label=""` while the client renderer removes it. On an unlabelled
|
|
142
|
+
// toggle that would put an empty name on the very control whose name is
|
|
143
|
+
// supposed to come from its slotted text, and disagree with the hydrated
|
|
144
|
+
// DOM on the exact attribute this forwarding exists to get right.
|
|
145
|
+
const labelledBy = this._hostAttr('aria-labelledby');
|
|
146
|
+
const label = this._hostAttr('aria-label');
|
|
147
|
+
const cls = toggleClass({ variant: this.variant, size: this.size });
|
|
148
|
+
const pressed = String(this.pressed);
|
|
149
|
+
const state = this.pressed ? 'on' : 'off';
|
|
150
|
+
// aria-labelledby beats aria-label per accname, so it is checked first and
|
|
151
|
+
// only ONE of the two is ever emitted.
|
|
152
|
+
if (labelledBy) {
|
|
153
|
+
return html`<button
|
|
154
|
+
type="button"
|
|
155
|
+
data-slot="toggle"
|
|
156
|
+
class=${cls}
|
|
157
|
+
aria-pressed=${pressed}
|
|
158
|
+
aria-labelledby=${labelledBy}
|
|
159
|
+
data-state=${state}
|
|
160
|
+
?disabled=${this.disabled}
|
|
161
|
+
@click=${this._onClick}
|
|
162
|
+
><slot></slot></button>`;
|
|
163
|
+
}
|
|
164
|
+
if (label) {
|
|
165
|
+
return html`<button
|
|
166
|
+
type="button"
|
|
167
|
+
data-slot="toggle"
|
|
168
|
+
class=${cls}
|
|
169
|
+
aria-pressed=${pressed}
|
|
170
|
+
aria-label=${label}
|
|
171
|
+
data-state=${state}
|
|
172
|
+
?disabled=${this.disabled}
|
|
173
|
+
@click=${this._onClick}
|
|
174
|
+
><slot></slot></button>`;
|
|
175
|
+
}
|
|
97
176
|
return html`<button
|
|
98
177
|
type="button"
|
|
99
178
|
data-slot="toggle"
|
|
100
|
-
class=${
|
|
101
|
-
aria-pressed=${
|
|
102
|
-
data-state=${
|
|
179
|
+
class=${cls}
|
|
180
|
+
aria-pressed=${pressed}
|
|
181
|
+
data-state=${state}
|
|
103
182
|
?disabled=${this.disabled}
|
|
104
183
|
@click=${this._onClick}
|
|
105
184
|
><slot></slot></button>`;
|
|
@@ -30,6 +30,20 @@
|
|
|
30
30
|
*
|
|
31
31
|
* Programmatic API on <ui-tooltip>: `.show()` · `.hide()`.
|
|
32
32
|
*
|
|
33
|
+
* Keyboard:
|
|
34
|
+
* Escape dismiss a showing tip, leaving focus on the trigger
|
|
35
|
+
*
|
|
36
|
+
* A11y (owned by the element, nothing to supply):
|
|
37
|
+
* The focusable trigger references the tip via `aria-describedby`, so a
|
|
38
|
+
* screen reader appends the tip to the trigger's own name, and the tip opens
|
|
39
|
+
* on focus as well as hover. Escape dismisses a showing tip without moving
|
|
40
|
+
* focus (APG requires the escape hatch, since a tip can cover the content
|
|
41
|
+
* underneath it). A CLOSED tooltip never consumes Escape, so it cannot
|
|
42
|
+
* swallow the key from a dialog it happens to sit inside.
|
|
43
|
+
* What you DO supply: an accessible name on the trigger control itself. The
|
|
44
|
+
* tip is a DESCRIPTION, not a name, so an icon-only trigger still needs its
|
|
45
|
+
* own `aria-label` as the example below shows.
|
|
46
|
+
*
|
|
33
47
|
* Design tokens used: --foreground, --background.
|
|
34
48
|
*
|
|
35
49
|
* @example
|
|
@@ -75,6 +89,8 @@ export class UiTooltip extends WebComponent({
|
|
|
75
89
|
_showTimer: number | undefined;
|
|
76
90
|
_hideTimer: number | undefined;
|
|
77
91
|
|
|
92
|
+
_keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e);
|
|
93
|
+
|
|
78
94
|
constructor() {
|
|
79
95
|
super();
|
|
80
96
|
this.open = false;
|
|
@@ -98,10 +114,43 @@ export class UiTooltip extends WebComponent({
|
|
|
98
114
|
}
|
|
99
115
|
|
|
100
116
|
disconnectedCallback(): void {
|
|
117
|
+
this._unbindEscape();
|
|
101
118
|
this._disposeBeforeCache?.();
|
|
102
119
|
super.disconnectedCallback?.();
|
|
103
120
|
}
|
|
104
121
|
|
|
122
|
+
// APG Tooltip requires an Escape escape-hatch: a tip can cover the content
|
|
123
|
+
// underneath it, and a keyboard user who cannot dismiss it is stuck. Focus
|
|
124
|
+
// is deliberately untouched, since the tip is not focusable and the trigger
|
|
125
|
+
// should keep focus (moving it would lose the user's place).
|
|
126
|
+
//
|
|
127
|
+
// The listener is bound only WHILE OPEN, which is how a closed tooltip is
|
|
128
|
+
// kept from consuming Escape that a dialog (or anything else) wants.
|
|
129
|
+
_onKeyDown(e: KeyboardEvent): void {
|
|
130
|
+
if (e.key !== 'Escape' || !this.open) return;
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
this._dismiss();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Immediate, unlike hide()'s 100ms hover-out grace: a dismissal is a
|
|
136
|
+
// deliberate act, so it should not sit around waiting to be cancelled.
|
|
137
|
+
_dismiss(): void {
|
|
138
|
+
clearTimeout(this._showTimer);
|
|
139
|
+
clearTimeout(this._hideTimer);
|
|
140
|
+
this.open = false;
|
|
141
|
+
lastTooltipHideAt = Date.now();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_bindEscape(): void {
|
|
145
|
+
if (typeof document === 'undefined') return;
|
|
146
|
+
document.addEventListener('keydown', this._keyHandler);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_unbindEscape(): void {
|
|
150
|
+
if (typeof document === 'undefined') return;
|
|
151
|
+
document.removeEventListener('keydown', this._keyHandler);
|
|
152
|
+
}
|
|
153
|
+
|
|
105
154
|
// APG tooltip wiring: the focusable trigger references the tip via
|
|
106
155
|
// aria-describedby, so a screen reader appends the tip text to the
|
|
107
156
|
// trigger's own name ("Help, button, Helpful tip"). The tip is JS-driven,
|
|
@@ -140,6 +189,10 @@ export class UiTooltip extends WebComponent({
|
|
|
140
189
|
|
|
141
190
|
hide(): void {
|
|
142
191
|
clearTimeout(this._showTimer);
|
|
192
|
+
// Clear the PENDING hide before scheduling the next one: overwriting the
|
|
193
|
+
// handle alone orphans the old timer, which then fires on its own and
|
|
194
|
+
// closes a tip a later show() had already reopened.
|
|
195
|
+
clearTimeout(this._hideTimer);
|
|
143
196
|
this._hideTimer = window.setTimeout(() => {
|
|
144
197
|
this.open = false;
|
|
145
198
|
lastTooltipHideAt = Date.now();
|
|
@@ -157,6 +210,10 @@ export class UiTooltip extends WebComponent({
|
|
|
157
210
|
if (!changedProperties.has('open')) return;
|
|
158
211
|
// Skip the constructor's initial open=false set.
|
|
159
212
|
if (changedProperties.get('open') === undefined) return;
|
|
213
|
+
// Bind synchronously rather than inside _syncContent, which bails early on
|
|
214
|
+
// engines with no Popover API: Escape has to work regardless of that.
|
|
215
|
+
if (this.open) this._bindEscape();
|
|
216
|
+
else this._unbindEscape();
|
|
160
217
|
// Defer one microtask so the content child's [popover] inner element
|
|
161
218
|
// has committed; we drive its showPopover() / hidePopover() from here.
|
|
162
219
|
queueMicrotask(() => this._syncContent());
|