@openvoxproject/voxblocks 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvoxproject/voxblocks",
3
- "version": "0.12.0",
3
+ "version": "0.15.0",
4
4
  "description": "Web components for OpenVox community web sites and apps",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
+ }
@@ -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
- * A single-line text input with label and help note.
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 : 'text input'}
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
+ }