@openvoxproject/voxblocks 0.11.0 → 0.14.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 (43) hide show
  1. package/README.md +2 -2
  2. package/dist/cdn/voxblocks.css +1 -1
  3. package/dist/cdn/voxblocks.js +1780 -1437
  4. package/dist/types/components/accordion/vox-accordion.d.ts +0 -2
  5. package/dist/types/components/datum/vox-datum.d.ts +25 -0
  6. package/dist/types/components/disclosure/vox-disclosure.d.ts +0 -2
  7. package/dist/types/components/dropdown/vox-dropdown.d.ts +0 -3
  8. package/dist/types/components/header/vox-header.d.ts +0 -2
  9. package/dist/types/components/menu/vox-menu.d.ts +44 -0
  10. package/dist/types/components/pagination/vox-pagination.d.ts +4 -6
  11. package/dist/types/components/record-list/vox-record-list.d.ts +53 -0
  12. package/dist/types/components/sidenav/vox-sidenav.d.ts +0 -4
  13. package/dist/types/index.d.ts +5 -0
  14. package/dist/types/internal/field.d.ts +0 -4
  15. package/dist/voxblocks.css +1 -1
  16. package/dist/voxblocks.js +1622 -1279
  17. package/package.json +1 -1
  18. package/src/components/accordion/vox-accordion.ts +1 -6
  19. package/src/components/alert/vox-alert.ts +1 -5
  20. package/src/components/avatar/vox-avatar.ts +2 -9
  21. package/src/components/card/vox-card.ts +1 -1
  22. package/src/components/checkbox/vox-checkbox.ts +3 -4
  23. package/src/components/datum/vox-datum.ts +76 -0
  24. package/src/components/dialog/vox-dialog.ts +2 -2
  25. package/src/components/disclosure/vox-disclosure.ts +1 -6
  26. package/src/components/dropdown/vox-dropdown.ts +14 -32
  27. package/src/components/empty-state/vox-empty-state.ts +1 -1
  28. package/src/components/file-input/vox-file-input.ts +1 -4
  29. package/src/components/header/vox-header.ts +0 -10
  30. package/src/components/input/vox-input.ts +0 -2
  31. package/src/components/link-hub/vox-link-hub.ts +1 -1
  32. package/src/components/menu/vox-menu.ts +203 -0
  33. package/src/components/pagination/vox-pagination.ts +6 -9
  34. package/src/components/radio/vox-radio-group.ts +6 -17
  35. package/src/components/record-list/vox-record-list.ts +263 -0
  36. package/src/components/select/vox-select.ts +0 -3
  37. package/src/components/sidenav/vox-sidenav.ts +1 -16
  38. package/src/components/step-indicator/vox-step-indicator.ts +2 -16
  39. package/src/components/tabs/vox-tabs.ts +2 -26
  40. package/src/components/textarea/vox-textarea.ts +0 -2
  41. package/src/index.ts +5 -0
  42. package/src/internal/field.ts +2 -20
  43. 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.11.0",
3
+ "version": "0.14.0",
4
4
  "description": "Web components for OpenVox community web sites and apps",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -48,13 +48,9 @@ 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
-
53
51
  @property() heading = '';
54
52
  @property({ type: Boolean, reflect: true }) open = false;
55
53
 
56
- private readonly panelId = `vox-accordion-panel-${VoxAccordionItem.nextId++}`;
57
-
58
54
  static styles = css`
59
55
  :host {
60
56
  display: block;
@@ -131,7 +127,6 @@ export class VoxAccordionItem extends LitElement {
131
127
  <button
132
128
  class="trigger"
133
129
  aria-expanded=${this.open ? 'true' : 'false'}
134
- aria-controls=${this.panelId}
135
130
  @click=${this.toggle}
136
131
  >
137
132
  ${this.heading}
@@ -140,7 +135,7 @@ export class VoxAccordionItem extends LitElement {
140
135
  </svg>
141
136
  </button>
142
137
  </h3>
143
- <div id=${this.panelId} class="panel" ?hidden=${!this.open}>
138
+ <div class="panel" ?hidden=${!this.open}>
144
139
  <slot></slot>
145
140
  </div>
146
141
  `;
@@ -154,12 +154,8 @@ export class VoxAlert extends LitElement {
154
154
  }
155
155
 
156
156
  render() {
157
- const isUrgent = this.variant === 'danger' || this.variant === 'warning';
158
157
  return html`
159
- <div
160
- class="alert ${this.variant}"
161
- role=${isUrgent ? 'alert' : 'status'}
162
- >
158
+ <div class="alert ${this.variant}" role="alert">
163
159
  <svg
164
160
  class="icon"
165
161
  viewBox="0 0 48 48"
@@ -1,4 +1,4 @@
1
- import { LitElement, html, css, nothing } from 'lit';
1
+ import { LitElement, html, css } from 'lit';
2
2
  import { customElement, property } from 'lit/decorators.js';
3
3
 
4
4
  export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -63,15 +63,8 @@ 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;
69
66
  return html`
70
- <span
71
- class="avatar"
72
- role=${labelled ? 'img' : nothing}
73
- aria-label=${labelled ? this.alt : nothing}
74
- >
67
+ <span class="avatar" role=${this.src ? 'presentation' : 'img'} aria-label=${this.alt}>
75
68
  ${this.src
76
69
  ? html`<img src=${this.src} alt=${this.alt} />`
77
70
  : 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' : ''}" aria-hidden="true">
132
+ <div class="icon ${this.hasIcon ? 'has-icon' : ''}">
133
133
  <slot name="icon" @slotchange=${this.handleIconSlotChange}></slot>
134
134
  </div>
135
135
  <h3 class="heading">${this.heading}</h3>
@@ -60,9 +60,10 @@ 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;
64
63
  this.internals.setValidity(
65
- this.invalid ? { valueMissing: true } : {},
64
+ this.required && !this.checked
65
+ ? { valueMissing: true }
66
+ : {},
66
67
  'Please check this box.',
67
68
  this.renderRoot.querySelector('input') ?? undefined,
68
69
  );
@@ -80,8 +81,6 @@ export class VoxCheckbox extends VoxFieldElement {
80
81
  type="checkbox"
81
82
  .checked=${this.checked}
82
83
  ?disabled=${this.disabled}
83
- ?required=${this.required}
84
- aria-invalid=${this.invalid ? 'true' : 'false'}
85
84
  @change=${this.handleChange}
86
85
  />
87
86
  <span class="box" aria-hidden="true">
@@ -0,0 +1,76 @@
1
+ import { LitElement, html, css, nothing } from 'lit';
2
+ import { customElement, property } from 'lit/decorators.js';
3
+
4
+ /**
5
+ * A small labeled value with an optional icon — an owner, a date, a
6
+ * target host, anything that's "icon + value" — for use inside record
7
+ * rows, cards, or wherever a compact data point is needed.
8
+ *
9
+ * `name` isn't shown visually (the icon, if any, carries that meaning for
10
+ * sighted users); it's exposed to assistive tech as a spoken label, e.g.
11
+ * "User: J. Smith".
12
+ *
13
+ * @slot icon - Optional icon before the value, e.g. `<vox-icon size="sm">`.
14
+ * @slot - The value.
15
+ */
16
+ @customElement('vox-datum')
17
+ export class VoxDatum extends LitElement {
18
+ @property() name = '';
19
+
20
+ static styles = css`
21
+ :host {
22
+ display: inline-flex;
23
+ align-items: center;
24
+ gap: var(--vox-space-1);
25
+ font-family: var(--vox-font-family-base);
26
+ font-size: 13px;
27
+ color: var(--vox-color-text-2);
28
+ }
29
+
30
+ .icon {
31
+ display: flex;
32
+ flex: none;
33
+ color: var(--vox-color-text-3);
34
+ }
35
+
36
+ .icon:not(.has-icon) {
37
+ display: none;
38
+ }
39
+
40
+ .sr-only {
41
+ position: absolute;
42
+ width: 1px;
43
+ height: 1px;
44
+ padding: 0;
45
+ margin: -1px;
46
+ overflow: hidden;
47
+ clip: rect(0, 0, 0, 0);
48
+ white-space: nowrap;
49
+ border: 0;
50
+ }
51
+ `;
52
+
53
+ private hasIcon = false;
54
+
55
+ private handleIconSlotChange(event: Event) {
56
+ const slot = event.target as HTMLSlotElement;
57
+ this.hasIcon = slot.assignedNodes({ flatten: true }).length > 0;
58
+ this.requestUpdate();
59
+ }
60
+
61
+ render() {
62
+ return html`
63
+ <span class="icon ${this.hasIcon ? 'has-icon' : ''}">
64
+ <slot name="icon" @slotchange=${this.handleIconSlotChange}></slot>
65
+ </span>
66
+ ${this.name ? html`<span class="sr-only">${this.name}: </span>` : nothing}
67
+ <slot></slot>
68
+ `;
69
+ }
70
+ }
71
+
72
+ declare global {
73
+ interface HTMLElementTagNameMap {
74
+ 'vox-datum': VoxDatum;
75
+ }
76
+ }
@@ -148,12 +148,12 @@ export class VoxDialog extends LitElement {
148
148
  render() {
149
149
  return html`
150
150
  <dialog
151
- aria-labelledby=${this.heading ? 'heading' : nothing}
151
+ aria-label=${this.heading || nothing}
152
152
  @close=${this.handleNativeClose}
153
153
  @click=${this.handleClick}
154
154
  >
155
155
  <div class="header">
156
- <h2 class="heading" id="heading">${this.heading}</h2>
156
+ <h2 class="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,13 +9,9 @@ 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
-
14
12
  @property() summary = 'Show details';
15
13
  @property({ type: Boolean, reflect: true }) open = false;
16
14
 
17
- private readonly panelId = `vox-disclosure-panel-${VoxDisclosure.nextId++}`;
18
-
19
15
  static styles = css`
20
16
  :host {
21
17
  display: block;
@@ -81,7 +77,6 @@ export class VoxDisclosure extends LitElement {
81
77
  <button
82
78
  class="trigger"
83
79
  aria-expanded=${this.open ? 'true' : 'false'}
84
- aria-controls=${this.panelId}
85
80
  @click=${this.toggle}
86
81
  >
87
82
  <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">
@@ -89,7 +84,7 @@ export class VoxDisclosure extends LitElement {
89
84
  </svg>
90
85
  ${this.summary}
91
86
  </button>
92
- <div id=${this.panelId} class="panel" ?hidden=${!this.open}>
87
+ <div class="panel" ?hidden=${!this.open}>
93
88
  <slot></slot>
94
89
  </div>
95
90
  `;
@@ -22,6 +22,12 @@ export class VoxDropdown extends LitElement {
22
22
  :host {
23
23
  position: relative;
24
24
  display: inline-block;
25
+ /* Without this, a flex/grid container's default stretch alignment
26
+ grows the host to fill the cross axis while the trigger button
27
+ inside stays content-sized — and since the menu's "top: 100%" is
28
+ measured against the host's own box, it then opens far below the
29
+ trigger instead of right under it. */
30
+ align-self: flex-start;
25
31
  font-family: var(--vox-font-family-base);
26
32
  }
27
33
 
@@ -122,14 +128,12 @@ export class VoxDropdown extends LitElement {
122
128
  super.connectedCallback();
123
129
  document.addEventListener('click', this.handleOutsideClick);
124
130
  this.addEventListener('keydown', this.handleKeydown);
125
- this.addEventListener('focusout', this.handleFocusOut);
126
131
  }
127
132
 
128
133
  disconnectedCallback() {
129
134
  super.disconnectedCallback();
130
135
  document.removeEventListener('click', this.handleOutsideClick);
131
136
  this.removeEventListener('keydown', this.handleKeydown);
132
- this.removeEventListener('focusout', this.handleFocusOut);
133
137
  }
134
138
 
135
139
  private handleOutsideClick = (event: MouseEvent) => {
@@ -138,20 +142,6 @@ export class VoxDropdown extends LitElement {
138
142
  }
139
143
  };
140
144
 
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
-
155
145
  private handleKeydown = (event: KeyboardEvent) => {
156
146
  if (event.key === 'Escape' && this.open) {
157
147
  this.open = false;
@@ -159,27 +149,20 @@ export class VoxDropdown extends LitElement {
159
149
  return;
160
150
  }
161
151
 
162
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
152
+ if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && this.open) {
163
153
  event.preventDefault();
164
- const delta = event.key === 'ArrowDown' ? 1 : -1;
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
154
  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
+ if (items.length === 0) return;
156
+ const active = document.activeElement as HTMLElement;
157
+ const index = items.indexOf(active);
158
+ const delta = event.key === 'ArrowDown' ? 1 : -1;
159
+ const next = items[(Math.max(index, 0) + delta + items.length) % items.length];
160
+ next.focus();
175
161
  }
176
162
  };
177
163
 
178
164
  private toggle() {
179
165
  this.open = !this.open;
180
- if (this.open) {
181
- this.updateComplete.then(() => this.focusItem(0));
182
- }
183
166
  }
184
167
 
185
168
  render() {
@@ -188,7 +171,6 @@ export class VoxDropdown extends LitElement {
188
171
  class="trigger"
189
172
  aria-expanded=${this.open ? 'true' : 'false'}
190
173
  aria-haspopup="true"
191
- aria-controls="menu"
192
174
  @click=${this.toggle}
193
175
  >
194
176
  ${this.label}
@@ -196,7 +178,7 @@ export class VoxDropdown extends LitElement {
196
178
  <path d="m6 9 6 6 6-6" />
197
179
  </svg>
198
180
  </button>
199
- <div id="menu" class="menu">
181
+ <div class="menu">
200
182
  <slot @click=${() => (this.open = false)}></slot>
201
183
  </div>
202
184
  `;
@@ -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" aria-hidden="true"><slot name="icon"></slot></div>
63
+ <div class="icon"><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,9 +84,8 @@ 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;
88
87
  this.internals.setValidity(
89
- this.invalid ? { valueMissing: true } : {},
88
+ this.required && files.length === 0 ? { valueMissing: true } : {},
90
89
  'Please select a file.',
91
90
  this.inputEl,
92
91
  );
@@ -104,8 +103,6 @@ export class VoxFileInput extends VoxFieldElement {
104
103
  accept=${ifDefined(this.accept)}
105
104
  ?multiple=${this.multiple}
106
105
  ?disabled=${this.disabled}
107
- aria-describedby=${ifDefined(this.noteId)}
108
- aria-invalid=${this.invalid ? 'true' : 'false'}
109
106
  @change=${this.handleChange}
110
107
  />
111
108
  <span class="picker">
@@ -157,14 +157,12 @@ 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);
161
160
  }
162
161
 
163
162
  disconnectedCallback() {
164
163
  super.disconnectedCallback();
165
164
  document.removeEventListener('click', this.handleOutsideClick);
166
165
  this.removeEventListener('keydown', this.handleKeydown);
167
- this.removeEventListener('focusout', this.handleFocusOut);
168
166
  }
169
167
 
170
168
  private handleOutsideClick = (event: MouseEvent) => {
@@ -173,14 +171,6 @@ export class VoxHeader extends LitElement {
173
171
  }
174
172
  };
175
173
 
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
-
184
174
  private handleKeydown = (event: KeyboardEvent) => {
185
175
  if (event.key === 'Escape' && this.mobileOpen) {
186
176
  this.mobileOpen = false;
@@ -56,8 +56,6 @@ 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'}
61
59
  @input=${this.handleInput}
62
60
  @change=${this.handleChange}
63
61
  />
@@ -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' : ''}" aria-hidden="true">
109
+ <span class="icon ${this.hasIcon ? 'has-icon' : ''}">
110
110
  <slot name="icon" @slotchange=${this.handleIconSlotChange}></slot>
111
111
  </span>
112
112
  ${this.heading}
@@ -0,0 +1,203 @@
1
+ import { LitElement, html, css, nothing } from 'lit';
2
+ import { customElement, property } from 'lit/decorators.js';
3
+
4
+ export type MenuPlacement = 'bottom-start' | 'bottom-end';
5
+
6
+ /**
7
+ * An overlay menu anchored to an arbitrary trigger — an avatar, an icon
8
+ * button, anything — unlike `<vox-dropdown>`, which owns a fixed
9
+ * label+chevron trigger of its own.
10
+ *
11
+ * ```html
12
+ * <vox-menu label="Account menu">
13
+ * <vox-avatar slot="trigger" initials="UN"></vox-avatar>
14
+ * <a href="/profile">Profile</a>
15
+ * <a href="/settings">Settings</a>
16
+ * <hr />
17
+ * <button type="button">Log out</button>
18
+ * </vox-menu>
19
+ * ```
20
+ *
21
+ * @slot trigger - Content shown inside the trigger button, e.g. a `<vox-avatar>`.
22
+ * @slot - Menu entries (`<a>`, `<button>`, `<hr>` for separators).
23
+ * @fires vox-close - When the menu closes (Escape, outside click, or an entry click).
24
+ */
25
+ @customElement('vox-menu')
26
+ export class VoxMenu extends LitElement {
27
+ /**
28
+ * Accessible name for the trigger button. Set this when the `trigger`
29
+ * slot has no visible text of its own (e.g. an avatar-only trigger);
30
+ * leave unset when it does, so that text stays the accessible name.
31
+ */
32
+ @property() label?: string;
33
+ @property({ reflect: true }) placement: MenuPlacement = 'bottom-end';
34
+ @property({ type: Boolean, reflect: true }) open = false;
35
+
36
+ static styles = css`
37
+ :host {
38
+ position: relative;
39
+ display: inline-block;
40
+ /* Without this, a flex/grid container's default stretch alignment
41
+ grows the host to fill the cross axis (e.g. a tall sibling, or a
42
+ container given a min-height for layout purposes) while the
43
+ trigger button inside stays content-sized — and since the menu's
44
+ "top: 100%" is measured against the host's own box, it then opens
45
+ far below the trigger instead of right under it. */
46
+ align-self: flex-start;
47
+ font-family: var(--vox-font-family-base);
48
+ }
49
+
50
+ .trigger {
51
+ display: inline-flex;
52
+ align-items: center;
53
+ background: none;
54
+ border: none;
55
+ padding: 0;
56
+ border-radius: var(--vox-radius-md);
57
+ color: inherit;
58
+ font: inherit;
59
+ cursor: pointer;
60
+ }
61
+
62
+ .trigger:focus-visible {
63
+ outline: 2px solid var(--vox-color-brand-1);
64
+ outline-offset: 2px;
65
+ }
66
+
67
+ .menu {
68
+ position: absolute;
69
+ top: calc(100% + 4px);
70
+ z-index: 10;
71
+ min-width: 180px;
72
+ display: none;
73
+ flex-direction: column;
74
+ padding: var(--vox-space-2);
75
+ background-color: var(--vox-color-bg-elv);
76
+ border: 1px solid var(--vox-color-divider);
77
+ border-radius: var(--vox-radius-md);
78
+ box-shadow: var(--vox-shadow-2);
79
+ }
80
+
81
+ :host([placement='bottom-start']) .menu {
82
+ left: 0;
83
+ }
84
+
85
+ :host([placement='bottom-end']) .menu {
86
+ right: 0;
87
+ }
88
+
89
+ :host([open]) .menu {
90
+ display: flex;
91
+ }
92
+
93
+ ::slotted(a),
94
+ ::slotted(button) {
95
+ display: block;
96
+ width: 100%;
97
+ box-sizing: border-box;
98
+ padding: var(--vox-space-2) var(--vox-space-3);
99
+ background: none;
100
+ border: none;
101
+ border-radius: var(--vox-radius-sm);
102
+ color: var(--vox-color-text-1);
103
+ font-family: inherit;
104
+ font-size: 14px;
105
+ text-align: left;
106
+ text-decoration: none;
107
+ cursor: pointer;
108
+ white-space: nowrap;
109
+ }
110
+
111
+ ::slotted(a:hover),
112
+ ::slotted(button:hover),
113
+ ::slotted(a:focus-visible),
114
+ ::slotted(button:focus-visible) {
115
+ background-color: var(--vox-color-brand-soft);
116
+ color: var(--vox-color-brand-1);
117
+ outline: none;
118
+ }
119
+
120
+ ::slotted(hr) {
121
+ width: 100%;
122
+ margin: var(--vox-space-1) 0;
123
+ border: none;
124
+ border-top: 1px solid var(--vox-color-divider);
125
+ }
126
+ `;
127
+
128
+ connectedCallback() {
129
+ super.connectedCallback();
130
+ document.addEventListener('click', this.handleOutsideClick);
131
+ this.addEventListener('keydown', this.handleKeydown);
132
+ }
133
+
134
+ disconnectedCallback() {
135
+ super.disconnectedCallback();
136
+ document.removeEventListener('click', this.handleOutsideClick);
137
+ this.removeEventListener('keydown', this.handleKeydown);
138
+ }
139
+
140
+ private handleOutsideClick = (event: MouseEvent) => {
141
+ if (this.open && !event.composedPath().includes(this)) {
142
+ this.close();
143
+ }
144
+ };
145
+
146
+ private handleKeydown = (event: KeyboardEvent) => {
147
+ if (event.key === 'Escape' && this.open) {
148
+ this.close();
149
+ this.renderRoot.querySelector<HTMLElement>('.trigger')?.focus();
150
+ return;
151
+ }
152
+
153
+ if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && this.open) {
154
+ event.preventDefault();
155
+ const items = [...this.querySelectorAll<HTMLElement>('a, button')].filter(
156
+ (el) => el.slot !== 'trigger',
157
+ );
158
+ if (items.length === 0) return;
159
+ const active = document.activeElement as HTMLElement;
160
+ const index = items.indexOf(active);
161
+ const delta = event.key === 'ArrowDown' ? 1 : -1;
162
+ const next = items[(Math.max(index, 0) + delta + items.length) % items.length];
163
+ next.focus();
164
+ }
165
+ };
166
+
167
+ private toggle() {
168
+ if (this.open) {
169
+ this.close();
170
+ } else {
171
+ this.open = true;
172
+ }
173
+ }
174
+
175
+ private close() {
176
+ if (!this.open) return;
177
+ this.open = false;
178
+ this.dispatchEvent(new CustomEvent('vox-close', { bubbles: true, composed: true }));
179
+ }
180
+
181
+ render() {
182
+ return html`
183
+ <button
184
+ class="trigger"
185
+ aria-expanded=${this.open ? 'true' : 'false'}
186
+ aria-haspopup="true"
187
+ aria-label=${this.label ?? nothing}
188
+ @click=${this.toggle}
189
+ >
190
+ <slot name="trigger"></slot>
191
+ </button>
192
+ <div class="menu" role="menu">
193
+ <slot @click=${() => this.close()}></slot>
194
+ </div>
195
+ `;
196
+ }
197
+ }
198
+
199
+ declare global {
200
+ interface HTMLElementTagNameMap {
201
+ 'vox-menu': VoxMenu;
202
+ }
203
+ }
@@ -2,20 +2,18 @@ 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, 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:
5
+ * Page-number navigation. Children are plain links; mark the current
6
+ * page with `aria-current="page"`:
8
7
  *
9
8
  * ```html
10
9
  * <vox-pagination>
11
10
  * <a href="?page=1">1</a>
12
- * <span aria-current="page">2</span>
11
+ * <a href="?page=2" aria-current="page">2</a>
13
12
  * <a href="?page=3">3</a>
14
13
  * </vox-pagination>
15
14
  * ```
16
15
  *
17
- * @slot - `<a>` elements (including any prev/next links) and the
18
- * current-page `<span>`.
16
+ * @slot - `<a>` elements (including any prev/next links).
19
17
  */
20
18
  @customElement('vox-pagination')
21
19
  export class VoxPagination extends LitElement {
@@ -33,8 +31,7 @@ export class VoxPagination extends LitElement {
33
31
  flex-wrap: wrap;
34
32
  }
35
33
 
36
- ::slotted(a),
37
- ::slotted(span) {
34
+ ::slotted(a) {
38
35
  display: inline-flex;
39
36
  align-items: center;
40
37
  justify-content: center;
@@ -56,7 +53,7 @@ export class VoxPagination extends LitElement {
56
53
  background-color: var(--vox-color-brand-soft);
57
54
  }
58
55
 
59
- ::slotted([aria-current='page']) {
56
+ ::slotted(a[aria-current='page']) {
60
57
  background-color: var(--vox-color-brand-3);
61
58
  color: var(--vox-color-text-inverse);
62
59
  font-weight: 600;