@openvoxproject/voxblocks 0.9.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/LICENSE +202 -0
- package/README.md +66 -0
- package/dist/cdn/voxblocks.css +1 -0
- package/dist/cdn/voxblocks.js +5057 -0
- package/dist/types/components/accordion/vox-accordion.d.ts +32 -0
- package/dist/types/components/alert/vox-alert.d.ts +23 -0
- package/dist/types/components/avatar/vox-avatar.d.ts +18 -0
- package/dist/types/components/badge/vox-badge.d.ts +17 -0
- package/dist/types/components/billboard/vox-billboard.d.ts +22 -0
- package/dist/types/components/breadcrumbs/vox-breadcrumbs.d.ts +24 -0
- package/dist/types/components/button/vox-button.d.ts +24 -0
- package/dist/types/components/calendar-tile/vox-calendar-tile.d.ts +18 -0
- package/dist/types/components/callout/vox-callout.d.ts +20 -0
- package/dist/types/components/card/vox-card.d.ts +28 -0
- package/dist/types/components/checkbox/vox-checkbox.d.ts +20 -0
- package/dist/types/components/cta/vox-cta.d.ts +17 -0
- package/dist/types/components/cta-band/vox-cta-band.d.ts +22 -0
- package/dist/types/components/dialog/vox-dialog.d.ts +30 -0
- package/dist/types/components/disclosure/vox-disclosure.d.ts +19 -0
- package/dist/types/components/dropdown/vox-dropdown.d.ts +29 -0
- package/dist/types/components/empty-state/vox-empty-state.d.ts +18 -0
- package/dist/types/components/file-input/vox-file-input.d.ts +22 -0
- package/dist/types/components/footer/vox-footer.d.ts +28 -0
- package/dist/types/components/grid/vox-grid.d.ts +22 -0
- package/dist/types/components/header/vox-header.d.ts +23 -0
- package/dist/types/components/hero/vox-hero.d.ts +21 -0
- package/dist/types/components/input/vox-input.d.ts +24 -0
- package/dist/types/components/input-group/vox-input-group.d.ts +25 -0
- package/dist/types/components/link-hub/vox-link-hub.d.ts +27 -0
- package/dist/types/components/loader/vox-loader.d.ts +16 -0
- package/dist/types/components/pagination/vox-pagination.d.ts +25 -0
- package/dist/types/components/quote/vox-quote.d.ts +18 -0
- package/dist/types/components/radio/vox-radio-group.d.ts +23 -0
- package/dist/types/components/radio/vox-radio.d.ts +21 -0
- package/dist/types/components/select/vox-select.d.ts +27 -0
- package/dist/types/components/series-nav/vox-series-nav.d.ts +18 -0
- package/dist/types/components/sidenav/vox-sidenav.d.ts +50 -0
- package/dist/types/components/sponsor/vox-sponsor.d.ts +35 -0
- package/dist/types/components/stat/vox-stat.d.ts +17 -0
- package/dist/types/components/step-indicator/vox-step-indicator.d.ts +28 -0
- package/dist/types/components/subnav/vox-subnav.d.ts +18 -0
- package/dist/types/components/switch/vox-switch.d.ts +20 -0
- package/dist/types/components/tabs/vox-tabs.d.ts +57 -0
- package/dist/types/components/textarea/vox-textarea.d.ts +22 -0
- package/dist/types/components/theme-toggle/vox-theme-toggle.d.ts +35 -0
- package/dist/types/components/timeline/vox-timeline.d.ts +27 -0
- package/dist/types/index.d.ts +51 -0
- package/dist/types/internal/field.d.ts +27 -0
- package/dist/voxblocks.css +1 -0
- package/dist/voxblocks.js +4350 -0
- package/package.json +59 -0
- package/src/components/accordion/vox-accordion.ts +150 -0
- package/src/components/alert/vox-alert.ts +155 -0
- package/src/components/avatar/vox-avatar.ts +80 -0
- package/src/components/badge/vox-badge.ts +67 -0
- package/src/components/billboard/vox-billboard.ts +109 -0
- package/src/components/breadcrumbs/vox-breadcrumbs.ts +68 -0
- package/src/components/button/vox-button.ts +165 -0
- package/src/components/calendar-tile/vox-calendar-tile.ts +74 -0
- package/src/components/callout/vox-callout.ts +97 -0
- package/src/components/card/vox-card.ts +147 -0
- package/src/components/checkbox/vox-checkbox.ts +101 -0
- package/src/components/cta/vox-cta.ts +69 -0
- package/src/components/cta-band/vox-cta-band.ts +105 -0
- package/src/components/dialog/vox-dialog.ts +176 -0
- package/src/components/disclosure/vox-disclosure.ts +98 -0
- package/src/components/dropdown/vox-dropdown.ts +186 -0
- package/src/components/empty-state/vox-empty-state.ts +76 -0
- package/src/components/file-input/vox-file-input.ts +127 -0
- package/src/components/footer/vox-footer.ts +109 -0
- package/src/components/grid/vox-grid.ts +54 -0
- package/src/components/header/vox-header.ts +109 -0
- package/src/components/hero/vox-hero.ts +100 -0
- package/src/components/input/vox-input.ts +72 -0
- package/src/components/input-group/vox-input-group.ts +85 -0
- package/src/components/link-hub/vox-link-hub.ts +108 -0
- package/src/components/loader/vox-loader.ts +82 -0
- package/src/components/pagination/vox-pagination.ts +76 -0
- package/src/components/quote/vox-quote.ts +82 -0
- package/src/components/radio/vox-radio-group.ts +117 -0
- package/src/components/radio/vox-radio.ts +102 -0
- package/src/components/select/vox-select.ts +89 -0
- package/src/components/series-nav/vox-series-nav.ts +94 -0
- package/src/components/sidenav/vox-sidenav.ts +192 -0
- package/src/components/sponsor/vox-sponsor.ts +187 -0
- package/src/components/stat/vox-stat.ts +55 -0
- package/src/components/step-indicator/vox-step-indicator.ts +152 -0
- package/src/components/subnav/vox-subnav.ts +64 -0
- package/src/components/switch/vox-switch.ts +90 -0
- package/src/components/tabs/vox-tabs.ts +201 -0
- package/src/components/textarea/vox-textarea.ts +75 -0
- package/src/components/theme-toggle/vox-theme-toggle.ts +164 -0
- package/src/components/timeline/vox-timeline.ts +100 -0
- package/src/index.ts +65 -0
- package/src/internal/field.ts +165 -0
- package/src/styles/utilities.css +409 -0
- package/src/tokens/tokens.css +161 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A grid of prominent links with descriptions, for hub/landing pages.
|
|
6
|
+
*
|
|
7
|
+
* @slot - `<vox-link-hub-item>` elements.
|
|
8
|
+
*/
|
|
9
|
+
@customElement('vox-link-hub')
|
|
10
|
+
export class VoxLinkHub extends LitElement {
|
|
11
|
+
static styles = css`
|
|
12
|
+
:host {
|
|
13
|
+
display: grid;
|
|
14
|
+
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
|
|
15
|
+
gap: var(--vox-space-4);
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
render() {
|
|
20
|
+
return html`<slot></slot>`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One link in a `<vox-link-hub>`.
|
|
26
|
+
*
|
|
27
|
+
* @slot - Short description under the link title.
|
|
28
|
+
*/
|
|
29
|
+
@customElement('vox-link-hub-item')
|
|
30
|
+
export class VoxLinkHubItem extends LitElement {
|
|
31
|
+
@property() href = '#';
|
|
32
|
+
@property() heading = '';
|
|
33
|
+
|
|
34
|
+
static styles = css`
|
|
35
|
+
:host {
|
|
36
|
+
display: block;
|
|
37
|
+
font-family: var(--vox-font-family-base);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
a {
|
|
41
|
+
display: block;
|
|
42
|
+
height: 100%;
|
|
43
|
+
box-sizing: border-box;
|
|
44
|
+
padding: var(--vox-space-4);
|
|
45
|
+
border-bottom: 2px solid var(--vox-color-divider);
|
|
46
|
+
text-decoration: none;
|
|
47
|
+
transition: border-color var(--vox-transition-base);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
a:hover,
|
|
51
|
+
a:focus-visible {
|
|
52
|
+
border-bottom-color: var(--vox-color-brand-1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
a:focus-visible {
|
|
56
|
+
outline: 2px solid var(--vox-color-brand-1);
|
|
57
|
+
outline-offset: 2px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.heading {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: var(--vox-space-2);
|
|
64
|
+
font-size: 16px;
|
|
65
|
+
font-weight: 600;
|
|
66
|
+
color: var(--vox-color-brand-1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.heading svg {
|
|
70
|
+
width: 14px;
|
|
71
|
+
height: 14px;
|
|
72
|
+
transition: transform var(--vox-transition-fast);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
a:hover .heading svg {
|
|
76
|
+
transform: translateX(3px);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.body {
|
|
80
|
+
margin-top: var(--vox-space-1);
|
|
81
|
+
font-size: 14px;
|
|
82
|
+
line-height: 1.6;
|
|
83
|
+
color: var(--vox-color-text-2);
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
render() {
|
|
88
|
+
return html`
|
|
89
|
+
<a href=${this.href}>
|
|
90
|
+
<span class="heading">
|
|
91
|
+
${this.heading}
|
|
92
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
93
|
+
<path d="M5 12h14" />
|
|
94
|
+
<path d="m13 6 6 6-6 6" />
|
|
95
|
+
</svg>
|
|
96
|
+
</span>
|
|
97
|
+
<span class="body"><slot></slot></span>
|
|
98
|
+
</a>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
declare global {
|
|
104
|
+
interface HTMLElementTagNameMap {
|
|
105
|
+
'vox-link-hub': VoxLinkHub;
|
|
106
|
+
'vox-link-hub-item': VoxLinkHubItem;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
|
|
4
|
+
export type LoaderSize = 'sm' | 'md' | 'lg';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A loading spinner. The `label` is announced to screen readers.
|
|
8
|
+
*/
|
|
9
|
+
@customElement('vox-loader')
|
|
10
|
+
export class VoxLoader extends LitElement {
|
|
11
|
+
@property({ reflect: true }) size: LoaderSize = 'md';
|
|
12
|
+
@property() label = 'Loading';
|
|
13
|
+
|
|
14
|
+
static styles = css`
|
|
15
|
+
:host {
|
|
16
|
+
display: inline-block;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.spinner {
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
border-radius: 50%;
|
|
22
|
+
border-style: solid;
|
|
23
|
+
border-color: var(--vox-color-brand-soft);
|
|
24
|
+
border-top-color: var(--vox-color-brand-3);
|
|
25
|
+
animation: spin 0.8s linear infinite;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
:host([size='sm']) .spinner {
|
|
29
|
+
width: 16px;
|
|
30
|
+
height: 16px;
|
|
31
|
+
border-width: 2px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
:host([size='md']) .spinner {
|
|
35
|
+
width: 28px;
|
|
36
|
+
height: 28px;
|
|
37
|
+
border-width: 3px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
:host([size='lg']) .spinner {
|
|
41
|
+
width: 44px;
|
|
42
|
+
height: 44px;
|
|
43
|
+
border-width: 4px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@keyframes spin {
|
|
47
|
+
to {
|
|
48
|
+
transform: rotate(360deg);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@media (prefers-reduced-motion: reduce) {
|
|
53
|
+
.spinner {
|
|
54
|
+
animation-duration: 2.4s;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.visually-hidden {
|
|
59
|
+
position: absolute;
|
|
60
|
+
width: 1px;
|
|
61
|
+
height: 1px;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
clip: rect(0 0 0 0);
|
|
64
|
+
white-space: nowrap;
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
render() {
|
|
69
|
+
return html`
|
|
70
|
+
<div role="status">
|
|
71
|
+
<div class="spinner"></div>
|
|
72
|
+
<span class="visually-hidden">${this.label}</span>
|
|
73
|
+
</div>
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
declare global {
|
|
79
|
+
interface HTMLElementTagNameMap {
|
|
80
|
+
'vox-loader': VoxLoader;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Page-number navigation. Children are plain links; mark the current
|
|
6
|
+
* page with `aria-current="page"`:
|
|
7
|
+
*
|
|
8
|
+
* ```html
|
|
9
|
+
* <vox-pagination>
|
|
10
|
+
* <a href="?page=1">1</a>
|
|
11
|
+
* <a href="?page=2" aria-current="page">2</a>
|
|
12
|
+
* <a href="?page=3">3</a>
|
|
13
|
+
* </vox-pagination>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @slot - `<a>` elements (including any prev/next links).
|
|
17
|
+
*/
|
|
18
|
+
@customElement('vox-pagination')
|
|
19
|
+
export class VoxPagination extends LitElement {
|
|
20
|
+
@property() label = 'Pagination';
|
|
21
|
+
|
|
22
|
+
static styles = css`
|
|
23
|
+
:host {
|
|
24
|
+
display: block;
|
|
25
|
+
font-family: var(--vox-font-family-base);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
nav {
|
|
29
|
+
display: flex;
|
|
30
|
+
gap: var(--vox-space-1);
|
|
31
|
+
flex-wrap: wrap;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
::slotted(a) {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
min-width: 34px;
|
|
39
|
+
height: 34px;
|
|
40
|
+
padding: 0 var(--vox-space-2);
|
|
41
|
+
border-radius: var(--vox-radius-md);
|
|
42
|
+
color: var(--vox-color-text-2);
|
|
43
|
+
font-size: 14px;
|
|
44
|
+
font-weight: 500;
|
|
45
|
+
text-decoration: none;
|
|
46
|
+
transition:
|
|
47
|
+
color var(--vox-transition-fast),
|
|
48
|
+
background-color var(--vox-transition-fast);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
::slotted(a:hover) {
|
|
52
|
+
color: var(--vox-color-brand-1);
|
|
53
|
+
background-color: var(--vox-color-brand-soft);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
::slotted(a[aria-current='page']) {
|
|
57
|
+
background-color: var(--vox-color-brand-3);
|
|
58
|
+
color: var(--vox-color-text-inverse);
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
render() {
|
|
64
|
+
return html`
|
|
65
|
+
<nav aria-label=${this.label}>
|
|
66
|
+
<slot></slot>
|
|
67
|
+
</nav>
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
declare global {
|
|
73
|
+
interface HTMLElementTagNameMap {
|
|
74
|
+
'vox-pagination': VoxPagination;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { LitElement, html, css, nothing } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A pull quote with optional attribution.
|
|
6
|
+
*
|
|
7
|
+
* @slot - Quote text.
|
|
8
|
+
*/
|
|
9
|
+
@customElement('vox-quote')
|
|
10
|
+
export class VoxQuote extends LitElement {
|
|
11
|
+
@property() attribution = '';
|
|
12
|
+
|
|
13
|
+
/** Additional context under the attribution, e.g. a role or company. */
|
|
14
|
+
@property() detail = '';
|
|
15
|
+
|
|
16
|
+
static styles = css`
|
|
17
|
+
:host {
|
|
18
|
+
display: block;
|
|
19
|
+
font-family: var(--vox-font-family-base);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
blockquote {
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding-left: var(--vox-space-6);
|
|
25
|
+
border-left: 4px solid var(--vox-color-brand-3);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.text {
|
|
29
|
+
font-family: var(--vox-font-family-display);
|
|
30
|
+
font-style: italic;
|
|
31
|
+
font-size: 20px;
|
|
32
|
+
line-height: 1.6;
|
|
33
|
+
color: var(--vox-color-text-1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.text::before {
|
|
37
|
+
content: '“';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.text::after {
|
|
41
|
+
content: '”';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
footer {
|
|
45
|
+
margin-top: var(--vox-space-3);
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.attribution {
|
|
50
|
+
font-weight: 600;
|
|
51
|
+
color: var(--vox-color-text-1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.detail {
|
|
55
|
+
color: var(--vox-color-text-2);
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
render() {
|
|
60
|
+
return html`
|
|
61
|
+
<blockquote>
|
|
62
|
+
<div class="text"><slot></slot></div>
|
|
63
|
+
${this.attribution
|
|
64
|
+
? html`
|
|
65
|
+
<footer>
|
|
66
|
+
<span class="attribution">${this.attribution}</span>
|
|
67
|
+
${this.detail
|
|
68
|
+
? html`<span class="detail"> — ${this.detail}</span>`
|
|
69
|
+
: nothing}
|
|
70
|
+
</footer>
|
|
71
|
+
`
|
|
72
|
+
: nothing}
|
|
73
|
+
</blockquote>
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
declare global {
|
|
79
|
+
interface HTMLElementTagNameMap {
|
|
80
|
+
'vox-quote': VoxQuote;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
import { VoxFieldElement, fieldStyles } from '../../internal/field.js';
|
|
4
|
+
import type { VoxRadio } from './vox-radio.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Groups `<vox-radio>` options: manages selection, arrow-key navigation,
|
|
8
|
+
* and submits the selected value with the surrounding form.
|
|
9
|
+
*
|
|
10
|
+
* @slot - `<vox-radio>` elements.
|
|
11
|
+
*/
|
|
12
|
+
@customElement('vox-radio-group')
|
|
13
|
+
export class VoxRadioGroup extends VoxFieldElement {
|
|
14
|
+
@property() value = '';
|
|
15
|
+
|
|
16
|
+
static styles = [
|
|
17
|
+
fieldStyles,
|
|
18
|
+
css`
|
|
19
|
+
.options {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
gap: var(--vox-space-2);
|
|
23
|
+
margin-top: var(--vox-space-1);
|
|
24
|
+
}
|
|
25
|
+
`,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
private get radios(): VoxRadio[] {
|
|
29
|
+
return [...this.querySelectorAll<VoxRadio>('vox-radio')];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
formResetCallback() {
|
|
33
|
+
this.value = '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
updated() {
|
|
37
|
+
this.internals.setFormValue(this.value || null);
|
|
38
|
+
this.internals.setValidity(
|
|
39
|
+
this.required && !this.value ? { valueMissing: true } : {},
|
|
40
|
+
'Please select an option.',
|
|
41
|
+
this,
|
|
42
|
+
);
|
|
43
|
+
this.syncRadios();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private syncRadios() {
|
|
47
|
+
const radios = this.radios;
|
|
48
|
+
const anyChecked = radios.some((r) => r.value === this.value && this.value !== '');
|
|
49
|
+
radios.forEach((radio, index) => {
|
|
50
|
+
radio.checked = this.value !== '' && radio.value === this.value;
|
|
51
|
+
// Roving tabindex: the checked radio is tabbable, else the first one.
|
|
52
|
+
const tabbable = anyChecked ? radio.checked : index === 0;
|
|
53
|
+
radio.shadowRoot
|
|
54
|
+
?.querySelector('.radio')
|
|
55
|
+
?.setAttribute('tabindex', tabbable ? '0' : '-1');
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private handleSelect(event: Event) {
|
|
60
|
+
const radio = event.target as VoxRadio;
|
|
61
|
+
if (!(radio instanceof HTMLElement) || radio.tagName !== 'VOX-RADIO') return;
|
|
62
|
+
event.stopPropagation();
|
|
63
|
+
if (this.value !== radio.value) {
|
|
64
|
+
this.value = radio.value;
|
|
65
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private handleKeydown(event: KeyboardEvent) {
|
|
70
|
+
const keys: Record<string, number> = {
|
|
71
|
+
ArrowDown: 1,
|
|
72
|
+
ArrowRight: 1,
|
|
73
|
+
ArrowUp: -1,
|
|
74
|
+
ArrowLeft: -1,
|
|
75
|
+
};
|
|
76
|
+
const delta = keys[event.key];
|
|
77
|
+
if (!delta) return;
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
|
|
80
|
+
const radios = this.radios.filter((r) => !r.disabled);
|
|
81
|
+
if (radios.length === 0) return;
|
|
82
|
+
const currentIndex = radios.findIndex((r) => r.checked);
|
|
83
|
+
const nextIndex =
|
|
84
|
+
(Math.max(currentIndex, 0) + delta + radios.length) % radios.length;
|
|
85
|
+
const next = radios[nextIndex];
|
|
86
|
+
next.select();
|
|
87
|
+
next.shadowRoot?.querySelector<HTMLElement>('.radio')?.focus();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render() {
|
|
91
|
+
return html`
|
|
92
|
+
<div class="field" role="radiogroup" aria-label=${this.label}>
|
|
93
|
+
${this.label
|
|
94
|
+
? html`<span class="label">
|
|
95
|
+
${this.label}${this.required
|
|
96
|
+
? html`<span class="required-mark" aria-hidden="true"> *</span>`
|
|
97
|
+
: ''}
|
|
98
|
+
</span>`
|
|
99
|
+
: ''}
|
|
100
|
+
<div
|
|
101
|
+
class="options"
|
|
102
|
+
@vox-radio-select=${this.handleSelect}
|
|
103
|
+
@keydown=${this.handleKeydown}
|
|
104
|
+
>
|
|
105
|
+
<slot @slotchange=${this.syncRadios}></slot>
|
|
106
|
+
</div>
|
|
107
|
+
${this.renderNote()}
|
|
108
|
+
</div>
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
declare global {
|
|
114
|
+
interface HTMLElementTagNameMap {
|
|
115
|
+
'vox-radio-group': VoxRadioGroup;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A single radio option. Must be placed inside a `<vox-radio-group>`,
|
|
6
|
+
* which handles selection state, keyboard navigation, and form value.
|
|
7
|
+
*
|
|
8
|
+
* @slot - Radio label.
|
|
9
|
+
*/
|
|
10
|
+
@customElement('vox-radio')
|
|
11
|
+
export class VoxRadio extends LitElement {
|
|
12
|
+
@property() value = '';
|
|
13
|
+
@property({ type: Boolean, reflect: true }) checked = false;
|
|
14
|
+
@property({ type: Boolean, reflect: true }) disabled = false;
|
|
15
|
+
|
|
16
|
+
static styles = css`
|
|
17
|
+
:host {
|
|
18
|
+
display: block;
|
|
19
|
+
font-family: var(--vox-font-family-base);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
:host([disabled]) {
|
|
23
|
+
opacity: 0.6;
|
|
24
|
+
pointer-events: none;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.radio {
|
|
28
|
+
display: inline-flex;
|
|
29
|
+
align-items: flex-start;
|
|
30
|
+
gap: var(--vox-space-2);
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
font-size: 14px;
|
|
33
|
+
line-height: 1.5;
|
|
34
|
+
color: var(--vox-color-text-1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.radio:focus {
|
|
38
|
+
outline: none;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.circle {
|
|
42
|
+
flex: none;
|
|
43
|
+
box-sizing: border-box;
|
|
44
|
+
width: 18px;
|
|
45
|
+
height: 18px;
|
|
46
|
+
margin-top: 2px;
|
|
47
|
+
border: 1px solid var(--vox-color-border);
|
|
48
|
+
border-radius: 50%;
|
|
49
|
+
background-color: var(--vox-color-bg);
|
|
50
|
+
transition:
|
|
51
|
+
border-color var(--vox-transition-fast),
|
|
52
|
+
box-shadow var(--vox-transition-fast);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
:host([checked]) .circle {
|
|
56
|
+
border-color: var(--vox-color-brand-3);
|
|
57
|
+
border-width: 5px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.radio:focus-visible .circle {
|
|
61
|
+
outline: 2px solid var(--vox-color-brand-1);
|
|
62
|
+
outline-offset: 2px;
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
select() {
|
|
67
|
+
if (this.disabled) return;
|
|
68
|
+
this.dispatchEvent(
|
|
69
|
+
new CustomEvent('vox-radio-select', { bubbles: true, composed: true }),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private handleKeydown(event: KeyboardEvent) {
|
|
74
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
this.select();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
render() {
|
|
81
|
+
return html`
|
|
82
|
+
<span
|
|
83
|
+
class="radio"
|
|
84
|
+
role="radio"
|
|
85
|
+
aria-checked=${this.checked ? 'true' : 'false'}
|
|
86
|
+
aria-disabled=${this.disabled ? 'true' : 'false'}
|
|
87
|
+
tabindex=${this.checked ? '0' : '-1'}
|
|
88
|
+
@click=${this.select}
|
|
89
|
+
@keydown=${this.handleKeydown}
|
|
90
|
+
>
|
|
91
|
+
<span class="circle" aria-hidden="true"></span>
|
|
92
|
+
<span class="text"><slot></slot></span>
|
|
93
|
+
</span>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
declare global {
|
|
99
|
+
interface HTMLElementTagNameMap {
|
|
100
|
+
'vox-radio': VoxRadio;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { html, css } from 'lit';
|
|
2
|
+
import { customElement, property, query } from 'lit/decorators.js';
|
|
3
|
+
import { VoxFieldElement, fieldStyles } from '../../internal/field.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A select input. Options are provided as light-DOM `<option>` /
|
|
7
|
+
* `<optgroup>` children and mirrored into the shadow select:
|
|
8
|
+
*
|
|
9
|
+
* ```html
|
|
10
|
+
* <vox-select label="Distribution">
|
|
11
|
+
* <option value="deb">Debian</option>
|
|
12
|
+
* <option value="rpm">RHEL</option>
|
|
13
|
+
* </vox-select>
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
@customElement('vox-select')
|
|
17
|
+
export class VoxSelect extends VoxFieldElement {
|
|
18
|
+
@property() value = '';
|
|
19
|
+
|
|
20
|
+
@query('select') private selectEl!: HTMLSelectElement;
|
|
21
|
+
|
|
22
|
+
static styles = [
|
|
23
|
+
fieldStyles,
|
|
24
|
+
css`
|
|
25
|
+
select.control {
|
|
26
|
+
appearance: none;
|
|
27
|
+
padding-right: var(--vox-space-8);
|
|
28
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23808080' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
|
29
|
+
background-repeat: no-repeat;
|
|
30
|
+
background-position: right var(--vox-space-3) center;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
`,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
formResetCallback() {
|
|
37
|
+
this.value = '';
|
|
38
|
+
this.syncOptions();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
updated() {
|
|
42
|
+
this.internals.setFormValue(this.value);
|
|
43
|
+
if (this.selectEl) this.syncValidity(this.selectEl);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private syncOptions() {
|
|
47
|
+
if (!this.selectEl) return;
|
|
48
|
+
const slot = this.renderRoot.querySelector('slot');
|
|
49
|
+
if (!slot) return;
|
|
50
|
+
this.selectEl.replaceChildren(
|
|
51
|
+
...slot
|
|
52
|
+
.assignedElements()
|
|
53
|
+
.filter((el) => el instanceof HTMLOptionElement || el instanceof HTMLOptGroupElement)
|
|
54
|
+
.map((el) => el.cloneNode(true)),
|
|
55
|
+
);
|
|
56
|
+
if (this.value) {
|
|
57
|
+
this.selectEl.value = this.value;
|
|
58
|
+
}
|
|
59
|
+
this.value = this.selectEl.value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private handleChange() {
|
|
63
|
+
this.value = this.selectEl.value;
|
|
64
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
render() {
|
|
68
|
+
return html`
|
|
69
|
+
<div class="field">
|
|
70
|
+
${this.renderLabel('select')}
|
|
71
|
+
<select
|
|
72
|
+
id="select"
|
|
73
|
+
class="control"
|
|
74
|
+
?required=${this.required}
|
|
75
|
+
?disabled=${this.disabled}
|
|
76
|
+
@change=${this.handleChange}
|
|
77
|
+
></select>
|
|
78
|
+
${this.renderNote()}
|
|
79
|
+
</div>
|
|
80
|
+
<div hidden><slot @slotchange=${this.syncOptions}></slot></div>
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
declare global {
|
|
86
|
+
interface HTMLElementTagNameMap {
|
|
87
|
+
'vox-select': VoxSelect;
|
|
88
|
+
}
|
|
89
|
+
}
|