@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.
- package/README.md +17 -9
- package/dist/cdn/voxblocks.css +1 -1
- package/dist/cdn/voxblocks.js +1987 -1279
- package/dist/types/components/accordion/vox-accordion.d.ts +2 -0
- package/dist/types/components/alert/vox-alert.d.ts +2 -1
- package/dist/types/components/callout/vox-callout.d.ts +2 -1
- package/dist/types/components/disclosure/vox-disclosure.d.ts +2 -0
- package/dist/types/components/dropdown/vox-dropdown.d.ts +3 -0
- package/dist/types/components/header/vox-header.d.ts +11 -1
- package/dist/types/components/icon/icon-paths.d.ts +30 -0
- package/dist/types/components/icon/vox-icon.d.ts +22 -0
- package/dist/types/components/link-hub/vox-link-hub.d.ts +3 -0
- package/dist/types/components/pagination/vox-pagination.d.ts +6 -4
- package/dist/types/components/sidenav/vox-sidenav.d.ts +20 -0
- package/dist/types/components/toc/vox-toc.d.ts +43 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/internal/field.d.ts +4 -0
- package/dist/voxblocks.css +1 -1
- package/dist/voxblocks.js +1705 -997
- package/package.json +9 -6
- package/src/components/accordion/vox-accordion.ts +6 -1
- package/src/components/alert/vox-alert.ts +46 -2
- package/src/components/avatar/vox-avatar.ts +9 -2
- package/src/components/callout/vox-callout.ts +53 -3
- package/src/components/card/vox-card.ts +6 -1
- package/src/components/checkbox/vox-checkbox.ts +4 -3
- package/src/components/dialog/vox-dialog.ts +2 -2
- package/src/components/disclosure/vox-disclosure.ts +6 -1
- package/src/components/dropdown/vox-dropdown.ts +32 -8
- package/src/components/empty-state/vox-empty-state.ts +1 -1
- package/src/components/file-input/vox-file-input.ts +4 -1
- package/src/components/header/vox-header.ts +142 -7
- package/src/components/icon/icon-paths.ts +287 -0
- package/src/components/icon/vox-icon.ts +78 -0
- package/src/components/input/vox-input.ts +2 -0
- package/src/components/link-hub/vox-link-hub.ts +21 -0
- package/src/components/pagination/vox-pagination.ts +9 -6
- package/src/components/radio/vox-radio-group.ts +17 -6
- package/src/components/select/vox-select.ts +3 -0
- package/src/components/sidenav/vox-sidenav.ts +164 -4
- package/src/components/step-indicator/vox-step-indicator.ts +16 -2
- package/src/components/tabs/vox-tabs.ts +26 -2
- package/src/components/textarea/vox-textarea.ts +2 -0
- package/src/components/toc/vox-toc.ts +140 -0
- package/src/index.ts +6 -0
- package/src/internal/field.ts +20 -2
- package/src/styles/utilities.css +158 -0
- package/src/tokens/palette.css +95 -0
- 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.
|
|
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.
|
|
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
|
|
52
|
-
|
|
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
|
|
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
|
-
<
|
|
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:
|
|
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"
|
|
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
|
|
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';
|
package/src/internal/field.ts
CHANGED
|
@@ -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;
|