@nectary/components 5.42.1 → 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
@@ -6695,7 +6695,7 @@ class Input extends NectaryElement {
6695
6695
  break;
6696
6696
  }
6697
6697
  case "aria-label": {
6698
- this.#$input.ariaLabel = newVal;
6698
+ updateAttribute(this.#$input, "aria-label", newVal);
6699
6699
  break;
6700
6700
  }
6701
6701
  }
@@ -7219,6 +7219,9 @@ const getPopOrientation = (orientation) => {
7219
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>';
7220
7220
  const TIP_SIZE = 16;
7221
7221
  const template$S = document.createElement("template");
7222
+ const isFocusableContent = (element) => {
7223
+ return typeof element?.focusOnOpen === "function";
7224
+ };
7222
7225
  template$S.innerHTML = templateHTML$S;
7223
7226
  class Popover extends NectaryElement {
7224
7227
  #$pop;
@@ -7276,6 +7279,7 @@ class Popover extends NectaryElement {
7276
7279
  "open",
7277
7280
  "modal",
7278
7281
  "allow-scroll",
7282
+ "focus-content-on-open",
7279
7283
  "tip",
7280
7284
  "aria-label",
7281
7285
  "aria-description"
@@ -7306,6 +7310,10 @@ class Popover extends NectaryElement {
7306
7310
  updateBooleanAttribute(this, name, isAttrTrue(newVal));
7307
7311
  break;
7308
7312
  }
7313
+ case "focus-content-on-open": {
7314
+ updateBooleanAttribute(this, name, isAttrTrue(newVal));
7315
+ break;
7316
+ }
7309
7317
  case "modal":
7310
7318
  case "open": {
7311
7319
  updateAttribute(this.#$pop, name, newVal);
@@ -7335,6 +7343,12 @@ class Popover extends NectaryElement {
7335
7343
  get allowScroll() {
7336
7344
  return getBooleanAttribute(this, "allow-scroll");
7337
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
+ }
7338
7352
  set open(isOpen) {
7339
7353
  updateBooleanAttribute(this, "open", isOpen);
7340
7354
  }
@@ -7369,6 +7383,12 @@ class Popover extends NectaryElement {
7369
7383
  }
7370
7384
  return elements[0];
7371
7385
  }
7386
+ #focusSlottedContentOnOpen() {
7387
+ const slottedContent = this.#getFirstAssignedElementInSlot(this.#$contentSlot);
7388
+ if (isFocusableContent(slottedContent)) {
7389
+ slottedContent.focusOnOpen();
7390
+ }
7391
+ }
7372
7392
  #onPopClose = () => {
7373
7393
  this.#dispatchCloseEvent();
7374
7394
  };
@@ -7385,6 +7405,10 @@ class Popover extends NectaryElement {
7385
7405
  if (e.detail) {
7386
7406
  this.#updateTipOrientation();
7387
7407
  this.#updateContentMaxWidth();
7408
+ if (this.focusContentOnOpen) {
7409
+ this.#$content.getBoundingClientRect();
7410
+ this.#focusSlottedContentOnOpen();
7411
+ }
7388
7412
  } else {
7389
7413
  this.#resetTipOrientation();
7390
7414
  }
@@ -15309,8 +15333,9 @@ class SelectMenuOption extends NectaryElement {
15309
15333
  }
15310
15334
  defineCustomElement("sinch-select-menu-option", SelectMenuOption);
15311
15335
  const isSelectMenuOption = (el) => el.localName === "sinch-select-menu-option";
15312
- 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>';
15313
15337
  const ITEM_HEIGHT = 40;
15338
+ const DEFAULT_SEARCH_LABEL = "Search";
15314
15339
  const template$h = document.createElement("template");
15315
15340
  template$h.innerHTML = templateHTML$h;
15316
15341
  class SelectMenu extends NectaryElement {
@@ -15337,13 +15362,14 @@ class SelectMenu extends NectaryElement {
15337
15362
  this.#searchDebounce = debounceTimeout(200)(this.#updateSearchValue);
15338
15363
  }
15339
15364
  connectedCallback() {
15365
+ super.connectedCallback();
15340
15366
  this.#controller = new AbortController();
15341
15367
  const options = {
15342
15368
  signal: this.#controller.signal
15343
15369
  };
15344
- this.setAttribute("role", "listbox");
15345
- this.#internals.role = "listbox";
15370
+ this.setAttribute("role", "group");
15346
15371
  this.tabIndex = 0;
15372
+ this.#syncListboxLabel();
15347
15373
  this.addEventListener("keydown", this.#onListboxKeyDown, options);
15348
15374
  this.addEventListener("focus", this.#onFocus, options);
15349
15375
  this.addEventListener("blur", this.#onListboxBlur, options);
@@ -15388,6 +15414,7 @@ class SelectMenu extends NectaryElement {
15388
15414
  this.#searchDebounce.cancel();
15389
15415
  this.#controller.abort();
15390
15416
  this.#controller = null;
15417
+ super.disconnectedCallback();
15391
15418
  }
15392
15419
  formAssociatedCallback() {
15393
15420
  setFormValue(this.#internals, CSVToFormData(this.name, this.value));
@@ -15412,6 +15439,8 @@ class SelectMenu extends NectaryElement {
15412
15439
  "rows",
15413
15440
  "multiple",
15414
15441
  "searchable",
15442
+ "aria-label",
15443
+ "aria-labelledby",
15415
15444
  // eslint-disable-next-line @nectary/observed-attribute-accessor -- baseline backlog: missing set searchValue
15416
15445
  "search-value",
15417
15446
  // eslint-disable-next-line @nectary/observed-attribute-accessor -- baseline backlog: missing set searchPlaceholder
@@ -15425,17 +15454,24 @@ class SelectMenu extends NectaryElement {
15425
15454
  case "multiple": {
15426
15455
  this.#onValueChange(this.value);
15427
15456
  updateExplicitBooleanAttribute(
15428
- this,
15457
+ this.#$listbox,
15429
15458
  "aria-multiselectable",
15430
15459
  isAttrTrue(newVal)
15431
15460
  );
15432
- this.#internals.ariaMultiSelectable = isAttrTrue(newVal).toString();
15433
15461
  break;
15434
15462
  }
15435
15463
  case "searchable": {
15436
15464
  this.#onOptionSlotChange();
15437
15465
  break;
15438
15466
  }
15467
+ case "aria-label": {
15468
+ this.#syncListboxLabel();
15469
+ break;
15470
+ }
15471
+ case "aria-labelledby": {
15472
+ this.#syncListboxLabel();
15473
+ break;
15474
+ }
15439
15475
  case "search-autocomplete": {
15440
15476
  updateAttribute(this.#$search, "autocomplete", newVal);
15441
15477
  break;
@@ -15449,7 +15485,7 @@ class SelectMenu extends NectaryElement {
15449
15485
  break;
15450
15486
  }
15451
15487
  case "search-placeholder": {
15452
- updateAttribute(this.#$search, "placeholder", newVal);
15488
+ this.#updateSearchPlaceholder(newVal);
15453
15489
  break;
15454
15490
  }
15455
15491
  case "search-value": {
@@ -15497,6 +15533,18 @@ class SelectMenu extends NectaryElement {
15497
15533
  const searchableAttribute = this.getAttribute("searchable");
15498
15534
  return searchableAttribute === null ? searchableAttribute : isAttrTrue(searchableAttribute);
15499
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
+ }
15500
15548
  set "search-autocomplete"(autocomplete) {
15501
15549
  updateAttribute(this.#$search, "autocomplete", autocomplete);
15502
15550
  }
@@ -15504,7 +15552,7 @@ class SelectMenu extends NectaryElement {
15504
15552
  return getAttribute(this.#$search, "autocomplete", "");
15505
15553
  }
15506
15554
  set "search-placeholder"(placeholder) {
15507
- updateAttribute(this.#$search, "placeholder", placeholder);
15555
+ this.#updateSearchPlaceholder(placeholder);
15508
15556
  }
15509
15557
  get "search-placeholder"() {
15510
15558
  return getAttribute(this.#$search, "placeholder", "");
@@ -15519,12 +15567,22 @@ class SelectMenu extends NectaryElement {
15519
15567
  get focusable() {
15520
15568
  return true;
15521
15569
  }
15570
+ focusOnOpen() {
15571
+ this.#focusActiveSearch();
15572
+ }
15522
15573
  #onFocus = () => {
15523
- const isSearchActive = hasClass(this.#$search, "active");
15524
- if (isSearchActive) {
15525
- this.#$search.focus();
15574
+ if (getDeepActiveElement(this.ownerDocument) !== this) {
15575
+ return;
15526
15576
  }
15577
+ this.#focusActiveSearch();
15527
15578
  };
15579
+ #focusActiveSearch() {
15580
+ if (!this.isDomConnected || !hasClass(this.#$search, "active")) {
15581
+ return;
15582
+ }
15583
+ this.#$search.getBoundingClientRect();
15584
+ this.#$search.focus();
15585
+ }
15528
15586
  #onListboxBlur = () => {
15529
15587
  this.#selectOption(null);
15530
15588
  };
@@ -15589,7 +15647,7 @@ class SelectMenu extends NectaryElement {
15589
15647
  #onContextVisibility = (e) => {
15590
15648
  if (e.detail) {
15591
15649
  this.#selectOption(this.#findCheckedOption());
15592
- this.#onFocus();
15650
+ this.#focusActiveSearch();
15593
15651
  } else {
15594
15652
  this.#selectOption(null);
15595
15653
  }
@@ -15627,7 +15685,30 @@ class SelectMenu extends NectaryElement {
15627
15685
  this.#dispatchChangeEvent(option);
15628
15686
  }
15629
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
+ };
15630
15710
  #onOptionSlotChange = () => {
15711
+ this.#syncListboxChildren();
15631
15712
  if (this.hasAttribute("rows")) {
15632
15713
  this.#updateListboxMaxHeight();
15633
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.1",
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;