@getdom/studio 0.2.2 → 0.2.3

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.
@@ -22,6 +22,7 @@ import {
22
22
 
23
23
  const OBSERVED = [
24
24
  'value',
25
+ 'value-label',
25
26
  'open',
26
27
  'align',
27
28
  'offset',
@@ -30,24 +31,29 @@ const OBSERVED = [
30
31
  'floating-mode',
31
32
  ];
32
33
 
34
+ /** Read an option's plain-text label when no explicit label was supplied. */
33
35
  function textOf(option) {
34
36
  return option.textContent.trim();
35
37
  }
36
38
 
39
+ /** Resolve an option's stable committed value. */
37
40
  function valueOf(option) {
38
41
  return option.dataset.value ?? option.getAttribute('value') ?? option.value ?? textOf(option);
39
42
  }
40
43
 
44
+ /** Resolve display text independently of rich option markup. */
41
45
  function labelOf(option) {
42
46
  return option.dataset.label ?? option.getAttribute('label') ?? textOf(option);
43
47
  }
44
48
 
49
+ /** Match literal search text without interpreting HTML or regular expressions. */
45
50
  function matchesOption(option, query) {
46
51
  if (!query) return true;
47
- const haystack = `${option.dataset.value || ''} ${option.dataset.label || ''} ${option.textContent}`.toLowerCase();
52
+ const haystack = (option.dataset.search ?? `${option.dataset.value || ''} ${option.dataset.label || ''} ${option.textContent}`).toLowerCase();
48
53
  return haystack.includes(query);
49
54
  }
50
55
 
56
+ /** Dispatch a cancelable custom-value request before committing free text. */
51
57
  function dispatchCancelable(host, type, detail) {
52
58
  const event = new CustomEvent(type, {
53
59
  detail,
@@ -102,11 +108,14 @@ function hideListPopover(list) {
102
108
  export class ComboBase extends DomElementBase {
103
109
  static get observedAttributes() { return OBSERVED; }
104
110
 
111
+ /** Initialize committed-selection, transient-query and owned listener state. */
105
112
  constructor() {
106
113
  super();
107
114
  this._active = -1;
108
115
  this._open = false;
109
116
  this._options = [];
117
+ this._query = '';
118
+ this._selection = null;
110
119
  this._unbindFloating = null;
111
120
  this._mutationObserver = null;
112
121
  this._wiredList = null;
@@ -120,6 +129,7 @@ export class ComboBase extends DomElementBase {
120
129
  get _valueMode() { return 'option'; }
121
130
  get _activeOnFilter() { return true; }
122
131
 
132
+ /** Read the shared floating placement contract from host attributes. */
123
133
  _positionOpts() {
124
134
  return {
125
135
  align: this.getAttribute('align') || 'left',
@@ -130,6 +140,7 @@ export class ComboBase extends DomElementBase {
130
140
  };
131
141
  }
132
142
 
143
+ /** Wire the input and optional external list after the custom element connects. */
133
144
  connectedCallback() {
134
145
  this._input = this.querySelector('[slot="input"], [data-input]');
135
146
  this._toggle = this.querySelector('[slot="toggle"], [data-toggle]');
@@ -149,12 +160,18 @@ export class ComboBase extends DomElementBase {
149
160
  if (this._list) this._wireList();
150
161
 
151
162
  this.on(this._input, 'input', () => this._handleInput());
152
- this.on(this._input, 'focus', () => this._filter(this._input.value, { open: true }));
163
+ this.on(this._input, 'focus', () => {
164
+ if (!this._open) this._filter('', { open: true, preferSelected: true });
165
+ });
166
+ this.on(this._input, 'click', () => {
167
+ if (!this._open) this._filter('', { open: true, preferSelected: true });
168
+ });
153
169
  this.on(this._input, 'keydown', (e) => this._onKey(e));
154
170
  if (this.hasAttribute('value')) this._syncInputFromValue();
155
171
  if (this.hasAttribute('open')) this._setOpen(true);
156
172
  }
157
173
 
174
+ /** Release document listeners, observers, floating effects and list ownership. */
158
175
  disconnectedCallback() {
159
176
  this._setOpen(false);
160
177
  clearFloatingEffectTimer(this._list);
@@ -163,11 +180,14 @@ export class ComboBase extends DomElementBase {
163
180
  this._mutationObserver = null;
164
181
  this._wiredList?.removeEventListener('mousedown', this._onListPointerDown);
165
182
  this._wiredList?.removeEventListener('mousemove', this._onListPointerMove);
183
+ if (this._wiredList?.__elementComboOwner === this) delete this._wiredList.__elementComboOwner;
166
184
  this._wiredList = null;
185
+ this._list = null;
167
186
  super.disconnectedCallback();
168
187
  document.removeEventListener('mousedown', this._onDocDown);
169
188
  }
170
189
 
190
+ /** Resolve either a slotted list or its application-provided teleported target. */
171
191
  _resolveList() {
172
192
  const inner = this.querySelector('[slot="list"], [data-list]');
173
193
  if (inner) return inner;
@@ -175,6 +195,7 @@ export class ComboBase extends DomElementBase {
175
195
  return id ? document.getElementById(id) : null;
176
196
  }
177
197
 
198
+ /** Reconnect the list after a framework replaces or teleports its element. */
178
199
  _ensureList() {
179
200
  if (!this._list || !this._list.isConnected) {
180
201
  this._list = this._resolveList();
@@ -183,6 +204,7 @@ export class ComboBase extends DomElementBase {
183
204
  return this._list;
184
205
  }
185
206
 
207
+ /** Own one list's pointer events and observe async option changes without reopening it. */
186
208
  _wireList() {
187
209
  if (!this._list || this._list.__elementComboOwner === this) return;
188
210
  const id = uid('cmb');
@@ -202,27 +224,46 @@ export class ComboBase extends DomElementBase {
202
224
  this._list.addEventListener('mousemove', this._onListPointerMove);
203
225
  this._mutationObserver?.disconnect();
204
226
  this._mutationObserver = new MutationObserver(() => {
205
- this._refreshOptions();
206
- if (this._open || document.activeElement === this._input) {
207
- this._filter(this._input.value, { open: true, emitQuery: false });
227
+ if (this._open) {
228
+ this._filter(this._query, { open: true, emitQuery: false, preserveActive: true });
208
229
  this._positionList();
209
- }
230
+ } else this._refreshOptions();
231
+ });
232
+ this._mutationObserver.observe(this._list, {
233
+ childList: true,
234
+ subtree: true,
235
+ characterData: true,
236
+ attributes: true,
237
+ attributeFilter: ['data-value', 'data-label', 'data-search', 'aria-disabled', 'disabled'],
210
238
  });
211
- this._mutationObserver.observe(this._list, { childList: true, subtree: true, characterData: true });
212
239
  this._refreshOptions();
213
240
  this._syncInputFromValue();
214
241
  }
215
242
 
243
+ /** Refresh selectable rows while keeping status and paging controls out of navigation. */
216
244
  _refreshOptions() {
217
245
  if (!this._list) return;
218
246
  const id = this._list.id || uid('cmb-list');
219
- this._options = Array.from(this._list.children).filter((option) => !option.hasAttribute('data-disabled'));
247
+ this._options = Array.from(this._list.children).filter((option) => option.hasAttribute('data-value'));
220
248
  this._options.forEach((option, index) => {
221
249
  option.setAttribute('role', 'option');
222
250
  option.id = option.id || `${id}-opt-${index}`;
251
+ option.setAttribute('aria-selected', String(this._isSelected(option)));
223
252
  });
224
253
  }
225
254
 
255
+ /** Test committed selection independently from the keyboard-active row. */
256
+ _isSelected(option) {
257
+ if (this._valueMode === 'text') return labelOf(option) === this.value && Boolean(this.value);
258
+ return String(valueOf(option)) === this.value;
259
+ }
260
+
261
+ /** Keep disabled rows visible but exclude them from navigation and commitment. */
262
+ _isDisabled(option) {
263
+ return option.hasAttribute('disabled') || option.hasAttribute('data-disabled') || option.getAttribute('aria-disabled') === 'true';
264
+ }
265
+
266
+ /** Emit intentional typing separately from the committed option value. */
226
267
  _handleInput() {
227
268
  const value = this._input.value;
228
269
  this.emit('dom:input', { value });
@@ -230,24 +271,29 @@ export class ComboBase extends DomElementBase {
230
271
  if (this._freeText) this.setAttribute('value', value);
231
272
  }
232
273
 
274
+ /** Restore the committed label, including a selection outside the current page. */
233
275
  _syncInputFromValue() {
234
276
  if (!this._input || !this.hasAttribute('value')) return;
235
277
  const value = this.getAttribute('value') || '';
278
+ this._refreshOptions();
236
279
  if (this._valueMode === 'text') {
237
280
  if (this._input.value !== value) this._input.value = value;
238
281
  return;
239
282
  }
240
283
  const selected = this._options.find((option) => valueOf(option) === value);
241
- const display = selected ? labelOf(selected) : value;
284
+ if (selected) this._selection = { value, label: labelOf(selected) };
285
+ const display = selected ? labelOf(selected) : this.getAttribute('value-label') ?? (this._selection?.value === value ? this._selection.label : value);
242
286
  if (this._input.value !== display) this._input.value = display;
243
287
  }
244
288
 
289
+ /** Close only when pointer interaction is outside both the host and its popup. */
245
290
  _onDocDown(e) {
246
291
  if (this.contains(e.target)) return;
247
292
  if (this._list && this._list.contains(e.target)) return;
248
293
  this._setOpen(false);
249
294
  }
250
295
 
296
+ /** Size and position the floating list relative to its input. */
251
297
  _positionList() {
252
298
  if (!this._input || !this._list || this._list.hidden) return;
253
299
  const rect = this._input.getBoundingClientRect();
@@ -255,16 +301,19 @@ export class ComboBase extends DomElementBase {
255
301
  applyFloatingPosition(this._input, this._list, this._positionOpts());
256
302
  }
257
303
 
304
+ /** Track viewport, content and anchor changes while the popup is open. */
258
305
  _bindFloating() {
259
306
  this._unbindFloating?.();
260
307
  this._unbindFloating = autoUpdateFloating(this._input, this._list, () => this._positionList());
261
308
  }
262
309
 
310
+ /** Stop all floating-position observers owned by this control. */
263
311
  _unbindFloating() {
264
312
  this._unbindFloating?.();
265
313
  this._unbindFloating = null;
266
314
  }
267
315
 
316
+ /** Hide the native popover and clear its completed animation state. */
268
317
  _finishListClose(list = this._list) {
269
318
  if (!list) return;
270
319
  hideListPopover(list);
@@ -272,6 +321,7 @@ export class ComboBase extends DomElementBase {
272
321
  resetFloatingEffectState(list);
273
322
  }
274
323
 
324
+ /** Promote the list to the top layer and start the shared enter effect. */
275
325
  _showList() {
276
326
  const list = this._list;
277
327
  list.hidden = false;
@@ -282,6 +332,7 @@ export class ComboBase extends DomElementBase {
282
332
  });
283
333
  }
284
334
 
335
+ /** Run the leave effect without allowing a stale close to hide a reopened list. */
285
336
  _hideList() {
286
337
  const list = this._list;
287
338
  beginFloatingEffectLeave(list, {
@@ -290,8 +341,11 @@ export class ComboBase extends DomElementBase {
290
341
  });
291
342
  }
292
343
 
344
+ /** Synchronize logical state, ARIA, listeners and lifecycle events exactly once. */
293
345
  _setOpen(open) {
294
346
  if (!this._ensureList()) return;
347
+ if (open && (this._input.disabled || this._input.readOnly)) return;
348
+ const changed = open !== this._open;
295
349
  if (open === this._open && this._list.hidden === !open) {
296
350
  if (open) this._positionList();
297
351
  return;
@@ -310,72 +364,104 @@ export class ComboBase extends DomElementBase {
310
364
  } else {
311
365
  this._unbindFloating();
312
366
  this._setActive(-1);
367
+ this._query = '';
368
+ if (!this._freeText) this._syncInputFromValue();
313
369
  document.removeEventListener('mousedown', this._onDocDown);
314
370
  }
371
+ if (changed) this.emit(open ? 'dom:open' : 'dom:close');
315
372
  }
316
373
 
317
- _filter(value, { open = true, emitQuery = true } = {}) {
374
+ /** Filter transient input only; selection labels never become a reopening query. */
375
+ _filter(value, { open = true, emitQuery = true, preserveActive = false, preferSelected = false } = {}) {
376
+ this._query = value;
318
377
  if (!this._ensureList()) {
319
378
  if (emitQuery) this.emit('dom:query', { query: value });
320
379
  return;
321
380
  }
322
381
  if (emitQuery) this.emit('dom:query', { query: value });
323
382
 
383
+ const activeOption = preserveActive ? this._options[this._active] : null;
324
384
  this._refreshOptions();
325
385
  const query = value.trim().toLowerCase();
326
386
  let firstVisible = -1;
327
387
 
328
388
  this._options.forEach((option, index) => {
329
- const match = matchesOption(option, query);
389
+ const match = this.getAttribute('filter-options') === 'false' || matchesOption(option, query);
330
390
  option.hidden = !match;
331
- if (match && firstVisible === -1) firstVisible = index;
391
+ if (match && !this._isDisabled(option) && firstVisible === -1) firstVisible = index;
332
392
  });
333
393
 
334
394
  this._list.toggleAttribute('data-empty', firstVisible < 0);
335
- this._setOpen(open && firstVisible >= 0);
336
- this._setActive(this._activeOnFilter ? firstVisible : -1);
395
+ this._setOpen(open);
396
+ const selected = preferSelected ? this._options.findIndex((option) => !option.hidden && !this._isDisabled(option) && this._isSelected(option)) : -1;
397
+ const previous = activeOption && !activeOption.hidden ? this._options.indexOf(activeOption) : -1;
398
+ this._setActive(previous >= 0 ? previous : selected >= 0 ? selected : this._activeOnFilter ? firstVisible : -1);
399
+ if (preferSelected && selected >= 0) this._scrollActive();
400
+ else if (!preserveActive && this._list) this._list.scrollTop = 0;
337
401
  this._positionList();
338
402
  }
339
403
 
404
+ /** Mark navigation without changing aria-selected, which describes commitment. */
340
405
  _setActive(index) {
341
406
  this._active = index;
342
407
  this._options.forEach((option, optionIndex) => {
343
408
  const active = optionIndex === index;
344
409
  option.toggleAttribute('data-active', active);
345
- option.setAttribute('aria-selected', String(active));
410
+ option.setAttribute('aria-selected', String(this._isSelected(option)));
346
411
  });
347
412
  if (index >= 0 && this._options[index]) this._input.setAttribute('aria-activedescendant', this._options[index].id);
348
413
  else this._input.removeAttribute('aria-activedescendant');
349
414
  }
350
415
 
416
+ /** Scroll within the list only, without moving the containing page. */
417
+ _scrollActive() {
418
+ const option = this._options[this._active];
419
+ if (!option || !this._list) return;
420
+ const row = option.getBoundingClientRect();
421
+ const list = this._list.getBoundingClientRect();
422
+ const scale = list.height / this._list.offsetHeight || 1;
423
+ if (row.top < list.top) this._list.scrollTop += (row.top - list.top) / scale;
424
+ else if (row.bottom > list.bottom) this._list.scrollTop += (row.bottom - list.bottom) / scale;
425
+ }
426
+
427
+ /** Return enabled visible rows with their stable positions in the rendered list. */
351
428
  _visible() {
352
429
  this._refreshOptions();
353
430
  return this._options
354
431
  .map((option, index) => ({ option, index }))
355
- .filter(({ option }) => !option.hidden);
432
+ .filter(({ option }) => !option.hidden && !this._isDisabled(option));
356
433
  }
357
434
 
435
+ /** Move the active descendant, scrolling it into view or requesting another page. */
358
436
  _move(delta) {
359
437
  const visible = this._visible();
360
438
  if (!visible.length) return;
361
439
  const current = visible.findIndex(({ index }) => index === this._active);
440
+ if (delta > 0 && current === visible.length - 1 && this.hasAttribute('has-more')) {
441
+ this.emit('dom:load-more', { query: this._query });
442
+ return;
443
+ }
362
444
  if (current === -1) {
363
445
  this._setActive(delta > 0 ? visible[0].index : visible[visible.length - 1].index);
446
+ this._scrollActive();
364
447
  return;
365
448
  }
366
449
  const next = visible[(current + delta + visible.length) % visible.length];
367
450
  this._setActive(next.index);
451
+ this._scrollActive();
368
452
  }
369
453
 
454
+ /** Handle selection, free-text commitment, dismissal and paginated navigation. */
370
455
  _onKey(e) {
456
+ if (this._input.disabled || this._input.readOnly || e.isComposing) return;
371
457
  if (e.key === 'ArrowDown') {
372
458
  e.preventDefault();
373
- if (!this._open) this._filter(this._input.value, { open: true });
374
- this._move(1);
459
+ if (!this._open) this._filter('', { open: true, preferSelected: true });
460
+ else this._move(1);
375
461
  } else if (e.key === 'ArrowUp') {
376
462
  e.preventDefault();
377
- if (!this._open) this._filter(this._input.value, { open: true });
378
- this._move(-1);
463
+ if (!this._open) this._filter('', { open: true, preferSelected: true });
464
+ else this._move(-1);
379
465
  } else if (e.key === 'Enter') {
380
466
  if (this._active >= 0) {
381
467
  e.preventDefault();
@@ -387,33 +473,41 @@ export class ComboBase extends DomElementBase {
387
473
  } else if (e.key === 'Escape') {
388
474
  e.preventDefault();
389
475
  this._setOpen(false);
476
+ if (!this._freeText) this._syncInputFromValue();
477
+ } else if (e.key === 'Tab') {
478
+ this._setOpen(false);
390
479
  }
391
480
  }
392
481
 
482
+ /** Commit only an enabled option while keeping focus on the input. */
393
483
  _onListPointerDown(e) {
394
484
  const option = e.target.closest?.('[role="option"]');
395
- if (!option || !this._list.contains(option) || option.hidden) return;
485
+ if (!option || !this._list.contains(option) || option.hidden || this._isDisabled(option)) return;
396
486
  e.preventDefault();
397
487
  this._commit(option);
398
488
  }
399
489
 
490
+ /** Update pointer activity independently of committed selection. */
400
491
  _onListPointerMove(e) {
401
492
  const option = e.target.closest?.('[role="option"]');
402
- if (!option || !this._list.contains(option) || option.hidden) return;
493
+ if (!option || !this._list.contains(option) || option.hidden || this._isDisabled(option)) return;
403
494
  const index = this._options.indexOf(option);
404
495
  if (index >= 0) this._setActive(index);
405
496
  }
406
497
 
498
+ /** Toggle from committed state without using its display label as a search query. */
407
499
  _toggleList() {
500
+ if (this._input.disabled || this._input.readOnly) return;
408
501
  if (!this._ensureList()) return;
409
502
  const wasOpen = this._open;
410
503
  this._input.focus();
411
504
  if (wasOpen) this._setOpen(false);
412
- else this._filter('', { open: true });
505
+ else if (!this._open) this._filter('', { open: true, preferSelected: true });
413
506
  }
414
507
 
508
+ /** Commit one enabled option, clear transient query state and notify consumers. */
415
509
  _commit(option) {
416
- if (!option) return;
510
+ if (!option || this._isDisabled(option) || this._input.disabled || this._input.readOnly) return;
417
511
  const value = valueOf(option);
418
512
  const detail = {
419
513
  value,
@@ -423,6 +517,8 @@ export class ComboBase extends DomElementBase {
423
517
  };
424
518
  const inputValue = this._valueMode === 'text' ? detail.label : detail.label;
425
519
  const hostValue = this._valueMode === 'text' ? detail.label : value;
520
+ this._selection = { value: hostValue, label: detail.label };
521
+ this._query = '';
426
522
 
427
523
  this._input.value = inputValue;
428
524
  this.setAttribute('value', hostValue);
@@ -431,6 +527,7 @@ export class ComboBase extends DomElementBase {
431
527
  this.emit('dom:change', detail);
432
528
  }
433
529
 
530
+ /** Commit permitted free text after a cancelable application validation event. */
434
531
  _commitCustom(rawValue) {
435
532
  const value = rawValue.trim();
436
533
  if (!value) return;
@@ -443,9 +540,10 @@ export class ComboBase extends DomElementBase {
443
540
  this.emit('dom:change', detail);
444
541
  }
445
542
 
543
+ /** Apply external value, open-state and floating-placement updates. */
446
544
  attributeChangedCallback(name, _old, value) {
447
545
  if (!this.isConnected || !this._input) return;
448
- if (name === 'value') this._syncInputFromValue();
546
+ if (name === 'value' || (name === 'value-label' && !this._open)) this._syncInputFromValue();
449
547
  if (name === 'open') this._setOpen(value !== null);
450
548
  if (['align', 'offset', 'placement', 'collision-padding', 'floating-mode'].includes(name) && this._open) this._positionList();
451
549
  }
@@ -469,12 +567,18 @@ export class DomComboboxElement extends ComboBase {
469
567
  { name: 'placement', type: 'string', description: 'Preferred floating placement before collision handling.' },
470
568
  { name: 'floating-mode', type: "'viewport' | 'anchor'", description: 'viewport keeps the list inside the browser; anchor keeps it attached to the input while scrolling.' },
471
569
  { name: 'data-menu-id', type: 'string', description: 'Id of an external or teleported list element.' },
570
+ { name: 'filter-options', type: 'string', description: 'Set false for server-filtered results. Query events still fire.' },
571
+ { name: 'has-more', type: 'boolean', description: 'Request another application-owned page when navigating beyond the loaded options.' },
572
+ { name: 'value-label', type: 'string', description: 'Resolved label for a committed value outside the current page.' },
472
573
  ],
473
574
  events: [
474
575
  { name: 'dom:input', payload: '{ value }', description: 'Fired whenever the text value changes.' },
475
576
  { name: 'dom:query', payload: '{ query }', description: 'Fired whenever the user types. Useful for server lookups.' },
476
577
  { name: 'dom:select', payload: '{ value, label, option }', description: 'Fired when an option is selected.' },
477
578
  { name: 'dom:change', payload: '{ value, option, custom }', description: 'Fired when an option is committed.' },
579
+ { name: 'dom:open', description: 'Fired when the list opens, including an empty/loading list.' },
580
+ { name: 'dom:close', description: 'Fired when the list closes. Later option updates do not reopen it.' },
581
+ { name: 'dom:load-more', payload: '{ query }', description: 'Fired at the loaded keyboard boundary when has-more is set.' },
478
582
  ],
479
583
  keyboard: [
480
584
  { keys: '↑ / ↓', action: 'Move active option.' },
@@ -130,7 +130,7 @@ export class DomDropdownElement extends DomElementBase {
130
130
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
131
131
  e.preventDefault();
132
132
  const position = e.key === 'ArrowUp' ? 'last' : 'preferred';
133
- if (this._open || isPopoverOpen(this._menu)) {
133
+ if (this._open) {
134
134
  this._focusMenuStart(position, true);
135
135
  return;
136
136
  }
@@ -363,9 +363,10 @@ export class DomDropdownElement extends DomElementBase {
363
363
  this._syncOpen(next, false);
364
364
  }
365
365
 
366
+ /** Toggle logical state, including while a previous close animation is finishing. */
366
367
  toggle() {
367
368
  if (!this._ensureMenu()) return;
368
- this._syncOpen(!isPopoverOpen(this._menu), false);
369
+ this._syncOpen(!this._open, false);
369
370
  }
370
371
 
371
372
  _select(el, ev) {
@@ -5,6 +5,7 @@ export declare class DomListboxElement extends DomElementBase {
5
5
  static readonly observedAttributes?: Array<string>;
6
6
  static readonly __doc?: HeadlessElementDoc;
7
7
  value: string;
8
+ readonly values: Array<string>;
8
9
  readonly options: Array<HTMLElement>;
9
10
  }
10
11
  export interface HeadlessElementDoc {
@@ -1,11 +1,12 @@
1
1
  import { DomElementBase, createRoving, defineDomElement } from './base.js';
2
2
 
3
+ /** Resolve a listbox row's stable value, independent of its rich rendering. */
3
4
  function valueOf(option) {
4
5
  return option.dataset.value ?? option.textContent.trim();
5
6
  }
6
7
 
7
8
  export class DomListboxElement extends DomElementBase {
8
- static get observedAttributes() { return ['value', 'orientation']; }
9
+ static get observedAttributes() { return ['value', 'orientation', 'multiple']; }
9
10
 
10
11
  static __doc = {
11
12
  name: 'dom-listbox',
@@ -14,10 +15,13 @@ export class DomListboxElement extends DomElementBase {
14
15
  attributes: [
15
16
  { name: 'value', type: 'string', description: 'Selected option value.' },
16
17
  { name: 'orientation', type: "'vertical' | 'horizontal'", description: 'Arrow-key direction. Defaults to vertical.' },
18
+ { name: 'multiple', type: 'boolean', description: 'Toggle multiple choices. value is then a JSON array; values returns the parsed selected strings.' },
19
+ { name: 'has-more', type: 'boolean', description: 'Emit dom:load-more instead of wrapping beyond the final loaded option.' },
17
20
  ],
18
21
  events: [
19
22
  { name: 'dom:change', payload: '{ value, option }', description: 'Fired when selection changes.' },
20
23
  { name: 'dom:select', payload: '{ value, option }', description: 'Fired when an option is selected.' },
24
+ { name: 'dom:load-more', description: 'Fired when ArrowDown reaches the last loaded option and has-more is set.' },
21
25
  ],
22
26
  keyboard: [
23
27
  { keys: '↑ / ↓', action: 'Move active option.' },
@@ -26,6 +30,7 @@ export class DomListboxElement extends DomElementBase {
26
30
  ],
27
31
  };
28
32
 
33
+ /** Initialize roving navigation and coalesced option refresh state. */
29
34
  constructor() {
30
35
  super();
31
36
  this._roving = null;
@@ -36,6 +41,7 @@ export class DomListboxElement extends DomElementBase {
36
41
  this._scheduleRefresh = this._scheduleRefresh.bind(this);
37
42
  }
38
43
 
44
+ /** Observe dynamic option pages and attach list-level keyboard and pointer handlers. */
39
45
  connectedCallback() {
40
46
  this.setAttribute('role', this.getAttribute('role') || 'listbox');
41
47
  this._refresh();
@@ -50,6 +56,7 @@ export class DomListboxElement extends DomElementBase {
50
56
  });
51
57
  }
52
58
 
59
+ /** Refresh selection and navigation when the public host state changes. */
53
60
  attributeChangedCallback() {
54
61
  this._refresh();
55
62
  }
@@ -60,11 +67,23 @@ export class DomListboxElement extends DomElementBase {
60
67
  else this.setAttribute('value', value);
61
68
  }
62
69
 
70
+ /** Read selected values; multiple lists serialize the value attribute as JSON. */
71
+ get values() {
72
+ if (!this.hasAttribute('multiple')) return [this.value];
73
+ try {
74
+ const values = JSON.parse(this.value || '[]');
75
+ return Array.isArray(values) ? values.map(String) : [];
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
63
81
  get options() {
64
82
  return Array.from(this.querySelectorAll('[role="option"]'))
65
83
  .filter((option) => !option.hasAttribute('disabled') && option.getAttribute('aria-disabled') !== 'true');
66
84
  }
67
85
 
86
+ /** Coalesce option mutations into one animation-frame navigation refresh. */
68
87
  _scheduleRefresh() {
69
88
  if (this._refreshFrame) return;
70
89
  this._refreshFrame = requestAnimationFrame(() => {
@@ -73,37 +92,53 @@ export class DomListboxElement extends DomElementBase {
73
92
  });
74
93
  }
75
94
 
95
+ /** Preserve focused rows across page appends and expose committed ARIA selection. */
76
96
  _refresh() {
77
97
  const options = this.options;
78
98
  const orientation = this.getAttribute('orientation') || 'vertical';
79
99
  const focused = options.findIndex((option) => option === document.activeElement || option.contains(document.activeElement));
80
- const selected = options.findIndex((option) => valueOf(option) === this.value);
100
+ const values = new Set(this.values);
101
+ const selected = options.findIndex((option) => values.has(valueOf(option)));
81
102
  const active = focused >= 0 ? focused : Math.max(0, selected);
82
103
  this.setAttribute('aria-orientation', orientation);
104
+ this.setAttribute('aria-multiselectable', String(this.hasAttribute('multiple')));
83
105
  options.forEach((option, index) => {
84
106
  option.tabIndex = index === active ? 0 : -1;
85
- option.setAttribute('aria-selected', String(valueOf(option) === this.value));
107
+ option.setAttribute('aria-selected', String(values.has(valueOf(option))));
86
108
  });
87
109
  this._roving = createRoving({ items: options, orientation, loop: true });
88
110
  this._roving.setActive(active, { focus: false, select: false });
89
111
  }
90
112
 
113
+ /** Commit or toggle an enabled option while preserving multi-selection values. */
91
114
  _select(option, event) {
115
+ if (!this.options.includes(option)) return;
92
116
  const value = valueOf(option);
93
117
  const changed = value !== this.value;
94
- this.value = value;
118
+ if (this.hasAttribute('multiple')) {
119
+ const values = this.values;
120
+ this.value = JSON.stringify(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]);
121
+ } else this.value = value;
95
122
  this._refresh();
96
- this.emit('dom:select', { value, option, event });
123
+ this.emit('dom:select', { value, values: this.values, option, event });
97
124
  if (changed) this.emit('dom:change', { value, option, event });
98
125
  }
99
126
 
127
+ /** Route clicks from rich option descendants to the containing selectable row. */
100
128
  _onClick(event) {
101
129
  const option = event.target.closest('[role="option"]');
102
130
  if (!option || !this.contains(option)) return;
103
131
  this._select(option, event);
104
132
  }
105
133
 
134
+ /** Navigate enabled options and request more at a paginated keyboard boundary. */
106
135
  _onKey(event) {
136
+ const options = this.options;
137
+ if (event.key === 'ArrowDown' && document.activeElement === options.at(-1) && this.hasAttribute('has-more')) {
138
+ event.preventDefault();
139
+ this.emit('dom:load-more');
140
+ return;
141
+ }
107
142
  this._roving?.onKey(event);
108
143
  if (event.key !== 'Enter' && event.key !== ' ') return;
109
144
  const option = document.activeElement?.closest?.('[role="option"]');
@@ -112,6 +147,7 @@ export class DomListboxElement extends DomElementBase {
112
147
  this._select(option, event);
113
148
  }
114
149
 
150
+ /** Release the option observer, pending frame and registered event handlers. */
115
151
  disconnectedCallback() {
116
152
  this._observer?.disconnect();
117
153
  this._observer = null;