@limetech/lime-elements 40.21.0 → 40.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [40.21.1](https://github.com/Lundalogik/lime-elements/compare/v40.21.0...v40.21.1) (2026-11-04)
2
+
3
+ ### Bug Fixes
4
+
5
+
6
+ * **input-field:** give the focus back to the field with the up arrow ([e706469](https://github.com/Lundalogik/lime-elements/commit/e706469dbab4f84ff171e761aa77ecbf6be52933))
7
+ * **picker:** give the focus back to the field with the up arrow ([d25ca36](https://github.com/Lundalogik/lime-elements/commit/d25ca36e2b2558739a7541d86b3504b1234a2c69))
8
+ * **picker:** keep the suggestions open while one of them has the focus ([552f736](https://github.com/Lundalogik/lime-elements/commit/552f73688f21fc68780fdbfa0c03bf892b9fae7c))
9
+ * **picker:** move into the suggestions when an arrow key comes early ([5bea664](https://github.com/Lundalogik/lime-elements/commit/5bea664878e68bcb4597dd1241ce71d4d96bc792))
10
+
1
11
  ## [40.21.0](https://github.com/Lundalogik/lime-elements/compare/v40.20.1...v40.21.0) (2026-11-03)
2
12
 
3
13
  ### Features
@@ -642,10 +642,20 @@ const InputField = class {
642
642
  this.showCompletions = false;
643
643
  return;
644
644
  }
645
+ if (event.key === keycodes.ARROW_UP && this.isKeyPressedOnFirstOption(event)) {
646
+ event.preventDefault();
647
+ this.setFocus();
648
+ return;
649
+ }
645
650
  if ([keycodes.TAB, keycodes.ENTER].includes(event.key) || typeahead.isTextEditingKey(event)) {
646
651
  this.setFocus();
647
652
  }
648
653
  };
654
+ this.isKeyPressedOnFirstOption = (event) => {
655
+ const list = event.currentTarget;
656
+ const firstOption = list.shadowRoot.querySelector('.mdc-deprecated-list-item');
657
+ return !!firstOption && event.composedPath().includes(firstOption);
658
+ };
649
659
  this.handleCloseMenu = () => {
650
660
  this.showCompletions = false;
651
661
  };
@@ -5850,6 +5850,164 @@ function base64ArrayBuffer(arrayBuffer) {
5850
5850
  return base64;
5851
5851
  }
5852
5852
 
5853
+ // Parser for the OTP-Token header of draft-goto-otp-token-01, an Internet-Draft that expires
5854
+ // on 25 March 2027. By then check for a newer revision of the draft or a published RFC, the
5855
+ // header name and its parameters are still open questions in the draft.
5856
+ //
5857
+ // The header value is a structured field item (RFC 9651): a String holding the passcode,
5858
+ // with an `origin` parameter naming the origin the passcode is bound to. Unknown parameters
5859
+ // are ignored, but they still have to be valid, otherwise the whole field fails to parse.
5860
+ // RFC 9651 lets a parser limit the size of a field. A passcode and an origin take well under
5861
+ // a kilobyte, the limit keeps the regular expressions below away from megabyte inputs
5862
+ const MAX_FIELD_LENGTH = 8192;
5863
+ // The bare item syntax of RFC 9651 4.2.3.1, keyed by the first character of the item. An
5864
+ // Integer has at most 15 digits, a Decimal at most 12 before and 1 to 3 after the dot, and a
5865
+ // Date is an Integer. Whatever follows a match is left for the caller, which only accepts a
5866
+ // parameter separator or the end of the field, so a 16th digit or a 4th decimal fails there
5867
+ const NUMBER = /-?(?:\d{1,12}\.\d{1,3}|\d{1,15})/y;
5868
+ const STRING = /"((?:[\x20\x21\x23-\x5b\x5d-\x7e]|\\["\\])*)"/y;
5869
+ const TOKEN = /[A-Za-z*][!#$%&'*+\-.^_`|~0-9A-Za-z:/]*/y;
5870
+ const BYTE_SEQUENCE = /:[A-Za-z0-9+/=]*:/y;
5871
+ const BOOLEAN = /\?[01]/y;
5872
+ const DATE = /@-?\d{1,15}/y;
5873
+ const DISPLAY_STRING = /%"((?:[\x20\x21\x23\x24\x26-\x7e]|%[0-9a-f]{2})*)"/y;
5874
+ const KEY = /[a-z*][a-z0-9_\-.*]*/y;
5875
+ const SPACES = / */y;
5876
+ // Matches the sticky expression at the cursor and moves past it, returns null if it does not match
5877
+ function match(cursor, re) {
5878
+ re.lastIndex = cursor.pos;
5879
+ const result = re.exec(cursor.input);
5880
+ if (result) {
5881
+ cursor.pos = re.lastIndex;
5882
+ }
5883
+ return result;
5884
+ }
5885
+ // RFC 9651 4.2.5, returns the unescaped String or null if there is no valid String at the cursor
5886
+ function parseString(cursor) {
5887
+ const result = match(cursor, STRING);
5888
+ return result && result[1].replace(/\\(["\\])/g, '$1');
5889
+ }
5890
+ // RFC 9651 4.2.10. The percent encoded bytes must be valid UTF-8, which decodeURIComponent
5891
+ // checks as it decodes them
5892
+ function isDisplayString(cursor) {
5893
+ const result = match(cursor, DISPLAY_STRING);
5894
+ if (!result) {
5895
+ return false;
5896
+ }
5897
+ try {
5898
+ return true;
5899
+ }
5900
+ catch {
5901
+ return false;
5902
+ }
5903
+ }
5904
+ // RFC 9651 4.2.3.1. Returns the value of a String, true for any other valid bare item and
5905
+ // false for an invalid one
5906
+ function parseBareItem(cursor) {
5907
+ const c = cursor.input.charAt(cursor.pos);
5908
+ switch (c) {
5909
+ case '"':
5910
+ return parseString(cursor) ?? false;
5911
+ case ':':
5912
+ return !!match(cursor, BYTE_SEQUENCE);
5913
+ case '?':
5914
+ return !!match(cursor, BOOLEAN);
5915
+ case '@':
5916
+ return !!match(cursor, DATE);
5917
+ case '%':
5918
+ return isDisplayString(cursor);
5919
+ }
5920
+ return !!match(cursor, c === '-' || (c >= '0' && c <= '9') ? NUMBER : TOKEN);
5921
+ }
5922
+ /**
5923
+ * Parses a structured field item (RFC 9651 4.2 with field_type "item") whose bare item is a
5924
+ * String. Returns the String and the String parameters, or null if the value does not parse
5925
+ * or the bare item is of another type. A parameter of any other type is stored as true
5926
+ * @internal
5927
+ */
5928
+ function parseStringItem(value) {
5929
+ const cursor = { input: value, pos: 0 };
5930
+ match(cursor, SPACES);
5931
+ const item = parseString(cursor);
5932
+ if (item === null) {
5933
+ return null;
5934
+ }
5935
+ // a later parameter with the same key replaces the earlier one
5936
+ const params = new Map();
5937
+ while (cursor.input[cursor.pos] === ';') {
5938
+ cursor.pos++;
5939
+ match(cursor, SPACES);
5940
+ const key = match(cursor, KEY);
5941
+ if (!key) {
5942
+ return null;
5943
+ }
5944
+ let paramValue = true;
5945
+ if (cursor.input[cursor.pos] === '=') {
5946
+ cursor.pos++;
5947
+ paramValue = parseBareItem(cursor);
5948
+ if (paramValue === false) {
5949
+ return null;
5950
+ }
5951
+ }
5952
+ params.set(key[0], paramValue);
5953
+ }
5954
+ match(cursor, SPACES);
5955
+ return cursor.pos === cursor.input.length ? { value: item, params } : null;
5956
+ }
5957
+ /**
5958
+ * Checks that the origin is the canonical ASCII serialization of a potentially trustworthy
5959
+ * origin, as the draft requires: https, or http on a loopback host
5960
+ * @internal
5961
+ */
5962
+ function isOtpOrigin(origin) {
5963
+ let url;
5964
+ try {
5965
+ url = new URL(origin);
5966
+ }
5967
+ catch {
5968
+ return false;
5969
+ }
5970
+ // the URL parser lowercases the host, converts it to punycode and drops the default port, so
5971
+ // a value that is not canonical, or has a path, userinfo or an opaque origin, does not match
5972
+ // its own origin. The WHATWG parser lets characters such as a quote or a brace into a host,
5973
+ // which the origin grammar (RFC 6454) does not allow, so only a DNS name, an IPv4 address or a
5974
+ // bracketed IPv6 address is accepted
5975
+ if (url.origin !== origin || !/^([a-z0-9_-]+\.)*[a-z0-9_-]+$|^\[[0-9a-f:.]+\]$/.test(url.hostname)) {
5976
+ return false;
5977
+ }
5978
+ const isLoopback = /^(localhost|.+\.localhost|127(\.\d+){3}|\[::1\])$/.test(url.hostname);
5979
+ return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback);
5980
+ }
5981
+ /**
5982
+ * Returns the one-time passcode from the OTP-Token header of a message, following the
5983
+ * receiver steps of the draft (section 3.2), or undefined if the message has no valid one.
5984
+ * Only the header of the message itself counts, the draft has an OTP-Token in a MIME part
5985
+ * ignored, and more than one OTP-Token header makes the message have none
5986
+ * @internal
5987
+ */
5988
+ function getOtp(headers) {
5989
+ let otpHeader;
5990
+ for (const header of headers) {
5991
+ if (header.key === 'otp-token') {
5992
+ if (otpHeader) {
5993
+ return undefined;
5994
+ }
5995
+ otpHeader = header;
5996
+ }
5997
+ }
5998
+ if (!otpHeader || otpHeader.value.length > MAX_FIELD_LENGTH) {
5999
+ return undefined;
6000
+ }
6001
+ // the header value is already unfolded, which the draft requires before the structured field parse
6002
+ const parsed = parseStringItem(otpHeader.value);
6003
+ const origin = parsed?.params.get('origin');
6004
+ // an empty passcode is valid syntax, but there is nothing in it to fill in
6005
+ if (!parsed?.value || typeof origin !== 'string' || !isOtpOrigin(origin)) {
6006
+ return undefined;
6007
+ }
6008
+ return { code: parsed.value, origin };
6009
+ }
6010
+
5853
6011
  function isReadableStream(value) {
5854
6012
  return !!value && typeof value.getReader === 'function';
5855
6013
  }
@@ -6425,6 +6583,10 @@ class PostalMime {
6425
6583
  // enforce ISO format if seems to be a valid date
6426
6584
  message.date = date.toString() === 'Invalid Date' ? dateHeader.value : date.toISOString();
6427
6585
  }
6586
+ const otp = getOtp(headers);
6587
+ if (otp) {
6588
+ message.otp = otp;
6589
+ }
6428
6590
  if (this.textContent.html) {
6429
6591
  message.html = this.textContent.html;
6430
6592
  }
@@ -19,6 +19,8 @@ const pickerCss = () => `@charset "UTF-8";:host{position:relative;display:block}
19
19
  const SEARCH_DEBOUNCE = 300;
20
20
  const CHIP_SET_TAG_NAME = 'limel-chip-set';
21
21
  const DEFAULT_SEARCHER_MAX_RESULTS = 20;
22
+ const MAX_FRAMES_TO_WAIT_FOR_LIST = 20;
23
+ const ENABLED_LIST_ITEM = '.mdc-deprecated-list-item:not(.mdc-deprecated-list-item--disabled)';
22
24
  const Picker = class {
23
25
  constructor(hostRef) {
24
26
  index.registerInstance(this, hostRef);
@@ -95,7 +97,8 @@ const Picker = class {
95
97
  this.chips = [];
96
98
  // Should NOT be decorated with State(), since this
97
99
  // should not trigger a re-render by itself.
98
- this.chipSetEditMode = false;
100
+ this.isEditing = false;
101
+ this.suggestionHasFocus = false;
99
102
  this.suggestionsMessage = liveMessage.createLiveMessage(() => this.getSuggestionsMessage());
100
103
  this.getValueId = (item) => {
101
104
  const value = item.value;
@@ -179,6 +182,7 @@ const Picker = class {
179
182
  this.suggestionsMessage.update();
180
183
  }
181
184
  disconnectedCallback() {
185
+ this.forgetSuggestionFocus();
182
186
  this.debouncedSearch.cancel();
183
187
  this.suggestionsMessage.update.cancel();
184
188
  }
@@ -193,9 +197,10 @@ const Picker = class {
193
197
  this.host.setAttribute('tabindex', '0');
194
198
  }
195
199
  async componentWillUpdate() {
196
- this.chipSetEditMode = false;
200
+ this.isEditing = false;
197
201
  if (this.chipSet) {
198
- this.chipSetEditMode = await this.chipSet.getEditMode();
202
+ this.isEditing =
203
+ this.suggestionHasFocus || (await this.chipSet.getEditMode());
199
204
  }
200
205
  }
201
206
  render() {
@@ -204,7 +209,7 @@ const Picker = class {
204
209
  if (!this.multiple) {
205
210
  props.maxItems = 1;
206
211
  }
207
- return (index.h(index.Host, { key: 'b8a2414331b89aa646ecdab2a0e5fb9bfce06de4' }, index.h("limel-chip-set", Object.assign({ key: 'bf4fe7ed7973f40c903116cd538f485e3f44881d', type: "input", inputType: "search", label: this.label, helperText: this.helperText, leadingIcon: this.leadingIcon, value: this.chips, disabled: this.disabled, invalid: this.invalid, delimiter: this.renderDelimiter(), readonly: this.readonly, required: this.required, searchLabel: this.searchLabel, language: this.language, onInput: this.handleTextInput, onKeyDown: this.handleInputKeyDown, onChange: this.handleChange, onInteract: this.handleInteract, onStartEdit: this.handleInputFieldFocus, onStopEdit: this.handleStopEditAndBlur, emptyInputOnBlur: false, emptyInputOnChange: false, clearAllButton: this.multiple && !this.chipSetEditMode, suggestionListId: this.canOpenList() ? this.portalId : undefined, suggestionListOpen: this.suggestionsEngaged && dropdownContent.length > 0 }, props)), this.renderPortal(dropdownContent), index.h("span", { key: '3967fc14d3207567c5976c48c1154b96ea3fe285', class: "suggestions-message", "aria-live": "polite", ref: this.suggestionsMessage.setElement })));
212
+ return (index.h(index.Host, { key: '1670bff5822f930a3eca69ad2a016cea978b3822' }, index.h("limel-chip-set", Object.assign({ key: '173a7facd5271992e792ce6a56a34a8890c9135f', type: "input", inputType: "search", label: this.label, helperText: this.helperText, leadingIcon: this.leadingIcon, value: this.chips, disabled: this.disabled, invalid: this.invalid, delimiter: this.renderDelimiter(), readonly: this.readonly, required: this.required, searchLabel: this.searchLabel, language: this.language, onInput: this.handleTextInput, onKeyDown: this.handleInputKeyDown, onChange: this.handleChange, onInteract: this.handleInteract, onStartEdit: this.handleInputFieldFocus, onStopEdit: this.handleStopEditAndBlur, emptyInputOnBlur: false, emptyInputOnChange: false, clearAllButton: this.multiple && !this.isEditing, suggestionListId: this.canOpenList() ? this.portalId : undefined, suggestionListOpen: this.suggestionsEngaged && dropdownContent.length > 0 }, props)), this.renderPortal(dropdownContent), index.h("span", { key: 'c151104c5059aadc3cfa526267184b663e87bebd', class: "suggestions-message", "aria-live": "polite", ref: this.suggestionsMessage.setElement })));
208
213
  }
209
214
  onChangeValue() {
210
215
  this.chips = this.createChips(this.value);
@@ -277,7 +282,7 @@ const Picker = class {
277
282
  return !this.readonly && !this.isFull();
278
283
  }
279
284
  shouldShowDropDownContent() {
280
- return this.canOpenList() && !!this.chipSetEditMode;
285
+ return this.canOpenList() && !!this.isEditing;
281
286
  }
282
287
  getDropdownContent() {
283
288
  var _a;
@@ -346,10 +351,20 @@ const Picker = class {
346
351
  this.chipSet.setFocus();
347
352
  return;
348
353
  }
354
+ if (event.key === keycodes.ARROW_UP && this.isKeyPressedOnFirstItem(event)) {
355
+ event.preventDefault();
356
+ this.chipSet.setFocus();
357
+ return;
358
+ }
349
359
  if ([keycodes.TAB, keycodes.ENTER].includes(event.key) || typeahead.isTextEditingKey(event)) {
350
360
  this.chipSet.setFocus();
351
361
  }
352
362
  }
363
+ isKeyPressedOnFirstItem(event) {
364
+ const list = event.currentTarget;
365
+ const firstItem = list.shadowRoot.querySelector(ENABLED_LIST_ITEM);
366
+ return !!firstItem && event.composedPath().includes(firstItem);
367
+ }
353
368
  renderPortal(content = []) {
354
369
  const dropdownZIndex = getComputedStyle(this.host).getPropertyValue('--dropdown-z-index');
355
370
  return (index.h("limel-portal", { visible: content.length > 0, containerId: this.portalId, inheritParentWidth: true, containerStyle: { 'z-index': dropdownZIndex } }, index.h("limel-menu-surface", { open: content.length > 0, allowClicksElement: this.host, style: {
@@ -383,6 +398,7 @@ const Picker = class {
383
398
  const query = event.detail;
384
399
  this.textValue = query;
385
400
  this.suggestionsEngaged = true;
401
+ this.forgetSuggestionFocus();
386
402
  this.debouncedSearch(query);
387
403
  // If the search-query is an empty string, bypass debouncing.
388
404
  if (query === '') {
@@ -432,6 +448,7 @@ const Picker = class {
432
448
  if (!event.detail) {
433
449
  return;
434
450
  }
451
+ this.suggestionHasFocus = false;
435
452
  this.action.emit(event.detail.value);
436
453
  this.items = [];
437
454
  }
@@ -440,6 +457,7 @@ const Picker = class {
440
457
  * Prevent focus if the picker has a value and does not support multiple values
441
458
  */
442
459
  handleInputFieldFocus() {
460
+ this.forgetSuggestionFocus();
443
461
  const query = this.textValue;
444
462
  this.debouncedSearch(query);
445
463
  }
@@ -477,7 +495,6 @@ const Picker = class {
477
495
  * @param event - event
478
496
  */
479
497
  handleInputKeyDown(event) {
480
- var _a;
481
498
  this.suggestionsEngaged = true;
482
499
  const isUp = event.key === keycodes.ARROW_UP;
483
500
  const isDown = event.key === keycodes.ARROW_DOWN;
@@ -491,28 +508,47 @@ const Picker = class {
491
508
  if (!isUp && !isDown) {
492
509
  return;
493
510
  }
494
- const list = (_a = document.querySelector(` #${this.portalId} limel-list[data-suggestions]`)) !== null && _a !== void 0 ? _a : document.querySelector(` #${this.portalId} limel-list`);
495
- if (!list) {
511
+ const position = isDown ? 'first' : 'last';
512
+ if (this.focusSuggestion(position)) {
513
+ event.preventDefault();
496
514
  return;
497
515
  }
498
- if (isDown) {
499
- // Match by class only (not `:first-child`) so a leading
500
- // `ListSeparator` doesn't shadow the first focusable item.
501
- const listElement = list.shadowRoot.querySelector('.mdc-deprecated-list-item');
502
- if (!listElement) {
516
+ this.suggestionToFocusWhenShown = position;
517
+ }
518
+ focusSuggestion(position) {
519
+ var _a;
520
+ const list = (_a = document.querySelector(` #${this.portalId} limel-list[data-suggestions]`)) !== null && _a !== void 0 ? _a : document.querySelector(` #${this.portalId} limel-list`);
521
+ if (!list) {
522
+ return false;
523
+ }
524
+ // Match by class only (not `:first-child`) so a leading
525
+ // `ListSeparator` doesn't shadow the first focusable item.
526
+ const items = list.shadowRoot.querySelectorAll(ENABLED_LIST_ITEM);
527
+ const item = position === 'first' ? items[0] : [...items].at(-1);
528
+ item === null || item === void 0 ? void 0 : item.focus();
529
+ this.suggestionHasFocus =
530
+ !!item && list.shadowRoot.activeElement === item;
531
+ return this.suggestionHasFocus;
532
+ }
533
+ focusSuggestionWhenShown(position) {
534
+ cancelAnimationFrame(this.suggestionFocusFrame);
535
+ let frames = 0;
536
+ const focusSuggestion = () => {
537
+ if (this.focusSuggestion(position)) {
503
538
  return;
504
539
  }
505
- event.preventDefault();
506
- listElement.focus();
507
- return;
508
- }
509
- const listItems = list.shadowRoot.querySelectorAll('.mdc-deprecated-list-item');
510
- const listElement = [...listItems].at(-1);
511
- if (!listElement) {
512
- return;
513
- }
514
- event.preventDefault();
515
- listElement.focus();
540
+ frames += 1;
541
+ if (frames < MAX_FRAMES_TO_WAIT_FOR_LIST) {
542
+ this.suggestionFocusFrame =
543
+ requestAnimationFrame(focusSuggestion);
544
+ }
545
+ };
546
+ this.suggestionFocusFrame = requestAnimationFrame(focusSuggestion);
547
+ }
548
+ forgetSuggestionFocus() {
549
+ this.suggestionHasFocus = false;
550
+ this.suggestionToFocusWhenShown = undefined;
551
+ cancelAnimationFrame(this.suggestionFocusFrame);
516
552
  }
517
553
  handleSearchResult(query, result) {
518
554
  var _a;
@@ -531,6 +567,11 @@ const Picker = class {
531
567
  }
532
568
  this.items = this.prependSearchHeader(query, nextItems);
533
569
  this.loading = false;
570
+ const position = this.suggestionToFocusWhenShown;
571
+ this.suggestionToFocusWhenShown = undefined;
572
+ if (position && this.hasPickableItems(nextItems)) {
573
+ this.focusSuggestionWhenShown(position);
574
+ }
534
575
  }
535
576
  hasPickableItems(items) {
536
577
  return items.some((item) => !('separator' in item));
@@ -553,7 +594,8 @@ const Picker = class {
553
594
  /**
554
595
  * Shared prelude for any flow that ends the current search session:
555
596
  * wipe the chip-set's visible text, reset the picker's `textValue`,
556
- * and cancel any in-flight debounced search.
597
+ * cancel any in-flight debounced search, and forget that a suggestion
598
+ * has the focus, or that an arrow key is waiting for the suggestions.
557
599
  *
558
600
  * Used by `clearInputField` (which then drops the dropdown
559
601
  * entirely) and `resetSearchToDefault` (which re-runs the searcher
@@ -563,6 +605,7 @@ const Picker = class {
563
605
  this.chipSet.emptyInput();
564
606
  this.textValue = '';
565
607
  this.debouncedSearch.cancel();
608
+ this.forgetSuggestionFocus();
566
609
  }
567
610
  clearInputField() {
568
611
  this.clearTextValue();
@@ -524,10 +524,20 @@ export class InputField {
524
524
  this.showCompletions = false;
525
525
  return;
526
526
  }
527
+ if (event.key === ARROW_UP && this.isKeyPressedOnFirstOption(event)) {
528
+ event.preventDefault();
529
+ this.setFocus();
530
+ return;
531
+ }
527
532
  if ([TAB, ENTER].includes(event.key) || isTextEditingKey(event)) {
528
533
  this.setFocus();
529
534
  }
530
535
  };
536
+ this.isKeyPressedOnFirstOption = (event) => {
537
+ const list = event.currentTarget;
538
+ const firstOption = list.shadowRoot.querySelector('.mdc-deprecated-list-item');
539
+ return !!firstOption && event.composedPath().includes(firstOption);
540
+ };
531
541
  this.handleCloseMenu = () => {
532
542
  this.showCompletions = false;
533
543
  };
@@ -10,6 +10,8 @@ import { debounce } from "lodash-es";
10
10
  const SEARCH_DEBOUNCE = 300;
11
11
  const CHIP_SET_TAG_NAME = 'limel-chip-set';
12
12
  const DEFAULT_SEARCHER_MAX_RESULTS = 20;
13
+ const MAX_FRAMES_TO_WAIT_FOR_LIST = 20;
14
+ const ENABLED_LIST_ITEM = '.mdc-deprecated-list-item:not(.mdc-deprecated-list-item--disabled)';
13
15
  /**
14
16
  * @exampleComponent limel-example-picker-basic
15
17
  * @exampleComponent limel-example-picker-multiple
@@ -98,7 +100,8 @@ export class Picker {
98
100
  this.chips = [];
99
101
  // Should NOT be decorated with State(), since this
100
102
  // should not trigger a re-render by itself.
101
- this.chipSetEditMode = false;
103
+ this.isEditing = false;
104
+ this.suggestionHasFocus = false;
102
105
  this.suggestionsMessage = createLiveMessage(() => this.getSuggestionsMessage());
103
106
  this.getValueId = (item) => {
104
107
  const value = item.value;
@@ -182,6 +185,7 @@ export class Picker {
182
185
  this.suggestionsMessage.update();
183
186
  }
184
187
  disconnectedCallback() {
188
+ this.forgetSuggestionFocus();
185
189
  this.debouncedSearch.cancel();
186
190
  this.suggestionsMessage.update.cancel();
187
191
  }
@@ -196,9 +200,10 @@ export class Picker {
196
200
  this.host.setAttribute('tabindex', '0');
197
201
  }
198
202
  async componentWillUpdate() {
199
- this.chipSetEditMode = false;
203
+ this.isEditing = false;
200
204
  if (this.chipSet) {
201
- this.chipSetEditMode = await this.chipSet.getEditMode();
205
+ this.isEditing =
206
+ this.suggestionHasFocus || (await this.chipSet.getEditMode());
202
207
  }
203
208
  }
204
209
  render() {
@@ -207,7 +212,7 @@ export class Picker {
207
212
  if (!this.multiple) {
208
213
  props.maxItems = 1;
209
214
  }
210
- return (h(Host, { key: 'b8a2414331b89aa646ecdab2a0e5fb9bfce06de4' }, h("limel-chip-set", Object.assign({ key: 'bf4fe7ed7973f40c903116cd538f485e3f44881d', type: "input", inputType: "search", label: this.label, helperText: this.helperText, leadingIcon: this.leadingIcon, value: this.chips, disabled: this.disabled, invalid: this.invalid, delimiter: this.renderDelimiter(), readonly: this.readonly, required: this.required, searchLabel: this.searchLabel, language: this.language, onInput: this.handleTextInput, onKeyDown: this.handleInputKeyDown, onChange: this.handleChange, onInteract: this.handleInteract, onStartEdit: this.handleInputFieldFocus, onStopEdit: this.handleStopEditAndBlur, emptyInputOnBlur: false, emptyInputOnChange: false, clearAllButton: this.multiple && !this.chipSetEditMode, suggestionListId: this.canOpenList() ? this.portalId : undefined, suggestionListOpen: this.suggestionsEngaged && dropdownContent.length > 0 }, props)), this.renderPortal(dropdownContent), h("span", { key: '3967fc14d3207567c5976c48c1154b96ea3fe285', class: "suggestions-message", "aria-live": "polite", ref: this.suggestionsMessage.setElement })));
215
+ return (h(Host, { key: '1670bff5822f930a3eca69ad2a016cea978b3822' }, h("limel-chip-set", Object.assign({ key: '173a7facd5271992e792ce6a56a34a8890c9135f', type: "input", inputType: "search", label: this.label, helperText: this.helperText, leadingIcon: this.leadingIcon, value: this.chips, disabled: this.disabled, invalid: this.invalid, delimiter: this.renderDelimiter(), readonly: this.readonly, required: this.required, searchLabel: this.searchLabel, language: this.language, onInput: this.handleTextInput, onKeyDown: this.handleInputKeyDown, onChange: this.handleChange, onInteract: this.handleInteract, onStartEdit: this.handleInputFieldFocus, onStopEdit: this.handleStopEditAndBlur, emptyInputOnBlur: false, emptyInputOnChange: false, clearAllButton: this.multiple && !this.isEditing, suggestionListId: this.canOpenList() ? this.portalId : undefined, suggestionListOpen: this.suggestionsEngaged && dropdownContent.length > 0 }, props)), this.renderPortal(dropdownContent), h("span", { key: 'c151104c5059aadc3cfa526267184b663e87bebd', class: "suggestions-message", "aria-live": "polite", ref: this.suggestionsMessage.setElement })));
211
216
  }
212
217
  onChangeValue() {
213
218
  this.chips = this.createChips(this.value);
@@ -280,7 +285,7 @@ export class Picker {
280
285
  return !this.readonly && !this.isFull();
281
286
  }
282
287
  shouldShowDropDownContent() {
283
- return this.canOpenList() && !!this.chipSetEditMode;
288
+ return this.canOpenList() && !!this.isEditing;
284
289
  }
285
290
  getDropdownContent() {
286
291
  var _a;
@@ -349,10 +354,20 @@ export class Picker {
349
354
  this.chipSet.setFocus();
350
355
  return;
351
356
  }
357
+ if (event.key === ARROW_UP && this.isKeyPressedOnFirstItem(event)) {
358
+ event.preventDefault();
359
+ this.chipSet.setFocus();
360
+ return;
361
+ }
352
362
  if ([TAB, ENTER].includes(event.key) || isTextEditingKey(event)) {
353
363
  this.chipSet.setFocus();
354
364
  }
355
365
  }
366
+ isKeyPressedOnFirstItem(event) {
367
+ const list = event.currentTarget;
368
+ const firstItem = list.shadowRoot.querySelector(ENABLED_LIST_ITEM);
369
+ return !!firstItem && event.composedPath().includes(firstItem);
370
+ }
356
371
  renderPortal(content = []) {
357
372
  const dropdownZIndex = getComputedStyle(this.host).getPropertyValue('--dropdown-z-index');
358
373
  return (h("limel-portal", { visible: content.length > 0, containerId: this.portalId, inheritParentWidth: true, containerStyle: { 'z-index': dropdownZIndex } }, h("limel-menu-surface", { open: content.length > 0, allowClicksElement: this.host, style: {
@@ -386,6 +401,7 @@ export class Picker {
386
401
  const query = event.detail;
387
402
  this.textValue = query;
388
403
  this.suggestionsEngaged = true;
404
+ this.forgetSuggestionFocus();
389
405
  this.debouncedSearch(query);
390
406
  // If the search-query is an empty string, bypass debouncing.
391
407
  if (query === '') {
@@ -435,6 +451,7 @@ export class Picker {
435
451
  if (!event.detail) {
436
452
  return;
437
453
  }
454
+ this.suggestionHasFocus = false;
438
455
  this.action.emit(event.detail.value);
439
456
  this.items = [];
440
457
  }
@@ -443,6 +460,7 @@ export class Picker {
443
460
  * Prevent focus if the picker has a value and does not support multiple values
444
461
  */
445
462
  handleInputFieldFocus() {
463
+ this.forgetSuggestionFocus();
446
464
  const query = this.textValue;
447
465
  this.debouncedSearch(query);
448
466
  }
@@ -480,7 +498,6 @@ export class Picker {
480
498
  * @param event - event
481
499
  */
482
500
  handleInputKeyDown(event) {
483
- var _a;
484
501
  this.suggestionsEngaged = true;
485
502
  const isUp = event.key === ARROW_UP;
486
503
  const isDown = event.key === ARROW_DOWN;
@@ -494,28 +511,47 @@ export class Picker {
494
511
  if (!isUp && !isDown) {
495
512
  return;
496
513
  }
514
+ const position = isDown ? 'first' : 'last';
515
+ if (this.focusSuggestion(position)) {
516
+ event.preventDefault();
517
+ return;
518
+ }
519
+ this.suggestionToFocusWhenShown = position;
520
+ }
521
+ focusSuggestion(position) {
522
+ var _a;
497
523
  const list = (_a = document.querySelector(` #${this.portalId} limel-list[data-suggestions]`)) !== null && _a !== void 0 ? _a : document.querySelector(` #${this.portalId} limel-list`);
498
524
  if (!list) {
499
- return;
525
+ return false;
500
526
  }
501
- if (isDown) {
502
- // Match by class only (not `:first-child`) so a leading
503
- // `ListSeparator` doesn't shadow the first focusable item.
504
- const listElement = list.shadowRoot.querySelector('.mdc-deprecated-list-item');
505
- if (!listElement) {
527
+ // Match by class only (not `:first-child`) so a leading
528
+ // `ListSeparator` doesn't shadow the first focusable item.
529
+ const items = list.shadowRoot.querySelectorAll(ENABLED_LIST_ITEM);
530
+ const item = position === 'first' ? items[0] : [...items].at(-1);
531
+ item === null || item === void 0 ? void 0 : item.focus();
532
+ this.suggestionHasFocus =
533
+ !!item && list.shadowRoot.activeElement === item;
534
+ return this.suggestionHasFocus;
535
+ }
536
+ focusSuggestionWhenShown(position) {
537
+ cancelAnimationFrame(this.suggestionFocusFrame);
538
+ let frames = 0;
539
+ const focusSuggestion = () => {
540
+ if (this.focusSuggestion(position)) {
506
541
  return;
507
542
  }
508
- event.preventDefault();
509
- listElement.focus();
510
- return;
511
- }
512
- const listItems = list.shadowRoot.querySelectorAll('.mdc-deprecated-list-item');
513
- const listElement = [...listItems].at(-1);
514
- if (!listElement) {
515
- return;
516
- }
517
- event.preventDefault();
518
- listElement.focus();
543
+ frames += 1;
544
+ if (frames < MAX_FRAMES_TO_WAIT_FOR_LIST) {
545
+ this.suggestionFocusFrame =
546
+ requestAnimationFrame(focusSuggestion);
547
+ }
548
+ };
549
+ this.suggestionFocusFrame = requestAnimationFrame(focusSuggestion);
550
+ }
551
+ forgetSuggestionFocus() {
552
+ this.suggestionHasFocus = false;
553
+ this.suggestionToFocusWhenShown = undefined;
554
+ cancelAnimationFrame(this.suggestionFocusFrame);
519
555
  }
520
556
  handleSearchResult(query, result) {
521
557
  var _a;
@@ -534,6 +570,11 @@ export class Picker {
534
570
  }
535
571
  this.items = this.prependSearchHeader(query, nextItems);
536
572
  this.loading = false;
573
+ const position = this.suggestionToFocusWhenShown;
574
+ this.suggestionToFocusWhenShown = undefined;
575
+ if (position && this.hasPickableItems(nextItems)) {
576
+ this.focusSuggestionWhenShown(position);
577
+ }
537
578
  }
538
579
  hasPickableItems(items) {
539
580
  return items.some((item) => !('separator' in item));
@@ -556,7 +597,8 @@ export class Picker {
556
597
  /**
557
598
  * Shared prelude for any flow that ends the current search session:
558
599
  * wipe the chip-set's visible text, reset the picker's `textValue`,
559
- * and cancel any in-flight debounced search.
600
+ * cancel any in-flight debounced search, and forget that a suggestion
601
+ * has the focus, or that an arrow key is waiting for the suggestions.
560
602
  *
561
603
  * Used by `clearInputField` (which then drops the dropdown
562
604
  * entirely) and `resetSearchToDefault` (which re-runs the searcher
@@ -566,6 +608,7 @@ export class Picker {
566
608
  this.chipSet.emptyInput();
567
609
  this.textValue = '';
568
610
  this.debouncedSearch.cancel();
611
+ this.forgetSuggestionFocus();
569
612
  }
570
613
  clearInputField() {
571
614
  this.clearTextValue();
@@ -640,10 +640,20 @@ const InputField = class {
640
640
  this.showCompletions = false;
641
641
  return;
642
642
  }
643
+ if (event.key === ARROW_UP && this.isKeyPressedOnFirstOption(event)) {
644
+ event.preventDefault();
645
+ this.setFocus();
646
+ return;
647
+ }
643
648
  if ([TAB, ENTER].includes(event.key) || isTextEditingKey(event)) {
644
649
  this.setFocus();
645
650
  }
646
651
  };
652
+ this.isKeyPressedOnFirstOption = (event) => {
653
+ const list = event.currentTarget;
654
+ const firstOption = list.shadowRoot.querySelector('.mdc-deprecated-list-item');
655
+ return !!firstOption && event.composedPath().includes(firstOption);
656
+ };
647
657
  this.handleCloseMenu = () => {
648
658
  this.showCompletions = false;
649
659
  };