@openvoxproject/voxblocks 0.14.0 → 0.15.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 +2406 -1640
- package/dist/types/components/code-block/languages.d.ts +28 -0
- package/dist/types/components/code-block/vox-code-block.d.ts +34 -0
- package/dist/types/components/combobox/vox-combobox.d.ts +56 -0
- package/dist/types/components/input/vox-input.d.ts +18 -1
- package/dist/types/components/range/vox-range.d.ts +36 -0
- package/dist/types/components/select/vox-select.d.ts +17 -0
- package/dist/types/index.d.ts +4 -5
- package/dist/voxblocks.css +1 -1
- package/dist/voxblocks.js +2160 -1394
- package/package.json +1 -1
- package/src/components/code-block/languages.ts +259 -0
- package/src/components/code-block/vox-code-block.ts +302 -0
- package/src/components/combobox/vox-combobox.ts +394 -0
- package/src/components/dropdown/vox-dropdown.ts +0 -6
- package/src/components/input/vox-input.ts +52 -2
- package/src/components/range/vox-range.ts +194 -0
- package/src/components/select/vox-select.ts +68 -3
- package/src/index.ts +4 -5
- package/src/tokens/tokens.css +17 -0
- package/dist/types/components/datum/vox-datum.d.ts +0 -25
- package/dist/types/components/menu/vox-menu.d.ts +0 -44
- package/dist/types/components/record-list/vox-record-list.d.ts +0 -53
- package/src/components/datum/vox-datum.ts +0 -76
- package/src/components/menu/vox-menu.ts +0 -203
- package/src/components/record-list/vox-record-list.ts +0 -263
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { html, css, nothing, type PropertyValues } from 'lit';
|
|
2
|
+
import { customElement, property, state, query } from 'lit/decorators.js';
|
|
3
|
+
import { live } from 'lit/directives/live.js';
|
|
4
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
5
|
+
import { classMap } from 'lit/directives/class-map.js';
|
|
6
|
+
import { VoxFieldElement, fieldStyles } from '../../internal/field.js';
|
|
7
|
+
|
|
8
|
+
interface ComboboxOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
disabled: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A text input that filters a list of options as you type. Options are
|
|
16
|
+
* provided as light-DOM `<option>` children, the same as `<vox-select>`:
|
|
17
|
+
*
|
|
18
|
+
* ```html
|
|
19
|
+
* <vox-combobox label="Module">
|
|
20
|
+
* <option value="nginx">puppet-nginx</option>
|
|
21
|
+
* <option value="apache">puppet-apache</option>
|
|
22
|
+
* </vox-combobox>
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Implements the ARIA 1.2 combobox pattern: focus stays on the input and the
|
|
26
|
+
* active option is tracked with `aria-activedescendant`.
|
|
27
|
+
* Participates in native form submission via ElementInternals.
|
|
28
|
+
*/
|
|
29
|
+
@customElement('vox-combobox')
|
|
30
|
+
export class VoxCombobox extends VoxFieldElement {
|
|
31
|
+
/** The selected option's value. */
|
|
32
|
+
@property() value = '';
|
|
33
|
+
|
|
34
|
+
@property() placeholder?: string;
|
|
35
|
+
|
|
36
|
+
/** Accept text that doesn't match any option. */
|
|
37
|
+
@property({ type: Boolean, attribute: 'allow-custom' }) allowCustom = false;
|
|
38
|
+
|
|
39
|
+
/** Message shown when the filter matches nothing. */
|
|
40
|
+
@property({ attribute: 'empty-text' }) emptyText = 'No matches';
|
|
41
|
+
|
|
42
|
+
/** Text currently in the input. Drives filtering. */
|
|
43
|
+
@state() private query = '';
|
|
44
|
+
@state() private open = false;
|
|
45
|
+
@state() private activeIndex = -1;
|
|
46
|
+
@state() private options: ComboboxOption[] = [];
|
|
47
|
+
|
|
48
|
+
@query('input') private inputEl!: HTMLInputElement;
|
|
49
|
+
@query('.listbox') private listboxEl!: HTMLElement;
|
|
50
|
+
|
|
51
|
+
static styles = [
|
|
52
|
+
fieldStyles,
|
|
53
|
+
css`
|
|
54
|
+
/*
|
|
55
|
+
* The popup anchors to this shell, not to .field: an absolutely
|
|
56
|
+
* positioned child of a flex container takes its static position from
|
|
57
|
+
* the container's content-box origin, which would put it over the label.
|
|
58
|
+
*/
|
|
59
|
+
.combo {
|
|
60
|
+
position: relative;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.control {
|
|
64
|
+
padding-right: var(--vox-space-8);
|
|
65
|
+
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");
|
|
66
|
+
background-repeat: no-repeat;
|
|
67
|
+
background-position: right var(--vox-space-3) center;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.listbox {
|
|
71
|
+
position: absolute;
|
|
72
|
+
z-index: 20;
|
|
73
|
+
top: calc(100% + var(--vox-space-1));
|
|
74
|
+
left: 0;
|
|
75
|
+
right: 0;
|
|
76
|
+
margin: 0;
|
|
77
|
+
padding: var(--vox-space-1);
|
|
78
|
+
max-height: 15rem;
|
|
79
|
+
overflow-y: auto;
|
|
80
|
+
list-style: none;
|
|
81
|
+
background-color: var(--vox-color-bg-elv);
|
|
82
|
+
border: 1px solid var(--vox-color-border);
|
|
83
|
+
border-radius: var(--vox-radius-md);
|
|
84
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 12%);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.option {
|
|
88
|
+
padding: var(--vox-space-2) var(--vox-space-3);
|
|
89
|
+
border-radius: var(--vox-radius-sm);
|
|
90
|
+
font-size: 14px;
|
|
91
|
+
line-height: 1.5;
|
|
92
|
+
color: var(--vox-color-text-1);
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* Focus stays on the input, so the active option is styled rather than
|
|
98
|
+
* focused. Colour alone can't carry it (WCAG 1.4.1) — the selected
|
|
99
|
+
* option also gets a check mark.
|
|
100
|
+
*/
|
|
101
|
+
.option[data-active] {
|
|
102
|
+
background-color: var(--vox-color-brand-soft);
|
|
103
|
+
outline: 2px solid var(--vox-color-brand-1);
|
|
104
|
+
outline-offset: -2px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.option[aria-selected='true'] {
|
|
108
|
+
font-weight: 600;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.option[aria-selected='true']::after {
|
|
112
|
+
content: ' ✓';
|
|
113
|
+
color: var(--vox-color-brand-1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.option[aria-disabled='true'] {
|
|
117
|
+
color: var(--vox-color-text-3);
|
|
118
|
+
cursor: not-allowed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.empty {
|
|
122
|
+
padding: var(--vox-space-2) var(--vox-space-3);
|
|
123
|
+
font-size: 14px;
|
|
124
|
+
color: var(--vox-color-text-2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Announced, never shown. */
|
|
128
|
+
.sr-only {
|
|
129
|
+
position: absolute;
|
|
130
|
+
width: 1px;
|
|
131
|
+
height: 1px;
|
|
132
|
+
padding: 0;
|
|
133
|
+
margin: -1px;
|
|
134
|
+
overflow: hidden;
|
|
135
|
+
clip-path: inset(50%);
|
|
136
|
+
white-space: nowrap;
|
|
137
|
+
}
|
|
138
|
+
`,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
private get filtered(): ComboboxOption[] {
|
|
142
|
+
const needle = this.query.trim().toLowerCase();
|
|
143
|
+
// An exact match means the user picked it; show the whole list, not one row.
|
|
144
|
+
if (!needle || needle === this.selectedLabel.toLowerCase()) return this.options;
|
|
145
|
+
return this.options.filter((option) => option.label.toLowerCase().includes(needle));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private get selectedLabel(): string {
|
|
149
|
+
return this.options.find((option) => option.value === this.value)?.label ?? '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
willUpdate(changed: PropertyValues<this>) {
|
|
153
|
+
// Reflect a programmatic value change in the input — but never while the
|
|
154
|
+
// user is typing into it.
|
|
155
|
+
if (changed.has('value') && this.shadowRoot?.activeElement === null) {
|
|
156
|
+
this.query = this.selectedLabel;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
formResetCallback() {
|
|
161
|
+
this.value = '';
|
|
162
|
+
this.query = '';
|
|
163
|
+
this.close();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
updated() {
|
|
167
|
+
this.internals.setFormValue(this.value || null);
|
|
168
|
+
this.internals.setValidity(
|
|
169
|
+
this.required && !this.value ? { valueMissing: true } : {},
|
|
170
|
+
'Please select an option.',
|
|
171
|
+
this.inputEl,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
focus(options?: FocusOptions) {
|
|
176
|
+
this.inputEl?.focus(options);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private readOptions() {
|
|
180
|
+
this.options = [...this.querySelectorAll('option')].map((option) => ({
|
|
181
|
+
value: option.value,
|
|
182
|
+
label: option.textContent?.trim() ?? '',
|
|
183
|
+
disabled: option.disabled,
|
|
184
|
+
}));
|
|
185
|
+
// A value set before the options arrived has a label only now.
|
|
186
|
+
if (this.value && !this.query) this.query = this.selectedLabel;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private openList() {
|
|
190
|
+
if (this.open || this.disabled) return;
|
|
191
|
+
this.open = true;
|
|
192
|
+
const selected = this.filtered.findIndex((option) => option.value === this.value);
|
|
193
|
+
this.activeIndex = selected;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private close() {
|
|
197
|
+
this.open = false;
|
|
198
|
+
this.activeIndex = -1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private select(option: ComboboxOption) {
|
|
202
|
+
if (option.disabled) return;
|
|
203
|
+
this.value = option.value;
|
|
204
|
+
this.query = option.label;
|
|
205
|
+
this.close();
|
|
206
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private moveActive(delta: number) {
|
|
210
|
+
const matches = this.filtered;
|
|
211
|
+
if (matches.length === 0) return;
|
|
212
|
+
const selectable = matches.filter((option) => !option.disabled);
|
|
213
|
+
if (selectable.length === 0) return;
|
|
214
|
+
|
|
215
|
+
let next = this.activeIndex;
|
|
216
|
+
// Step over disabled rows rather than landing on one.
|
|
217
|
+
for (let i = 0; i < matches.length; i++) {
|
|
218
|
+
next = (next + delta + matches.length) % matches.length;
|
|
219
|
+
if (!matches[next].disabled) break;
|
|
220
|
+
}
|
|
221
|
+
this.activeIndex = next;
|
|
222
|
+
this.scrollActiveIntoView();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private scrollActiveIntoView() {
|
|
226
|
+
this.updateComplete.then(() => {
|
|
227
|
+
this.listboxEl
|
|
228
|
+
?.querySelector('[data-active]')
|
|
229
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private handleInput(event: Event) {
|
|
234
|
+
this.query = (event.target as HTMLInputElement).value;
|
|
235
|
+
this.openList();
|
|
236
|
+
this.activeIndex = -1;
|
|
237
|
+
// In strict mode the committed selection stands until the user picks
|
|
238
|
+
// another or leaves the field — a half-typed query is not a value.
|
|
239
|
+
if (this.allowCustom) this.value = this.query;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private handleKeydown(event: KeyboardEvent) {
|
|
243
|
+
switch (event.key) {
|
|
244
|
+
case 'ArrowDown':
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
if (!this.open) {
|
|
247
|
+
this.openList();
|
|
248
|
+
if (this.activeIndex === -1) this.moveActive(1);
|
|
249
|
+
} else {
|
|
250
|
+
this.moveActive(1);
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
case 'ArrowUp':
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
if (!this.open) {
|
|
256
|
+
this.openList();
|
|
257
|
+
this.activeIndex = this.filtered.length;
|
|
258
|
+
}
|
|
259
|
+
this.moveActive(-1);
|
|
260
|
+
break;
|
|
261
|
+
case 'Home':
|
|
262
|
+
if (!this.open) return;
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
this.activeIndex = -1;
|
|
265
|
+
this.moveActive(1);
|
|
266
|
+
break;
|
|
267
|
+
case 'End':
|
|
268
|
+
if (!this.open) return;
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
this.activeIndex = this.filtered.length;
|
|
271
|
+
this.moveActive(-1);
|
|
272
|
+
break;
|
|
273
|
+
case 'Enter': {
|
|
274
|
+
if (!this.open) return;
|
|
275
|
+
const active = this.filtered[this.activeIndex];
|
|
276
|
+
if (active) {
|
|
277
|
+
// Only swallow Enter when it picks something, so a plain Enter
|
|
278
|
+
// still submits the surrounding form.
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
this.select(active);
|
|
281
|
+
}
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case 'Escape':
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
if (this.open) {
|
|
287
|
+
this.close();
|
|
288
|
+
} else if (this.value || this.query) {
|
|
289
|
+
this.query = '';
|
|
290
|
+
this.value = '';
|
|
291
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
case 'Tab':
|
|
295
|
+
this.close();
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private handleBlur() {
|
|
301
|
+
this.close();
|
|
302
|
+
if (this.allowCustom) return;
|
|
303
|
+
// An emptied field clears the selection; anything else that didn't
|
|
304
|
+
// resolve to an option reverts to the last committed one.
|
|
305
|
+
if (this.query === '') {
|
|
306
|
+
if (this.value) {
|
|
307
|
+
this.value = '';
|
|
308
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.query = this.selectedLabel;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private optionId(index: number) {
|
|
316
|
+
return `option-${index}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
render() {
|
|
320
|
+
const matches = this.filtered;
|
|
321
|
+
const activeId =
|
|
322
|
+
this.open && this.activeIndex >= 0 ? this.optionId(this.activeIndex) : undefined;
|
|
323
|
+
|
|
324
|
+
return html`
|
|
325
|
+
<div class="field">
|
|
326
|
+
${this.renderLabel('combobox')}
|
|
327
|
+
<div class="combo">
|
|
328
|
+
<input
|
|
329
|
+
id="combobox"
|
|
330
|
+
class="control"
|
|
331
|
+
type="text"
|
|
332
|
+
role="combobox"
|
|
333
|
+
.value=${live(this.query)}
|
|
334
|
+
placeholder=${ifDefined(this.placeholder)}
|
|
335
|
+
autocomplete="off"
|
|
336
|
+
aria-expanded=${this.open ? 'true' : 'false'}
|
|
337
|
+
aria-controls="listbox"
|
|
338
|
+
aria-autocomplete="list"
|
|
339
|
+
aria-activedescendant=${ifDefined(activeId)}
|
|
340
|
+
aria-label=${this.label ? nothing : 'combobox'}
|
|
341
|
+
?required=${this.required}
|
|
342
|
+
?disabled=${this.disabled}
|
|
343
|
+
@input=${this.handleInput}
|
|
344
|
+
@keydown=${this.handleKeydown}
|
|
345
|
+
@blur=${this.handleBlur}
|
|
346
|
+
@mousedown=${this.openList}
|
|
347
|
+
/>
|
|
348
|
+
<ul
|
|
349
|
+
class="listbox"
|
|
350
|
+
id="listbox"
|
|
351
|
+
role="listbox"
|
|
352
|
+
aria-label=${this.label || 'options'}
|
|
353
|
+
?hidden=${!this.open}
|
|
354
|
+
>
|
|
355
|
+
${matches.length === 0
|
|
356
|
+
? html`<li class="empty" role="presentation">${this.emptyText}</li>`
|
|
357
|
+
: matches.map(
|
|
358
|
+
(option, index) => html`
|
|
359
|
+
<li
|
|
360
|
+
id=${this.optionId(index)}
|
|
361
|
+
class=${classMap({ option: true })}
|
|
362
|
+
role="option"
|
|
363
|
+
aria-selected=${option.value === this.value ? 'true' : 'false'}
|
|
364
|
+
aria-disabled=${option.disabled ? 'true' : 'false'}
|
|
365
|
+
?data-active=${index === this.activeIndex}
|
|
366
|
+
@mousedown=${(event: Event) => {
|
|
367
|
+
// Beat the input's blur so the click still registers.
|
|
368
|
+
event.preventDefault();
|
|
369
|
+
this.select(option);
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
${option.label}
|
|
373
|
+
</li>
|
|
374
|
+
`,
|
|
375
|
+
)}
|
|
376
|
+
</ul>
|
|
377
|
+
</div>
|
|
378
|
+
${this.renderNote()}
|
|
379
|
+
<span class="sr-only" role="status" aria-live="polite">
|
|
380
|
+
${this.open
|
|
381
|
+
? `${matches.length} ${matches.length === 1 ? 'option' : 'options'} available`
|
|
382
|
+
: ''}
|
|
383
|
+
</span>
|
|
384
|
+
</div>
|
|
385
|
+
<div hidden><slot @slotchange=${this.readOptions}></slot></div>
|
|
386
|
+
`;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
declare global {
|
|
391
|
+
interface HTMLElementTagNameMap {
|
|
392
|
+
'vox-combobox': VoxCombobox;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -22,12 +22,6 @@ 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;
|
|
31
25
|
font-family: var(--vox-font-family-base);
|
|
32
26
|
}
|
|
33
27
|
|
|
@@ -5,7 +5,29 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
|
|
5
5
|
import { VoxFieldElement, fieldStyles } from '../../internal/field.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Fallback accessible names, keyed by input type. Used only when no `label`
|
|
9
|
+
* is supplied — every control still needs an accessible name (WCAG 4.1.2),
|
|
10
|
+
* and "text input" is wrong for a date or password field.
|
|
11
|
+
*/
|
|
12
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
13
|
+
text: 'text input',
|
|
14
|
+
email: 'email address',
|
|
15
|
+
number: 'number',
|
|
16
|
+
password: 'password',
|
|
17
|
+
search: 'search',
|
|
18
|
+
tel: 'telephone number',
|
|
19
|
+
url: 'web address',
|
|
20
|
+
date: 'date',
|
|
21
|
+
time: 'time',
|
|
22
|
+
'datetime-local': 'date and time',
|
|
23
|
+
month: 'month',
|
|
24
|
+
week: 'week',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A single-line input with label and help note. Accepts any native input
|
|
29
|
+
* type; `min`/`max`/`step`/`pattern` make the numeric, date and pattern
|
|
30
|
+
* types enforceable.
|
|
9
31
|
* Participates in native form submission via ElementInternals.
|
|
10
32
|
*/
|
|
11
33
|
@customElement('vox-input')
|
|
@@ -16,8 +38,29 @@ export class VoxInput extends VoxFieldElement {
|
|
|
16
38
|
@property() autocomplete?: string;
|
|
17
39
|
@property({ type: Boolean, reflect: true }) readonly = false;
|
|
18
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Bounds and granularity for the numeric and date-like types. Left as
|
|
43
|
+
* strings so date types can take `min="2026-01-01"` and number types
|
|
44
|
+
* `min="0"` through the same attribute.
|
|
45
|
+
*/
|
|
46
|
+
@property() min?: string;
|
|
47
|
+
@property() max?: string;
|
|
48
|
+
@property() step?: string;
|
|
49
|
+
|
|
50
|
+
/** Constraints for the text-like types. */
|
|
51
|
+
@property() pattern?: string;
|
|
52
|
+
@property() minlength?: string;
|
|
53
|
+
@property() maxlength?: string;
|
|
54
|
+
|
|
55
|
+
/** On-screen keyboard hint for touch devices. */
|
|
56
|
+
@property() inputmode?: string;
|
|
57
|
+
|
|
19
58
|
static styles = fieldStyles;
|
|
20
59
|
|
|
60
|
+
private get accessibleName() {
|
|
61
|
+
return TYPE_LABELS[this.type] ?? `${this.type} input`;
|
|
62
|
+
}
|
|
63
|
+
|
|
21
64
|
formResetCallback() {
|
|
22
65
|
this.value = '';
|
|
23
66
|
}
|
|
@@ -49,13 +92,20 @@ export class VoxInput extends VoxFieldElement {
|
|
|
49
92
|
id="input"
|
|
50
93
|
class="control"
|
|
51
94
|
type=${this.type}
|
|
95
|
+
min=${ifDefined(this.min)}
|
|
96
|
+
max=${ifDefined(this.max)}
|
|
97
|
+
step=${ifDefined(this.step)}
|
|
52
98
|
.value=${live(this.value)}
|
|
53
99
|
placeholder=${ifDefined(this.placeholder)}
|
|
54
100
|
autocomplete=${ifDefined(this.autocomplete)}
|
|
101
|
+
pattern=${ifDefined(this.pattern)}
|
|
102
|
+
minlength=${ifDefined(this.minlength)}
|
|
103
|
+
maxlength=${ifDefined(this.maxlength)}
|
|
104
|
+
inputmode=${ifDefined(this.inputmode)}
|
|
55
105
|
?required=${this.required}
|
|
56
106
|
?readonly=${this.readonly}
|
|
57
107
|
?disabled=${this.disabled}
|
|
58
|
-
aria-label=${this.label ? nothing :
|
|
108
|
+
aria-label=${this.label ? nothing : this.accessibleName}
|
|
59
109
|
@input=${this.handleInput}
|
|
60
110
|
@change=${this.handleChange}
|
|
61
111
|
/>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { html, css, nothing } from 'lit';
|
|
2
|
+
import { customElement, property, query } from 'lit/decorators.js';
|
|
3
|
+
import { live } from 'lit/directives/live.js';
|
|
4
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
5
|
+
import { VoxFieldElement, fieldStyles } from '../../internal/field.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A slider for picking a number from a range. Built on a native
|
|
9
|
+
* `<input type="range">` so keyboard interaction, the `slider` role and
|
|
10
|
+
* screen-reader value announcements come from the platform.
|
|
11
|
+
* Participates in native form submission via ElementInternals.
|
|
12
|
+
*/
|
|
13
|
+
@customElement('vox-range')
|
|
14
|
+
export class VoxRange extends VoxFieldElement {
|
|
15
|
+
@property() value = '';
|
|
16
|
+
@property() min = '0';
|
|
17
|
+
@property() max = '100';
|
|
18
|
+
@property() step = '1';
|
|
19
|
+
|
|
20
|
+
/** Show the current value beside the label. */
|
|
21
|
+
@property({ type: Boolean, attribute: 'show-value' }) showValue = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Unit appended to the displayed value and to the spoken value. Use for
|
|
25
|
+
* things the number alone doesn't convey, e.g. "%" or "MB".
|
|
26
|
+
*/
|
|
27
|
+
@property() unit = '';
|
|
28
|
+
|
|
29
|
+
@query('input') private inputEl!: HTMLInputElement;
|
|
30
|
+
|
|
31
|
+
/** Value to restore on form reset, per the native reset behaviour. */
|
|
32
|
+
private defaultValue = '';
|
|
33
|
+
|
|
34
|
+
static styles = [
|
|
35
|
+
fieldStyles,
|
|
36
|
+
css`
|
|
37
|
+
.row {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: var(--vox-space-3);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.readout {
|
|
44
|
+
flex: none;
|
|
45
|
+
min-width: 3.5ch;
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
font-variant-numeric: tabular-nums;
|
|
48
|
+
color: var(--vox-color-text-2);
|
|
49
|
+
text-align: right;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
input.control {
|
|
53
|
+
appearance: none;
|
|
54
|
+
-webkit-appearance: none;
|
|
55
|
+
padding: 0;
|
|
56
|
+
border: none;
|
|
57
|
+
background: transparent;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
/* Room for the thumb's focus ring at both ends of the track. */
|
|
60
|
+
height: 24px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
input.control:focus {
|
|
64
|
+
outline: none;
|
|
65
|
+
border: none;
|
|
66
|
+
box-shadow: none;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
input.control:disabled {
|
|
70
|
+
cursor: not-allowed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
* Track and thumb need vendor-prefixed selectors, and a browser drops
|
|
75
|
+
* the whole rule if it doesn't recognise one — so no grouping here.
|
|
76
|
+
*/
|
|
77
|
+
input.control::-webkit-slider-runnable-track {
|
|
78
|
+
height: 6px;
|
|
79
|
+
border-radius: var(--vox-radius-full);
|
|
80
|
+
/* 3:1 against the page background, per WCAG 1.4.11. */
|
|
81
|
+
background-color: var(--vox-color-border);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
input.control::-moz-range-track {
|
|
85
|
+
height: 6px;
|
|
86
|
+
border-radius: var(--vox-radius-full);
|
|
87
|
+
background-color: var(--vox-color-border);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
input.control::-webkit-slider-thumb {
|
|
91
|
+
appearance: none;
|
|
92
|
+
-webkit-appearance: none;
|
|
93
|
+
width: 20px;
|
|
94
|
+
height: 20px;
|
|
95
|
+
margin-top: -7px;
|
|
96
|
+
border: 2px solid var(--vox-color-bg);
|
|
97
|
+
border-radius: var(--vox-radius-full);
|
|
98
|
+
background-color: var(--vox-color-brand-1);
|
|
99
|
+
transition: box-shadow var(--vox-transition-fast);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
input.control::-moz-range-thumb {
|
|
103
|
+
width: 20px;
|
|
104
|
+
height: 20px;
|
|
105
|
+
border: 2px solid var(--vox-color-bg);
|
|
106
|
+
border-radius: var(--vox-radius-full);
|
|
107
|
+
background-color: var(--vox-color-brand-1);
|
|
108
|
+
transition: box-shadow var(--vox-transition-fast);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
input.control:focus-visible::-webkit-slider-thumb {
|
|
112
|
+
box-shadow: 0 0 0 3px var(--vox-color-brand-soft);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
input.control:focus-visible::-moz-range-thumb {
|
|
116
|
+
box-shadow: 0 0 0 3px var(--vox-color-brand-soft);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@media (prefers-reduced-motion: reduce) {
|
|
120
|
+
input.control::-webkit-slider-thumb,
|
|
121
|
+
input.control::-moz-range-thumb {
|
|
122
|
+
transition: none;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
`,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
connectedCallback() {
|
|
129
|
+
super.connectedCallback();
|
|
130
|
+
// Midpoint is what a native range reports when it has no value attribute.
|
|
131
|
+
if (this.value === '') {
|
|
132
|
+
const midpoint = (Number(this.min) + Number(this.max)) / 2;
|
|
133
|
+
this.value = String(Number.isFinite(midpoint) ? midpoint : this.min);
|
|
134
|
+
}
|
|
135
|
+
this.defaultValue = this.value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
formResetCallback() {
|
|
139
|
+
this.value = this.defaultValue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
updated() {
|
|
143
|
+
this.internals.setFormValue(this.value);
|
|
144
|
+
if (this.inputEl) this.syncValidity(this.inputEl);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
focus(options?: FocusOptions) {
|
|
148
|
+
this.inputEl?.focus(options);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private handleInput(event: Event) {
|
|
152
|
+
this.value = (event.target as HTMLInputElement).value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private handleChange() {
|
|
156
|
+
// Native change events don't cross shadow boundaries; re-dispatch.
|
|
157
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
render() {
|
|
161
|
+
const display = this.unit ? `${this.value}${this.unit}` : this.value;
|
|
162
|
+
return html`
|
|
163
|
+
<div class="field">
|
|
164
|
+
${this.renderLabel('range')}
|
|
165
|
+
<div class="row">
|
|
166
|
+
<input
|
|
167
|
+
id="range"
|
|
168
|
+
class="control"
|
|
169
|
+
type="range"
|
|
170
|
+
min=${this.min}
|
|
171
|
+
max=${this.max}
|
|
172
|
+
step=${this.step}
|
|
173
|
+
.value=${live(this.value)}
|
|
174
|
+
?disabled=${this.disabled}
|
|
175
|
+
aria-label=${this.label ? nothing : 'slider'}
|
|
176
|
+
aria-valuetext=${ifDefined(this.unit ? display : undefined)}
|
|
177
|
+
@input=${this.handleInput}
|
|
178
|
+
@change=${this.handleChange}
|
|
179
|
+
/>
|
|
180
|
+
${this.showValue
|
|
181
|
+
? html`<span class="readout" aria-hidden="true">${display}</span>`
|
|
182
|
+
: nothing}
|
|
183
|
+
</div>
|
|
184
|
+
${this.renderNote()}
|
|
185
|
+
</div>
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
declare global {
|
|
191
|
+
interface HTMLElementTagNameMap {
|
|
192
|
+
'vox-range': VoxRange;
|
|
193
|
+
}
|
|
194
|
+
}
|