@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openvoxproject/voxblocks",
3
- "version": "0.9.0",
4
- "description": "Web components for Vox Pupuli OpenVox community web sites and apps",
3
+ "version": "0.11.0",
4
+ "description": "Web components for OpenVox community web sites and apps",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -35,9 +35,11 @@
35
35
  "scripts": {
36
36
  "prepare": "npm run build",
37
37
  "build": "vite build && vite build --config vite.cdn.config.ts && tsc -p tsconfig.build.json",
38
- "docs:dev": "vitepress dev docs",
39
- "docs:build": "vitepress build docs",
40
- "docs:preview": "vitepress preview docs",
38
+ "docs:dev": "rm -rf docs/assets docs/_data/manifest.json && concurrently -n vite,jekyll -c blue,magenta \"npm:docs:assets:watch\" \"npm:docs:jekyll:wait\"",
39
+ "docs:assets:watch": "vite build --config docs/vite.config.js --watch",
40
+ "docs:jekyll:wait": "wait-on docs/_data/manifest.json && npm run docs:jekyll",
41
+ "docs:jekyll": "BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll serve --livereload --source docs --destination docs/_site",
42
+ "docs:build": "vite build --config docs/vite.config.js && BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build --source docs --destination docs/_site --config docs/_config.yml,docs/_config_production.yml",
41
43
  "storybook": "storybook dev -p 6006",
42
44
  "build-storybook": "storybook build"
43
45
  },
@@ -47,10 +49,11 @@
47
49
  "devDependencies": {
48
50
  "@storybook/web-components": "^10.5.5",
49
51
  "@storybook/web-components-vite": "^10.5.5",
52
+ "concurrently": "^9.1.0",
50
53
  "storybook": "^10.5.5",
51
54
  "typescript": "^5.6.0",
52
55
  "vite": "^6.0.0",
53
- "vitepress": "^1.5.0"
56
+ "wait-on": "^9.1.0"
54
57
  },
55
58
  "allowScripts": {
56
59
  "esbuild@0.25.12": true,
@@ -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
  `;
@@ -1,10 +1,19 @@
1
1
  import { LitElement, html, css, nothing } from 'lit';
2
2
  import { customElement, property } from 'lit/decorators.js';
3
+ import { ICON_PATHS, type IconName } from '../icon/icon-paths.js';
3
4
 
4
5
  export type AlertVariant = 'info' | 'success' | 'warning' | 'danger';
5
6
 
7
+ const VARIANT_ICON: Record<AlertVariant, IconName> = {
8
+ info: 'info',
9
+ success: 'check-circle',
10
+ warning: 'warning',
11
+ danger: 'x-circle',
12
+ };
13
+
6
14
  /**
7
- * A page-level notification message, optionally dismissible.
15
+ * A page-level notification message, optionally dismissible. Shows an
16
+ * icon matching `variant` automatically — no icon slot to fill in.
8
17
  * For static advisory text inside prose, see `<vox-callout>`.
9
18
  *
10
19
  * @slot - Alert message.
@@ -43,6 +52,13 @@ export class VoxAlert extends LitElement {
43
52
  flex: 1 1 auto;
44
53
  }
45
54
 
55
+ .icon {
56
+ flex: none;
57
+ width: 20px;
58
+ height: 20px;
59
+ margin-top: 1px;
60
+ }
61
+
46
62
  .heading {
47
63
  margin: 0 0 var(--vox-space-1);
48
64
  font-size: 14px;
@@ -56,6 +72,9 @@ export class VoxAlert extends LitElement {
56
72
  .info .heading {
57
73
  color: var(--vox-color-brand-1);
58
74
  }
75
+ .info .icon {
76
+ color: var(--vox-color-brand-1);
77
+ }
59
78
 
60
79
  .success {
61
80
  background-color: var(--vox-color-tip-soft);
@@ -64,6 +83,9 @@ export class VoxAlert extends LitElement {
64
83
  .success .heading {
65
84
  color: var(--vox-color-tip-1);
66
85
  }
86
+ .success .icon {
87
+ color: var(--vox-color-tip-1);
88
+ }
67
89
 
68
90
  .warning {
69
91
  background-color: var(--vox-color-warning-soft);
@@ -72,6 +94,9 @@ export class VoxAlert extends LitElement {
72
94
  .warning .heading {
73
95
  color: var(--vox-color-warning-1);
74
96
  }
97
+ .warning .icon {
98
+ color: var(--vox-color-warning-1);
99
+ }
75
100
 
76
101
  .danger {
77
102
  background-color: var(--vox-color-danger-soft);
@@ -80,6 +105,9 @@ export class VoxAlert extends LitElement {
80
105
  .danger .heading {
81
106
  color: var(--vox-color-danger-1);
82
107
  }
108
+ .danger .icon {
109
+ color: var(--vox-color-danger-1);
110
+ }
83
111
 
84
112
  .close {
85
113
  flex: none;
@@ -126,8 +154,24 @@ export class VoxAlert extends LitElement {
126
154
  }
127
155
 
128
156
  render() {
157
+ const isUrgent = this.variant === 'danger' || this.variant === 'warning';
129
158
  return html`
130
- <div class="alert ${this.variant}" role="alert">
159
+ <div
160
+ class="alert ${this.variant}"
161
+ role=${isUrgent ? 'alert' : 'status'}
162
+ >
163
+ <svg
164
+ class="icon"
165
+ viewBox="0 0 48 48"
166
+ fill="none"
167
+ stroke="currentColor"
168
+ stroke-width="2"
169
+ stroke-linecap="round"
170
+ stroke-linejoin="round"
171
+ aria-hidden="true"
172
+ >
173
+ ${ICON_PATHS[VARIANT_ICON[this.variant]]}
174
+ </svg>
131
175
  <div class="body">
132
176
  ${this.heading
133
177
  ? html`<p class="heading">${this.heading}</p>`
@@ -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}
@@ -1,5 +1,6 @@
1
1
  import { LitElement, html, css } from 'lit';
2
2
  import { customElement, property } from 'lit/decorators.js';
3
+ import { ICON_PATHS, type IconName } from '../icon/icon-paths.js';
3
4
 
4
5
  export type CalloutVariant = 'info' | 'tip' | 'warning' | 'danger';
5
6
 
@@ -10,9 +11,17 @@ const DEFAULT_HEADINGS: Record<CalloutVariant, string> = {
10
11
  danger: 'Danger',
11
12
  };
12
13
 
14
+ const VARIANT_ICON: Record<CalloutVariant, IconName> = {
15
+ info: 'info',
16
+ tip: 'check-circle',
17
+ warning: 'warning',
18
+ danger: 'x-circle',
19
+ };
20
+
13
21
  /**
14
22
  * An admonition block matching the custom containers on the OpenVox docs
15
- * (::: tip, ::: warning, ...).
23
+ * (::: tip, ::: warning, ...). Shows an icon matching `variant`
24
+ * automatically — no icon slot to fill in.
16
25
  *
17
26
  * @slot - Callout body content.
18
27
  */
@@ -29,6 +38,9 @@ export class VoxCallout extends LitElement {
29
38
  }
30
39
 
31
40
  .callout {
41
+ display: flex;
42
+ gap: var(--vox-space-3);
43
+ align-items: flex-start;
32
44
  border-radius: var(--vox-radius-md);
33
45
  padding: var(--vox-space-4);
34
46
  font-family: var(--vox-font-family-base);
@@ -37,6 +49,18 @@ export class VoxCallout extends LitElement {
37
49
  color: var(--vox-color-text-2);
38
50
  }
39
51
 
52
+ .icon {
53
+ flex: none;
54
+ width: 18px;
55
+ height: 18px;
56
+ margin-top: 2px;
57
+ }
58
+
59
+ .content {
60
+ flex: 1 1 auto;
61
+ min-width: 0;
62
+ }
63
+
40
64
  .heading {
41
65
  margin: 0 0 var(--vox-space-2);
42
66
  font-size: 14px;
@@ -49,6 +73,9 @@ export class VoxCallout extends LitElement {
49
73
  .info .heading {
50
74
  color: var(--vox-color-text-1);
51
75
  }
76
+ .info .icon {
77
+ color: var(--vox-color-text-1);
78
+ }
52
79
 
53
80
  .tip {
54
81
  background-color: var(--vox-color-tip-soft);
@@ -56,6 +83,9 @@ export class VoxCallout extends LitElement {
56
83
  .tip .heading {
57
84
  color: var(--vox-color-tip-1);
58
85
  }
86
+ .tip .icon {
87
+ color: var(--vox-color-tip-1);
88
+ }
59
89
 
60
90
  .warning {
61
91
  background-color: var(--vox-color-warning-soft);
@@ -63,6 +93,9 @@ export class VoxCallout extends LitElement {
63
93
  .warning .heading {
64
94
  color: var(--vox-color-warning-1);
65
95
  }
96
+ .warning .icon {
97
+ color: var(--vox-color-warning-1);
98
+ }
66
99
 
67
100
  .danger {
68
101
  background-color: var(--vox-color-danger-soft);
@@ -70,6 +103,9 @@ export class VoxCallout extends LitElement {
70
103
  .danger .heading {
71
104
  color: var(--vox-color-danger-1);
72
105
  }
106
+ .danger .icon {
107
+ color: var(--vox-color-danger-1);
108
+ }
73
109
 
74
110
  ::slotted(p:first-child) {
75
111
  margin-top: 0;
@@ -83,8 +119,22 @@ export class VoxCallout extends LitElement {
83
119
  render() {
84
120
  return html`
85
121
  <div class="callout ${this.variant}" role="note">
86
- <p class="heading">${this.heading ?? DEFAULT_HEADINGS[this.variant]}</p>
87
- <slot></slot>
122
+ <svg
123
+ class="icon"
124
+ viewBox="0 0 48 48"
125
+ fill="none"
126
+ stroke="currentColor"
127
+ stroke-width="2"
128
+ stroke-linecap="round"
129
+ stroke-linejoin="round"
130
+ aria-hidden="true"
131
+ >
132
+ ${ICON_PATHS[VARIANT_ICON[this.variant]]}
133
+ </svg>
134
+ <div class="content">
135
+ <p class="heading">${this.heading ?? DEFAULT_HEADINGS[this.variant]}</p>
136
+ <slot></slot>
137
+ </div>
88
138
  </div>
89
139
  `;
90
140
  }
@@ -32,6 +32,11 @@ export class VoxCard extends LitElement {
32
32
  border-radius: var(--vox-radius-lg);
33
33
  padding: var(--vox-space-6);
34
34
  font-family: var(--vox-font-family-base);
35
+ /* Slotted content (e.g. an icon using stroke="currentColor")
36
+ inherits from this element's position in the flat tree — when
37
+ rendered as an <a>, that means the browser's default link color
38
+ without this, since nothing else in .card sets one. */
39
+ color: var(--vox-color-text-1);
35
40
  text-decoration: none;
36
41
  transition: border-color var(--vox-transition-base);
37
42
  }
@@ -124,7 +129,7 @@ export class VoxCard extends LitElement {
124
129
  <div class="badge ${this.hasBadge ? 'has-badge' : ''}">
125
130
  <slot name="badge" @slotchange=${this.handleBadgeSlotChange}></slot>
126
131
  </div>
127
- <div class="icon ${this.hasIcon ? 'has-icon' : ''}">
132
+ <div class="icon ${this.hasIcon ? 'has-icon' : ''}" aria-hidden="true">
128
133
  <slot name="icon" @slotchange=${this.handleIconSlotChange}></slot>
129
134
  </div>
130
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">