@momentum-design/components 0.122.16 → 0.122.18

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.
@@ -66,6 +66,8 @@ declare const Select_base: import("../../utils/mixins/index.types").Constructor<
66
66
  * @cssproperty --mdc-select-listbox-width - The width of the listbox inside the select (default: `--mdc-select-width`).
67
67
  */
68
68
  declare class Select extends Select_base implements AssociatedFormControl {
69
+ /** @internal */
70
+ private itemsStore;
69
71
  /**
70
72
  * The placeholder text which will be shown on the text if provided.
71
73
  */
@@ -126,11 +128,18 @@ declare class Select extends Select_base implements AssociatedFormControl {
126
128
  /** @internal */
127
129
  displayPopover: boolean;
128
130
  /** @internal */
131
+ private animationFrameId?;
132
+ /** @internal */
129
133
  private initialSelectedOption;
130
134
  /** @internal */
131
- private itemsStore;
135
+ private debounceSearch?;
136
+ /** @internal */
137
+ private debounceTime;
138
+ /** @internal */
139
+ private searchString;
132
140
  constructor();
133
141
  connectedCallback(): void;
142
+ disconnectedCallback(): void;
134
143
  /** @internal */
135
144
  get navItems(): Option[];
136
145
  /**
@@ -218,16 +227,35 @@ declare class Select extends Select_base implements AssociatedFormControl {
218
227
  * @param event - The mouse event which triggered this function.
219
228
  */
220
229
  private handleClickCombobox;
230
+ private setupDebounceSearch;
231
+ private debounceSearchKey;
232
+ /**
233
+ * Filters the given option labels based on the given search key.
234
+ * It returns a new array of options that have labels starting with the given search key case-insensitive.
235
+ *
236
+ * @param options - The options to filter.
237
+ * @param searchKey - The search key to filter by.
238
+ * @returns The filtered options.
239
+ */
240
+ private filterOptionsBySearchKey;
241
+ /**
242
+ * Handles the selection of an option based on the filter string.
243
+ * It will select the first option from the filtered list if it is not empty.
244
+ * If the filtered list is empty, it will do nothing.
245
+ * @param searchKey - The filter string to search for options.
246
+ */
247
+ private handleSelectedOptionBasedOnFilter;
221
248
  /**
222
249
  * Handles the keydown event on the select element when the popover is closed.
223
250
  * The options are as follows:
224
- * - ARROW_DOWN, ARROW_UP, SPACE: Opens the popover and prevents the default scrolling behavior.
225
- * - ENTER: Opens the popover, prevents default scrolling, and submits the form if the popover is closed.
251
+ * - ARROW_DOWN, ARROW_UP, ENTER, SPACE: Opens the popover and prevents the default scrolling behavior.
226
252
  * - HOME: Opens the popover and sets focus and tabindex on the first option.
227
253
  * - END: Opens the popover and sets focus and tabindex on the last option.
254
+ * - Any key: Opens the popover and sets focus on the first option which starts with the key.
228
255
  * @param event - The keyboard event.
229
256
  */
230
257
  private handleKeydownCombobox;
258
+ private resetTabIndexAndSetFocusAfterUpdate;
231
259
  /**
232
260
  * If the native input is focused, it will focus the visual combobox.
233
261
  * This is to ensure that the visual combobox is focused when the native input is focused.
@@ -237,6 +265,8 @@ declare class Select extends Select_base implements AssociatedFormControl {
237
265
  * @internal
238
266
  */
239
267
  private handleNativeInputFocus;
268
+ private handleSelectedOptionByKeyInput;
269
+ private handleKeydownPopover;
240
270
  render(): import("lit-html").TemplateResult<1>;
241
271
  static styles: Array<CSSResult>;
242
272
  }
@@ -24,6 +24,7 @@ import { DEFAULTS as FORMFIELD_DEFAULTS, VALIDATION } from '../formfieldwrapper/
24
24
  import { TAG_NAME as OPTION_TAG_NAME } from '../option/option.constants';
25
25
  import { DEFAULTS as POPOVER_DEFAULTS, POPOVER_PLACEMENT } from '../popover/popover.constants';
26
26
  import { TYPE, VALID_TEXT_TAGS } from '../text/text.constants';
27
+ import { debounce } from '../../utils/debounce';
27
28
  import { ARROW_ICON, LISTBOX_ID, TRIGGER_ID } from './select.constants';
28
29
  import styles from './select.styles';
29
30
  /**
@@ -136,16 +137,68 @@ class Select extends ListNavigationMixin(CaptureDestroyEventForChildElement(Auto
136
137
  /** @internal */
137
138
  this.initialSelectedOption = null;
138
139
  /** @internal */
140
+ this.debounceTime = 500;
141
+ /** @internal */
142
+ this.searchString = '';
143
+ /** @internal */
144
+ this.onStoreUpdate = (option, changeType, index, options) => {
145
+ switch (changeType) {
146
+ case 'added':
147
+ option.setAttribute('tabindex', '-1');
148
+ break;
149
+ case 'removed': {
150
+ if (index === -1 || options.length === 0) {
151
+ return;
152
+ }
153
+ let newIndex = index + 1;
154
+ if (newIndex >= options.length) {
155
+ newIndex = index - 1;
156
+ }
157
+ if (newIndex === -1 && this.displayPopover) {
158
+ this.displayPopover = false;
159
+ this.handleNativeInputFocus();
160
+ return;
161
+ }
162
+ if (option.tabIndex === 0) {
163
+ this.resetTabIndexes(newIndex);
164
+ }
165
+ if (option.hasAttribute('selected')) {
166
+ let newOption = null;
167
+ // If there is no placeholder, then we set the first option as selected option.
168
+ // If the the first option is about to removed then we set the next (second) option as selected.
169
+ // The next (second) option will become first one, when the option is fully removed.
170
+ if (!this.placeholder) {
171
+ newOption = index === 0 ? options[newIndex] : options[0];
172
+ }
173
+ this.setSelectedOption(newOption);
174
+ }
175
+ break;
176
+ }
177
+ default:
178
+ break;
179
+ }
180
+ };
181
+ /** @internal */
182
+ this.isValidItem = (item) => item.matches(`${OPTION_TAG_NAME}:not([disabled])`);
183
+ this.addEventListener(LIFE_CYCLE_EVENTS.MODIFIED, this.handleModifiedEvent);
139
184
  this.itemsStore = new ElementStore(this, {
140
185
  isValidItem: this.isValidItem,
141
186
  onStoreUpdate: this.onStoreUpdate,
142
187
  });
143
- this.addEventListener(LIFE_CYCLE_EVENTS.MODIFIED, this.handleModifiedEvent);
144
188
  }
145
189
  connectedCallback() {
146
190
  super.connectedCallback();
147
191
  this.loop = 'false';
148
192
  this.initialFocus = 0;
193
+ this.setupDebounceSearch();
194
+ }
195
+ disconnectedCallback() {
196
+ var _a;
197
+ super.disconnectedCallback();
198
+ // cancel any pending debounced action and clear DOM timeouts
199
+ (_a = this.debounceSearch) === null || _a === void 0 ? void 0 : _a.cancel();
200
+ // cancel any pending animation frames
201
+ window.cancelAnimationFrame(this.animationFrameId);
149
202
  }
150
203
  /** @internal */
151
204
  get navItems() {
@@ -198,36 +251,6 @@ class Select extends ListNavigationMixin(CaptureDestroyEventForChildElement(Auto
198
251
  }
199
252
  }
200
253
  /** @internal */
201
- onStoreUpdate(option, changeType, index) {
202
- switch (changeType) {
203
- case 'added':
204
- option.setAttribute('tabindex', '-1');
205
- break;
206
- case 'removed': {
207
- if (index === -1 || option.tabIndex !== 0) {
208
- return;
209
- }
210
- let newIndex = index + 1;
211
- if (newIndex >= this.navItems.length) {
212
- newIndex = index - 1;
213
- }
214
- if (newIndex === -1) {
215
- this.displayPopover = false;
216
- this.handleNativeInputFocus();
217
- return;
218
- }
219
- this.resetTabIndexes(newIndex);
220
- break;
221
- }
222
- default:
223
- break;
224
- }
225
- }
226
- /** @internal */
227
- isValidItem(item) {
228
- return item.matches(`${OPTION_TAG_NAME}:not([disabled])`);
229
- }
230
- /** @internal */
231
254
  getFirstSelectedOption() {
232
255
  return this.navItems.find(option => option.hasAttribute('selected'));
233
256
  }
@@ -461,13 +484,64 @@ class Select extends ListNavigationMixin(CaptureDestroyEventForChildElement(Auto
461
484
  this.displayPopover = !this.displayPopover;
462
485
  event.stopPropagation();
463
486
  }
487
+ setupDebounceSearch() {
488
+ this.debounceSearch = debounce(() => {
489
+ // for every 500ms, we will reset the search string.
490
+ this.searchString = '';
491
+ }, this.debounceTime);
492
+ }
493
+ debounceSearchKey(letter) {
494
+ var _a;
495
+ (_a = this.debounceSearch) === null || _a === void 0 ? void 0 : _a.call(this);
496
+ // add most recent letter to saved search string
497
+ this.searchString += letter;
498
+ return this.searchString;
499
+ }
500
+ /**
501
+ * Filters the given option labels based on the given search key.
502
+ * It returns a new array of options that have labels starting with the given search key case-insensitive.
503
+ *
504
+ * @param options - The options to filter.
505
+ * @param searchKey - The search key to filter by.
506
+ * @returns The filtered options.
507
+ */
508
+ filterOptionsBySearchKey(options, searchKey) {
509
+ return options.filter(option => { var _a; return (_a = option.getAttribute('label')) === null || _a === void 0 ? void 0 : _a.toLowerCase().startsWith(searchKey.toLowerCase()); });
510
+ }
511
+ /**
512
+ * Handles the selection of an option based on the filter string.
513
+ * It will select the first option from the filtered list if it is not empty.
514
+ * If the filtered list is empty, it will do nothing.
515
+ * @param searchKey - The filter string to search for options.
516
+ */
517
+ handleSelectedOptionBasedOnFilter(searchKey) {
518
+ const startIndex = this.navItems.findIndex(option => option.tabIndex === 0) + 1;
519
+ const orderedOptions = [...this.navItems.slice(startIndex), ...this.navItems.slice(0, startIndex)];
520
+ // First, we search for an exact match with then entire search key
521
+ const filteredResults = this.filterOptionsBySearchKey(orderedOptions, searchKey);
522
+ let newOption = null;
523
+ if (filteredResults.length) {
524
+ // If the key is an exact match, then we set the first option
525
+ [newOption] = filteredResults;
526
+ }
527
+ else if (searchKey.split('').every(letter => letter === searchKey[0])) {
528
+ // If the key is same, then we cycle through all options which start with the same letter
529
+ const nextOptionFromList = this.navItems[startIndex];
530
+ const optionsWhichStartWithSameLetter = this.filterOptionsBySearchKey(orderedOptions, searchKey[0]);
531
+ const nextPossibleOption = optionsWhichStartWithSameLetter.filter(option => option === nextOptionFromList);
532
+ newOption = nextPossibleOption.length ? nextPossibleOption[0] : optionsWhichStartWithSameLetter[0];
533
+ }
534
+ if (this.navItems.indexOf(newOption) !== -1) {
535
+ this.resetTabIndexAndSetFocusAfterUpdate(this.navItems.indexOf(newOption));
536
+ }
537
+ }
464
538
  /**
465
539
  * Handles the keydown event on the select element when the popover is closed.
466
540
  * The options are as follows:
467
- * - ARROW_DOWN, ARROW_UP, SPACE: Opens the popover and prevents the default scrolling behavior.
468
- * - ENTER: Opens the popover, prevents default scrolling, and submits the form if the popover is closed.
541
+ * - ARROW_DOWN, ARROW_UP, ENTER, SPACE: Opens the popover and prevents the default scrolling behavior.
469
542
  * - HOME: Opens the popover and sets focus and tabindex on the first option.
470
543
  * - END: Opens the popover and sets focus and tabindex on the last option.
544
+ * - Any key: Opens the popover and sets focus on the first option which starts with the key.
471
545
  * @param event - The keyboard event.
472
546
  */
473
547
  handleKeydownCombobox(event) {
@@ -477,32 +551,48 @@ class Select extends ListNavigationMixin(CaptureDestroyEventForChildElement(Auto
477
551
  switch (event.key) {
478
552
  case KEYS.ARROW_DOWN:
479
553
  case KEYS.ARROW_UP:
480
- this.displayPopover = true;
481
- // Prevent the default browser behavior of scrolling down
482
- event.preventDefault();
483
- event.stopPropagation();
484
- break;
485
554
  case KEYS.ENTER:
486
555
  case KEYS.SPACE:
487
556
  this.displayPopover = true;
488
- // Prevent the default browser behavior of scrolling down
489
- event.preventDefault();
490
557
  event.stopPropagation();
491
558
  break;
492
559
  case KEYS.HOME: {
493
560
  this.displayPopover = true;
494
- this.resetTabIndexAndSetFocus(0);
495
- event.preventDefault();
561
+ this.resetTabIndexAndSetFocusAfterUpdate(0);
496
562
  break;
497
563
  }
498
564
  case KEYS.END: {
499
565
  this.displayPopover = true;
500
- this.resetTabIndexAndSetFocus(this.navItems.length - 1);
501
- event.preventDefault();
566
+ this.resetTabIndexAndSetFocusAfterUpdate(this.navItems.length - 1);
502
567
  break;
503
568
  }
504
- default:
569
+ default: {
570
+ if (event.key.length === 1) {
571
+ this.displayPopover = true;
572
+ this.handleSelectedOptionByKeyInput(event.key);
573
+ }
505
574
  break;
575
+ }
576
+ }
577
+ event.preventDefault();
578
+ event.stopPropagation();
579
+ }
580
+ resetTabIndexAndSetFocusAfterUpdate(newOptionIndex) {
581
+ if (this.displayPopover) {
582
+ // When the popover is opened (`this.displayPopover` is true), the underlying DOM (especially
583
+ // the select listbox inside the popover) may not yet be fully rendered or attached to the layout tree.
584
+ // Calling `resetTabIndexAndSetFocus()` immediately in the same frame would fail because
585
+ // the listbox or its scroll container might still have a height of `0` or not be ready for focus.
586
+ // Wrapping the call inside `window.requestAnimationFrame()` defers the execution until the next
587
+ // browser paint cycle — ensuring that:
588
+ // 1. The DOM updates from Lit’s rendering cycle are flushed.
589
+ // 2. The popover and its scroll container are laid out and measurable.
590
+ // 3. The correct element can safely receive focus and scroll into view.
591
+ this.animationFrameId = window.requestAnimationFrame(() => {
592
+ // We need to reset the tabindex after the component renders,
593
+ // so that the dropdown will open and the focus can be set.
594
+ this.resetTabIndexAndSetFocus(newOptionIndex);
595
+ });
506
596
  }
507
597
  }
508
598
  /**
@@ -516,6 +606,15 @@ class Select extends ListNavigationMixin(CaptureDestroyEventForChildElement(Auto
516
606
  handleNativeInputFocus() {
517
607
  this.visualCombobox.focus();
518
608
  }
609
+ handleSelectedOptionByKeyInput(searchKey) {
610
+ const searchString = this.debounceSearchKey(searchKey);
611
+ this.handleSelectedOptionBasedOnFilter(searchString);
612
+ }
613
+ handleKeydownPopover(event) {
614
+ if (event.key.length === 1) {
615
+ this.handleSelectedOptionByKeyInput(event.key);
616
+ }
617
+ }
519
618
  render() {
520
619
  var _a, _b, _c, _d, _e, _f;
521
620
  return html `
@@ -589,6 +688,7 @@ class Select extends ListNavigationMixin(CaptureDestroyEventForChildElement(Auto
589
688
  focus-back-to-trigger
590
689
  focus-trap
591
690
  size
691
+ @keydown="${this.handleKeydownPopover}"
592
692
  boundary="${ifDefined(this.boundary)}"
593
693
  strategy="${ifDefined(this.strategy)}"
594
694
  placement="${this.placement}"