@openvoxproject/voxblocks 0.10.0 → 0.11.0
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/dist/cdn/voxblocks.css +1 -1
- package/dist/cdn/voxblocks.js +1179 -1075
- package/dist/types/components/accordion/vox-accordion.d.ts +2 -0
- package/dist/types/components/disclosure/vox-disclosure.d.ts +2 -0
- package/dist/types/components/dropdown/vox-dropdown.d.ts +3 -0
- package/dist/types/components/header/vox-header.d.ts +2 -0
- package/dist/types/components/pagination/vox-pagination.d.ts +6 -4
- package/dist/types/components/sidenav/vox-sidenav.d.ts +4 -0
- package/dist/types/internal/field.d.ts +4 -0
- package/dist/voxblocks.css +1 -1
- package/dist/voxblocks.js +1000 -896
- package/package.json +1 -1
- package/src/components/accordion/vox-accordion.ts +6 -1
- package/src/components/alert/vox-alert.ts +5 -1
- package/src/components/avatar/vox-avatar.ts +9 -2
- package/src/components/card/vox-card.ts +1 -1
- package/src/components/checkbox/vox-checkbox.ts +4 -3
- package/src/components/dialog/vox-dialog.ts +2 -2
- package/src/components/disclosure/vox-disclosure.ts +6 -1
- package/src/components/dropdown/vox-dropdown.ts +32 -8
- package/src/components/empty-state/vox-empty-state.ts +1 -1
- package/src/components/file-input/vox-file-input.ts +4 -1
- package/src/components/header/vox-header.ts +10 -0
- package/src/components/input/vox-input.ts +2 -0
- package/src/components/link-hub/vox-link-hub.ts +1 -1
- package/src/components/pagination/vox-pagination.ts +9 -6
- package/src/components/radio/vox-radio-group.ts +17 -6
- package/src/components/select/vox-select.ts +3 -0
- package/src/components/sidenav/vox-sidenav.ts +16 -1
- package/src/components/step-indicator/vox-step-indicator.ts +16 -2
- package/src/components/tabs/vox-tabs.ts +26 -2
- package/src/components/textarea/vox-textarea.ts +2 -0
- package/src/internal/field.ts +20 -2
- package/src/tokens/tokens.css +7 -7
package/package.json
CHANGED
|
@@ -48,9 +48,13 @@ export class VoxAccordion extends LitElement {
|
|
|
48
48
|
*/
|
|
49
49
|
@customElement('vox-accordion-item')
|
|
50
50
|
export class VoxAccordionItem extends LitElement {
|
|
51
|
+
private static nextId = 0;
|
|
52
|
+
|
|
51
53
|
@property() heading = '';
|
|
52
54
|
@property({ type: Boolean, reflect: true }) open = false;
|
|
53
55
|
|
|
56
|
+
private readonly panelId = `vox-accordion-panel-${VoxAccordionItem.nextId++}`;
|
|
57
|
+
|
|
54
58
|
static styles = css`
|
|
55
59
|
:host {
|
|
56
60
|
display: block;
|
|
@@ -127,6 +131,7 @@ export class VoxAccordionItem extends LitElement {
|
|
|
127
131
|
<button
|
|
128
132
|
class="trigger"
|
|
129
133
|
aria-expanded=${this.open ? 'true' : 'false'}
|
|
134
|
+
aria-controls=${this.panelId}
|
|
130
135
|
@click=${this.toggle}
|
|
131
136
|
>
|
|
132
137
|
${this.heading}
|
|
@@ -135,7 +140,7 @@ export class VoxAccordionItem extends LitElement {
|
|
|
135
140
|
</svg>
|
|
136
141
|
</button>
|
|
137
142
|
</h3>
|
|
138
|
-
<div class="panel" ?hidden=${!this.open}>
|
|
143
|
+
<div id=${this.panelId} class="panel" ?hidden=${!this.open}>
|
|
139
144
|
<slot></slot>
|
|
140
145
|
</div>
|
|
141
146
|
`;
|
|
@@ -154,8 +154,12 @@ export class VoxAlert extends LitElement {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
render() {
|
|
157
|
+
const isUrgent = this.variant === 'danger' || this.variant === 'warning';
|
|
157
158
|
return html`
|
|
158
|
-
<div
|
|
159
|
+
<div
|
|
160
|
+
class="alert ${this.variant}"
|
|
161
|
+
role=${isUrgent ? 'alert' : 'status'}
|
|
162
|
+
>
|
|
159
163
|
<svg
|
|
160
164
|
class="icon"
|
|
161
165
|
viewBox="0 0 48 48"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LitElement, html, css } from 'lit';
|
|
1
|
+
import { LitElement, html, css, nothing } from 'lit';
|
|
2
2
|
import { customElement, property } from 'lit/decorators.js';
|
|
3
3
|
|
|
4
4
|
export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
@@ -63,8 +63,15 @@ export class VoxAvatar extends LitElement {
|
|
|
63
63
|
`;
|
|
64
64
|
|
|
65
65
|
render() {
|
|
66
|
+
// Without `alt`, fall back to letting AT read the initials as plain text
|
|
67
|
+
// rather than hiding them behind an empty aria-label via role="img".
|
|
68
|
+
const labelled = !this.src && this.alt;
|
|
66
69
|
return html`
|
|
67
|
-
<span
|
|
70
|
+
<span
|
|
71
|
+
class="avatar"
|
|
72
|
+
role=${labelled ? 'img' : nothing}
|
|
73
|
+
aria-label=${labelled ? this.alt : nothing}
|
|
74
|
+
>
|
|
68
75
|
${this.src
|
|
69
76
|
? html`<img src=${this.src} alt=${this.alt} />`
|
|
70
77
|
: this.initials}
|
|
@@ -129,7 +129,7 @@ export class VoxCard extends LitElement {
|
|
|
129
129
|
<div class="badge ${this.hasBadge ? 'has-badge' : ''}">
|
|
130
130
|
<slot name="badge" @slotchange=${this.handleBadgeSlotChange}></slot>
|
|
131
131
|
</div>
|
|
132
|
-
<div class="icon ${this.hasIcon ? 'has-icon' : ''}">
|
|
132
|
+
<div class="icon ${this.hasIcon ? 'has-icon' : ''}" aria-hidden="true">
|
|
133
133
|
<slot name="icon" @slotchange=${this.handleIconSlotChange}></slot>
|
|
134
134
|
</div>
|
|
135
135
|
<h3 class="heading">${this.heading}</h3>
|
|
@@ -60,10 +60,9 @@ export class VoxCheckbox extends VoxFieldElement {
|
|
|
60
60
|
|
|
61
61
|
updated() {
|
|
62
62
|
this.internals.setFormValue(this.checked ? this.value : null);
|
|
63
|
+
this.invalid = this.required && !this.checked;
|
|
63
64
|
this.internals.setValidity(
|
|
64
|
-
this.
|
|
65
|
-
? { valueMissing: true }
|
|
66
|
-
: {},
|
|
65
|
+
this.invalid ? { valueMissing: true } : {},
|
|
67
66
|
'Please check this box.',
|
|
68
67
|
this.renderRoot.querySelector('input') ?? undefined,
|
|
69
68
|
);
|
|
@@ -81,6 +80,8 @@ export class VoxCheckbox extends VoxFieldElement {
|
|
|
81
80
|
type="checkbox"
|
|
82
81
|
.checked=${this.checked}
|
|
83
82
|
?disabled=${this.disabled}
|
|
83
|
+
?required=${this.required}
|
|
84
|
+
aria-invalid=${this.invalid ? 'true' : 'false'}
|
|
84
85
|
@change=${this.handleChange}
|
|
85
86
|
/>
|
|
86
87
|
<span class="box" aria-hidden="true">
|
|
@@ -148,12 +148,12 @@ export class VoxDialog extends LitElement {
|
|
|
148
148
|
render() {
|
|
149
149
|
return html`
|
|
150
150
|
<dialog
|
|
151
|
-
aria-
|
|
151
|
+
aria-labelledby=${this.heading ? 'heading' : nothing}
|
|
152
152
|
@close=${this.handleNativeClose}
|
|
153
153
|
@click=${this.handleClick}
|
|
154
154
|
>
|
|
155
155
|
<div class="header">
|
|
156
|
-
<h2 class="heading">${this.heading}</h2>
|
|
156
|
+
<h2 class="heading" id="heading">${this.heading}</h2>
|
|
157
157
|
<button class="close" aria-label="Close dialog" @click=${this.close}>
|
|
158
158
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
159
159
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
@@ -9,9 +9,13 @@ import { customElement, property } from 'lit/decorators.js';
|
|
|
9
9
|
*/
|
|
10
10
|
@customElement('vox-disclosure')
|
|
11
11
|
export class VoxDisclosure extends LitElement {
|
|
12
|
+
private static nextId = 0;
|
|
13
|
+
|
|
12
14
|
@property() summary = 'Show details';
|
|
13
15
|
@property({ type: Boolean, reflect: true }) open = false;
|
|
14
16
|
|
|
17
|
+
private readonly panelId = `vox-disclosure-panel-${VoxDisclosure.nextId++}`;
|
|
18
|
+
|
|
15
19
|
static styles = css`
|
|
16
20
|
:host {
|
|
17
21
|
display: block;
|
|
@@ -77,6 +81,7 @@ export class VoxDisclosure extends LitElement {
|
|
|
77
81
|
<button
|
|
78
82
|
class="trigger"
|
|
79
83
|
aria-expanded=${this.open ? 'true' : 'false'}
|
|
84
|
+
aria-controls=${this.panelId}
|
|
80
85
|
@click=${this.toggle}
|
|
81
86
|
>
|
|
82
87
|
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
@@ -84,7 +89,7 @@ export class VoxDisclosure extends LitElement {
|
|
|
84
89
|
</svg>
|
|
85
90
|
${this.summary}
|
|
86
91
|
</button>
|
|
87
|
-
<div class="panel" ?hidden=${!this.open}>
|
|
92
|
+
<div id=${this.panelId} class="panel" ?hidden=${!this.open}>
|
|
88
93
|
<slot></slot>
|
|
89
94
|
</div>
|
|
90
95
|
`;
|
|
@@ -122,12 +122,14 @@ export class VoxDropdown extends LitElement {
|
|
|
122
122
|
super.connectedCallback();
|
|
123
123
|
document.addEventListener('click', this.handleOutsideClick);
|
|
124
124
|
this.addEventListener('keydown', this.handleKeydown);
|
|
125
|
+
this.addEventListener('focusout', this.handleFocusOut);
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
disconnectedCallback() {
|
|
128
129
|
super.disconnectedCallback();
|
|
129
130
|
document.removeEventListener('click', this.handleOutsideClick);
|
|
130
131
|
this.removeEventListener('keydown', this.handleKeydown);
|
|
132
|
+
this.removeEventListener('focusout', this.handleFocusOut);
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
private handleOutsideClick = (event: MouseEvent) => {
|
|
@@ -136,6 +138,20 @@ export class VoxDropdown extends LitElement {
|
|
|
136
138
|
}
|
|
137
139
|
};
|
|
138
140
|
|
|
141
|
+
/** Close when focus leaves the component entirely (e.g. Tab past the last item). */
|
|
142
|
+
private handleFocusOut = (event: FocusEvent) => {
|
|
143
|
+
const next = event.relatedTarget as Node | null;
|
|
144
|
+
if (this.open && !(next && this.contains(next))) {
|
|
145
|
+
this.open = false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
private focusItem(index: number) {
|
|
150
|
+
const items = [...this.querySelectorAll<HTMLElement>('a, button')];
|
|
151
|
+
if (items.length === 0) return;
|
|
152
|
+
items[(index + items.length) % items.length].focus();
|
|
153
|
+
}
|
|
154
|
+
|
|
139
155
|
private handleKeydown = (event: KeyboardEvent) => {
|
|
140
156
|
if (event.key === 'Escape' && this.open) {
|
|
141
157
|
this.open = false;
|
|
@@ -143,20 +159,27 @@ export class VoxDropdown extends LitElement {
|
|
|
143
159
|
return;
|
|
144
160
|
}
|
|
145
161
|
|
|
146
|
-
if (
|
|
162
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
147
163
|
event.preventDefault();
|
|
148
|
-
const items = [...this.querySelectorAll<HTMLElement>('a, button')];
|
|
149
|
-
if (items.length === 0) return;
|
|
150
|
-
const active = document.activeElement as HTMLElement;
|
|
151
|
-
const index = items.indexOf(active);
|
|
152
164
|
const delta = event.key === 'ArrowDown' ? 1 : -1;
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
if (!this.open) {
|
|
166
|
+
this.open = true;
|
|
167
|
+
this.updateComplete.then(() =>
|
|
168
|
+
this.focusItem(event.key === 'ArrowDown' ? 0 : -1),
|
|
169
|
+
);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const items = [...this.querySelectorAll<HTMLElement>('a, button')];
|
|
173
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
174
|
+
this.focusItem(Math.max(index, 0) + delta);
|
|
155
175
|
}
|
|
156
176
|
};
|
|
157
177
|
|
|
158
178
|
private toggle() {
|
|
159
179
|
this.open = !this.open;
|
|
180
|
+
if (this.open) {
|
|
181
|
+
this.updateComplete.then(() => this.focusItem(0));
|
|
182
|
+
}
|
|
160
183
|
}
|
|
161
184
|
|
|
162
185
|
render() {
|
|
@@ -165,6 +188,7 @@ export class VoxDropdown extends LitElement {
|
|
|
165
188
|
class="trigger"
|
|
166
189
|
aria-expanded=${this.open ? 'true' : 'false'}
|
|
167
190
|
aria-haspopup="true"
|
|
191
|
+
aria-controls="menu"
|
|
168
192
|
@click=${this.toggle}
|
|
169
193
|
>
|
|
170
194
|
${this.label}
|
|
@@ -172,7 +196,7 @@ export class VoxDropdown extends LitElement {
|
|
|
172
196
|
<path d="m6 9 6 6 6-6" />
|
|
173
197
|
</svg>
|
|
174
198
|
</button>
|
|
175
|
-
<div class="menu">
|
|
199
|
+
<div id="menu" class="menu">
|
|
176
200
|
<slot @click=${() => (this.open = false)}></slot>
|
|
177
201
|
</div>
|
|
178
202
|
`;
|
|
@@ -60,7 +60,7 @@ export class VoxEmptyState extends LitElement {
|
|
|
60
60
|
render() {
|
|
61
61
|
return html`
|
|
62
62
|
<div class="empty">
|
|
63
|
-
<div class="icon"><slot name="icon"></slot></div>
|
|
63
|
+
<div class="icon" aria-hidden="true"><slot name="icon"></slot></div>
|
|
64
64
|
<h3 class="heading">${this.heading}</h3>
|
|
65
65
|
<div class="body"><slot></slot></div>
|
|
66
66
|
<div class="actions"><slot name="actions"></slot></div>
|
|
@@ -84,8 +84,9 @@ export class VoxFileInput extends VoxFieldElement {
|
|
|
84
84
|
for (const file of files) data.append(this.name, file);
|
|
85
85
|
this.internals.setFormValue(files.length > 0 ? data : null);
|
|
86
86
|
|
|
87
|
+
this.invalid = this.required && files.length === 0;
|
|
87
88
|
this.internals.setValidity(
|
|
88
|
-
this.
|
|
89
|
+
this.invalid ? { valueMissing: true } : {},
|
|
89
90
|
'Please select a file.',
|
|
90
91
|
this.inputEl,
|
|
91
92
|
);
|
|
@@ -103,6 +104,8 @@ export class VoxFileInput extends VoxFieldElement {
|
|
|
103
104
|
accept=${ifDefined(this.accept)}
|
|
104
105
|
?multiple=${this.multiple}
|
|
105
106
|
?disabled=${this.disabled}
|
|
107
|
+
aria-describedby=${ifDefined(this.noteId)}
|
|
108
|
+
aria-invalid=${this.invalid ? 'true' : 'false'}
|
|
106
109
|
@change=${this.handleChange}
|
|
107
110
|
/>
|
|
108
111
|
<span class="picker">
|
|
@@ -157,12 +157,14 @@ export class VoxHeader extends LitElement {
|
|
|
157
157
|
super.connectedCallback();
|
|
158
158
|
document.addEventListener('click', this.handleOutsideClick);
|
|
159
159
|
this.addEventListener('keydown', this.handleKeydown);
|
|
160
|
+
this.addEventListener('focusout', this.handleFocusOut);
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
disconnectedCallback() {
|
|
163
164
|
super.disconnectedCallback();
|
|
164
165
|
document.removeEventListener('click', this.handleOutsideClick);
|
|
165
166
|
this.removeEventListener('keydown', this.handleKeydown);
|
|
167
|
+
this.removeEventListener('focusout', this.handleFocusOut);
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
private handleOutsideClick = (event: MouseEvent) => {
|
|
@@ -171,6 +173,14 @@ export class VoxHeader extends LitElement {
|
|
|
171
173
|
}
|
|
172
174
|
};
|
|
173
175
|
|
|
176
|
+
/** Close the mobile menu once focus tabs past its last item. */
|
|
177
|
+
private handleFocusOut = (event: FocusEvent) => {
|
|
178
|
+
const next = event.relatedTarget as Node | null;
|
|
179
|
+
if (this.mobileOpen && !(next && this.contains(next))) {
|
|
180
|
+
this.mobileOpen = false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
174
184
|
private handleKeydown = (event: KeyboardEvent) => {
|
|
175
185
|
if (event.key === 'Escape' && this.mobileOpen) {
|
|
176
186
|
this.mobileOpen = false;
|
|
@@ -56,6 +56,8 @@ export class VoxInput extends VoxFieldElement {
|
|
|
56
56
|
?readonly=${this.readonly}
|
|
57
57
|
?disabled=${this.disabled}
|
|
58
58
|
aria-label=${this.label ? nothing : 'text input'}
|
|
59
|
+
aria-describedby=${ifDefined(this.noteId)}
|
|
60
|
+
aria-invalid=${this.invalid ? 'true' : 'false'}
|
|
59
61
|
@input=${this.handleInput}
|
|
60
62
|
@change=${this.handleChange}
|
|
61
63
|
/>
|
|
@@ -106,7 +106,7 @@ export class VoxLinkHubItem extends LitElement {
|
|
|
106
106
|
return html`
|
|
107
107
|
<a href=${this.href}>
|
|
108
108
|
<span class="heading">
|
|
109
|
-
<span class="icon ${this.hasIcon ? 'has-icon' : ''}">
|
|
109
|
+
<span class="icon ${this.hasIcon ? 'has-icon' : ''}" aria-hidden="true">
|
|
110
110
|
<slot name="icon" @slotchange=${this.handleIconSlotChange}></slot>
|
|
111
111
|
</span>
|
|
112
112
|
${this.heading}
|
|
@@ -2,18 +2,20 @@ import { LitElement, html, css } from 'lit';
|
|
|
2
2
|
import { customElement, property } from 'lit/decorators.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Page-number navigation. Children are plain links
|
|
6
|
-
* page
|
|
5
|
+
* Page-number navigation. Children are plain links, with the current
|
|
6
|
+
* page as a non-interactive `<span aria-current="page">` — like
|
|
7
|
+
* `<vox-breadcrumbs>`, it shouldn't be a link to itself:
|
|
7
8
|
*
|
|
8
9
|
* ```html
|
|
9
10
|
* <vox-pagination>
|
|
10
11
|
* <a href="?page=1">1</a>
|
|
11
|
-
* <
|
|
12
|
+
* <span aria-current="page">2</span>
|
|
12
13
|
* <a href="?page=3">3</a>
|
|
13
14
|
* </vox-pagination>
|
|
14
15
|
* ```
|
|
15
16
|
*
|
|
16
|
-
* @slot - `<a>` elements (including any prev/next links)
|
|
17
|
+
* @slot - `<a>` elements (including any prev/next links) and the
|
|
18
|
+
* current-page `<span>`.
|
|
17
19
|
*/
|
|
18
20
|
@customElement('vox-pagination')
|
|
19
21
|
export class VoxPagination extends LitElement {
|
|
@@ -31,7 +33,8 @@ export class VoxPagination extends LitElement {
|
|
|
31
33
|
flex-wrap: wrap;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
::slotted(a)
|
|
36
|
+
::slotted(a),
|
|
37
|
+
::slotted(span) {
|
|
35
38
|
display: inline-flex;
|
|
36
39
|
align-items: center;
|
|
37
40
|
justify-content: center;
|
|
@@ -53,7 +56,7 @@ export class VoxPagination extends LitElement {
|
|
|
53
56
|
background-color: var(--vox-color-brand-soft);
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
::slotted(
|
|
59
|
+
::slotted([aria-current='page']) {
|
|
57
60
|
background-color: var(--vox-color-brand-3);
|
|
58
61
|
color: var(--vox-color-text-inverse);
|
|
59
62
|
font-weight: 600;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { html, css } from 'lit';
|
|
1
|
+
import { html, css, nothing } from 'lit';
|
|
2
2
|
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
3
4
|
import { VoxFieldElement, fieldStyles } from '../../internal/field.js';
|
|
4
5
|
import type { VoxRadio } from './vox-radio.js';
|
|
5
6
|
|
|
@@ -35,8 +36,9 @@ export class VoxRadioGroup extends VoxFieldElement {
|
|
|
35
36
|
|
|
36
37
|
updated() {
|
|
37
38
|
this.internals.setFormValue(this.value || null);
|
|
39
|
+
this.invalid = this.required && !this.value;
|
|
38
40
|
this.internals.setValidity(
|
|
39
|
-
this.
|
|
41
|
+
this.invalid ? { valueMissing: true } : {},
|
|
40
42
|
'Please select an option.',
|
|
41
43
|
this,
|
|
42
44
|
);
|
|
@@ -46,10 +48,12 @@ export class VoxRadioGroup extends VoxFieldElement {
|
|
|
46
48
|
private syncRadios() {
|
|
47
49
|
const radios = this.radios;
|
|
48
50
|
const anyChecked = radios.some((r) => r.value === this.value && this.value !== '');
|
|
49
|
-
radios.
|
|
51
|
+
const firstEnabled = radios.find((r) => !r.disabled);
|
|
52
|
+
radios.forEach((radio) => {
|
|
50
53
|
radio.checked = this.value !== '' && radio.value === this.value;
|
|
51
|
-
// Roving tabindex: the checked radio is tabbable, else the first
|
|
52
|
-
|
|
54
|
+
// Roving tabindex: the checked radio is tabbable, else the first
|
|
55
|
+
// enabled one (a disabled radio can't receive focus at all).
|
|
56
|
+
const tabbable = anyChecked ? radio.checked : radio === firstEnabled;
|
|
53
57
|
radio.shadowRoot
|
|
54
58
|
?.querySelector('.radio')
|
|
55
59
|
?.setAttribute('tabindex', tabbable ? '0' : '-1');
|
|
@@ -89,7 +93,14 @@ export class VoxRadioGroup extends VoxFieldElement {
|
|
|
89
93
|
|
|
90
94
|
render() {
|
|
91
95
|
return html`
|
|
92
|
-
<div
|
|
96
|
+
<div
|
|
97
|
+
class="field"
|
|
98
|
+
role="radiogroup"
|
|
99
|
+
aria-label=${this.label || nothing}
|
|
100
|
+
aria-describedby=${ifDefined(this.noteId)}
|
|
101
|
+
aria-invalid=${this.invalid ? 'true' : 'false'}
|
|
102
|
+
aria-required=${this.required ? 'true' : 'false'}
|
|
103
|
+
>
|
|
93
104
|
${this.label
|
|
94
105
|
? html`<span class="label">
|
|
95
106
|
${this.label}${this.required
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { html, css } from 'lit';
|
|
2
2
|
import { customElement, property, query } from 'lit/decorators.js';
|
|
3
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
3
4
|
import { VoxFieldElement, fieldStyles } from '../../internal/field.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -73,6 +74,8 @@ export class VoxSelect extends VoxFieldElement {
|
|
|
73
74
|
class="control"
|
|
74
75
|
?required=${this.required}
|
|
75
76
|
?disabled=${this.disabled}
|
|
77
|
+
aria-describedby=${ifDefined(this.noteId)}
|
|
78
|
+
aria-invalid=${this.invalid ? 'true' : 'false'}
|
|
76
79
|
@change=${this.handleChange}
|
|
77
80
|
></select>
|
|
78
81
|
${this.renderNote()}
|
|
@@ -94,12 +94,14 @@ export class VoxSidenav extends LitElement {
|
|
|
94
94
|
super.connectedCallback();
|
|
95
95
|
document.addEventListener('click', this.handleOutsideClick);
|
|
96
96
|
this.addEventListener('keydown', this.handleKeydown);
|
|
97
|
+
this.addEventListener('focusout', this.handleFocusOut);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
disconnectedCallback() {
|
|
100
101
|
super.disconnectedCallback();
|
|
101
102
|
document.removeEventListener('click', this.handleOutsideClick);
|
|
102
103
|
this.removeEventListener('keydown', this.handleKeydown);
|
|
104
|
+
this.removeEventListener('focusout', this.handleFocusOut);
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
private handleOutsideClick = (event: MouseEvent) => {
|
|
@@ -108,6 +110,14 @@ export class VoxSidenav extends LitElement {
|
|
|
108
110
|
}
|
|
109
111
|
};
|
|
110
112
|
|
|
113
|
+
/** Close the mobile menu once focus tabs past its last item. */
|
|
114
|
+
private handleFocusOut = (event: FocusEvent) => {
|
|
115
|
+
const next = event.relatedTarget as Node | null;
|
|
116
|
+
if (this.mobileOpen && !(next && this.contains(next))) {
|
|
117
|
+
this.mobileOpen = false;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
111
121
|
private handleKeydown = (event: KeyboardEvent) => {
|
|
112
122
|
if (event.key === 'Escape' && this.mobileOpen) {
|
|
113
123
|
this.mobileOpen = false;
|
|
@@ -170,9 +180,13 @@ export class VoxSidenav extends LitElement {
|
|
|
170
180
|
*/
|
|
171
181
|
@customElement('vox-sidenav-group')
|
|
172
182
|
export class VoxSidenavGroup extends LitElement {
|
|
183
|
+
private static nextId = 0;
|
|
184
|
+
|
|
173
185
|
@property() heading = '';
|
|
174
186
|
@property({ type: Boolean, reflect: true }) open = false;
|
|
175
187
|
|
|
188
|
+
private readonly itemsId = `vox-sidenav-group-items-${VoxSidenavGroup.nextId++}`;
|
|
189
|
+
|
|
176
190
|
static styles = css`
|
|
177
191
|
:host {
|
|
178
192
|
display: block;
|
|
@@ -240,6 +254,7 @@ export class VoxSidenavGroup extends LitElement {
|
|
|
240
254
|
<button
|
|
241
255
|
class="trigger"
|
|
242
256
|
aria-expanded=${this.open ? 'true' : 'false'}
|
|
257
|
+
aria-controls=${this.itemsId}
|
|
243
258
|
@click=${this.toggle}
|
|
244
259
|
>
|
|
245
260
|
${this.heading}
|
|
@@ -247,7 +262,7 @@ export class VoxSidenavGroup extends LitElement {
|
|
|
247
262
|
<path d="m9 6 6 6-6 6" />
|
|
248
263
|
</svg>
|
|
249
264
|
</button>
|
|
250
|
-
<div class="items"><slot></slot></div>
|
|
265
|
+
<div id=${this.itemsId} class="items"><slot></slot></div>
|
|
251
266
|
`;
|
|
252
267
|
}
|
|
253
268
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LitElement, html, css } from 'lit';
|
|
1
|
+
import { LitElement, html, css, nothing } from 'lit';
|
|
2
2
|
import { customElement, property } from 'lit/decorators.js';
|
|
3
3
|
|
|
4
4
|
export type StepState = 'complete' | 'current' | 'upcoming';
|
|
@@ -122,6 +122,15 @@ export class VoxStep extends LitElement {
|
|
|
122
122
|
font-weight: 600;
|
|
123
123
|
color: var(--vox-color-text-1);
|
|
124
124
|
}
|
|
125
|
+
|
|
126
|
+
.visually-hidden {
|
|
127
|
+
position: absolute;
|
|
128
|
+
width: 1px;
|
|
129
|
+
height: 1px;
|
|
130
|
+
overflow: hidden;
|
|
131
|
+
clip: rect(0 0 0 0);
|
|
132
|
+
white-space: nowrap;
|
|
133
|
+
}
|
|
125
134
|
`;
|
|
126
135
|
|
|
127
136
|
render() {
|
|
@@ -138,7 +147,12 @@ export class VoxStep extends LitElement {
|
|
|
138
147
|
</svg>`
|
|
139
148
|
: this.number}
|
|
140
149
|
</span>
|
|
141
|
-
<span class="label"
|
|
150
|
+
<span class="label">
|
|
151
|
+
${this.label}
|
|
152
|
+
${this.state !== 'current'
|
|
153
|
+
? html`<span class="visually-hidden"> (${this.state})</span>`
|
|
154
|
+
: nothing}
|
|
155
|
+
</span>
|
|
142
156
|
</div>
|
|
143
157
|
`;
|
|
144
158
|
}
|
|
@@ -65,11 +65,18 @@ export class VoxTabs extends LitElement {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
private handleKeydown(event: KeyboardEvent) {
|
|
68
|
+
const tabs = this.tabs;
|
|
69
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
const target = tabs[event.key === 'Home' ? 0 : tabs.length - 1];
|
|
72
|
+
target.select();
|
|
73
|
+
target.focusTab();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
68
76
|
const deltas: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 };
|
|
69
77
|
const delta = deltas[event.key];
|
|
70
78
|
if (!delta) return;
|
|
71
79
|
event.preventDefault();
|
|
72
|
-
const tabs = this.tabs;
|
|
73
80
|
const index = tabs.findIndex((t) => t.selected);
|
|
74
81
|
const next = tabs[(index + delta + tabs.length) % tabs.length];
|
|
75
82
|
next.select();
|
|
@@ -150,9 +157,11 @@ export class VoxTab extends LitElement {
|
|
|
150
157
|
render() {
|
|
151
158
|
return html`
|
|
152
159
|
<button
|
|
160
|
+
id="tab-${this.panel}"
|
|
153
161
|
class="tab"
|
|
154
162
|
role="tab"
|
|
155
163
|
aria-selected=${this.selected ? 'true' : 'false'}
|
|
164
|
+
aria-controls="panel-${this.panel}"
|
|
156
165
|
tabindex=${this.selected ? '0' : '-1'}
|
|
157
166
|
@click=${this.select}
|
|
158
167
|
>
|
|
@@ -185,10 +194,25 @@ export class VoxTabPanel extends LitElement {
|
|
|
185
194
|
:host([active]) {
|
|
186
195
|
display: block;
|
|
187
196
|
}
|
|
197
|
+
|
|
198
|
+
div:focus-visible {
|
|
199
|
+
outline: 2px solid var(--vox-color-brand-1);
|
|
200
|
+
outline-offset: 2px;
|
|
201
|
+
border-radius: var(--vox-radius-sm);
|
|
202
|
+
}
|
|
188
203
|
`;
|
|
189
204
|
|
|
190
205
|
render() {
|
|
191
|
-
return html
|
|
206
|
+
return html`
|
|
207
|
+
<div
|
|
208
|
+
id="panel-${this.name}"
|
|
209
|
+
role="tabpanel"
|
|
210
|
+
aria-labelledby="tab-${this.name}"
|
|
211
|
+
tabindex="0"
|
|
212
|
+
>
|
|
213
|
+
<slot></slot>
|
|
214
|
+
</div>
|
|
215
|
+
`;
|
|
192
216
|
}
|
|
193
217
|
}
|
|
194
218
|
|
|
@@ -59,6 +59,8 @@ export class VoxTextarea extends VoxFieldElement {
|
|
|
59
59
|
?required=${this.required}
|
|
60
60
|
?readonly=${this.readonly}
|
|
61
61
|
?disabled=${this.disabled}
|
|
62
|
+
aria-describedby=${ifDefined(this.noteId)}
|
|
63
|
+
aria-invalid=${this.invalid ? 'true' : 'false'}
|
|
62
64
|
@input=${this.handleInput}
|
|
63
65
|
@change=${this.handleChange}
|
|
64
66
|
></textarea>
|
package/src/internal/field.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LitElement, html, css, nothing } from 'lit';
|
|
2
|
-
import { property } from 'lit/decorators.js';
|
|
2
|
+
import { property, state } from 'lit/decorators.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Shared base for form-associated VoxBlocks controls. Uses ElementInternals
|
|
@@ -10,6 +10,9 @@ export class VoxFieldElement extends LitElement {
|
|
|
10
10
|
|
|
11
11
|
protected internals: ElementInternals;
|
|
12
12
|
|
|
13
|
+
/** Whether the control currently fails validation; drives `aria-invalid`. */
|
|
14
|
+
@state() protected invalid = false;
|
|
15
|
+
|
|
13
16
|
@property() name = '';
|
|
14
17
|
@property() label = '';
|
|
15
18
|
@property() note = '';
|
|
@@ -46,6 +49,7 @@ export class VoxFieldElement extends LitElement {
|
|
|
46
49
|
inner: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
|
|
47
50
|
) {
|
|
48
51
|
this.internals.setValidity(inner.validity, inner.validationMessage, inner);
|
|
52
|
+
this.invalid = !inner.validity.valid;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
protected renderLabel(forId: string) {
|
|
@@ -59,8 +63,13 @@ export class VoxFieldElement extends LitElement {
|
|
|
59
63
|
`;
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
/** `note`'s id when present, for `aria-describedby` on the native control. */
|
|
67
|
+
protected get noteId(): string | undefined {
|
|
68
|
+
return this.note ? 'note' : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
protected renderNote() {
|
|
63
|
-
return this.note ? html`<p class="note">${this.note}</p>` : nothing;
|
|
72
|
+
return this.note ? html`<p class="note" id="note">${this.note}</p>` : nothing;
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
75
|
|
|
@@ -119,6 +128,15 @@ export const fieldStyles = css`
|
|
|
119
128
|
box-shadow: 0 0 0 3px var(--vox-color-brand-soft);
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
.control[aria-invalid='true'] {
|
|
132
|
+
border-color: var(--vox-color-danger-1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.control[aria-invalid='true']:focus {
|
|
136
|
+
border-color: var(--vox-color-danger-1);
|
|
137
|
+
box-shadow: 0 0 0 3px var(--vox-color-danger-soft);
|
|
138
|
+
}
|
|
139
|
+
|
|
122
140
|
/* Corner flattening when placed inside a <vox-input-group>. */
|
|
123
141
|
:host([data-vox-group]) .control {
|
|
124
142
|
border-radius: 0;
|