@openvoxproject/voxblocks 0.9.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 (49) hide show
  1. package/README.md +17 -9
  2. package/dist/cdn/voxblocks.css +1 -1
  3. package/dist/cdn/voxblocks.js +1987 -1279
  4. package/dist/types/components/accordion/vox-accordion.d.ts +2 -0
  5. package/dist/types/components/alert/vox-alert.d.ts +2 -1
  6. package/dist/types/components/callout/vox-callout.d.ts +2 -1
  7. package/dist/types/components/disclosure/vox-disclosure.d.ts +2 -0
  8. package/dist/types/components/dropdown/vox-dropdown.d.ts +3 -0
  9. package/dist/types/components/header/vox-header.d.ts +11 -1
  10. package/dist/types/components/icon/icon-paths.d.ts +30 -0
  11. package/dist/types/components/icon/vox-icon.d.ts +22 -0
  12. package/dist/types/components/link-hub/vox-link-hub.d.ts +3 -0
  13. package/dist/types/components/pagination/vox-pagination.d.ts +6 -4
  14. package/dist/types/components/sidenav/vox-sidenav.d.ts +20 -0
  15. package/dist/types/components/toc/vox-toc.d.ts +43 -0
  16. package/dist/types/index.d.ts +4 -0
  17. package/dist/types/internal/field.d.ts +4 -0
  18. package/dist/voxblocks.css +1 -1
  19. package/dist/voxblocks.js +1705 -997
  20. package/package.json +9 -6
  21. package/src/components/accordion/vox-accordion.ts +6 -1
  22. package/src/components/alert/vox-alert.ts +46 -2
  23. package/src/components/avatar/vox-avatar.ts +9 -2
  24. package/src/components/callout/vox-callout.ts +53 -3
  25. package/src/components/card/vox-card.ts +6 -1
  26. package/src/components/checkbox/vox-checkbox.ts +4 -3
  27. package/src/components/dialog/vox-dialog.ts +2 -2
  28. package/src/components/disclosure/vox-disclosure.ts +6 -1
  29. package/src/components/dropdown/vox-dropdown.ts +32 -8
  30. package/src/components/empty-state/vox-empty-state.ts +1 -1
  31. package/src/components/file-input/vox-file-input.ts +4 -1
  32. package/src/components/header/vox-header.ts +142 -7
  33. package/src/components/icon/icon-paths.ts +287 -0
  34. package/src/components/icon/vox-icon.ts +78 -0
  35. package/src/components/input/vox-input.ts +2 -0
  36. package/src/components/link-hub/vox-link-hub.ts +21 -0
  37. package/src/components/pagination/vox-pagination.ts +9 -6
  38. package/src/components/radio/vox-radio-group.ts +17 -6
  39. package/src/components/select/vox-select.ts +3 -0
  40. package/src/components/sidenav/vox-sidenav.ts +164 -4
  41. package/src/components/step-indicator/vox-step-indicator.ts +16 -2
  42. package/src/components/tabs/vox-tabs.ts +26 -2
  43. package/src/components/textarea/vox-textarea.ts +2 -0
  44. package/src/components/toc/vox-toc.ts +140 -0
  45. package/src/index.ts +6 -0
  46. package/src/internal/field.ts +20 -2
  47. package/src/styles/utilities.css +158 -0
  48. package/src/tokens/palette.css +95 -0
  49. package/src/tokens/tokens.css +7 -7
@@ -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()}
@@ -1,8 +1,12 @@
1
1
  import { LitElement, html, css } from 'lit';
2
- import { customElement, property } from 'lit/decorators.js';
2
+ import { customElement, property, state } from 'lit/decorators.js';
3
+ import { ICON_PATHS } from '../icon/icon-paths.js';
3
4
 
4
5
  /**
5
6
  * A vertical section navigation, in the style of the OpenVox docs sidebar.
7
+ * Below 768px, collapses behind a toggle button — the same breakpoint and
8
+ * pattern `vox-header` uses for its own nav, so a page's top nav and side
9
+ * nav switch to mobile mode together.
6
10
  *
7
11
  * ```html
8
12
  * <vox-sidenav>
@@ -17,24 +21,152 @@ import { customElement, property } from 'lit/decorators.js';
17
21
  */
18
22
  @customElement('vox-sidenav')
19
23
  export class VoxSidenav extends LitElement {
24
+ /** Accessible name for the `<nav>` landmark. */
20
25
  @property() label = 'Section';
21
26
 
27
+ /** Visible text on the mobile toggle button. */
28
+ @property({ attribute: 'toggle-label' }) toggleLabel = 'Menu';
29
+
30
+ @state() private mobileOpen = false;
31
+
22
32
  static styles = css`
23
33
  :host {
24
34
  display: block;
25
35
  font-family: var(--vox-font-family-base);
26
36
  }
27
37
 
38
+ .toggle {
39
+ display: none;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ width: 100%;
43
+ gap: var(--vox-space-2);
44
+ padding: var(--vox-space-2) var(--vox-space-3);
45
+ background: none;
46
+ border: 1px solid var(--vox-color-divider);
47
+ border-radius: var(--vox-radius-md);
48
+ color: var(--vox-color-text-1);
49
+ font-family: inherit;
50
+ font-size: 14px;
51
+ font-weight: 600;
52
+ cursor: pointer;
53
+ }
54
+
55
+ .toggle:hover {
56
+ border-color: var(--vox-color-brand-1);
57
+ color: var(--vox-color-brand-1);
58
+ }
59
+
60
+ .toggle:focus-visible {
61
+ outline: 2px solid var(--vox-color-brand-1);
62
+ outline-offset: 2px;
63
+ }
64
+
65
+ .toggle-icon {
66
+ width: 18px;
67
+ height: 18px;
68
+ flex: none;
69
+ }
70
+
28
71
  nav {
29
72
  display: flex;
30
73
  flex-direction: column;
31
74
  gap: 2px;
32
75
  }
76
+
77
+ @media (max-width: 768px) {
78
+ .toggle {
79
+ display: flex;
80
+ }
81
+
82
+ nav {
83
+ display: none;
84
+ margin-top: var(--vox-space-2);
85
+ }
86
+
87
+ nav.open {
88
+ display: flex;
89
+ }
90
+ }
33
91
  `;
34
92
 
93
+ connectedCallback() {
94
+ super.connectedCallback();
95
+ document.addEventListener('click', this.handleOutsideClick);
96
+ this.addEventListener('keydown', this.handleKeydown);
97
+ this.addEventListener('focusout', this.handleFocusOut);
98
+ }
99
+
100
+ disconnectedCallback() {
101
+ super.disconnectedCallback();
102
+ document.removeEventListener('click', this.handleOutsideClick);
103
+ this.removeEventListener('keydown', this.handleKeydown);
104
+ this.removeEventListener('focusout', this.handleFocusOut);
105
+ }
106
+
107
+ private handleOutsideClick = (event: MouseEvent) => {
108
+ if (this.mobileOpen && !event.composedPath().includes(this)) {
109
+ this.mobileOpen = false;
110
+ }
111
+ };
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
+
121
+ private handleKeydown = (event: KeyboardEvent) => {
122
+ if (event.key === 'Escape' && this.mobileOpen) {
123
+ this.mobileOpen = false;
124
+ this.renderRoot.querySelector<HTMLElement>('.toggle')?.focus();
125
+ }
126
+ };
127
+
128
+ private toggleMobile = () => {
129
+ this.mobileOpen = !this.mobileOpen;
130
+ };
131
+
132
+ private handleNavClick = (event: MouseEvent) => {
133
+ // Only close on an actual navigation (an <a> in the composed path) —
134
+ // not on a vox-sidenav-group's own expand/collapse trigger, which is
135
+ // a <button> nested inside this same <nav>.
136
+ if (event.composedPath().some((el) => el instanceof HTMLAnchorElement)) {
137
+ this.mobileOpen = false;
138
+ }
139
+ };
140
+
35
141
  render() {
36
142
  return html`
37
- <nav aria-label=${this.label}>
143
+ <button
144
+ type="button"
145
+ class="toggle"
146
+ aria-expanded=${this.mobileOpen ? 'true' : 'false'}
147
+ aria-controls="nav"
148
+ @click=${this.toggleMobile}
149
+ >
150
+ ${this.toggleLabel}
151
+ <svg
152
+ class="toggle-icon"
153
+ viewBox="0 0 48 48"
154
+ fill="none"
155
+ stroke="currentColor"
156
+ stroke-width="2"
157
+ stroke-linecap="round"
158
+ stroke-linejoin="round"
159
+ aria-hidden="true"
160
+ >
161
+ ${this.mobileOpen ? ICON_PATHS.close : ICON_PATHS.menu}
162
+ </svg>
163
+ </button>
164
+ <nav
165
+ id="nav"
166
+ class=${this.mobileOpen ? 'open' : ''}
167
+ aria-label=${this.label}
168
+ @click=${this.handleNavClick}
169
+ >
38
170
  <slot></slot>
39
171
  </nav>
40
172
  `;
@@ -48,9 +180,13 @@ export class VoxSidenav extends LitElement {
48
180
  */
49
181
  @customElement('vox-sidenav-group')
50
182
  export class VoxSidenavGroup extends LitElement {
183
+ private static nextId = 0;
184
+
51
185
  @property() heading = '';
52
186
  @property({ type: Boolean, reflect: true }) open = false;
53
187
 
188
+ private readonly itemsId = `vox-sidenav-group-items-${VoxSidenavGroup.nextId++}`;
189
+
54
190
  static styles = css`
55
191
  :host {
56
192
  display: block;
@@ -118,6 +254,7 @@ export class VoxSidenavGroup extends LitElement {
118
254
  <button
119
255
  class="trigger"
120
256
  aria-expanded=${this.open ? 'true' : 'false'}
257
+ aria-controls=${this.itemsId}
121
258
  @click=${this.toggle}
122
259
  >
123
260
  ${this.heading}
@@ -125,7 +262,7 @@ export class VoxSidenavGroup extends LitElement {
125
262
  <path d="m9 6 6 6-6 6" />
126
263
  </svg>
127
264
  </button>
128
- <div class="items"><slot></slot></div>
265
+ <div id=${this.itemsId} class="items"><slot></slot></div>
129
266
  `;
130
267
  }
131
268
  }
@@ -134,6 +271,7 @@ export class VoxSidenavGroup extends LitElement {
134
271
  * One sidenav link. Set `current` on the active page's item.
135
272
  *
136
273
  * @slot - Link text.
274
+ * @slot icon - Optional icon before the link text, e.g. `<vox-icon>`.
137
275
  */
138
276
  @customElement('vox-sidenav-item')
139
277
  export class VoxSidenavItem extends LitElement {
@@ -147,7 +285,9 @@ export class VoxSidenavItem extends LitElement {
147
285
  }
148
286
 
149
287
  a {
150
- display: block;
288
+ display: flex;
289
+ align-items: center;
290
+ gap: var(--vox-space-2);
151
291
  padding: var(--vox-space-2) var(--vox-space-3);
152
292
  border-radius: var(--vox-radius-sm);
153
293
  color: var(--vox-color-text-2);
@@ -172,11 +312,31 @@ export class VoxSidenavItem extends LitElement {
172
312
  color: var(--vox-color-brand-1);
173
313
  font-weight: 600;
174
314
  }
315
+
316
+ .icon {
317
+ display: flex;
318
+ flex: none;
319
+ }
320
+
321
+ .icon:not(.has-icon) {
322
+ display: none;
323
+ }
175
324
  `;
176
325
 
326
+ private hasIcon = false;
327
+
328
+ private handleIconSlotChange(event: Event) {
329
+ const slot = event.target as HTMLSlotElement;
330
+ this.hasIcon = slot.assignedNodes({ flatten: true }).length > 0;
331
+ this.requestUpdate();
332
+ }
333
+
177
334
  render() {
178
335
  return html`
179
336
  <a href=${this.href} aria-current=${this.current ? 'page' : 'false'}>
337
+ <span class="icon ${this.hasIcon ? 'has-icon' : ''}">
338
+ <slot name="icon" @slotchange=${this.handleIconSlotChange}></slot>
339
+ </span>
180
340
  <slot></slot>
181
341
  </a>
182
342
  `;
@@ -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>
@@ -0,0 +1,140 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { customElement, property } from 'lit/decorators.js';
3
+
4
+ /**
5
+ * An in-page table of contents / heading outline, in the style of the
6
+ * OpenVox docs "On this page" panel. Nest `<vox-toc-item>` for
7
+ * sub-headings via the `children` slot.
8
+ *
9
+ * ```html
10
+ * <vox-toc heading="On this page">
11
+ * <vox-toc-item href="#palette" current>Palette</vox-toc-item>
12
+ * <vox-toc-item href="#pairing-hues">
13
+ * Pairing hues
14
+ * <vox-toc-item slot="children" href="#soft-badge">Soft badge</vox-toc-item>
15
+ * </vox-toc-item>
16
+ * </vox-toc>
17
+ * ```
18
+ *
19
+ * @slot - `<vox-toc-item>` elements.
20
+ */
21
+ @customElement('vox-toc')
22
+ export class VoxToc extends LitElement {
23
+ @property() heading = 'On this page';
24
+
25
+ static styles = css`
26
+ :host {
27
+ display: block;
28
+ font-family: var(--vox-font-family-base);
29
+ }
30
+
31
+ .heading {
32
+ margin: 0 0 var(--vox-space-2);
33
+ padding-left: var(--vox-space-3);
34
+ font-size: 13px;
35
+ font-weight: 600;
36
+ color: var(--vox-color-text-1);
37
+ }
38
+
39
+ nav {
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: 1px;
43
+ border-left: 1px solid var(--vox-color-divider);
44
+ }
45
+ `;
46
+
47
+ render() {
48
+ return html`
49
+ <div class="heading" id="vox-toc-heading">${this.heading}</div>
50
+ <nav aria-labelledby="vox-toc-heading">
51
+ <slot></slot>
52
+ </nav>
53
+ `;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * One heading link in a vox-toc. Set `current` on the active section.
59
+ *
60
+ * @slot - Link text.
61
+ * @slot children - Nested `<vox-toc-item>` elements for sub-headings.
62
+ */
63
+ @customElement('vox-toc-item')
64
+ export class VoxTocItem extends LitElement {
65
+ @property() href = '#';
66
+ @property({ type: Boolean, reflect: true }) current = false;
67
+
68
+ static styles = css`
69
+ :host {
70
+ display: block;
71
+ font-family: var(--vox-font-family-base);
72
+ }
73
+
74
+ a {
75
+ display: block;
76
+ margin-left: -1px;
77
+ padding: var(--vox-space-1) var(--vox-space-3);
78
+ border-left: 2px solid transparent;
79
+ color: var(--vox-color-text-2);
80
+ font-size: 13px;
81
+ line-height: 1.5;
82
+ text-decoration: none;
83
+ transition:
84
+ color var(--vox-transition-fast),
85
+ border-color var(--vox-transition-fast);
86
+ }
87
+
88
+ a:hover {
89
+ color: var(--vox-color-text-1);
90
+ }
91
+
92
+ a:focus-visible {
93
+ outline: 2px solid var(--vox-color-brand-1);
94
+ outline-offset: -2px;
95
+ }
96
+
97
+ :host([current]) a {
98
+ border-left-color: var(--vox-color-brand-1);
99
+ color: var(--vox-color-brand-1);
100
+ font-weight: 600;
101
+ }
102
+
103
+ .children {
104
+ display: flex;
105
+ flex-direction: column;
106
+ gap: 1px;
107
+ padding-left: var(--vox-space-3);
108
+ }
109
+
110
+ .children:not(.has-children) {
111
+ display: none;
112
+ }
113
+ `;
114
+
115
+ private hasChildren = false;
116
+
117
+ private handleChildrenSlotChange(event: Event) {
118
+ const slot = event.target as HTMLSlotElement;
119
+ this.hasChildren = slot.assignedNodes({ flatten: true }).length > 0;
120
+ this.requestUpdate();
121
+ }
122
+
123
+ render() {
124
+ return html`
125
+ <a href=${this.href} aria-current=${this.current ? 'true' : 'false'}>
126
+ <slot></slot>
127
+ </a>
128
+ <div class="children ${this.hasChildren ? 'has-children' : ''}">
129
+ <slot name="children" @slotchange=${this.handleChildrenSlotChange}></slot>
130
+ </div>
131
+ `;
132
+ }
133
+ }
134
+
135
+ declare global {
136
+ interface HTMLElementTagNameMap {
137
+ 'vox-toc': VoxToc;
138
+ 'vox-toc-item': VoxTocItem;
139
+ }
140
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import './tokens/tokens.css';
2
+ import './tokens/palette.css';
2
3
  import './styles/utilities.css';
3
4
 
4
5
  // Actions
@@ -6,6 +7,9 @@ export { VoxButton } from './components/button/vox-button.js';
6
7
  export { VoxCta } from './components/cta/vox-cta.js';
7
8
  export { VoxThemeToggle } from './components/theme-toggle/vox-theme-toggle.js';
8
9
 
10
+ // Icon
11
+ export { VoxIcon } from './components/icon/vox-icon.js';
12
+
9
13
  // Forms
10
14
  export { VoxCheckbox } from './components/checkbox/vox-checkbox.js';
11
15
  export { VoxFileInput } from './components/file-input/vox-file-input.js';
@@ -28,6 +32,7 @@ export { VoxSeriesNav } from './components/series-nav/vox-series-nav.js';
28
32
  export { VoxSidenav, VoxSidenavGroup, VoxSidenavItem } from './components/sidenav/vox-sidenav.js';
29
33
  export { VoxSubnav } from './components/subnav/vox-subnav.js';
30
34
  export { VoxTabs, VoxTab, VoxTabPanel } from './components/tabs/vox-tabs.js';
35
+ export { VoxToc, VoxTocItem } from './components/toc/vox-toc.js';
31
36
 
32
37
  // Overlays
33
38
  export { VoxDialog } from './components/dialog/vox-dialog.js';
@@ -56,6 +61,7 @@ export { VoxStepIndicator, VoxStep } from './components/step-indicator/vox-step-
56
61
  export { VoxTimeline, VoxTimelineItem } from './components/timeline/vox-timeline.js';
57
62
 
58
63
  // Types
64
+ export type { IconName, IconSize } from './components/icon/vox-icon.js';
59
65
  export type { ButtonVariant, ButtonSize } from './components/button/vox-button.js';
60
66
  export type { CalloutVariant } from './components/callout/vox-callout.js';
61
67
  export type { BadgeVariant } from './components/badge/vox-badge.js';
@@ -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;