@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.
Files changed (34) hide show
  1. package/dist/cdn/voxblocks.css +1 -1
  2. package/dist/cdn/voxblocks.js +1179 -1075
  3. package/dist/types/components/accordion/vox-accordion.d.ts +2 -0
  4. package/dist/types/components/disclosure/vox-disclosure.d.ts +2 -0
  5. package/dist/types/components/dropdown/vox-dropdown.d.ts +3 -0
  6. package/dist/types/components/header/vox-header.d.ts +2 -0
  7. package/dist/types/components/pagination/vox-pagination.d.ts +6 -4
  8. package/dist/types/components/sidenav/vox-sidenav.d.ts +4 -0
  9. package/dist/types/internal/field.d.ts +4 -0
  10. package/dist/voxblocks.css +1 -1
  11. package/dist/voxblocks.js +1000 -896
  12. package/package.json +1 -1
  13. package/src/components/accordion/vox-accordion.ts +6 -1
  14. package/src/components/alert/vox-alert.ts +5 -1
  15. package/src/components/avatar/vox-avatar.ts +9 -2
  16. package/src/components/card/vox-card.ts +1 -1
  17. package/src/components/checkbox/vox-checkbox.ts +4 -3
  18. package/src/components/dialog/vox-dialog.ts +2 -2
  19. package/src/components/disclosure/vox-disclosure.ts +6 -1
  20. package/src/components/dropdown/vox-dropdown.ts +32 -8
  21. package/src/components/empty-state/vox-empty-state.ts +1 -1
  22. package/src/components/file-input/vox-file-input.ts +4 -1
  23. package/src/components/header/vox-header.ts +10 -0
  24. package/src/components/input/vox-input.ts +2 -0
  25. package/src/components/link-hub/vox-link-hub.ts +1 -1
  26. package/src/components/pagination/vox-pagination.ts +9 -6
  27. package/src/components/radio/vox-radio-group.ts +17 -6
  28. package/src/components/select/vox-select.ts +3 -0
  29. package/src/components/sidenav/vox-sidenav.ts +16 -1
  30. package/src/components/step-indicator/vox-step-indicator.ts +16 -2
  31. package/src/components/tabs/vox-tabs.ts +26 -2
  32. package/src/components/textarea/vox-textarea.ts +2 -0
  33. package/src/internal/field.ts +20 -2
  34. package/src/tokens/tokens.css +7 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvoxproject/voxblocks",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Web components for OpenVox community web sites and apps",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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 class="alert ${this.variant}" role="alert">
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 class="avatar" role=${this.src ? 'presentation' : 'img'} aria-label=${this.alt}>
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.required && !this.checked
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-label=${this.heading || nothing}
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 ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && this.open) {
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
- const next = items[(Math.max(index, 0) + delta + items.length) % items.length];
154
- next.focus();
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.required && files.length === 0 ? { valueMissing: true } : {},
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; mark the current
6
- * page with `aria-current="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
- * <a href="?page=2" aria-current="page">2</a>
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(a[aria-current='page']) {
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.required && !this.value ? { valueMissing: true } : {},
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.forEach((radio, index) => {
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 one.
52
- const tabbable = anyChecked ? radio.checked : index === 0;
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 class="field" role="radiogroup" aria-label=${this.label}>
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">${this.label}</span>
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`<div role="tabpanel"><slot></slot></div>`;
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>
@@ -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;