@fusion.dev/helix-headless 0.164.0 → 0.166.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.
Files changed (44) hide show
  1. package/custom-elements.json +837 -64
  2. package/dist/callout/root/callout-root.d.ts +8 -2
  3. package/dist/callout/root/callout-root.js +14 -1
  4. package/dist/code-block/root/code-block-root.d.ts +63 -0
  5. package/dist/code-block/root/code-block-root.js +117 -6
  6. package/dist/date-input/root/date-input-root.d.ts +3 -3
  7. package/dist/date-input/root/date-input-root.js +8 -5
  8. package/dist/element-events.generated.js +15 -2
  9. package/dist/filter-input/menu/filter-menu.d.ts +1 -0
  10. package/dist/filter-input/menu/filter-menu.js +1 -1
  11. package/dist/format-date/root/format-date-root.d.ts +3 -1
  12. package/dist/format-date/root/format-date-root.js +4 -1
  13. package/dist/lightbox/item/lightbox-item.js +3 -1
  14. package/dist/lightbox/root/lightbox-root.d.ts +28 -0
  15. package/dist/lightbox/root/lightbox-root.js +21 -2
  16. package/dist/listbox/root/listbox-root.js +2 -5
  17. package/dist/menu/item/menu-item.d.ts +20 -0
  18. package/dist/menu/item/menu-item.js +53 -20
  19. package/dist/menu/root/menu-root.d.ts +84 -1
  20. package/dist/menu/root/menu-root.js +187 -44
  21. package/dist/number-input/root/number-input-root.d.ts +32 -9
  22. package/dist/number-input/root/number-input-root.js +53 -9
  23. package/dist/object-inspector/root/object-inspector-root.d.ts +5 -0
  24. package/dist/object-inspector/root/object-inspector-root.js +5 -0
  25. package/dist/option/root/index.d.ts +1 -0
  26. package/dist/option/root/index.js +1 -0
  27. package/dist/steps/root/steps-root.d.ts +7 -0
  28. package/dist/steps/root/steps-root.js +7 -0
  29. package/dist/tag/root/index.d.ts +1 -0
  30. package/dist/tag/root/index.js +1 -0
  31. package/dist/time-input/root/time-input-root.d.ts +4 -3
  32. package/dist/time-input/root/time-input-root.js +5 -4
  33. package/dist/time-picker/root/time-picker-root.d.ts +1 -1
  34. package/dist/time-picker/root/time-picker-root.js +5 -2
  35. package/dist/video/root/video-root.d.ts +2 -0
  36. package/dist/video/root/video-root.js +4 -2
  37. package/dist/video-playlist/menu/video-playlist-menu.d.ts +1 -0
  38. package/dist/video-playlist/menu/video-playlist-menu.js +1 -1
  39. package/dist/video-playlist/root/video-playlist-root.js +15 -1
  40. package/lint.json +1 -1
  41. package/package.json +5 -5
  42. package/types/jsx.d.ts +310 -44
  43. package/types/vue.d.ts +68 -11
  44. package/types/words.d.ts +18 -0
@@ -4,7 +4,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { AriaForwardController, anchorElement, customElement, DrilldownController, deepActiveElement, deepContains, focusTarget, HelixElement, inlineStep, KeyboardNavigationController, linkDescription, MixinCollection, MixinControllerOpen, onUnlessFalse, PopoverController, scrollItemIntoView, sideOf, slottedWords, tellDisclosure, typeaheadBy, unlinkDescription, words, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, anchorElement, customElement, DrilldownController, deepActiveElement, deepContains, filterKeeps, focusTarget, HelixElement, HelixFilterEvent, inlineStep, KeyboardNavigationController, linkDescription, MixinCollection, MixinControllerOpen, markMatches, onUnlessFalse, PopoverController, scrollItemIntoView, sideOf, slottedWords, tellDisclosure, typeaheadBy, unlinkDescription, words, } from '@fusion.dev/helix-core';
8
8
  import { caretLeft } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property } from 'lit/decorators.js';
@@ -33,8 +33,12 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
33
33
  this.backLabel = 'Back';
34
34
  this.closeOnSelect = true;
35
35
  this.context = false;
36
+ this.filter = '';
37
+ this.filterable = false;
38
+ this.filterLabel = 'Filter';
36
39
  this.label = '';
37
40
  this.longPressLabel = 'Long press or press Alt+Down to show the menu';
41
+ this.noResultsText = 'No matches';
38
42
  this.offset = 4;
39
43
  this.longPress = new LongPress(this, {
40
44
  onLongPress: () => {
@@ -68,7 +72,7 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
68
72
  });
69
73
  this.aria = new AriaForwardController(this, {
70
74
  label: true,
71
- target: () => this.partNamed('surface'),
75
+ target: () => this.menuBox,
72
76
  });
73
77
  this.panel = new PopoverController(this, {
74
78
  anchor: () => this.context && this.point
@@ -89,6 +93,11 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
89
93
  surface: () => this.partNamed('surface'),
90
94
  });
91
95
  this.settledOnce = false;
96
+ this.filterPage = null;
97
+ this.handleFilterInput = (event) => {
98
+ event.stopPropagation();
99
+ this.filterBy(event.target.value);
100
+ };
92
101
  this.handleContextMenu = (event) => {
93
102
  if (!this.context) {
94
103
  return;
@@ -129,14 +138,9 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
129
138
  };
130
139
  this.handleFocusIn = (event) => {
131
140
  const item = this.items.find((row) => row.contains(event.target));
132
- if (!item) {
133
- return;
141
+ if (item) {
142
+ this.revealRow(item);
134
143
  }
135
- const holder = this.flies ? this.holderOf(item) : null;
136
- const surface = holder
137
- ? holder.shadowRoot?.querySelector('[part="surface"]')
138
- : this.partNamed('surface');
139
- scrollItemIntoView(item, { padding: 4, ...(surface ? { scroller: surface } : {}) });
140
144
  };
141
145
  this.handleKeydown = (event) => {
142
146
  if (!this.open) {
@@ -146,9 +150,23 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
146
150
  const items = this.items;
147
151
  const at = items.findIndex((item) => item.active);
148
152
  const along = inlineStep(this, event.key);
153
+ const typing = this.filterable && event.composedPath()[0] === this.field;
154
+ const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
149
155
  if (event.key === 'Escape') {
150
156
  event.stopPropagation();
151
- this.leave();
157
+ if (!this.filterable || this.filter === '') {
158
+ this.leave();
159
+ }
160
+ else {
161
+ this.filterBy('');
162
+ }
163
+ }
164
+ else if (typing && step !== 0) {
165
+ const count = items.length;
166
+ this.reach(items[at < 0 ? (step > 0 ? 0 : count - 1) : (at + step + count) % count]);
167
+ }
168
+ else if (typing && event.key === 'Enter') {
169
+ items[at]?.renderRoot.querySelector('[part="item"]')?.click();
152
170
  }
153
171
  else if (along === 1 && items[at]?.hasSubmenu) {
154
172
  this.enter(items[at]);
@@ -188,6 +206,14 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
188
206
  this.longPress.down(event);
189
207
  }
190
208
  };
209
+ this.handleMouseDown = (event) => {
210
+ if (this.filterable &&
211
+ this.open &&
212
+ event.composedPath()[0] !== this.field &&
213
+ !this.fromTrigger(event)) {
214
+ event.preventDefault();
215
+ }
216
+ };
191
217
  this.handlePointerMove = (event) => {
192
218
  this.longPress.move(event);
193
219
  };
@@ -209,7 +235,7 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
209
235
  }
210
236
  };
211
237
  this.handleHover = (event) => {
212
- if (!this.flies || !this.open || event.pointerType !== 'mouse') {
238
+ if (!(this.flies || this.filterable) || !this.open || event.pointerType !== 'mouse') {
213
239
  return;
214
240
  }
215
241
  const row = event
@@ -221,7 +247,10 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
221
247
  this.hovered = row;
222
248
  this.grace = undefined;
223
249
  if (!row.disabled) {
224
- row.focus();
250
+ this.reach(row);
251
+ }
252
+ if (!this.flies) {
253
+ return;
225
254
  }
226
255
  clearTimeout(this.hoverTimer);
227
256
  this.hoverTimer = setTimeout(() => {
@@ -334,7 +363,53 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
334
363
  }
335
364
  get items() {
336
365
  const rows = this.flies ? this.rowsOf(this.keyPage) : this.drilldown.rows;
337
- return rows.filter((row) => !row.disabled);
366
+ return rows.filter((row) => !row.disabled && this.kept(row));
367
+ }
368
+ get menuBox() {
369
+ return this.filterable
370
+ ? (this.renderRoot?.querySelector('#items') ?? null)
371
+ : this.partNamed('surface');
372
+ }
373
+ get field() {
374
+ return this.partNamed('search-field');
375
+ }
376
+ get filtering() {
377
+ return this.filterable && this.filter.trim() !== '' && this.filter !== this.refused;
378
+ }
379
+ kept(row) {
380
+ return (!this.filtering ||
381
+ this.holderOf(row) !== this.filterPage ||
382
+ filterKeeps(slottedWords(row, ['']), this.filter.trim(), this.filterMatch));
383
+ }
384
+ requestFilter(value) {
385
+ if (value === this.filter) {
386
+ return;
387
+ }
388
+ this.filterPage = this.flies ? this.keyPage : this.drilldown.page;
389
+ this.refused = this.dispatchEvent(new HelixFilterEvent({ value })) ? undefined : value;
390
+ this.filter = value;
391
+ }
392
+ filterBy(value) {
393
+ this.requestFilter(value);
394
+ void this.updateComplete.then(() => this.reach(this.items[0]));
395
+ }
396
+ reach(row) {
397
+ if (!this.filterable) {
398
+ row?.focus();
399
+ return;
400
+ }
401
+ if (this.highlighted && this.highlighted !== row) {
402
+ this.highlighted.highlighted = false;
403
+ }
404
+ this.highlighted = row;
405
+ const field = this.field;
406
+ if (field && 'ariaActiveDescendantElement' in field) {
407
+ field.ariaActiveDescendantElement = row ?? null;
408
+ }
409
+ if (row) {
410
+ row.highlighted = true;
411
+ this.revealRow(row);
412
+ }
338
413
  }
339
414
  show() {
340
415
  this.openAt('first');
@@ -345,7 +420,8 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
345
420
  }
346
421
  this.updateComplete.then(() => {
347
422
  const items = this.items;
348
- (end === 'first' ? items[0] : items[items.length - 1])?.focus();
423
+ this.field?.focus();
424
+ this.reach(end === 'first' ? items[0] : items[items.length - 1]);
349
425
  });
350
426
  }
351
427
  hide() {
@@ -355,6 +431,8 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
355
431
  this.panel.hide();
356
432
  this.drilldown.reset();
357
433
  this.stopHover();
434
+ this.requestFilter('');
435
+ this.reach(undefined);
358
436
  if (deepContains(this, deepActiveElement())) {
359
437
  ;
360
438
  (this.context ? (this.returnTo ?? this.trigger) : this.trigger).focus();
@@ -377,18 +455,20 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
377
455
  if (!this.drilldown.enter(item)) {
378
456
  return;
379
457
  }
458
+ this.requestFilter('');
380
459
  void this.updateComplete.then(async () => {
381
460
  if (!this.flies) {
382
- this.items[0]?.focusItem();
461
+ this.reach(this.items[0]);
383
462
  return;
384
463
  }
385
464
  await item.updateComplete;
386
465
  const first = this.rowsOf(item).find((row) => !row.disabled);
387
466
  await first?.updateComplete;
388
- first?.focusItem();
467
+ this.reach(first);
389
468
  });
390
469
  }
391
470
  leave() {
471
+ this.requestFilter('');
392
472
  if (this.flies) {
393
473
  const holder = this.keyPage;
394
474
  if (!holder) {
@@ -396,7 +476,7 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
396
476
  return;
397
477
  }
398
478
  this.openPage(this.holderOf(holder));
399
- this.updateComplete.then(() => holder.focus());
479
+ this.updateComplete.then(() => this.reach(holder));
400
480
  return;
401
481
  }
402
482
  if (this.drilldown.atRoot) {
@@ -405,7 +485,14 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
405
485
  }
406
486
  const leaving = this.drilldown.page;
407
487
  this.drilldown.back();
408
- this.updateComplete.then(() => leaving?.focus());
488
+ this.updateComplete.then(() => this.reach(leaving ?? undefined));
489
+ }
490
+ revealRow(item) {
491
+ const holder = this.flies ? this.holderOf(item) : null;
492
+ const surface = holder
493
+ ? holder.shadowRoot?.querySelector('[part="surface"]')
494
+ : this.partNamed('surface');
495
+ scrollItemIntoView(item, { padding: 4, ...(surface ? { scroller: surface } : {}) });
409
496
  }
410
497
  handleClosedKeydown(event) {
411
498
  if (this.context && event.shiftKey && event.key === 'F10' && this.fromTrigger(event)) {
@@ -462,6 +549,7 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
462
549
  this.addEventListener('focusin', this.handleFocusIn);
463
550
  this.addEventListener('contextmenu', this.handleContextMenu);
464
551
  this.addEventListener('pointerdown', this.handlePointerDown);
552
+ this.addEventListener('mousedown', this.handleMouseDown);
465
553
  this.addEventListener('pointermove', this.handlePointerMove);
466
554
  this.addEventListener('pointerup', this.handlePointerUp);
467
555
  this.addEventListener('pointercancel', this.handlePointerCancel);
@@ -484,6 +572,10 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
484
572
  this.removeEventListener('focusin', this.handleFocusIn);
485
573
  this.removeEventListener('contextmenu', this.handleContextMenu);
486
574
  this.removeEventListener('pointerdown', this.handlePointerDown);
575
+ this.removeEventListener('mousedown', this.handleMouseDown);
576
+ if (this.filterable) {
577
+ markMatches(this, [], '');
578
+ }
487
579
  this.removeEventListener('pointermove', this.handlePointerMove);
488
580
  this.removeEventListener('pointerup', this.handlePointerUp);
489
581
  this.removeEventListener('pointercancel', this.handlePointerCancel);
@@ -505,7 +597,9 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
505
597
  const top = this.flies || this.drilldown.atRoot;
506
598
  for (const child of this.children) {
507
599
  if (isSection(child)) {
508
- child.onPage = top;
600
+ child.onPage =
601
+ top &&
602
+ (!this.filtering || [...child.children].some((row) => isItem(row) && this.kept(row)));
509
603
  for (const row of child.children) {
510
604
  if (row.isMenuItem === true) {
511
605
  this.mark(row, top);
@@ -522,9 +616,18 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
522
616
  this.toggleState('drilled', !this.flies && !this.drilldown.atRoot);
523
617
  this.markColumns();
524
618
  this.nameSurface();
619
+ if (this.filterable) {
620
+ markMatches(this, this.rowsOf(this.filterPage).filter((row) => this.kept(row)), this.filtering ? this.filter.trim() : '');
621
+ const items = this.items;
622
+ if (changed.has('filter') &&
623
+ this.open &&
624
+ !items.includes(this.highlighted)) {
625
+ this.reach(items[0]);
626
+ }
627
+ }
525
628
  }
526
629
  nameSurface() {
527
- const surface = this.partNamed('surface');
630
+ const surface = this.menuBox;
528
631
  if (!surface || !('ariaLabelledByElements' in surface)) {
529
632
  return;
530
633
  }
@@ -544,7 +647,8 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
544
647
  }
545
648
  mark(item, onPage) {
546
649
  const page = this.drilldown.page;
547
- item.onPage = onPage;
650
+ item.onPage = onPage && this.kept(item);
651
+ item.virtual = this.filterable;
548
652
  const holds = page !== null && item.contains(page);
549
653
  item.flyout = this.flies;
550
654
  item.expanded = this.flies && holds;
@@ -553,7 +657,7 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
553
657
  for (const child of item.children) {
554
658
  const nested = child;
555
659
  if (nested.isMenuItem === true && child.slot === 'submenu') {
556
- this.mark(nested, onPage && holds);
660
+ this.mark(nested, item.onPage && holds);
557
661
  }
558
662
  }
559
663
  return;
@@ -568,6 +672,32 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
568
672
  }
569
673
  render() {
570
674
  const point = this.context && this.point;
675
+ const filtering = this.filtering;
676
+ const none = filtering && !this.rowsOf(this.filterPage).some((row) => this.kept(row));
677
+ this.announced = none ? this.noResultsText : '';
678
+ const rows = html `${this.flies || this.drilldown.atRoot
679
+ ? nothing
680
+ : html `<div part="header">
681
+ <button
682
+ part="back"
683
+ type="button"
684
+ role="menuitem"
685
+ tabindex="-1"
686
+ aria-label=${this.backLabel}
687
+ @click=${() => this.leave()}
688
+ >
689
+ <svg part="back-mark" data-mirror fill="currentColor" viewBox=${caretLeft.viewBox} aria-hidden="true">
690
+ ${unsafeSVG(caretLeft.content)}
691
+ </svg>
692
+ </button>
693
+ <span part="title">${this.drilldown.label}</span>
694
+ </div>`}
695
+ <slot @slotchange=${(event) => this.countRows(event)}></slot>
696
+ ${none
697
+ ? html `<p part="empty"><slot name="no-results">${this.noResultsText}</slot></p>`
698
+ : this.renderStatus(this.hasRows)}`;
699
+ const busy = this.loading ? 'true' : nothing;
700
+ const label = this.label || nothing;
571
701
  return html `<slot name="trigger" @click=${this.handleTriggerClick}></slot>${point
572
702
  ? html `<span
573
703
  id="point"
@@ -578,31 +708,27 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
578
708
  <div
579
709
  part="surface"
580
710
  popover="manual"
581
- role="menu"
582
- aria-busy=${this.loading ? 'true' : nothing}
583
- aria-label=${this.label || nothing}
711
+ role=${this.filterable ? nothing : 'menu'}
712
+ aria-busy=${this.filterable ? nothing : busy}
713
+ aria-label=${this.filterable ? nothing : label}
584
714
  data-drilled=${this.flies || this.drilldown.atRoot ? nothing : ''}
715
+ data-filtered=${filtering ? '' : nothing}
585
716
  ?hidden=${!this.open}
586
717
  >
587
- ${this.flies || this.drilldown.atRoot
588
- ? nothing
589
- : html `<div part="header">
590
- <button
591
- part="back"
592
- type="button"
593
- role="menuitem"
594
- tabindex="-1"
595
- aria-label=${this.backLabel}
596
- @click=${() => this.leave()}
597
- >
598
- <svg part="back-mark" data-mirror fill="currentColor" viewBox=${caretLeft.viewBox} aria-hidden="true">
599
- ${unsafeSVG(caretLeft.content)}
600
- </svg>
601
- </button>
602
- <span part="title">${this.drilldown.label}</span>
603
- </div>`}
604
- <slot @slotchange=${(event) => this.countRows(event)}></slot>
605
- ${this.renderStatus(this.hasRows)}
718
+ ${this.filterable
719
+ ? html `<input
720
+ part="search-field"
721
+ type="text"
722
+ autocomplete="off"
723
+ spellcheck="false"
724
+ .value=${this.filter}
725
+ placeholder=${this.filterLabel}
726
+ aria-label=${this.filterLabel}
727
+ aria-autocomplete="list"
728
+ aria-controls="items"
729
+ @input=${this.handleFilterInput}
730
+ /><div id="items" role="menu" aria-busy=${busy} aria-label=${label}>${rows}</div>`
731
+ : rows}
606
732
  </div>${this.renderLive(this.hasRows)}`;
607
733
  }
608
734
  };
@@ -616,6 +742,19 @@ __decorate([
616
742
  __decorate([
617
743
  property({ reflect: true, type: Boolean })
618
744
  ], HelixHeadlessMenu.prototype, "context", void 0);
745
+ __decorate([
746
+ property()
747
+ ], HelixHeadlessMenu.prototype, "filter", void 0);
748
+ __decorate([
749
+ property({ type: Boolean })
750
+ ], HelixHeadlessMenu.prototype, "filterable", void 0);
751
+ __decorate([
752
+ words,
753
+ property({ attribute: 'filter-label' })
754
+ ], HelixHeadlessMenu.prototype, "filterLabel", void 0);
755
+ __decorate([
756
+ property({ attribute: false })
757
+ ], HelixHeadlessMenu.prototype, "filterMatch", void 0);
619
758
  __decorate([
620
759
  property()
621
760
  ], HelixHeadlessMenu.prototype, "label", void 0);
@@ -623,6 +762,10 @@ __decorate([
623
762
  words,
624
763
  property({ attribute: 'long-press-label' })
625
764
  ], HelixHeadlessMenu.prototype, "longPressLabel", void 0);
765
+ __decorate([
766
+ words,
767
+ property({ attribute: 'no-results-text' })
768
+ ], HelixHeadlessMenu.prototype, "noResultsText", void 0);
626
769
  __decorate([
627
770
  property({ type: Number })
628
771
  ], HelixHeadlessMenu.prototype, "offset", void 0);
@@ -8,7 +8,7 @@ declare const HelixHeadlessNumberInput_base: (new (...args: any[]) => import("@f
8
8
  * A separate family from `Input`'s `type="number"`. It renders two buttons,
9
9
  * binds four keys `Input` does not, and handles clamping and stepping.
10
10
  *
11
- * The field is `type="text"` with `inputmode="decimal"`. Tested in a real
11
+ * The field is `type="text"` with an `inputmode` for a number. Tested in a real
12
12
  * browser, `type="number"` fails in three ways:
13
13
  * - Typing `12e` gives a `.value` of `""`, so the element cannot see what you
14
14
  * typed. A `type="text"` field keeps `"12e"`.
@@ -16,7 +16,11 @@ declare const HelixHeadlessNumberInput_base: (new (...args: any[]) => import("@f
16
16
  * to 4, when you only meant to scroll the page.
17
17
  * - `selectionStart` is `null`, so the caret cannot be managed.
18
18
  *
19
- * `inputmode="decimal"` keeps the numeric keypad on phones.
19
+ * `inputmode` keeps a numeric keypad on phones, chosen as React Aria's
20
+ * NumberField chooses it: a keypad with a minus key where the number can be
21
+ * negative, and one with a decimal separator where the format shows a
22
+ * fraction. An iPhone has no keypad with a minus key, so a field that can be
23
+ * negative takes the full keyboard there.
20
24
  *
21
25
  * `format-options` shows the number with `Intl.NumberFormat` in `locale`, as a
22
26
  * currency, a percent or a unit, while the field is not being edited. Focus
@@ -85,6 +89,8 @@ declare const HelixHeadlessNumberInput_base: (new (...args: any[]) => import("@f
85
89
  * @csspart increment-mark - The icon on the step up button.
86
90
  * @csspart label - The label above the field. It is also the grip you drag when `scrub` is set.
87
91
  *
92
+ * @cssstate at-max - The number is at `max`, so it cannot step up, as React Aria's NumberField says with `canIncrement`.
93
+ * @cssstate at-min - The number is at `min`, so it cannot step down.
88
94
  * @cssstate invalid - The control has an error to show, from the browser's validation or from your `feedback`.
89
95
  * @cssstate scrubbing - The label is being dragged to change the value, under `scrub`, as Zag's scrubber says while it is held.
90
96
  */
@@ -209,12 +215,13 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
209
215
  * invalid, as it is in a native number field.
210
216
  *
211
217
  * Unset, a step moves by one, or by one percent under a percent format, and
212
- * a typed number is rounded to the smallest amount `format-options` shows
213
- * rather than to whole steps, as React Aria's NumberField does: a field
214
- * showing dollars keeps the cents typed into it. Without `format-options`
215
- * the grid is whole numbers, as in a native number field.
218
+ * a typed number is rounded to the smallest amount the format shows, as
219
+ * React Aria's NumberField does: a field showing dollars keeps the cents
220
+ * typed into it, and one with no `format-options` keeps three decimal
221
+ * places. `any` puts the number on no grid, as it does in a native number
222
+ * field: a step moves by one, and any number in range is valid.
216
223
  */
217
- step?: number;
224
+ step?: number | 'any';
218
225
  /**
219
226
  * The message the browser shows when the number is off the step grid, with
220
227
  * `{below}` and `{above}` filled in with the nearest valid values, written in
@@ -251,16 +258,30 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
251
258
  * has no navigator and renders the spinbutton.
252
259
  */
253
260
  private readonly touchApple;
261
+ /** The phone the field is on, read once on the client, for the keypad it offers. */
262
+ private readonly phone;
263
+ /**
264
+ * The keypad a phone shows, as React Aria's NumberField chooses it.
265
+ * `numeric` on Android has a minus key and a decimal separator, and
266
+ * `decimal` has no minus key. On an iPhone neither has a minus key, so a
267
+ * number that can be negative takes the full keyboard. Measured before
268
+ * this: `decimal` with `min="-10"`, so a phone could not type the minus.
269
+ */
270
+ private get keypad();
254
271
  private get control();
255
272
  /** The value as a number, or `null` where the field holds nothing usable. */
256
273
  private get numeric();
257
274
  /** The grain last read off the format, with the locale and options it was read for. */
258
275
  private grainOf?;
276
+ /** The step as a number, or zero where it is unset or `any`. */
277
+ private get stepSize();
259
278
  /** How far one press moves: `step`, or the stride the format implies. */
260
279
  private get stride();
261
280
  /**
262
- * The grid a typed number is rounded to: `step` where it is set, and
263
- * otherwise the smallest amount the format shows, or whole numbers.
281
+ * The grid a typed number is rounded to: `step` where it is set, nothing
282
+ * where it is `any`, and otherwise the smallest amount the format shows.
283
+ * Measured before this: with no step and no format, `2,75` typed in German
284
+ * became 3 on blur.
264
285
  */
265
286
  private get grid();
266
287
  /**
@@ -323,6 +344,8 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
323
344
  * `handleStepperClick`.
324
345
  */
325
346
  private readonly handleStepperDown;
347
+ /** Keeps a pressed stepper from taking focus from the field. */
348
+ private readonly handleStepperMouseDown;
326
349
  /**
327
350
  * Ends a hold. The click that follows a release arrives in the same task,
328
351
  * so the press is forgotten after it.
@@ -12,6 +12,20 @@ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { decimals, formatGrain, strideOf } from '../grain.js';
13
13
  const SCRUB_PITCH = 4;
14
14
  const SCRUB_FINE = 16;
15
+ const PLAIN_FORMAT = {};
16
+ const stepConverter = {
17
+ fromAttribute: (text) => {
18
+ if (text === null) {
19
+ return undefined;
20
+ }
21
+ return text.trim().toLowerCase() === 'any' ? 'any' : Number(text);
22
+ },
23
+ toAttribute: (step) => step === undefined ? undefined : String(step),
24
+ };
25
+ const phoneOf = (platform) => {
26
+ const agent = platform?.userAgent ?? '';
27
+ return /iPhone|iPod/u.test(agent) ? 'iphone' : /android/iu.test(agent) ? 'android' : '';
28
+ };
15
29
  const PLAIN = /^-?(\d+(\.\d*)?|\.\d+)$/;
16
30
  const REPEAT_DELAY = 400;
17
31
  const REPEAT_EVERY = 60;
@@ -53,6 +67,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
53
67
  this.editing = true;
54
68
  };
55
69
  this.touchApple = appleTouch(typeof navigator === 'undefined' ? undefined : navigator);
70
+ this.phone = phoneOf(typeof navigator === 'undefined' ? undefined : navigator);
56
71
  this.pressStepped = false;
57
72
  this.handleStepperDown = (event) => {
58
73
  if (event.button !== 0) {
@@ -60,6 +75,9 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
60
75
  }
61
76
  const button = event.currentTarget;
62
77
  const up = button.getAttribute('part') === 'increment';
78
+ if (event.pointerType === 'mouse' && this.control && !this.control.matches(':focus')) {
79
+ this.control.focus({ preventScroll: true });
80
+ }
63
81
  clearTimeout(this.repeat);
64
82
  this.pressStepped = true;
65
83
  button.setPointerCapture?.(event.pointerId);
@@ -79,6 +97,9 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
79
97
  this.repeat = setTimeout(step, REPEAT_DELAY);
80
98
  }
81
99
  };
100
+ this.handleStepperMouseDown = (event) => {
101
+ event.preventDefault();
102
+ };
82
103
  this.stopRepeat = () => {
83
104
  clearTimeout(this.repeat);
84
105
  this.repeat = undefined;
@@ -217,6 +238,18 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
217
238
  }
218
239
  return formatNumber(parsed, this.locale, this.formatOptions);
219
240
  }
241
+ get keypad() {
242
+ const negative = this.min === undefined || this.min < 0;
243
+ const grid = this.grid;
244
+ const fraction = grid === 0 || !Number.isInteger(grid);
245
+ if (this.phone === 'iphone') {
246
+ return negative ? 'text' : fraction ? 'decimal' : 'numeric';
247
+ }
248
+ if (this.phone === 'android') {
249
+ return negative ? 'numeric' : fraction ? 'decimal' : 'numeric';
250
+ }
251
+ return 'numeric';
252
+ }
220
253
  get control() {
221
254
  return this.partNamed('control');
222
255
  }
@@ -227,15 +260,18 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
227
260
  }
228
261
  return parseNumber(text, this.locale, this.formatOptions);
229
262
  }
263
+ get stepSize() {
264
+ return typeof this.step === 'number' ? Math.abs(this.step) || 0 : 0;
265
+ }
230
266
  get stride() {
231
- return Math.abs(this.step ?? 0) || strideOf(this.formatOptions);
267
+ return this.stepSize || strideOf(this.formatOptions);
232
268
  }
233
269
  get grid() {
234
- const step = Math.abs(this.step ?? 0);
235
- const options = this.formatOptions;
236
- if (step || !options) {
237
- return step || 1;
270
+ const step = this.stepSize;
271
+ if (step || this.step === 'any') {
272
+ return step;
238
273
  }
274
+ const options = this.formatOptions ?? PLAIN_FORMAT;
239
275
  const [locale, read, grain] = this.grainOf ?? [];
240
276
  if (locale === this.locale && read === options && grain !== undefined) {
241
277
  return grain;
@@ -252,6 +288,9 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
252
288
  }
253
289
  onStep(value) {
254
290
  const step = this.grid;
291
+ if (!step) {
292
+ return true;
293
+ }
255
294
  const anchor = this.min ?? 0;
256
295
  const steps = (value - anchor) / step;
257
296
  return Math.abs(steps - Math.round(steps)) < 1e-9;
@@ -259,11 +298,11 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
259
298
  clamp(value) {
260
299
  const step = this.grid;
261
300
  const anchor = this.min ?? 0;
262
- const stepped = anchor + Math.round((value - anchor) / step) * step;
301
+ const stepped = step ? anchor + Math.round((value - anchor) / step) * step : value;
263
302
  const floored = this.min === undefined ? stepped : Math.max(this.min, stepped);
264
303
  const capped = this.max === undefined ? floored : Math.min(this.max, floored);
265
304
  const places = Math.max(decimals(step), decimals(anchor));
266
- return Number(capped.toFixed(places));
305
+ return step ? Number(capped.toFixed(places)) : capped;
267
306
  }
268
307
  nudge(steps) {
269
308
  if (this.isDisabled || this.readOnly) {
@@ -387,6 +426,9 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
387
426
  this.releaseCaret?.();
388
427
  this.releaseCaret = undefined;
389
428
  this.sync();
429
+ const parsed = this.numeric;
430
+ this.toggleState('at-min', parsed !== null && this.min !== undefined && parsed <= this.min);
431
+ this.toggleState('at-max', parsed !== null && this.max !== undefined && parsed >= this.max);
390
432
  }
391
433
  get blocksImplicitSubmission() {
392
434
  return true;
@@ -419,6 +461,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
419
461
  ?disabled=${this.isDisabled || this.readOnly || atMin}
420
462
  @click=${this.handleStepperClick}
421
463
  @lostpointercapture=${this.stopRepeat}
464
+ @mousedown=${this.handleStepperMouseDown}
422
465
  @pointercancel=${this.stopRepeat}
423
466
  @pointerdown=${this.handleStepperDown}
424
467
  @pointerup=${this.stopRepeat}
@@ -434,7 +477,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
434
477
  type="text"
435
478
  role=${this.touchApple ? nothing : 'spinbutton'}
436
479
  aria-roledescription=${this.touchApple || this.roleLabel === '' ? nothing : this.roleLabel}
437
- inputmode="decimal"
480
+ inputmode=${this.keypad}
438
481
  autocomplete=${this.autocomplete || nothing}
439
482
  name=${this.name || nothing}
440
483
  placeholder=${this.placeholder || nothing}
@@ -474,6 +517,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
474
517
  ?disabled=${this.isDisabled || this.readOnly || atMax}
475
518
  @click=${this.handleStepperClick}
476
519
  @lostpointercapture=${this.stopRepeat}
520
+ @mousedown=${this.handleStepperMouseDown}
477
521
  @pointercancel=${this.stopRepeat}
478
522
  @pointerdown=${this.handleStepperDown}
479
523
  @pointerup=${this.stopRepeat}
@@ -542,7 +586,7 @@ __decorate([
542
586
  property({ reflect: true, type: Boolean })
543
587
  ], HelixHeadlessNumberInput.prototype, "scrub", void 0);
544
588
  __decorate([
545
- property({ type: Number })
589
+ property({ converter: stepConverter })
546
590
  ], HelixHeadlessNumberInput.prototype, "step", void 0);
547
591
  __decorate([
548
592
  words,