@ni/nimble-components 24.1.6 → 24.1.7

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.
@@ -1,7 +1,7 @@
1
1
  import { __decorate } from "tslib";
2
- import { attr, html, observable, ref } from '@microsoft/fast-element';
3
- import { DesignSystem, Combobox as FoundationCombobox } from '@microsoft/fast-foundation';
4
- import { keyArrowDown, keyArrowUp, keyEnter, keySpace } from '@microsoft/fast-web-utilities';
2
+ import { DOM, Observable, attr, html, observable, ref } from '@microsoft/fast-element';
3
+ import { DesignSystem, ComboboxAutocomplete, SelectPosition, DelegatesARIACombobox, applyMixins, StartEnd } from '@microsoft/fast-foundation';
4
+ import { keyArrowDown, keyArrowUp, keyEnter, keyEscape, keySpace, keyTab, limit, uniqueId } from '@microsoft/fast-web-utilities';
5
5
  import { toggleButtonTag } from '../toggle-button';
6
6
  import { errorTextTemplate } from '../patterns/error/template';
7
7
  import { iconArrowExpanderDownTag } from '../icons/arrow-expander-down';
@@ -9,65 +9,156 @@ import { iconExclamationMarkTag } from '../icons/exclamation-mark';
9
9
  import { styles } from './styles';
10
10
  import { DropdownAppearance } from '../patterns/dropdown/types';
11
11
  import { template } from './template';
12
+ import { FormAssociatedCombobox } from './models/combobox-form-associated';
12
13
  /**
13
14
  * A nimble-styed HTML combobox
14
15
  */
15
- export class Combobox extends FoundationCombobox {
16
+ export class Combobox extends FormAssociatedCombobox {
16
17
  constructor() {
17
18
  super(...arguments);
18
19
  this.appearance = DropdownAppearance.underline;
19
20
  this.errorVisible = false;
21
+ /**
22
+ * The open attribute.
23
+ */
24
+ this.open = false;
25
+ /**
26
+ * The collection of currently filtered options.
27
+ */
28
+ this.filteredOptions = [];
20
29
  /** @internal */
21
30
  this.hasOverflow = false;
31
+ /**
32
+ * The unique id for the internal listbox element.
33
+ *
34
+ * @internal
35
+ */
36
+ this.listboxId = uniqueId('listbox-');
37
+ /**
38
+ * The max height for the listbox when opened.
39
+ *
40
+ * @internal
41
+ */
42
+ this.maxHeight = 0;
22
43
  this.valueUpdatedByInput = false;
44
+ this._value = '';
45
+ this.filter = '';
46
+ /**
47
+ * The initial state of the position attribute.
48
+ */
49
+ this.forcedPosition = false;
23
50
  }
24
51
  get value() {
25
- return super.value;
52
+ Observable.track(this, 'value');
53
+ return this._value;
26
54
  }
27
- // This override is to work around an issue in FAST where an old filter value
28
- // is used after programmatically setting the value property.
29
- // See: https://github.com/microsoft/fast/issues/6749
30
55
  set value(next) {
31
- super.value = next;
32
- // Workaround using index notation to manipulate private member
56
+ const prev = `${this._value}`;
57
+ let updatedValue = next;
58
+ if (this.$fastController.isConnected && this.options) {
59
+ const selectedIndex = this.options.findIndex(el => el.text.toLowerCase() === next.toLowerCase());
60
+ const prevSelectedValue = this.options[this.selectedIndex]?.text;
61
+ const nextSelectedValue = this.options[selectedIndex]?.text;
62
+ this.selectedIndex = prevSelectedValue !== nextSelectedValue
63
+ ? selectedIndex
64
+ : this.selectedIndex;
65
+ updatedValue = this.firstSelectedOption?.text || next;
66
+ }
67
+ if (prev !== updatedValue) {
68
+ this._value = updatedValue;
69
+ super.valueChanged(prev, updatedValue);
70
+ Observable.notify(this, 'value');
71
+ }
33
72
  // Can remove when following resolved: https://github.com/microsoft/fast/issues/6749
34
- // eslint-disable-next-line @typescript-eslint/dot-notation
35
- this['filter'] = next;
73
+ this.filter = next;
36
74
  this.filterOptions();
37
75
  this.selectedIndex = this.options
38
76
  .map(option => option.text)
39
77
  .indexOf(this.value);
40
78
  }
41
- // Workaround for https://github.com/microsoft/fast/issues/5123
42
- setPositioning() {
43
- if (!this.$fastController.isConnected) {
44
- // Don't call setPositioning() until we're connected,
45
- // since this.forcedPosition isn't initialized yet.
46
- return;
47
- }
48
- super.setPositioning();
79
+ /**
80
+ * The list of options.
81
+ *
82
+ * Overrides `Listbox.options`.
83
+ */
84
+ get options() {
85
+ Observable.track(this, 'options');
86
+ return this.filteredOptions?.length
87
+ ? this.filteredOptions
88
+ : this._options;
89
+ }
90
+ set options(value) {
91
+ this._options = value;
92
+ Observable.notify(this, 'options');
93
+ }
94
+ get isAutocompleteInline() {
95
+ return (this.autocomplete === ComboboxAutocomplete.inline
96
+ || this.isAutocompleteBoth);
97
+ }
98
+ get isAutocompleteList() {
99
+ return (this.autocomplete === ComboboxAutocomplete.list
100
+ || this.isAutocompleteBoth);
101
+ }
102
+ get isAutocompleteBoth() {
103
+ return this.autocomplete === ComboboxAutocomplete.both;
49
104
  }
50
- // Workaround for https://github.com/microsoft/fast/issues/5773
51
105
  slottedOptionsChanged(prev, next) {
106
+ // Workaround for https://github.com/microsoft/fast/issues/5773
52
107
  const value = this.value;
53
108
  super.slottedOptionsChanged(prev, next);
109
+ this.updateValue();
54
110
  if (value) {
55
111
  this.value = value;
56
112
  }
57
113
  }
58
114
  connectedCallback() {
59
115
  super.connectedCallback();
60
- // Call setPositioning() after this.forcedPosition is initialized.
116
+ this.forcedPosition = !!this.positionAttribute;
117
+ if (this.value) {
118
+ this.initialValue = this.value;
119
+ }
61
120
  this.setPositioning();
62
121
  this.updateInputAriaLabel();
63
122
  }
123
+ /**
124
+ * @internal
125
+ */
126
+ clickHandler(e) {
127
+ if (this.disabled) {
128
+ return false;
129
+ }
130
+ if (this.open) {
131
+ const captured = e.target.closest('option,[role=option]');
132
+ if (!captured || captured.disabled) {
133
+ return false;
134
+ }
135
+ this.selectedOptions = [captured];
136
+ this.control.value = captured.text;
137
+ this.clearSelectionRange();
138
+ this.updateValue(true);
139
+ }
140
+ this.open = !this.open;
141
+ if (this.open) {
142
+ this.control.focus();
143
+ }
144
+ return true;
145
+ }
146
+ /**
147
+ * @internal
148
+ */
64
149
  toggleButtonClickHandler(e) {
65
150
  e.stopImmediatePropagation();
66
151
  }
152
+ /**
153
+ * @internal
154
+ */
67
155
  toggleButtonChangeHandler(e) {
68
156
  this.open = this.dropdownButton.checked;
69
157
  e.stopImmediatePropagation();
70
158
  }
159
+ /**
160
+ * @internal
161
+ */
71
162
  toggleButtonKeyDownHandler(e) {
72
163
  switch (e.key) {
73
164
  case keyArrowUp:
@@ -81,20 +172,57 @@ export class Combobox extends FoundationCombobox {
81
172
  return true;
82
173
  }
83
174
  }
175
+ /**
176
+ * @internal
177
+ */
84
178
  filterOptions() {
85
- super.filterOptions();
179
+ if (!this.autocomplete
180
+ || this.autocomplete === ComboboxAutocomplete.none) {
181
+ this.filter = '';
182
+ }
183
+ const filter = this.filter.toLowerCase();
184
+ this.filteredOptions = this._options.filter(o => o.text.toLowerCase().startsWith(filter));
185
+ if (this.isAutocompleteList) {
186
+ if (!this.filteredOptions.length && !filter) {
187
+ this.filteredOptions = this._options;
188
+ }
189
+ this._options.forEach(o => {
190
+ o.visuallyHidden = !this.filteredOptions.includes(o);
191
+ });
192
+ }
86
193
  const enabledOptions = this.filteredOptions.filter(o => !o.disabled);
87
194
  this.filteredOptions = enabledOptions;
88
195
  }
89
196
  /**
90
- * This is a workaround for the issue described here: https://github.com/microsoft/fast/issues/6267
91
- * For now, we will update the value ourselves while a user types in text. Note that there is other
92
- * implementation related to this (like the 'keydownEventHandler') needed to create the complete set
93
- * of desired behavior described in the issue noted above.
197
+ * @internal
94
198
  */
95
- // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
96
199
  inputHandler(e) {
97
- const returnValue = super.inputHandler(e);
200
+ this.filter = this.control.value;
201
+ this.filterOptions();
202
+ if (!this.isAutocompleteInline) {
203
+ this.selectedIndex = this.options
204
+ .map(option => option.text)
205
+ .indexOf(this.control.value);
206
+ }
207
+ if (!(e.inputType.includes('deleteContent') || !this.filter.length)) {
208
+ if (this.isAutocompleteList && !this.open) {
209
+ this.open = true;
210
+ }
211
+ if (this.isAutocompleteInline) {
212
+ if (this.filteredOptions.length) {
213
+ this.selectedOptions = [this.filteredOptions[0]];
214
+ this.selectedIndex = this.options.indexOf(this.firstSelectedOption);
215
+ this.setInlineSelection();
216
+ }
217
+ else {
218
+ this.selectedIndex = -1;
219
+ }
220
+ }
221
+ }
222
+ // This is a workaround for the issue described here: https://github.com/microsoft/fast/issues/6267
223
+ // For now, we will update the value ourselves while a user types in text. Note that there is other
224
+ // implementation related to this (like the 'keydownEventHandler') needed to create the complete set
225
+ // of desired behavior described in the issue noted above.
98
226
  if (!this.valueUpdatedByInput) {
99
227
  this.valueBeforeTextUpdate = this.value;
100
228
  }
@@ -104,47 +232,260 @@ export class Combobox extends FoundationCombobox {
104
232
  this.focusAndScrollOptionIntoView();
105
233
  }
106
234
  this.value = this.control.value;
107
- return returnValue;
235
+ return true;
108
236
  }
109
- // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
110
237
  keydownHandler(e) {
111
- const returnValue = super.keydownHandler(e);
112
238
  if (e.ctrlKey || e.altKey) {
113
- return returnValue;
239
+ return true;
114
240
  }
115
241
  switch (e.key) {
116
242
  case keyEnter:
243
+ this.syncValue();
244
+ if (this.isAutocompleteInline) {
245
+ this.filter = this.value;
246
+ }
247
+ this.open = false;
248
+ this.clearSelectionRange();
117
249
  this.emitChangeIfValueUpdated();
118
250
  break;
251
+ case keyEscape:
252
+ if (!this.isAutocompleteInline) {
253
+ this.selectedIndex = -1;
254
+ }
255
+ if (this.open) {
256
+ this.open = false;
257
+ break;
258
+ }
259
+ this.value = '';
260
+ this.control.value = '';
261
+ this.filter = '';
262
+ this.filterOptions();
263
+ break;
264
+ case keyTab:
265
+ this.setInputToSelection();
266
+ if (!this.open) {
267
+ return true;
268
+ }
269
+ e.preventDefault();
270
+ this.open = false;
271
+ break;
119
272
  case keyArrowDown:
120
273
  case keyArrowUp:
274
+ this.filterOptions();
275
+ if (!this.open) {
276
+ this.open = true;
277
+ break;
278
+ }
279
+ if (this.filteredOptions.length > 0) {
280
+ super.keydownHandler(e);
281
+ }
282
+ if (this.isAutocompleteInline) {
283
+ this.setInlineSelection();
284
+ }
121
285
  if (this.open && this.valueUpdatedByInput) {
122
286
  this.valueUpdatedByInput = false;
123
287
  }
124
288
  break;
125
289
  default:
126
- return returnValue;
290
+ return true;
127
291
  }
128
- return returnValue;
292
+ return true;
129
293
  }
130
- // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
294
+ /**
295
+ * @internal
296
+ */
297
+ keyupHandler(e) {
298
+ const key = e.key;
299
+ switch (key) {
300
+ case 'ArrowLeft':
301
+ case 'ArrowRight':
302
+ case 'Backspace':
303
+ case 'Delete':
304
+ case 'Home':
305
+ case 'End': {
306
+ this.filter = this.control.value;
307
+ this.selectedIndex = -1;
308
+ this.filterOptions();
309
+ break;
310
+ }
311
+ default: {
312
+ break;
313
+ }
314
+ }
315
+ return true;
316
+ }
317
+ /**
318
+ * @internal
319
+ */
131
320
  focusoutHandler(e) {
132
- const returnValue = super.focusoutHandler(e);
321
+ this.syncValue();
322
+ if (this.open) {
323
+ const focusTarget = e.relatedTarget;
324
+ if (this.isSameNode(focusTarget)) {
325
+ this.focus();
326
+ }
327
+ }
133
328
  this.open = false;
134
329
  this.emitChangeIfValueUpdated();
135
- return returnValue;
330
+ return true;
331
+ }
332
+ /**
333
+ * Reset the element to its first selectable option when its parent form is reset.
334
+ *
335
+ * @internal
336
+ */
337
+ formResetCallback() {
338
+ super.formResetCallback();
339
+ this.setDefaultSelectedOption();
340
+ this.updateValue();
341
+ }
342
+ /** {@inheritDoc (FormAssociated:interface).validate} */
343
+ validate() {
344
+ super.validate(this.control);
345
+ }
346
+ /**
347
+ * Set the default selected options at initialization or reset.
348
+ *
349
+ * @internal
350
+ * @remarks
351
+ * Overrides `Listbox.setDefaultSelectedOption`
352
+ */
353
+ setDefaultSelectedOption() {
354
+ if (this.$fastController.isConnected && this.options) {
355
+ const selectedIndex = this.options.findIndex(el => el.getAttribute('selected') !== null || el.selected);
356
+ this.selectedIndex = selectedIndex;
357
+ if (!this.dirtyValue && this.firstSelectedOption) {
358
+ this.value = this.firstSelectedOption.text;
359
+ }
360
+ this.setSelectedOptions();
361
+ }
362
+ }
363
+ /**
364
+ * @internal
365
+ */
366
+ selectedIndexChanged(prev, next) {
367
+ if (this.$fastController.isConnected) {
368
+ const pinnedSelectedIndex = limit(-1, this.options.length - 1, next);
369
+ // we only want to call the super method when the selectedIndex is in range
370
+ if (pinnedSelectedIndex !== this.selectedIndex) {
371
+ this.selectedIndex = pinnedSelectedIndex;
372
+ return;
373
+ }
374
+ super.selectedIndexChanged(prev, pinnedSelectedIndex);
375
+ }
376
+ }
377
+ /**
378
+ * Synchronize the `aria-disabled` property when the `disabled` property changes.
379
+ *
380
+ * @internal
381
+ */
382
+ disabledChanged(prev, next) {
383
+ if (super.disabledChanged) {
384
+ super.disabledChanged(prev, next);
385
+ }
386
+ this.ariaDisabled = this.disabled ? 'true' : 'false';
387
+ }
388
+ /**
389
+ * Move focus to the previous selectable option.
390
+ *
391
+ * @internal
392
+ * @remarks
393
+ * Overrides `Listbox.selectPreviousOption`
394
+ */
395
+ selectPreviousOption() {
396
+ if (!this.disabled && this.selectedIndex >= 0) {
397
+ this.selectedIndex -= 1;
398
+ }
136
399
  }
400
+ /**
401
+ * @internal
402
+ */
403
+ setPositioning() {
404
+ // Workaround for https://github.com/microsoft/fast/issues/5123
405
+ if (!this.$fastController.isConnected) {
406
+ // Don't call setPositioning() until we're connected,
407
+ // since this.forcedPosition isn't initialized yet.
408
+ return;
409
+ }
410
+ const currentBox = this.getBoundingClientRect();
411
+ const viewportHeight = window.innerHeight;
412
+ const availableBottom = viewportHeight - currentBox.bottom;
413
+ if (this.forcedPosition) {
414
+ this.position = this.positionAttribute;
415
+ }
416
+ else if (currentBox.top > availableBottom) {
417
+ this.position = SelectPosition.above;
418
+ }
419
+ else {
420
+ this.position = SelectPosition.below;
421
+ }
422
+ this.positionAttribute = this.forcedPosition
423
+ ? this.positionAttribute
424
+ : this.position;
425
+ this.maxHeight = this.position === SelectPosition.above
426
+ ? Math.trunc(currentBox.top)
427
+ : Math.trunc(availableBottom);
428
+ }
429
+ /**
430
+ * Focus the control and scroll the first selected option into view.
431
+ *
432
+ * @internal
433
+ * @remarks
434
+ * Overrides: `Listbox.focusAndScrollOptionIntoView`
435
+ */
137
436
  focusAndScrollOptionIntoView() {
138
437
  if (this.open) {
139
- super.focusAndScrollOptionIntoView();
438
+ if (this.contains(document.activeElement)) {
439
+ this.control.focus();
440
+ if (this.firstSelectedOption) {
441
+ requestAnimationFrame(() => {
442
+ this.firstSelectedOption?.scrollIntoView({
443
+ block: 'nearest'
444
+ });
445
+ });
446
+ }
447
+ }
140
448
  }
141
449
  }
142
450
  openChanged() {
143
- super.openChanged();
451
+ if (this.open) {
452
+ this.ariaControls = this.listboxId;
453
+ this.ariaExpanded = 'true';
454
+ this.setPositioning();
455
+ this.focusAndScrollOptionIntoView();
456
+ // focus is directed to the element when `open` is changed programmatically
457
+ DOM.queueUpdate(() => this.focus());
458
+ }
459
+ else {
460
+ this.ariaControls = '';
461
+ this.ariaExpanded = 'false';
462
+ }
144
463
  if (this.dropdownButton) {
145
464
  this.dropdownButton.checked = this.open;
146
465
  }
147
466
  }
467
+ placeholderChanged() {
468
+ if (this.proxy instanceof HTMLInputElement) {
469
+ this.proxy.placeholder = this.placeholder ?? '';
470
+ }
471
+ }
472
+ /**
473
+ * Ensure that the entire list of options is used when setting the selected property.
474
+ * @internal
475
+ * @remarks
476
+ * Overrides: `Listbox.selectedOptionsChanged`
477
+ */
478
+ selectedOptionsChanged(_, next) {
479
+ if (this.$fastController.isConnected) {
480
+ this._options.forEach(o => {
481
+ o.selected = next.includes(o);
482
+ });
483
+ }
484
+ }
485
+ positionChanged(_, next) {
486
+ this.positionAttribute = next;
487
+ this.setPositioning();
488
+ }
148
489
  regionChanged(_prev, _next) {
149
490
  if (this.region && this.controlWrapper) {
150
491
  this.region.anchorElement = this.controlWrapper;
@@ -162,6 +503,49 @@ export class Combobox extends FoundationCombobox {
162
503
  maxHeightChanged() {
163
504
  this.updateListboxMaxHeightCssVariable();
164
505
  }
506
+ /**
507
+ * Sets the value and to match the first selected option.
508
+ */
509
+ updateValue(shouldEmit) {
510
+ if (this.$fastController.isConnected) {
511
+ this.value = this.firstSelectedOption?.text || this.control.value;
512
+ this.control.value = this.value;
513
+ }
514
+ if (shouldEmit) {
515
+ this.$emit('change');
516
+ }
517
+ }
518
+ /**
519
+ * Focus and set the content of the control based on the first selected option.
520
+ */
521
+ setInputToSelection() {
522
+ if (this.firstSelectedOption) {
523
+ this.control.value = this.firstSelectedOption.text;
524
+ this.control.focus();
525
+ }
526
+ }
527
+ /**
528
+ * Focus, set and select the content of the control based on the first selected option.
529
+ */
530
+ setInlineSelection() {
531
+ if (this.firstSelectedOption) {
532
+ this.setInputToSelection();
533
+ this.control.setSelectionRange(this.filter.length, this.control.value.length, 'backward');
534
+ }
535
+ }
536
+ clearSelectionRange() {
537
+ const controlValueLength = this.control.value.length;
538
+ this.control.setSelectionRange(controlValueLength, controlValueLength);
539
+ }
540
+ /**
541
+ * Determines if a value update should involve emitting a change event, then updates the value.
542
+ */
543
+ syncValue() {
544
+ const newValue = this.selectedIndex > -1
545
+ ? this.firstSelectedOption?.text
546
+ : this.control.value;
547
+ this.updateValue(this.value !== newValue);
548
+ }
165
549
  updateListboxMaxHeightCssVariable() {
166
550
  if (this.listbox) {
167
551
  this.listbox.style.setProperty('--ni-private-select-max-height', `${this.maxHeight}px`);
@@ -198,27 +582,51 @@ export class Combobox extends FoundationCombobox {
198
582
  __decorate([
199
583
  attr
200
584
  ], Combobox.prototype, "appearance", void 0);
201
- __decorate([
202
- observable
203
- ], Combobox.prototype, "dropdownButton", void 0);
204
585
  __decorate([
205
586
  attr({ attribute: 'error-text' })
206
587
  ], Combobox.prototype, "errorText", void 0);
207
588
  __decorate([
208
589
  attr({ attribute: 'error-visible', mode: 'boolean' })
209
590
  ], Combobox.prototype, "errorVisible", void 0);
591
+ __decorate([
592
+ attr({ attribute: 'autocomplete', mode: 'fromView' })
593
+ ], Combobox.prototype, "autocomplete", void 0);
594
+ __decorate([
595
+ attr({ attribute: 'position' })
596
+ ], Combobox.prototype, "positionAttribute", void 0);
597
+ __decorate([
598
+ attr({ attribute: 'open', mode: 'boolean' })
599
+ ], Combobox.prototype, "open", void 0);
600
+ __decorate([
601
+ attr
602
+ ], Combobox.prototype, "placeholder", void 0);
603
+ __decorate([
604
+ observable
605
+ ], Combobox.prototype, "position", void 0);
210
606
  __decorate([
211
607
  observable
212
608
  ], Combobox.prototype, "region", void 0);
213
609
  __decorate([
214
610
  observable
215
611
  ], Combobox.prototype, "controlWrapper", void 0);
612
+ __decorate([
613
+ observable
614
+ ], Combobox.prototype, "control", void 0);
615
+ __decorate([
616
+ observable
617
+ ], Combobox.prototype, "listbox", void 0);
618
+ __decorate([
619
+ observable
620
+ ], Combobox.prototype, "dropdownButton", void 0);
216
621
  __decorate([
217
622
  observable
218
623
  ], Combobox.prototype, "hasOverflow", void 0);
624
+ __decorate([
625
+ observable
626
+ ], Combobox.prototype, "maxHeight", void 0);
219
627
  const nimbleCombobox = Combobox.compose({
220
628
  baseName: 'combobox',
221
- baseClass: FoundationCombobox,
629
+ baseClass: FormAssociatedCombobox,
222
630
  template,
223
631
  styles,
224
632
  shadowOptions: {
@@ -256,6 +664,7 @@ const nimbleCombobox = Combobox.compose({
256
664
  ${errorTextTemplate}
257
665
  `
258
666
  });
667
+ applyMixins(Combobox, StartEnd, DelegatesARIACombobox);
259
668
  DesignSystem.getOrCreate().withPrefix('nimble').register(nimbleCombobox());
260
669
  export const comboboxTag = 'nimble-combobox';
261
670
  //# sourceMappingURL=index.js.map