@webjsdev/ui 0.3.4 → 0.3.6
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 +20 -2
- package/package.json +1 -1
- package/packages/registry/components/alert-dialog.ts +49 -32
- package/packages/registry/components/alert.ts +5 -0
- package/packages/registry/components/avatar.ts +5 -0
- package/packages/registry/components/badge.ts +4 -0
- package/packages/registry/components/breadcrumb.ts +5 -0
- package/packages/registry/components/button.ts +6 -0
- package/packages/registry/components/dialog.ts +44 -22
- package/packages/registry/components/dropdown-menu.ts +82 -32
- package/packages/registry/components/hover-card.ts +44 -13
- package/packages/registry/components/pagination.ts +5 -0
- package/packages/registry/components/progress.ts +5 -0
- package/packages/registry/components/separator.ts +5 -0
- package/packages/registry/components/skeleton.ts +5 -0
- package/packages/registry/components/sonner.ts +14 -6
- package/packages/registry/components/table.ts +7 -2
- package/packages/registry/components/tabs.ts +52 -33
- package/packages/registry/components/toggle-group.ts +73 -31
- package/packages/registry/components/toggle.ts +7 -13
- package/packages/registry/components/tooltip.ts +37 -11
- package/packages/registry/lib/utils.ts +27 -0
- package/packages/registry/registry.json +14 -1
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
*
|
|
39
39
|
* Design tokens used: --popover, --popover-foreground, --border.
|
|
40
40
|
*/
|
|
41
|
-
import { WebComponent, html } from '@webjsdev/core';
|
|
41
|
+
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
42
|
+
import { ensureId } from '../lib/utils.ts';
|
|
42
43
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
43
44
|
|
|
44
45
|
// `fixed m-0` opts out of the UA `[popover]` auto-centering margin so
|
|
@@ -52,18 +53,13 @@ export const hoverCardContentClass = (): string =>
|
|
|
52
53
|
// <ui-hover-card>
|
|
53
54
|
// --------------------------------------------------------------------------
|
|
54
55
|
|
|
55
|
-
export class UiHoverCard extends WebComponent
|
|
56
|
-
|
|
57
|
-
open: { type: Boolean, reflect: true },
|
|
58
|
-
openDelay: { type: Number },
|
|
59
|
-
closeDelay: { type: Number },
|
|
60
|
-
};
|
|
61
|
-
declare open: boolean;
|
|
56
|
+
export class UiHoverCard extends WebComponent({
|
|
57
|
+
open: prop(Boolean, { reflect: true }),
|
|
62
58
|
// `openDelay` / `closeDelay` ride the `open-delay` / `close-delay`
|
|
63
59
|
// attributes (shadcn parity), read as typed props.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
openDelay: Number,
|
|
61
|
+
closeDelay: Number,
|
|
62
|
+
}) {
|
|
67
63
|
_showTimer: number | undefined;
|
|
68
64
|
_hideTimer: number | undefined;
|
|
69
65
|
|
|
@@ -74,6 +70,37 @@ export class UiHoverCard extends WebComponent {
|
|
|
74
70
|
this.closeDelay = 300;
|
|
75
71
|
}
|
|
76
72
|
|
|
73
|
+
connectedCallback(): void {
|
|
74
|
+
super.connectedCallback?.();
|
|
75
|
+
// webjs projects slotted light-DOM children after the first render, so
|
|
76
|
+
// the trigger control is not in place at connect. Defer to the next
|
|
77
|
+
// frame, when the projection has run.
|
|
78
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
79
|
+
requestAnimationFrame(() => this._wireAria());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The trigger also opens on focus (see the @focusin handler), so it is
|
|
84
|
+
// keyboard-reachable: expose the popup relationship on the focusable
|
|
85
|
+
// control. aria-expanded is refreshed on every open transition.
|
|
86
|
+
_control(): HTMLElement | null {
|
|
87
|
+
const t = this.querySelector('ui-hover-card-trigger');
|
|
88
|
+
if (!t) return null;
|
|
89
|
+
return (
|
|
90
|
+
t.querySelector<HTMLElement>('a[href], button, [tabindex], [role="button"]') ??
|
|
91
|
+
(t as HTMLElement)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_wireAria(): void {
|
|
96
|
+
const control = this._control();
|
|
97
|
+
if (!control) return;
|
|
98
|
+
control.setAttribute('aria-haspopup', 'dialog');
|
|
99
|
+
control.setAttribute('aria-expanded', String(this.open));
|
|
100
|
+
const content = this.querySelector<HTMLElement>('ui-hover-card-content [role="dialog"]');
|
|
101
|
+
if (content) control.setAttribute('aria-controls', ensureId(content, 'ui-hovercard'));
|
|
102
|
+
}
|
|
103
|
+
|
|
77
104
|
// Back-compat getter.
|
|
78
105
|
get isOpen(): boolean { return this.open; }
|
|
79
106
|
|
|
@@ -98,8 +125,12 @@ export class UiHoverCard extends WebComponent {
|
|
|
98
125
|
if (!changedProperties.has('open')) return;
|
|
99
126
|
if (changedProperties.get('open') === undefined) return;
|
|
100
127
|
// Wait one microtask for <ui-hover-card-content>'s inner [popover]
|
|
101
|
-
// element to commit; we drive its showPopover() / hidePopover()
|
|
102
|
-
|
|
128
|
+
// element to commit; we drive its showPopover() / hidePopover() and
|
|
129
|
+
// refresh the trigger's aria-expanded.
|
|
130
|
+
queueMicrotask(() => {
|
|
131
|
+
this._wireAria();
|
|
132
|
+
this._syncContent();
|
|
133
|
+
});
|
|
103
134
|
}
|
|
104
135
|
|
|
105
136
|
_syncContent(): void {
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
* </ul>
|
|
24
24
|
* </nav>
|
|
25
25
|
*
|
|
26
|
+
* A11y (required for accessible output): wrap the list in <nav
|
|
27
|
+
* aria-label="pagination">, set aria-current="page" on the active page
|
|
28
|
+
* link, give an icon-only Previous / Next control an aria-label, and mark
|
|
29
|
+
* the ellipsis aria-hidden="true". The class helpers emit none of these.
|
|
30
|
+
*
|
|
26
31
|
* Design tokens used: inherited from buttonClass.
|
|
27
32
|
*/
|
|
28
33
|
import { cn } from '../lib/utils.ts';
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* <!-- Indeterminate (no `value` attribute): -->
|
|
17
17
|
* <progress class=${progressClass()}></progress>
|
|
18
18
|
*
|
|
19
|
+
* A11y (required for accessible output): give the <progress> an accessible
|
|
20
|
+
* name with aria-label (or a <label for>), e.g. aria-label="Upload
|
|
21
|
+
* progress". The native element supplies the role and value; only the name
|
|
22
|
+
* is the author's responsibility.
|
|
23
|
+
*
|
|
19
24
|
* Design tokens used: --primary.
|
|
20
25
|
*/
|
|
21
26
|
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* class=${separatorClass({ orientation: 'vertical' })}
|
|
14
14
|
* data-orientation="vertical"></div>
|
|
15
15
|
*
|
|
16
|
+
* A11y (required for accessible output): a meaningful divider needs
|
|
17
|
+
* role="separator" plus aria-orientation. A purely decorative one needs
|
|
18
|
+
* role="none" (or aria-hidden="true") so assistive tech does not announce
|
|
19
|
+
* an empty separator.
|
|
20
|
+
*
|
|
16
21
|
* Design tokens used: --border.
|
|
17
22
|
*/
|
|
18
23
|
import { cn } from '../lib/utils.ts';
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* <div class=${cn(skeletonClass(), 'h-4 w-32')}></div>
|
|
11
11
|
* <div class=${cn(skeletonClass(), 'h-12 w-12 rounded-full')}></div>
|
|
12
12
|
*
|
|
13
|
+
* A11y (required for accessible output): a skeleton is a decorative
|
|
14
|
+
* placeholder, so hide it from assistive tech with aria-hidden="true" (or
|
|
15
|
+
* mark the loading region aria-busy="true"). Announce the real content
|
|
16
|
+
* once it replaces the skeleton.
|
|
17
|
+
*
|
|
13
18
|
* Design tokens used: --accent.
|
|
14
19
|
*/
|
|
15
20
|
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
*
|
|
44
44
|
* Design tokens used: --popover, --popover-foreground, --border, --radius.
|
|
45
45
|
*/
|
|
46
|
-
import { WebComponent, html, repeat, unsafeHTML, signal } from '@webjsdev/core';
|
|
46
|
+
import { WebComponent, html, repeat, unsafeHTML, signal, prop } from '@webjsdev/core';
|
|
47
47
|
|
|
48
48
|
type ToastType = 'default' | 'success' | 'error' | 'info' | 'warning' | 'loading';
|
|
49
49
|
|
|
@@ -134,11 +134,9 @@ const TYPE_ICON_COLOR: Record<ToastType, string> = {
|
|
|
134
134
|
loading: 'text-muted-foreground',
|
|
135
135
|
};
|
|
136
136
|
|
|
137
|
-
export class UiSonner extends WebComponent
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
};
|
|
141
|
-
declare position: SonnerPosition;
|
|
137
|
+
export class UiSonner extends WebComponent({
|
|
138
|
+
position: prop<SonnerPosition>(String, { reflect: true }),
|
|
139
|
+
}) {
|
|
142
140
|
items = signal<ToastItem[]>([]);
|
|
143
141
|
|
|
144
142
|
constructor() {
|
|
@@ -190,8 +188,18 @@ export class UiSonner extends WebComponent {
|
|
|
190
188
|
|
|
191
189
|
render() {
|
|
192
190
|
const pos = POSITIONS[this.position] ?? POSITIONS['bottom-right'];
|
|
191
|
+
// The container is a persistent live region: it is in the DOM from the
|
|
192
|
+
// first render (even with zero toasts), so a screen reader announces
|
|
193
|
+
// each toast as it is inserted. It defaults to polite; an `error` toast
|
|
194
|
+
// carries its own role="alert" (assertive) and that innermost live
|
|
195
|
+
// region wins for that item.
|
|
193
196
|
return html`<div
|
|
194
197
|
data-slot="sonner"
|
|
198
|
+
role="region"
|
|
199
|
+
aria-label="Notifications"
|
|
200
|
+
aria-live="polite"
|
|
201
|
+
aria-relevant="additions text"
|
|
202
|
+
aria-atomic="false"
|
|
195
203
|
class=${`pointer-events-none fixed z-[100] flex flex-col gap-2 ${pos}`}
|
|
196
204
|
>
|
|
197
205
|
${repeat(
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* <table class=${tableClass()}>
|
|
19
19
|
* <thead class=${tableHeaderClass()}>
|
|
20
20
|
* <tr class=${tableRowClass()}>
|
|
21
|
-
* <th class=${tableHeadClass()}>Name</th>
|
|
22
|
-
* <th class=${tableHeadClass()}>Status</th>
|
|
21
|
+
* <th scope="col" class=${tableHeadClass()}>Name</th>
|
|
22
|
+
* <th scope="col" class=${tableHeadClass()}>Status</th>
|
|
23
23
|
* </tr>
|
|
24
24
|
* </thead>
|
|
25
25
|
* <tbody class=${tableBodyClass()}>
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
* </table>
|
|
33
33
|
* </div>
|
|
34
34
|
*
|
|
35
|
+
* A11y (required for accessible output): every header cell needs a scope
|
|
36
|
+
* (scope="col" on a column header, scope="row" on a row header) so screen
|
|
37
|
+
* readers map cells to their headers. Add a <caption> naming the table's
|
|
38
|
+
* purpose (it can be visually hidden if a heading already names it).
|
|
39
|
+
*
|
|
35
40
|
* Design tokens used: --muted, --muted-foreground, --foreground.
|
|
36
41
|
*/
|
|
37
42
|
|
|
@@ -43,8 +43,11 @@
|
|
|
43
43
|
* Design tokens used: --muted, --muted-foreground, --foreground, --background,
|
|
44
44
|
* --input, --ring.
|
|
45
45
|
*/
|
|
46
|
-
import { WebComponent, html } from '@webjsdev/core';
|
|
47
|
-
import { cn } from '../lib/utils.ts';
|
|
46
|
+
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
47
|
+
import { cn, ensureId } from '../lib/utils.ts';
|
|
48
|
+
|
|
49
|
+
// A tab `value` becomes part of an id, so reduce it to id-safe characters.
|
|
50
|
+
const idSafe = (s: string): string => s.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
48
51
|
|
|
49
52
|
// --------------------------------------------------------------------------
|
|
50
53
|
// Class helpers
|
|
@@ -92,20 +95,29 @@ const TABS_CONTENT_CLASS = 'flex-1 outline-none';
|
|
|
92
95
|
// requestUpdate() to re-render against the new parent value.
|
|
93
96
|
// --------------------------------------------------------------------------
|
|
94
97
|
|
|
95
|
-
export class UiTabs extends WebComponent
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
};
|
|
100
|
-
declare value: string;
|
|
101
|
-
declare orientation: 'horizontal' | 'vertical';
|
|
102
|
-
|
|
98
|
+
export class UiTabs extends WebComponent({
|
|
99
|
+
value: prop(String, { reflect: true }),
|
|
100
|
+
orientation: prop<'horizontal' | 'vertical'>(String, { reflect: true }),
|
|
101
|
+
}) {
|
|
103
102
|
constructor() {
|
|
104
103
|
super();
|
|
105
104
|
this.value = '';
|
|
106
105
|
this.orientation = 'horizontal';
|
|
107
106
|
}
|
|
108
107
|
|
|
108
|
+
// Stable per-instance scope so a trigger and its panel agree on the
|
|
109
|
+
// shared id stem (`<scope>-trigger-<value>` / `<scope>-panel-<value>`)
|
|
110
|
+
// even when several tab sets reuse the same `value` names on one page.
|
|
111
|
+
get scopeId(): string {
|
|
112
|
+
return ensureId(this, 'ui-tabs');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Builds the trigger / panel id pair a child derives from its `value`.
|
|
116
|
+
idsFor(value: string): { trigger: string; panel: string } {
|
|
117
|
+
const v = idSafe(value);
|
|
118
|
+
return { trigger: `${this.scopeId}-trigger-${v}`, panel: `${this.scopeId}-panel-${v}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
render() {
|
|
110
122
|
return html`<div
|
|
111
123
|
data-slot="tabs"
|
|
@@ -130,7 +142,7 @@ export class UiTabs extends WebComponent {
|
|
|
130
142
|
|
|
131
143
|
_broadcast(): void {
|
|
132
144
|
this.querySelectorAll<WebComponent>(
|
|
133
|
-
'ui-tabs-trigger, ui-tabs-content',
|
|
145
|
+
'ui-tabs-list, ui-tabs-trigger, ui-tabs-content',
|
|
134
146
|
).forEach((el) => el.requestUpdate?.());
|
|
135
147
|
}
|
|
136
148
|
}
|
|
@@ -140,21 +152,25 @@ UiTabs.register('ui-tabs');
|
|
|
140
152
|
// <ui-tabs-list>
|
|
141
153
|
// --------------------------------------------------------------------------
|
|
142
154
|
|
|
143
|
-
export class UiTabsList extends WebComponent
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
147
|
-
declare variant: TabsListVariant;
|
|
148
|
-
|
|
155
|
+
export class UiTabsList extends WebComponent({
|
|
156
|
+
variant: prop<TabsListVariant>(String, { reflect: true }),
|
|
157
|
+
}) {
|
|
149
158
|
constructor() {
|
|
150
159
|
super();
|
|
151
160
|
this.variant = 'default';
|
|
152
161
|
}
|
|
153
162
|
|
|
163
|
+
get _tabs(): UiTabs | null {
|
|
164
|
+
if (typeof this.closest !== 'function') return null;
|
|
165
|
+
return this.closest('ui-tabs') as UiTabs | null;
|
|
166
|
+
}
|
|
167
|
+
|
|
154
168
|
render() {
|
|
169
|
+
const orientation = this._tabs?.orientation ?? 'horizontal';
|
|
155
170
|
return html`<div
|
|
156
171
|
data-slot="tabs-list"
|
|
157
172
|
role="tablist"
|
|
173
|
+
aria-orientation=${orientation}
|
|
158
174
|
data-variant=${this.variant}
|
|
159
175
|
class=${tabsListClass({ variant: this.variant })}
|
|
160
176
|
><slot></slot></div>`;
|
|
@@ -166,20 +182,17 @@ UiTabsList.register('ui-tabs-list');
|
|
|
166
182
|
// <ui-tabs-trigger value="...">
|
|
167
183
|
// --------------------------------------------------------------------------
|
|
168
184
|
|
|
169
|
-
export class UiTabsTrigger extends WebComponent
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
};
|
|
173
|
-
declare value: string;
|
|
174
|
-
|
|
185
|
+
export class UiTabsTrigger extends WebComponent({
|
|
186
|
+
value: prop(String, { reflect: true }),
|
|
187
|
+
}) {
|
|
175
188
|
constructor() {
|
|
176
189
|
super();
|
|
177
190
|
this.value = '';
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
// render() runs server-side too
|
|
181
|
-
//
|
|
182
|
-
//
|
|
193
|
+
// render() runs server-side too. webjs resolves closest() at SSR against
|
|
194
|
+
// the enclosing-element ancestor chain, so the active tab is marked in the
|
|
195
|
+
// first paint (no hydration flash). The typeof guard stays defensive.
|
|
183
196
|
get _tabs(): UiTabs | null {
|
|
184
197
|
if (typeof this.closest !== 'function') return null;
|
|
185
198
|
return this.closest('ui-tabs') as UiTabs | null;
|
|
@@ -188,10 +201,13 @@ export class UiTabsTrigger extends WebComponent {
|
|
|
188
201
|
render() {
|
|
189
202
|
const tabs = this._tabs;
|
|
190
203
|
const active = !!tabs && tabs.value === this.value && this.value !== '';
|
|
204
|
+
const ids = tabs && this.value ? tabs.idsFor(this.value) : null;
|
|
191
205
|
return html`<button
|
|
192
206
|
type="button"
|
|
193
207
|
role="tab"
|
|
194
208
|
data-slot="tabs-trigger"
|
|
209
|
+
id=${ids ? ids.trigger : ''}
|
|
210
|
+
aria-controls=${ids ? ids.panel : ''}
|
|
195
211
|
data-state=${active ? 'active' : 'inactive'}
|
|
196
212
|
aria-selected=${String(active)}
|
|
197
213
|
tabindex=${active ? '0' : '-1'}
|
|
@@ -235,12 +251,9 @@ UiTabsTrigger.register('ui-tabs-trigger');
|
|
|
235
251
|
// <ui-tabs-content value="...">
|
|
236
252
|
// --------------------------------------------------------------------------
|
|
237
253
|
|
|
238
|
-
export class UiTabsContent extends WebComponent
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
};
|
|
242
|
-
declare value: string;
|
|
243
|
-
|
|
254
|
+
export class UiTabsContent extends WebComponent({
|
|
255
|
+
value: prop(String, { reflect: true }),
|
|
256
|
+
}) {
|
|
244
257
|
constructor() {
|
|
245
258
|
super();
|
|
246
259
|
this.value = '';
|
|
@@ -254,14 +267,20 @@ export class UiTabsContent extends WebComponent {
|
|
|
254
267
|
render() {
|
|
255
268
|
const tabs = this._tabs;
|
|
256
269
|
const active = !!tabs && tabs.value === this.value && this.value !== '';
|
|
270
|
+
const ids = tabs && this.value ? tabs.idsFor(this.value) : null;
|
|
257
271
|
// The host needs to be hidden when inactive so its rendered <section>
|
|
258
272
|
// is removed from layout (light DOM has no :host CSS to scope this).
|
|
259
273
|
// Use the native `hidden` IDL property rather than imperative
|
|
260
|
-
// setAttribute, so it reads as a property assignment.
|
|
274
|
+
// setAttribute, so it reads as a property assignment. `inert` on the
|
|
275
|
+
// host additionally pulls an inactive panel (and anything focusable
|
|
276
|
+
// inside it) out of the tab order and the accessibility tree.
|
|
261
277
|
this.hidden = !active;
|
|
278
|
+
this.inert = !active;
|
|
262
279
|
return html`<section
|
|
263
280
|
data-slot="tabs-content"
|
|
264
281
|
role="tabpanel"
|
|
282
|
+
id=${ids ? ids.panel : ''}
|
|
283
|
+
aria-labelledby=${ids ? ids.trigger : ''}
|
|
265
284
|
tabindex="0"
|
|
266
285
|
data-state=${active ? 'active' : 'inactive'}
|
|
267
286
|
class=${TABS_CONTENT_CLASS}
|
|
@@ -41,12 +41,14 @@
|
|
|
41
41
|
* Events:
|
|
42
42
|
* `ui-value-change` on <ui-toggle-group>: `{ detail: { value } }` after selection changes.
|
|
43
43
|
*
|
|
44
|
-
* Keyboard:
|
|
44
|
+
* Keyboard: Arrow keys move focus between items (roving tabindex, so the
|
|
45
|
+
* group is a single Tab stop), Home / End jump to the first / last item, and
|
|
46
|
+
* Enter / Space toggles the focused item.
|
|
45
47
|
*
|
|
46
48
|
* Design tokens used: inherited from toggleClass (--muted, --accent, --ring,
|
|
47
49
|
* --input, --destructive).
|
|
48
50
|
*/
|
|
49
|
-
import { WebComponent, html } from '@webjsdev/core';
|
|
51
|
+
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
50
52
|
import { cn } from '../lib/utils.ts';
|
|
51
53
|
import { toggleClass, type ToggleVariant, type ToggleSize } from './toggle.ts';
|
|
52
54
|
|
|
@@ -66,22 +68,14 @@ const ITEM_EXTRA =
|
|
|
66
68
|
// their own renders before we read / write their state.
|
|
67
69
|
// --------------------------------------------------------------------------
|
|
68
70
|
|
|
69
|
-
export class UiToggleGroup extends WebComponent
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
};
|
|
78
|
-
declare value: string;
|
|
79
|
-
declare type: 'single' | 'multiple';
|
|
80
|
-
declare variant: ToggleVariant;
|
|
81
|
-
declare size: ToggleSize;
|
|
82
|
-
declare spacing: string;
|
|
83
|
-
declare orientation: 'horizontal' | 'vertical';
|
|
84
|
-
|
|
71
|
+
export class UiToggleGroup extends WebComponent({
|
|
72
|
+
value: prop(String, { reflect: true }),
|
|
73
|
+
type: prop<'single' | 'multiple'>(String, { reflect: true }),
|
|
74
|
+
variant: prop<ToggleVariant>(String, { reflect: true }),
|
|
75
|
+
size: prop<ToggleSize>(String, { reflect: true }),
|
|
76
|
+
spacing: prop(String, { reflect: true }),
|
|
77
|
+
orientation: prop<'horizontal' | 'vertical'>(String, { reflect: true }),
|
|
78
|
+
}) {
|
|
85
79
|
constructor() {
|
|
86
80
|
super();
|
|
87
81
|
this.value = '';
|
|
@@ -117,9 +111,15 @@ export class UiToggleGroup extends WebComponent {
|
|
|
117
111
|
queueMicrotask(() => this._reflectItems());
|
|
118
112
|
}
|
|
119
113
|
|
|
114
|
+
_items(): UiToggleGroupItem[] {
|
|
115
|
+
return Array.from(this.querySelectorAll<UiToggleGroupItem>('ui-toggle-group-item'));
|
|
116
|
+
}
|
|
117
|
+
|
|
120
118
|
_reflectItems(): void {
|
|
121
119
|
const values = this._values;
|
|
122
|
-
this.
|
|
120
|
+
const items = this._items();
|
|
121
|
+
const current = items.find((el) => el.tabIndex === 0);
|
|
122
|
+
items.forEach((item) => {
|
|
123
123
|
const on = !!item.value && values.has(item.value);
|
|
124
124
|
// Reflect both on the host (for CSS sibling selectors like
|
|
125
125
|
// data-[spacing=0]:first:rounded-l-md that need to target the host
|
|
@@ -127,6 +127,31 @@ export class UiToggleGroup extends WebComponent {
|
|
|
127
127
|
// item's render() refreshes its inner styling.
|
|
128
128
|
item.pressed = on;
|
|
129
129
|
});
|
|
130
|
+
this._roving(items, current);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Roving tabindex (APG): exactly one item is in the tab order. Prefer the
|
|
134
|
+
// currently-focused item, then whichever was tabbable, then the first
|
|
135
|
+
// selected item, then the first item. Arrow keys move focus and shift the
|
|
136
|
+
// single tabbable slot via `focusItem`.
|
|
137
|
+
_roving(items: UiToggleGroupItem[], current?: UiToggleGroupItem): void {
|
|
138
|
+
if (!items.length) return;
|
|
139
|
+
const values = this._values;
|
|
140
|
+
const active =
|
|
141
|
+
typeof document !== 'undefined' ? (document.activeElement as Element | null) : null;
|
|
142
|
+
const focused = active ? items.find((i) => i === active) : undefined;
|
|
143
|
+
const selected = items.find((i) => !!i.value && values.has(i.value));
|
|
144
|
+
const tabbable = focused ?? current ?? selected ?? items[0];
|
|
145
|
+
items.forEach((item) => {
|
|
146
|
+
item.tabIndex = item === tabbable ? 0 : -1;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
focusItem(item: UiToggleGroupItem): void {
|
|
151
|
+
this._items().forEach((el) => {
|
|
152
|
+
el.tabIndex = el === item ? 0 : -1;
|
|
153
|
+
});
|
|
154
|
+
item.focus();
|
|
130
155
|
}
|
|
131
156
|
|
|
132
157
|
_onItemClick = (e: Event): void => {
|
|
@@ -159,23 +184,19 @@ UiToggleGroup.register('ui-toggle-group');
|
|
|
159
184
|
// Tailwind variant selectors on the joined-spacing rounded corners).
|
|
160
185
|
// --------------------------------------------------------------------------
|
|
161
186
|
|
|
162
|
-
export class UiToggleGroupItem extends WebComponent
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
};
|
|
167
|
-
declare value: string;
|
|
168
|
-
declare pressed: boolean;
|
|
169
|
-
|
|
187
|
+
export class UiToggleGroupItem extends WebComponent({
|
|
188
|
+
value: prop(String, { reflect: true }),
|
|
189
|
+
pressed: prop(Boolean, { reflect: true }),
|
|
190
|
+
}) {
|
|
170
191
|
constructor() {
|
|
171
192
|
super();
|
|
172
193
|
this.value = '';
|
|
173
194
|
this.pressed = false;
|
|
174
195
|
}
|
|
175
196
|
|
|
176
|
-
// render() runs server-side too.
|
|
177
|
-
//
|
|
178
|
-
//
|
|
197
|
+
// render() runs server-side too. webjs resolves closest() at SSR against
|
|
198
|
+
// the enclosing-element ancestor chain, so the pressed item is marked in
|
|
199
|
+
// the first paint (no hydration flash). The typeof guard stays defensive.
|
|
179
200
|
get _group(): UiToggleGroup | null {
|
|
180
201
|
if (typeof this.closest !== 'function') return null;
|
|
181
202
|
return this.closest('ui-toggle-group') as UiToggleGroup | null;
|
|
@@ -193,7 +214,10 @@ export class UiToggleGroupItem extends WebComponent {
|
|
|
193
214
|
connectedCallback(): void {
|
|
194
215
|
this.dataset.slot = 'toggle-group-item';
|
|
195
216
|
this.role = 'button';
|
|
196
|
-
|
|
217
|
+
// Roving tabindex: start outside the tab order. The group promotes
|
|
218
|
+
// exactly one item to tabindex 0 in _reflectItems (runs after first
|
|
219
|
+
// render); Arrow keys move focus and the tabbable slot from there.
|
|
220
|
+
this.tabIndex = -1;
|
|
197
221
|
this.addEventListener('click', this._onClick);
|
|
198
222
|
this.addEventListener('keydown', this._onKeyDown);
|
|
199
223
|
super.connectedCallback?.();
|
|
@@ -230,6 +254,24 @@ export class UiToggleGroupItem extends WebComponent {
|
|
|
230
254
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
231
255
|
e.preventDefault();
|
|
232
256
|
this._onClick();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const group = this._group;
|
|
260
|
+
if (!group) return;
|
|
261
|
+
const horizontal = group.orientation !== 'vertical';
|
|
262
|
+
const nextKey = horizontal ? 'ArrowRight' : 'ArrowDown';
|
|
263
|
+
const prevKey = horizontal ? 'ArrowLeft' : 'ArrowUp';
|
|
264
|
+
const items = group._items();
|
|
265
|
+
const idx = items.indexOf(this);
|
|
266
|
+
if (idx === -1) return;
|
|
267
|
+
let target: UiToggleGroupItem | null = null;
|
|
268
|
+
if (e.key === nextKey) target = items[(idx + 1) % items.length] ?? null;
|
|
269
|
+
else if (e.key === prevKey) target = items[(idx - 1 + items.length) % items.length] ?? null;
|
|
270
|
+
else if (e.key === 'Home') target = items[0] ?? null;
|
|
271
|
+
else if (e.key === 'End') target = items[items.length - 1] ?? null;
|
|
272
|
+
if (target) {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
group.focusItem(target);
|
|
233
275
|
}
|
|
234
276
|
};
|
|
235
277
|
}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* Design tokens used: --muted, --muted-foreground, --accent, --accent-foreground,
|
|
39
39
|
* --input, --background, --ring, --destructive.
|
|
40
40
|
*/
|
|
41
|
-
import { WebComponent, html } from '@webjsdev/core';
|
|
41
|
+
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
42
42
|
import { cn } from '../lib/utils.ts';
|
|
43
43
|
|
|
44
44
|
// cursor-pointer + select-none on BASE for both call sites: the
|
|
@@ -76,18 +76,12 @@ export function toggleClass(opts: { variant?: ToggleVariant; size?: ToggleSize }
|
|
|
76
76
|
// project through the default slot inside the inner button.
|
|
77
77
|
// --------------------------------------------------------------------------
|
|
78
78
|
|
|
79
|
-
export class UiToggle extends WebComponent
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
};
|
|
86
|
-
declare pressed: boolean;
|
|
87
|
-
declare variant: ToggleVariant;
|
|
88
|
-
declare size: ToggleSize;
|
|
89
|
-
declare disabled: boolean;
|
|
90
|
-
|
|
79
|
+
export class UiToggle extends WebComponent({
|
|
80
|
+
pressed: prop(Boolean, { reflect: true }),
|
|
81
|
+
variant: prop<ToggleVariant>(String, { reflect: true }),
|
|
82
|
+
size: prop<ToggleSize>(String, { reflect: true }),
|
|
83
|
+
disabled: prop(Boolean, { reflect: true }),
|
|
84
|
+
}) {
|
|
91
85
|
constructor() {
|
|
92
86
|
super();
|
|
93
87
|
this.pressed = false;
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
*
|
|
41
41
|
* Design tokens used: --foreground, --background.
|
|
42
42
|
*/
|
|
43
|
-
import { WebComponent, html } from '@webjsdev/core';
|
|
43
|
+
import { WebComponent, html, prop } from '@webjsdev/core';
|
|
44
|
+
import { ensureId } from '../lib/utils.ts';
|
|
44
45
|
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
45
46
|
|
|
46
47
|
// UA `[popover]` defaults paint a bordered, padded panel centered with
|
|
@@ -61,18 +62,13 @@ let lastTooltipHideAt = 0;
|
|
|
61
62
|
// <ui-tooltip>
|
|
62
63
|
// --------------------------------------------------------------------------
|
|
63
64
|
|
|
64
|
-
export class UiTooltip extends WebComponent
|
|
65
|
-
|
|
66
|
-
open: { type: Boolean, reflect: true },
|
|
67
|
-
delayDuration: { type: Number },
|
|
68
|
-
skipDelayDuration: { type: Number },
|
|
69
|
-
};
|
|
70
|
-
declare open: boolean;
|
|
65
|
+
export class UiTooltip extends WebComponent({
|
|
66
|
+
open: prop(Boolean, { reflect: true }),
|
|
71
67
|
// `delayDuration` / `skipDelayDuration` ride the `delay-duration` /
|
|
72
68
|
// `skip-delay-duration` attributes (shadcn parity), read as typed props.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
delayDuration: Number,
|
|
70
|
+
skipDelayDuration: Number,
|
|
71
|
+
}) {
|
|
76
72
|
_showTimer: number | undefined;
|
|
77
73
|
_hideTimer: number | undefined;
|
|
78
74
|
|
|
@@ -83,6 +79,36 @@ export class UiTooltip extends WebComponent {
|
|
|
83
79
|
this.skipDelayDuration = 300;
|
|
84
80
|
}
|
|
85
81
|
|
|
82
|
+
connectedCallback(): void {
|
|
83
|
+
super.connectedCallback?.();
|
|
84
|
+
// webjs projects slotted light-DOM children in a pass after the first
|
|
85
|
+
// render, so the trigger's focusable control is not in place yet at
|
|
86
|
+
// connect / firstUpdated. Defer to the next frame, by which point the
|
|
87
|
+
// projection has run and both the control and the tip content exist.
|
|
88
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
89
|
+
requestAnimationFrame(() => this._wireAria());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// APG tooltip wiring: the focusable trigger references the tip via
|
|
94
|
+
// aria-describedby, so a screen reader appends the tip text to the
|
|
95
|
+
// trigger's own name ("Help, button, Helpful tip"). The tip is JS-driven,
|
|
96
|
+
// so doing this at runtime is correct.
|
|
97
|
+
_wireAria(): void {
|
|
98
|
+
const triggerHost = this.querySelector('ui-tooltip-trigger');
|
|
99
|
+
const content = this.querySelector<HTMLElement>('ui-tooltip-content [role="tooltip"]');
|
|
100
|
+
if (!triggerHost || !content) return;
|
|
101
|
+
const control =
|
|
102
|
+
triggerHost.querySelector<HTMLElement>('button, a[href], [tabindex], [role="button"]') ??
|
|
103
|
+
(triggerHost as HTMLElement);
|
|
104
|
+
const id = ensureId(content, 'ui-tooltip');
|
|
105
|
+
const existing = control.getAttribute('aria-describedby');
|
|
106
|
+
if (!existing) control.setAttribute('aria-describedby', id);
|
|
107
|
+
else if (!existing.split(/\s+/).includes(id)) {
|
|
108
|
+
control.setAttribute('aria-describedby', `${existing} ${id}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
// Back-compat getter for tests + external code that read `el.isOpen`
|
|
87
113
|
// alongside the reactive `open` prop.
|
|
88
114
|
get isOpen(): boolean { return this.open; }
|