@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.
- package/README.md +2 -2
- package/dist/cdn/voxblocks.css +1 -1
- package/dist/cdn/voxblocks.js +1780 -1437
- package/dist/types/components/accordion/vox-accordion.d.ts +0 -2
- package/dist/types/components/datum/vox-datum.d.ts +25 -0
- package/dist/types/components/disclosure/vox-disclosure.d.ts +0 -2
- package/dist/types/components/dropdown/vox-dropdown.d.ts +0 -3
- package/dist/types/components/header/vox-header.d.ts +0 -2
- package/dist/types/components/menu/vox-menu.d.ts +44 -0
- package/dist/types/components/pagination/vox-pagination.d.ts +4 -6
- package/dist/types/components/record-list/vox-record-list.d.ts +53 -0
- package/dist/types/components/sidenav/vox-sidenav.d.ts +0 -4
- package/dist/types/index.d.ts +5 -0
- package/dist/types/internal/field.d.ts +0 -4
- package/dist/voxblocks.css +1 -1
- package/dist/voxblocks.js +1622 -1279
- package/package.json +1 -1
- package/src/components/accordion/vox-accordion.ts +1 -6
- package/src/components/alert/vox-alert.ts +1 -5
- package/src/components/avatar/vox-avatar.ts +2 -9
- package/src/components/card/vox-card.ts +1 -1
- package/src/components/checkbox/vox-checkbox.ts +3 -4
- package/src/components/datum/vox-datum.ts +76 -0
- package/src/components/dialog/vox-dialog.ts +2 -2
- package/src/components/disclosure/vox-disclosure.ts +1 -6
- package/src/components/dropdown/vox-dropdown.ts +14 -32
- package/src/components/empty-state/vox-empty-state.ts +1 -1
- package/src/components/file-input/vox-file-input.ts +1 -4
- package/src/components/header/vox-header.ts +0 -10
- package/src/components/input/vox-input.ts +0 -2
- package/src/components/link-hub/vox-link-hub.ts +1 -1
- package/src/components/menu/vox-menu.ts +203 -0
- package/src/components/pagination/vox-pagination.ts +6 -9
- package/src/components/radio/vox-radio-group.ts +6 -17
- package/src/components/record-list/vox-record-list.ts +263 -0
- package/src/components/select/vox-select.ts +0 -3
- package/src/components/sidenav/vox-sidenav.ts +1 -16
- package/src/components/step-indicator/vox-step-indicator.ts +2 -16
- package/src/components/tabs/vox-tabs.ts +2 -26
- package/src/components/textarea/vox-textarea.ts +0 -2
- package/src/index.ts +5 -0
- package/src/internal/field.ts +2 -20
- package/src/tokens/tokens.css +7 -7
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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' : ''}"
|
|
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.
|
|
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-
|
|
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"
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
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"
|
|
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.
|
|
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' : ''}"
|
|
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
|
|
6
|
-
* page
|
|
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
|
-
* <
|
|
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)
|
|
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;
|