@nectary/components 5.42.0 → 5.42.2

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/bundle.js CHANGED
@@ -1528,7 +1528,8 @@ const regStrikethrough = new RegExp("(?<!\\\\)~~(?<strike>.+?)(?<!\\\\)~~");
1528
1528
  const regButtonPlaceholder = new RegExp("(?<!\\\\)\\[\\[(?<button>[a-zA-Z0-9_-]+)\\]\\]");
1529
1529
  const regLink = new RegExp("(?<!\\\\)!?\\[(?<linktext>[^\\]]*?)\\]\\((?<linkhref>[^)]+?)\\)(\\{(?<linkattrs>[^)]+?)\\})?");
1530
1530
  const regChip = new RegExp("(?<!\\\\)\\{\\{(?<chip>[a-zA-Z0-9_-]+)\\}\\}");
1531
- const regEmoji = new RegExp("(?<emoji>(?![0-9*#])\\p{Emoji})", "u");
1531
+ const regEmojiSeq = "(?![0-9#*])\\p{Emoji}(?:\\uFE0F|\\p{Emoji_Modifier})?\\u20E3?";
1532
+ const regEmoji = new RegExp(`(?<emoji>\\p{RI}\\p{RI}|${regEmojiSeq}(?:\\u200D${regEmojiSeq})*)`, "u");
1532
1533
  const regUList = /^(?<indent>[\t ]*?)[*+-][\t ]+(?<ultext>.*?)[\t ]*?$/;
1533
1534
  const regOList = /^(?<indent>[\t ]*?)\d+\.[\t ]+(?<oltext>.*?)[\t ]*?$/;
1534
1535
  const regEscapedChars = /\\(?<escaped>[\\\*_\[\]`~\{\}])/;
@@ -6694,7 +6695,7 @@ class Input extends NectaryElement {
6694
6695
  break;
6695
6696
  }
6696
6697
  case "aria-label": {
6697
- this.#$input.ariaLabel = newVal;
6698
+ updateAttribute(this.#$input, "aria-label", newVal);
6698
6699
  break;
6699
6700
  }
6700
6701
  }
@@ -7218,6 +7219,9 @@ const getPopOrientation = (orientation) => {
7218
7219
  const templateHTML$S = '<style>:host{display:contents}#content-wrapper{position:relative;padding-top:4px;width:fit-content;min-width:100%}:host([tip]) #content-wrapper{padding-top:12px;filter:drop-shadow(var(--sinch-comp-popover-shadow))}:host([orientation^=top]) #content-wrapper{padding-top:0;padding-bottom:4px}:host([orientation=left]) #content-wrapper{padding-top:0;padding-right:4px}:host([orientation=right]) #content-wrapper{padding-top:0;padding-left:4px}:host([orientation^=top][tip]) #content-wrapper{padding-top:0;padding-bottom:12px}:host([orientation=left][tip]) #content-wrapper{padding-top:0;padding-right:12px}:host([orientation=right][tip]) #content-wrapper{padding-top:0;padding-left:12px}#content{background-color:var(--sinch-comp-popover-color-default-background-initial);border:1px solid var(--sinch-comp-popover-color-default-border-initial);border-radius:var(--sinch-comp-popover-shape-radius);box-shadow:var(--sinch-comp-popover-shadow);overflow:hidden}:host([tip]) #content{box-shadow:none}#tip{position:absolute;left:50%;top:13px;transform:translateX(-50%) rotate(180deg);transform-origin:top center;fill:var(--sinch-comp-popover-color-default-background-initial);stroke:var(--sinch-comp-popover-color-default-border-initial);stroke-linecap:square;pointer-events:none;display:none}:host([orientation^=top]) #tip{transform:translateX(-50%) rotate(0);top:calc(100% - 13px)}:host([orientation=left]) #tip{transform:translateX(-50%) rotate(-90deg);top:calc(50%);left:calc(100% - 13px)}:host([orientation=right]) #tip{transform:translateX(-50%) rotate(90deg);top:calc(50%);left:13px}:host([tip]) #tip:not(.hidden){display:block}</style><sinch-pop id="pop" inset="4"><slot name="target" slot="target"></slot><div id="content-wrapper" slot="content"><div id="content"><slot name="content"></slot></div><svg id="tip" width="16" height="9" aria-hidden="true"><path d="m0 0 8 8 8 -8"/></svg></div></sinch-pop>';
7219
7220
  const TIP_SIZE = 16;
7220
7221
  const template$S = document.createElement("template");
7222
+ const isFocusableContent = (element) => {
7223
+ return typeof element?.focusOnOpen === "function";
7224
+ };
7221
7225
  template$S.innerHTML = templateHTML$S;
7222
7226
  class Popover extends NectaryElement {
7223
7227
  #$pop;
@@ -7275,6 +7279,7 @@ class Popover extends NectaryElement {
7275
7279
  "open",
7276
7280
  "modal",
7277
7281
  "allow-scroll",
7282
+ "focus-content-on-open",
7278
7283
  "tip",
7279
7284
  "aria-label",
7280
7285
  "aria-description"
@@ -7305,6 +7310,10 @@ class Popover extends NectaryElement {
7305
7310
  updateBooleanAttribute(this, name, isAttrTrue(newVal));
7306
7311
  break;
7307
7312
  }
7313
+ case "focus-content-on-open": {
7314
+ updateBooleanAttribute(this, name, isAttrTrue(newVal));
7315
+ break;
7316
+ }
7308
7317
  case "modal":
7309
7318
  case "open": {
7310
7319
  updateAttribute(this.#$pop, name, newVal);
@@ -7334,6 +7343,12 @@ class Popover extends NectaryElement {
7334
7343
  get allowScroll() {
7335
7344
  return getBooleanAttribute(this, "allow-scroll");
7336
7345
  }
7346
+ set focusContentOnOpen(shouldFocus) {
7347
+ updateBooleanAttribute(this, "focus-content-on-open", shouldFocus);
7348
+ }
7349
+ get focusContentOnOpen() {
7350
+ return getBooleanAttribute(this, "focus-content-on-open");
7351
+ }
7337
7352
  set open(isOpen) {
7338
7353
  updateBooleanAttribute(this, "open", isOpen);
7339
7354
  }
@@ -7368,6 +7383,12 @@ class Popover extends NectaryElement {
7368
7383
  }
7369
7384
  return elements[0];
7370
7385
  }
7386
+ #focusSlottedContentOnOpen() {
7387
+ const slottedContent = this.#getFirstAssignedElementInSlot(this.#$contentSlot);
7388
+ if (isFocusableContent(slottedContent)) {
7389
+ slottedContent.focusOnOpen();
7390
+ }
7391
+ }
7371
7392
  #onPopClose = () => {
7372
7393
  this.#dispatchCloseEvent();
7373
7394
  };
@@ -7384,6 +7405,10 @@ class Popover extends NectaryElement {
7384
7405
  if (e.detail) {
7385
7406
  this.#updateTipOrientation();
7386
7407
  this.#updateContentMaxWidth();
7408
+ if (this.focusContentOnOpen) {
7409
+ this.#$content.getBoundingClientRect();
7410
+ this.#focusSlottedContentOnOpen();
7411
+ }
7387
7412
  } else {
7388
7413
  this.#resetTipOrientation();
7389
7414
  }
@@ -15308,8 +15333,9 @@ class SelectMenuOption extends NectaryElement {
15308
15333
  }
15309
15334
  defineCustomElement("sinch-select-menu-option", SelectMenuOption);
15310
15335
  const isSelectMenuOption = (el) => el.localName === "sinch-select-menu-option";
15311
- const templateHTML$h = '<style>:host{display:block;outline:0}#listbox{overflow-y:auto;max-height:var(--sinch-comp-select-menu-font-max-height)}#search{display:none;margin:10px}#search.active{display:block}#search-clear:not(.active){display:none}#not-found{display:flex;align-items:center;justify-content:center;width:100%;height:30px;margin-bottom:10px;pointer-events:none;user-select:none;--sinch-comp-text-font:var(--sinch-comp-select-menu-font-not-found-text);--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-not-found-text-initial)}#not-found:not(.active){display:none}::slotted(.hidden){display:none}::slotted(sinch-title){padding:8px 16px;--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-title-initial)}</style><sinch-input id="search" size="s" placeholder="Search"><sinch-icon icons-version="2" name="magnifying-glass" id="icon-search" slot="icon"></sinch-icon><sinch-button id="search-clear" slot="right"><sinch-icon icons-version="2" name="fa-xmark" slot="icon"></sinch-icon></sinch-button></sinch-input><div id="not-found"><sinch-text type="m">No results</sinch-text></div><div id="listbox" role="presentation"><slot></slot></div>';
15336
+ const templateHTML$h = '<style>:host{display:block;outline:0}#listbox{overflow-y:auto;max-height:var(--sinch-comp-select-menu-font-max-height)}#search{display:none;margin:10px}#search.active{display:block}#search-clear:not(.active){display:none}#not-found{display:flex;align-items:center;justify-content:center;width:100%;height:30px;margin-bottom:10px;pointer-events:none;user-select:none;--sinch-comp-text-font:var(--sinch-comp-select-menu-font-not-found-text);--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-not-found-text-initial)}#not-found:not(.active){display:none}::slotted(.hidden){display:none}::slotted(sinch-title){padding:8px 16px;--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-title-initial)}</style><sinch-input id="search" size="s" placeholder="Search" aria-label="Search"><sinch-icon icons-version="2" name="magnifying-glass" id="icon-search" slot="icon"></sinch-icon><sinch-button id="search-clear" slot="right"><sinch-icon icons-version="2" name="fa-xmark" slot="icon"></sinch-icon></sinch-button></sinch-input><div id="not-found"><sinch-text type="m">No results</sinch-text></div><div id="listbox" role="listbox" tabindex="-1"><slot></slot></div>';
15312
15337
  const ITEM_HEIGHT = 40;
15338
+ const DEFAULT_SEARCH_LABEL = "Search";
15313
15339
  const template$h = document.createElement("template");
15314
15340
  template$h.innerHTML = templateHTML$h;
15315
15341
  class SelectMenu extends NectaryElement {
@@ -15336,13 +15362,14 @@ class SelectMenu extends NectaryElement {
15336
15362
  this.#searchDebounce = debounceTimeout(200)(this.#updateSearchValue);
15337
15363
  }
15338
15364
  connectedCallback() {
15365
+ super.connectedCallback();
15339
15366
  this.#controller = new AbortController();
15340
15367
  const options = {
15341
15368
  signal: this.#controller.signal
15342
15369
  };
15343
- this.setAttribute("role", "listbox");
15344
- this.#internals.role = "listbox";
15370
+ this.setAttribute("role", "group");
15345
15371
  this.tabIndex = 0;
15372
+ this.#syncListboxLabel();
15346
15373
  this.addEventListener("keydown", this.#onListboxKeyDown, options);
15347
15374
  this.addEventListener("focus", this.#onFocus, options);
15348
15375
  this.addEventListener("blur", this.#onListboxBlur, options);
@@ -15387,6 +15414,7 @@ class SelectMenu extends NectaryElement {
15387
15414
  this.#searchDebounce.cancel();
15388
15415
  this.#controller.abort();
15389
15416
  this.#controller = null;
15417
+ super.disconnectedCallback();
15390
15418
  }
15391
15419
  formAssociatedCallback() {
15392
15420
  setFormValue(this.#internals, CSVToFormData(this.name, this.value));
@@ -15411,6 +15439,8 @@ class SelectMenu extends NectaryElement {
15411
15439
  "rows",
15412
15440
  "multiple",
15413
15441
  "searchable",
15442
+ "aria-label",
15443
+ "aria-labelledby",
15414
15444
  // eslint-disable-next-line @nectary/observed-attribute-accessor -- baseline backlog: missing set searchValue
15415
15445
  "search-value",
15416
15446
  // eslint-disable-next-line @nectary/observed-attribute-accessor -- baseline backlog: missing set searchPlaceholder
@@ -15424,17 +15454,24 @@ class SelectMenu extends NectaryElement {
15424
15454
  case "multiple": {
15425
15455
  this.#onValueChange(this.value);
15426
15456
  updateExplicitBooleanAttribute(
15427
- this,
15457
+ this.#$listbox,
15428
15458
  "aria-multiselectable",
15429
15459
  isAttrTrue(newVal)
15430
15460
  );
15431
- this.#internals.ariaMultiSelectable = isAttrTrue(newVal).toString();
15432
15461
  break;
15433
15462
  }
15434
15463
  case "searchable": {
15435
15464
  this.#onOptionSlotChange();
15436
15465
  break;
15437
15466
  }
15467
+ case "aria-label": {
15468
+ this.#syncListboxLabel();
15469
+ break;
15470
+ }
15471
+ case "aria-labelledby": {
15472
+ this.#syncListboxLabel();
15473
+ break;
15474
+ }
15438
15475
  case "search-autocomplete": {
15439
15476
  updateAttribute(this.#$search, "autocomplete", newVal);
15440
15477
  break;
@@ -15448,7 +15485,7 @@ class SelectMenu extends NectaryElement {
15448
15485
  break;
15449
15486
  }
15450
15487
  case "search-placeholder": {
15451
- updateAttribute(this.#$search, "placeholder", newVal);
15488
+ this.#updateSearchPlaceholder(newVal);
15452
15489
  break;
15453
15490
  }
15454
15491
  case "search-value": {
@@ -15496,6 +15533,18 @@ class SelectMenu extends NectaryElement {
15496
15533
  const searchableAttribute = this.getAttribute("searchable");
15497
15534
  return searchableAttribute === null ? searchableAttribute : isAttrTrue(searchableAttribute);
15498
15535
  }
15536
+ get "aria-label"() {
15537
+ return getAttribute(this, "aria-label");
15538
+ }
15539
+ set "aria-label"(value) {
15540
+ updateAttribute(this, "aria-label", value);
15541
+ }
15542
+ get "aria-labelledby"() {
15543
+ return getAttribute(this, "aria-labelledby");
15544
+ }
15545
+ set "aria-labelledby"(value) {
15546
+ updateAttribute(this, "aria-labelledby", value);
15547
+ }
15499
15548
  set "search-autocomplete"(autocomplete) {
15500
15549
  updateAttribute(this.#$search, "autocomplete", autocomplete);
15501
15550
  }
@@ -15503,7 +15552,7 @@ class SelectMenu extends NectaryElement {
15503
15552
  return getAttribute(this.#$search, "autocomplete", "");
15504
15553
  }
15505
15554
  set "search-placeholder"(placeholder) {
15506
- updateAttribute(this.#$search, "placeholder", placeholder);
15555
+ this.#updateSearchPlaceholder(placeholder);
15507
15556
  }
15508
15557
  get "search-placeholder"() {
15509
15558
  return getAttribute(this.#$search, "placeholder", "");
@@ -15518,12 +15567,22 @@ class SelectMenu extends NectaryElement {
15518
15567
  get focusable() {
15519
15568
  return true;
15520
15569
  }
15570
+ focusOnOpen() {
15571
+ this.#focusActiveSearch();
15572
+ }
15521
15573
  #onFocus = () => {
15522
- const isSearchActive = hasClass(this.#$search, "active");
15523
- if (isSearchActive) {
15524
- this.#$search.focus();
15574
+ if (getDeepActiveElement(this.ownerDocument) !== this) {
15575
+ return;
15525
15576
  }
15577
+ this.#focusActiveSearch();
15526
15578
  };
15579
+ #focusActiveSearch() {
15580
+ if (!this.isDomConnected || !hasClass(this.#$search, "active")) {
15581
+ return;
15582
+ }
15583
+ this.#$search.getBoundingClientRect();
15584
+ this.#$search.focus();
15585
+ }
15527
15586
  #onListboxBlur = () => {
15528
15587
  this.#selectOption(null);
15529
15588
  };
@@ -15588,7 +15647,7 @@ class SelectMenu extends NectaryElement {
15588
15647
  #onContextVisibility = (e) => {
15589
15648
  if (e.detail) {
15590
15649
  this.#selectOption(this.#findCheckedOption());
15591
- this.#onFocus();
15650
+ this.#focusActiveSearch();
15592
15651
  } else {
15593
15652
  this.#selectOption(null);
15594
15653
  }
@@ -15626,7 +15685,30 @@ class SelectMenu extends NectaryElement {
15626
15685
  this.#dispatchChangeEvent(option);
15627
15686
  }
15628
15687
  };
15688
+ #syncListboxLabel = () => {
15689
+ const labelledby = getAttribute(this, "aria-labelledby");
15690
+ const label = labelledby === null ? getAttribute(this, "aria-label") : null;
15691
+ updateAttribute(this.#$listbox, "aria-labelledby", labelledby);
15692
+ updateAttribute(this.#$listbox, "aria-label", label);
15693
+ };
15694
+ #updateSearchPlaceholder(placeholder) {
15695
+ const currentPlaceholder = getAttribute(this.#$search, "placeholder");
15696
+ const currentLabel = getAttribute(this.#$search, "aria-label");
15697
+ const isManagedLabel = currentLabel === null || currentLabel === DEFAULT_SEARCH_LABEL || currentLabel === currentPlaceholder;
15698
+ updateAttribute(this.#$search, "placeholder", placeholder);
15699
+ if (isManagedLabel) {
15700
+ updateAttribute(this.#$search, "aria-label", placeholder === null || placeholder === "" ? DEFAULT_SEARCH_LABEL : placeholder);
15701
+ }
15702
+ }
15703
+ #syncListboxChildren = () => {
15704
+ for (const $element of this.#$optionSlot.assignedElements()) {
15705
+ if (!isSelectMenuOption($element) && getAttribute($element, "aria-hidden") !== "true") {
15706
+ updateAttribute($element, "aria-hidden", "true");
15707
+ }
15708
+ }
15709
+ };
15629
15710
  #onOptionSlotChange = () => {
15711
+ this.#syncListboxChildren();
15630
15712
  if (this.hasAttribute("rows")) {
15631
15713
  this.#updateListboxMaxHeight();
15632
15714
  }
package/input/index.js CHANGED
@@ -301,7 +301,7 @@ class Input extends NectaryElement {
301
301
  break;
302
302
  }
303
303
  case "aria-label": {
304
- this.#$input.ariaLabel = newVal;
304
+ updateAttribute(this.#$input, "aria-label", newVal);
305
305
  break;
306
306
  }
307
307
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectary/components",
3
- "version": "5.42.0",
3
+ "version": "5.42.2",
4
4
  "files": [
5
5
  "**/*/*.css",
6
6
  "**/*/*.json",
@@ -13,6 +13,8 @@ export declare class Popover extends NectaryElement {
13
13
  get modal(): boolean;
14
14
  set allowScroll(allow: boolean);
15
15
  get allowScroll(): boolean;
16
+ set focusContentOnOpen(shouldFocus: boolean);
17
+ get focusContentOnOpen(): boolean;
16
18
  set open(isOpen: boolean);
17
19
  get open(): boolean;
18
20
  set tip(hasTip: boolean);
package/popover/index.js CHANGED
@@ -8,6 +8,9 @@ import { getPopOrientation, orientationValues } from "./utils.js";
8
8
  const templateHTML = '<style>:host{display:contents}#content-wrapper{position:relative;padding-top:4px;width:fit-content;min-width:100%}:host([tip]) #content-wrapper{padding-top:12px;filter:drop-shadow(var(--sinch-comp-popover-shadow))}:host([orientation^=top]) #content-wrapper{padding-top:0;padding-bottom:4px}:host([orientation=left]) #content-wrapper{padding-top:0;padding-right:4px}:host([orientation=right]) #content-wrapper{padding-top:0;padding-left:4px}:host([orientation^=top][tip]) #content-wrapper{padding-top:0;padding-bottom:12px}:host([orientation=left][tip]) #content-wrapper{padding-top:0;padding-right:12px}:host([orientation=right][tip]) #content-wrapper{padding-top:0;padding-left:12px}#content{background-color:var(--sinch-comp-popover-color-default-background-initial);border:1px solid var(--sinch-comp-popover-color-default-border-initial);border-radius:var(--sinch-comp-popover-shape-radius);box-shadow:var(--sinch-comp-popover-shadow);overflow:hidden}:host([tip]) #content{box-shadow:none}#tip{position:absolute;left:50%;top:13px;transform:translateX(-50%) rotate(180deg);transform-origin:top center;fill:var(--sinch-comp-popover-color-default-background-initial);stroke:var(--sinch-comp-popover-color-default-border-initial);stroke-linecap:square;pointer-events:none;display:none}:host([orientation^=top]) #tip{transform:translateX(-50%) rotate(0);top:calc(100% - 13px)}:host([orientation=left]) #tip{transform:translateX(-50%) rotate(-90deg);top:calc(50%);left:calc(100% - 13px)}:host([orientation=right]) #tip{transform:translateX(-50%) rotate(90deg);top:calc(50%);left:13px}:host([tip]) #tip:not(.hidden){display:block}</style><sinch-pop id="pop" inset="4"><slot name="target" slot="target"></slot><div id="content-wrapper" slot="content"><div id="content"><slot name="content"></slot></div><svg id="tip" width="16" height="9" aria-hidden="true"><path d="m0 0 8 8 8 -8"/></svg></div></sinch-pop>';
9
9
  const TIP_SIZE = 16;
10
10
  const template = document.createElement("template");
11
+ const isFocusableContent = (element) => {
12
+ return typeof element?.focusOnOpen === "function";
13
+ };
11
14
  template.innerHTML = templateHTML;
12
15
  class Popover extends NectaryElement {
13
16
  #$pop;
@@ -65,6 +68,7 @@ class Popover extends NectaryElement {
65
68
  "open",
66
69
  "modal",
67
70
  "allow-scroll",
71
+ "focus-content-on-open",
68
72
  "tip",
69
73
  "aria-label",
70
74
  "aria-description"
@@ -95,6 +99,10 @@ class Popover extends NectaryElement {
95
99
  updateBooleanAttribute(this, name, isAttrTrue(newVal));
96
100
  break;
97
101
  }
102
+ case "focus-content-on-open": {
103
+ updateBooleanAttribute(this, name, isAttrTrue(newVal));
104
+ break;
105
+ }
98
106
  case "modal":
99
107
  case "open": {
100
108
  updateAttribute(this.#$pop, name, newVal);
@@ -124,6 +132,12 @@ class Popover extends NectaryElement {
124
132
  get allowScroll() {
125
133
  return getBooleanAttribute(this, "allow-scroll");
126
134
  }
135
+ set focusContentOnOpen(shouldFocus) {
136
+ updateBooleanAttribute(this, "focus-content-on-open", shouldFocus);
137
+ }
138
+ get focusContentOnOpen() {
139
+ return getBooleanAttribute(this, "focus-content-on-open");
140
+ }
127
141
  set open(isOpen) {
128
142
  updateBooleanAttribute(this, "open", isOpen);
129
143
  }
@@ -158,6 +172,12 @@ class Popover extends NectaryElement {
158
172
  }
159
173
  return elements[0];
160
174
  }
175
+ #focusSlottedContentOnOpen() {
176
+ const slottedContent = this.#getFirstAssignedElementInSlot(this.#$contentSlot);
177
+ if (isFocusableContent(slottedContent)) {
178
+ slottedContent.focusOnOpen();
179
+ }
180
+ }
161
181
  #onPopClose = () => {
162
182
  this.#dispatchCloseEvent();
163
183
  };
@@ -174,6 +194,10 @@ class Popover extends NectaryElement {
174
194
  if (e.detail) {
175
195
  this.#updateTipOrientation();
176
196
  this.#updateContentMaxWidth();
197
+ if (this.focusContentOnOpen) {
198
+ this.#$content.getBoundingClientRect();
199
+ this.#focusSlottedContentOnOpen();
200
+ }
177
201
  } else {
178
202
  this.#resetTipOrientation();
179
203
  }
@@ -3,6 +3,8 @@ export type TSinchPopoverOrientation = 'top-left' | 'top-right' | 'bottom-left'
3
3
  export type TSinchPopoverProps = {
4
4
  /** Allow scrolling of the page when popover is open */
5
5
  'allow-scroll'?: boolean;
6
+ /** Let slotted content opt into handling focus when the popover opens */
7
+ 'focus-content-on-open'?: boolean;
6
8
  /** Open/close state */
7
9
  open: boolean;
8
10
  /** Orientation, where it *points to* from origin */
@@ -2,8 +2,9 @@ import '../input';
2
2
  import '../icon';
3
3
  import '../text';
4
4
  import { NectaryElement } from '../utils';
5
+ import type { Focusable } from '../types';
5
6
  export * from './types';
6
- export declare class SelectMenu extends NectaryElement {
7
+ export declare class SelectMenu extends NectaryElement implements Focusable {
7
8
  #private;
8
9
  static formAssociated: boolean;
9
10
  constructor();
@@ -24,6 +25,10 @@ export declare class SelectMenu extends NectaryElement {
24
25
  get multiple(): boolean;
25
26
  set searchable(isSearchable: boolean | null | undefined);
26
27
  get searchable(): boolean | null | undefined;
28
+ get 'aria-label'(): string | null;
29
+ set 'aria-label'(value: string | null);
30
+ get 'aria-labelledby'(): string | null;
31
+ set 'aria-labelledby'(value: string | null);
27
32
  set 'search-autocomplete'(autocomplete: string);
28
33
  get 'search-autocomplete'(): string;
29
34
  set 'search-placeholder'(placeholder: string);
@@ -31,4 +36,5 @@ export declare class SelectMenu extends NectaryElement {
31
36
  set 'search-value'(value: string);
32
37
  get 'search-value'(): string;
33
38
  get focusable(): boolean;
39
+ focusOnOpen(): void;
34
40
  }
@@ -4,14 +4,15 @@ import "../text/index.js";
4
4
  import { isSelectMenuOption } from "../select-menu-option/utils.js";
5
5
  import { subscribeContext } from "../utils/context.js";
6
6
  import { unpackCsv, getFirstCsvValue, updateCsv } from "../utils/csv.js";
7
- import { getBooleanAttribute, updateAttribute, updateExplicitBooleanAttribute, isAttrTrue, getAttribute, updateIntegerAttribute, getIntegerAttribute, updateBooleanAttribute, hasClass, setClass, attrValueToPixels } from "../utils/dom.js";
7
+ import { getBooleanAttribute, updateAttribute, updateExplicitBooleanAttribute, isAttrTrue, getAttribute, updateIntegerAttribute, getIntegerAttribute, updateBooleanAttribute, getDeepActiveElement, hasClass, setClass, attrValueToPixels } from "../utils/dom.js";
8
8
  import { defineCustomElement, NectaryElement } from "../utils/element.js";
9
9
  import { debounceTimeout } from "../utils/debounce.js";
10
10
  import { getReactEventHandler } from "../utils/get-react-event-handler.js";
11
11
  import { isTargetEqual } from "../utils/event-target.js";
12
12
  import { setFormValue, CSVToFormData } from "../utils/form.js";
13
- const templateHTML = '<style>:host{display:block;outline:0}#listbox{overflow-y:auto;max-height:var(--sinch-comp-select-menu-font-max-height)}#search{display:none;margin:10px}#search.active{display:block}#search-clear:not(.active){display:none}#not-found{display:flex;align-items:center;justify-content:center;width:100%;height:30px;margin-bottom:10px;pointer-events:none;user-select:none;--sinch-comp-text-font:var(--sinch-comp-select-menu-font-not-found-text);--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-not-found-text-initial)}#not-found:not(.active){display:none}::slotted(.hidden){display:none}::slotted(sinch-title){padding:8px 16px;--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-title-initial)}</style><sinch-input id="search" size="s" placeholder="Search"><sinch-icon icons-version="2" name="magnifying-glass" id="icon-search" slot="icon"></sinch-icon><sinch-button id="search-clear" slot="right"><sinch-icon icons-version="2" name="fa-xmark" slot="icon"></sinch-icon></sinch-button></sinch-input><div id="not-found"><sinch-text type="m">No results</sinch-text></div><div id="listbox" role="presentation"><slot></slot></div>';
13
+ const templateHTML = '<style>:host{display:block;outline:0}#listbox{overflow-y:auto;max-height:var(--sinch-comp-select-menu-font-max-height)}#search{display:none;margin:10px}#search.active{display:block}#search-clear:not(.active){display:none}#not-found{display:flex;align-items:center;justify-content:center;width:100%;height:30px;margin-bottom:10px;pointer-events:none;user-select:none;--sinch-comp-text-font:var(--sinch-comp-select-menu-font-not-found-text);--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-not-found-text-initial)}#not-found:not(.active){display:none}::slotted(.hidden){display:none}::slotted(sinch-title){padding:8px 16px;--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-title-initial)}</style><sinch-input id="search" size="s" placeholder="Search" aria-label="Search"><sinch-icon icons-version="2" name="magnifying-glass" id="icon-search" slot="icon"></sinch-icon><sinch-button id="search-clear" slot="right"><sinch-icon icons-version="2" name="fa-xmark" slot="icon"></sinch-icon></sinch-button></sinch-input><div id="not-found"><sinch-text type="m">No results</sinch-text></div><div id="listbox" role="listbox" tabindex="-1"><slot></slot></div>';
14
14
  const ITEM_HEIGHT = 40;
15
+ const DEFAULT_SEARCH_LABEL = "Search";
15
16
  const template = document.createElement("template");
16
17
  template.innerHTML = templateHTML;
17
18
  class SelectMenu extends NectaryElement {
@@ -38,13 +39,14 @@ class SelectMenu extends NectaryElement {
38
39
  this.#searchDebounce = debounceTimeout(200)(this.#updateSearchValue);
39
40
  }
40
41
  connectedCallback() {
42
+ super.connectedCallback();
41
43
  this.#controller = new AbortController();
42
44
  const options = {
43
45
  signal: this.#controller.signal
44
46
  };
45
- this.setAttribute("role", "listbox");
46
- this.#internals.role = "listbox";
47
+ this.setAttribute("role", "group");
47
48
  this.tabIndex = 0;
49
+ this.#syncListboxLabel();
48
50
  this.addEventListener("keydown", this.#onListboxKeyDown, options);
49
51
  this.addEventListener("focus", this.#onFocus, options);
50
52
  this.addEventListener("blur", this.#onListboxBlur, options);
@@ -89,6 +91,7 @@ class SelectMenu extends NectaryElement {
89
91
  this.#searchDebounce.cancel();
90
92
  this.#controller.abort();
91
93
  this.#controller = null;
94
+ super.disconnectedCallback();
92
95
  }
93
96
  formAssociatedCallback() {
94
97
  setFormValue(this.#internals, CSVToFormData(this.name, this.value));
@@ -113,6 +116,8 @@ class SelectMenu extends NectaryElement {
113
116
  "rows",
114
117
  "multiple",
115
118
  "searchable",
119
+ "aria-label",
120
+ "aria-labelledby",
116
121
  // eslint-disable-next-line @nectary/observed-attribute-accessor -- baseline backlog: missing set searchValue
117
122
  "search-value",
118
123
  // eslint-disable-next-line @nectary/observed-attribute-accessor -- baseline backlog: missing set searchPlaceholder
@@ -126,17 +131,24 @@ class SelectMenu extends NectaryElement {
126
131
  case "multiple": {
127
132
  this.#onValueChange(this.value);
128
133
  updateExplicitBooleanAttribute(
129
- this,
134
+ this.#$listbox,
130
135
  "aria-multiselectable",
131
136
  isAttrTrue(newVal)
132
137
  );
133
- this.#internals.ariaMultiSelectable = isAttrTrue(newVal).toString();
134
138
  break;
135
139
  }
136
140
  case "searchable": {
137
141
  this.#onOptionSlotChange();
138
142
  break;
139
143
  }
144
+ case "aria-label": {
145
+ this.#syncListboxLabel();
146
+ break;
147
+ }
148
+ case "aria-labelledby": {
149
+ this.#syncListboxLabel();
150
+ break;
151
+ }
140
152
  case "search-autocomplete": {
141
153
  updateAttribute(this.#$search, "autocomplete", newVal);
142
154
  break;
@@ -150,7 +162,7 @@ class SelectMenu extends NectaryElement {
150
162
  break;
151
163
  }
152
164
  case "search-placeholder": {
153
- updateAttribute(this.#$search, "placeholder", newVal);
165
+ this.#updateSearchPlaceholder(newVal);
154
166
  break;
155
167
  }
156
168
  case "search-value": {
@@ -198,6 +210,18 @@ class SelectMenu extends NectaryElement {
198
210
  const searchableAttribute = this.getAttribute("searchable");
199
211
  return searchableAttribute === null ? searchableAttribute : isAttrTrue(searchableAttribute);
200
212
  }
213
+ get "aria-label"() {
214
+ return getAttribute(this, "aria-label");
215
+ }
216
+ set "aria-label"(value) {
217
+ updateAttribute(this, "aria-label", value);
218
+ }
219
+ get "aria-labelledby"() {
220
+ return getAttribute(this, "aria-labelledby");
221
+ }
222
+ set "aria-labelledby"(value) {
223
+ updateAttribute(this, "aria-labelledby", value);
224
+ }
201
225
  set "search-autocomplete"(autocomplete) {
202
226
  updateAttribute(this.#$search, "autocomplete", autocomplete);
203
227
  }
@@ -205,7 +229,7 @@ class SelectMenu extends NectaryElement {
205
229
  return getAttribute(this.#$search, "autocomplete", "");
206
230
  }
207
231
  set "search-placeholder"(placeholder) {
208
- updateAttribute(this.#$search, "placeholder", placeholder);
232
+ this.#updateSearchPlaceholder(placeholder);
209
233
  }
210
234
  get "search-placeholder"() {
211
235
  return getAttribute(this.#$search, "placeholder", "");
@@ -220,12 +244,22 @@ class SelectMenu extends NectaryElement {
220
244
  get focusable() {
221
245
  return true;
222
246
  }
247
+ focusOnOpen() {
248
+ this.#focusActiveSearch();
249
+ }
223
250
  #onFocus = () => {
224
- const isSearchActive = hasClass(this.#$search, "active");
225
- if (isSearchActive) {
226
- this.#$search.focus();
251
+ if (getDeepActiveElement(this.ownerDocument) !== this) {
252
+ return;
227
253
  }
254
+ this.#focusActiveSearch();
228
255
  };
256
+ #focusActiveSearch() {
257
+ if (!this.isDomConnected || !hasClass(this.#$search, "active")) {
258
+ return;
259
+ }
260
+ this.#$search.getBoundingClientRect();
261
+ this.#$search.focus();
262
+ }
229
263
  #onListboxBlur = () => {
230
264
  this.#selectOption(null);
231
265
  };
@@ -290,7 +324,7 @@ class SelectMenu extends NectaryElement {
290
324
  #onContextVisibility = (e) => {
291
325
  if (e.detail) {
292
326
  this.#selectOption(this.#findCheckedOption());
293
- this.#onFocus();
327
+ this.#focusActiveSearch();
294
328
  } else {
295
329
  this.#selectOption(null);
296
330
  }
@@ -328,7 +362,30 @@ class SelectMenu extends NectaryElement {
328
362
  this.#dispatchChangeEvent(option);
329
363
  }
330
364
  };
365
+ #syncListboxLabel = () => {
366
+ const labelledby = getAttribute(this, "aria-labelledby");
367
+ const label = labelledby === null ? getAttribute(this, "aria-label") : null;
368
+ updateAttribute(this.#$listbox, "aria-labelledby", labelledby);
369
+ updateAttribute(this.#$listbox, "aria-label", label);
370
+ };
371
+ #updateSearchPlaceholder(placeholder) {
372
+ const currentPlaceholder = getAttribute(this.#$search, "placeholder");
373
+ const currentLabel = getAttribute(this.#$search, "aria-label");
374
+ const isManagedLabel = currentLabel === null || currentLabel === DEFAULT_SEARCH_LABEL || currentLabel === currentPlaceholder;
375
+ updateAttribute(this.#$search, "placeholder", placeholder);
376
+ if (isManagedLabel) {
377
+ updateAttribute(this.#$search, "aria-label", placeholder === null || placeholder === "" ? DEFAULT_SEARCH_LABEL : placeholder);
378
+ }
379
+ }
380
+ #syncListboxChildren = () => {
381
+ for (const $element of this.#$optionSlot.assignedElements()) {
382
+ if (!isSelectMenuOption($element) && getAttribute($element, "aria-hidden") !== "true") {
383
+ updateAttribute($element, "aria-hidden", "true");
384
+ }
385
+ }
386
+ };
331
387
  #onOptionSlotChange = () => {
388
+ this.#syncListboxChildren();
332
389
  if (this.hasAttribute("rows")) {
333
390
  this.#updateListboxMaxHeight();
334
391
  }
@@ -1,4 +1,4 @@
1
- import type { NectaryComponentReactByType, NectaryComponentVanillaByType, NectaryComponentReact, NectaryComponentVanilla } from '../types';
1
+ import type { Focusable, NectaryComponentReactByType, NectaryComponentVanillaByType, NectaryComponentReact, NectaryComponentVanilla } from '../types';
2
2
  export type TSinchSelectMenuProps = {
3
3
  /** Identification for uncontrolled form submissions */
4
4
  name?: string;
@@ -18,6 +18,8 @@ export type TSinchSelectMenuProps = {
18
18
  'search-placeholder'?: string;
19
19
  /** Label that is used for a11y */
20
20
  'aria-label': string;
21
+ /** Element ID reference that labels the listbox */
22
+ 'aria-labelledby'?: string;
21
23
  };
22
24
  export type TSinchSelectMenuEvents = {
23
25
  /** Change value handler */
@@ -33,10 +35,12 @@ export type TSinchSelectMenuStyle = {
33
35
  '--sinch-comp-select-menu-font-not-found-text'?: string;
34
36
  '--sinch-comp-select-menu-font-max-height'?: string;
35
37
  };
38
+ export type TSinchSelectMenuMethods = Focusable;
36
39
  export type TSinchSelectMenu = {
37
40
  props: TSinchSelectMenuProps;
38
41
  events: TSinchSelectMenuEvents;
39
42
  style: TSinchSelectMenuStyle;
43
+ methods: TSinchSelectMenuMethods;
40
44
  };
41
45
  export type TSinchSelectMenuElement = NectaryComponentVanillaByType<TSinchSelectMenu>;
42
46
  export type TSinchSelectMenuReact = NectaryComponentReactByType<TSinchSelectMenu>;
package/types.d.ts CHANGED
@@ -6,6 +6,10 @@ export type TRect = {
6
6
  width: number;
7
7
  height: number;
8
8
  };
9
+ export interface Focusable {
10
+ /** Called by containers that let slotted content choose where focus lands after opening. */
11
+ focusOnOpen(): void;
12
+ }
9
13
  export type NectaryComponentVanillaByType<T extends NectaryComponentMap[keyof NectaryComponentMap]> = Omit<HTMLElement, 'addEventListener' | 'removeEventListener'> & ExtendEventListeners<Required<SafeSelect<T, 'events'>>> & SetAttributes<Required<RemoveReadonly<SafeSelect<T, 'props'>>>> & Required<CamelCaseify<SafeSelect<T, 'props'>>> & Required<SafeSelect<T, 'methods'>>;
10
14
  export type NectaryComponentReactByType<T extends NectaryComponentMap[keyof NectaryComponentMap]> = PatchReactEvents<WebComponentReactBaseProp<NectaryComponentVanillaByType<T>>, ReactifyEvents<SafeSelect<T, 'events'>>> & RemoveReadonly<SafeSelect<T, 'props'>> & {
11
15
  style?: Partial<SafeSelect<T, 'style'>> & CSSProperties;
package/utils/markdown.js CHANGED
@@ -11,7 +11,8 @@ const regStrikethrough = new RegExp("(?<!\\\\)~~(?<strike>.+?)(?<!\\\\)~~");
11
11
  const regButtonPlaceholder = new RegExp("(?<!\\\\)\\[\\[(?<button>[a-zA-Z0-9_-]+)\\]\\]");
12
12
  const regLink = new RegExp("(?<!\\\\)!?\\[(?<linktext>[^\\]]*?)\\]\\((?<linkhref>[^)]+?)\\)(\\{(?<linkattrs>[^)]+?)\\})?");
13
13
  const regChip = new RegExp("(?<!\\\\)\\{\\{(?<chip>[a-zA-Z0-9_-]+)\\}\\}");
14
- const regEmoji = new RegExp("(?<emoji>(?![0-9*#])\\p{Emoji})", "u");
14
+ const regEmojiSeq = "(?![0-9#*])\\p{Emoji}(?:\\uFE0F|\\p{Emoji_Modifier})?\\u20E3?";
15
+ const regEmoji = new RegExp(`(?<emoji>\\p{RI}\\p{RI}|${regEmojiSeq}(?:\\u200D${regEmojiSeq})*)`, "u");
15
16
  const regUList = /^(?<indent>[\t ]*?)[*+-][\t ]+(?<ultext>.*?)[\t ]*?$/;
16
17
  const regOList = /^(?<indent>[\t ]*?)\d+\.[\t ]+(?<oltext>.*?)[\t ]*?$/;
17
18
  const regEscapedChars = /\\(?<escaped>[\\\*_\[\]`~\{\}])/;