material 3.3.3 → 3.4.0

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.
Files changed (183) hide show
  1. package/README.md +2 -2
  2. package/dist/components/badge/api.js +7 -5
  3. package/dist/components/badge/config.js +3 -15
  4. package/dist/components/badge/features.js +6 -10
  5. package/dist/components/chips/chip/chip.js +3 -0
  6. package/dist/components/chips/types.d.ts +1 -0
  7. package/dist/components/menu/features/controller.js +23 -75
  8. package/dist/components/menu/features/opener.js +3 -15
  9. package/dist/components/menu/types.d.ts +1 -0
  10. package/dist/components/search/features/input.js +1 -1
  11. package/dist/components/search/features/structure.js +1 -1
  12. package/dist/components/search/types.d.ts +1 -0
  13. package/dist/components/select/features.js +2 -0
  14. package/dist/components/select/types.d.ts +1 -0
  15. package/dist/components/slider/features/dom.js +27 -9
  16. package/dist/components/split-button/features/menu-on-demand.js +141 -0
  17. package/dist/components/split-button/features/menu.js +3 -4
  18. package/dist/components/tabs/tab.js +2 -1
  19. package/dist/components/text-field/config.js +21 -1
  20. package/dist/components/text-field/types.d.ts +1 -0
  21. package/dist/core/compose/features/badge.js +1 -1
  22. package/dist/core/compose/features/textinput.d.ts +1 -0
  23. package/dist/core/compose/features/textinput.js +1 -0
  24. package/dist/elements/css/switch.css +1 -1
  25. package/dist/elements/css/switch.js +1 -1
  26. package/dist/elements/preupgrade/badge.css +1 -1
  27. package/dist/elements/preupgrade/bottom-app-bar.css +1 -1
  28. package/dist/elements/preupgrade/bottom-sheet.css +1 -1
  29. package/dist/elements/preupgrade/button-group.css +1 -1
  30. package/dist/elements/preupgrade/button.css +1 -1
  31. package/dist/elements/preupgrade/card.css +1 -1
  32. package/dist/elements/preupgrade/carousel.css +1 -1
  33. package/dist/elements/preupgrade/checkbox.css +1 -1
  34. package/dist/elements/preupgrade/chips.css +1 -1
  35. package/dist/elements/preupgrade/datepicker.css +1 -1
  36. package/dist/elements/preupgrade/dialog.css +1 -1
  37. package/dist/elements/preupgrade/divider.css +1 -1
  38. package/dist/elements/preupgrade/drawer.css +1 -1
  39. package/dist/elements/preupgrade/extended-fab.css +1 -1
  40. package/dist/elements/preupgrade/fab-menu.css +1 -1
  41. package/dist/elements/preupgrade/fab.css +1 -1
  42. package/dist/elements/preupgrade/icon-button.css +1 -1
  43. package/dist/elements/preupgrade/list.css +1 -1
  44. package/dist/elements/preupgrade/loading-indicator.css +1 -1
  45. package/dist/elements/preupgrade/menu.css +1 -1
  46. package/dist/elements/preupgrade/navigation-bar.css +1 -1
  47. package/dist/elements/preupgrade/navigation-rail.css +1 -1
  48. package/dist/elements/preupgrade/progress.css +1 -1
  49. package/dist/elements/preupgrade/radios.css +1 -1
  50. package/dist/elements/preupgrade/search.css +1 -1
  51. package/dist/elements/preupgrade/select.css +1 -1
  52. package/dist/elements/preupgrade/side-sheet.css +1 -1
  53. package/dist/elements/preupgrade/slider.css +1 -1
  54. package/dist/elements/preupgrade/snackbar.css +1 -1
  55. package/dist/elements/preupgrade/split-button.css +1 -1
  56. package/dist/elements/preupgrade/switch.css +1 -1
  57. package/dist/elements/preupgrade/tabs.css +1 -1
  58. package/dist/elements/preupgrade/text-field.css +1 -1
  59. package/dist/elements/preupgrade/timepicker.css +1 -1
  60. package/dist/elements/preupgrade/toolbar.css +1 -1
  61. package/dist/elements/preupgrade/tooltip.css +1 -1
  62. package/dist/elements/preupgrade/top-app-bar.css +1 -1
  63. package/dist/elements/preupgrade.css +1 -1
  64. package/dist/package.json +1 -1
  65. package/dist/styles/badge.css +1 -1
  66. package/dist/styles/base.css +1 -1
  67. package/dist/styles/bottom-app-bar.css +1 -1
  68. package/dist/styles/bottom-sheet.css +1 -1
  69. package/dist/styles/button-group.css +1 -1
  70. package/dist/styles/button.css +1 -1
  71. package/dist/styles/card.css +1 -1
  72. package/dist/styles/carousel.css +1 -1
  73. package/dist/styles/checkbox.css +1 -1
  74. package/dist/styles/chips.css +1 -1
  75. package/dist/styles/contrast.css +1 -1
  76. package/dist/styles/datepicker.css +1 -1
  77. package/dist/styles/dialog.css +1 -1
  78. package/dist/styles/divider.css +1 -1
  79. package/dist/styles/drawer.css +1 -1
  80. package/dist/styles/extended-fab.css +1 -1
  81. package/dist/styles/fab-menu.css +1 -1
  82. package/dist/styles/fab.css +1 -1
  83. package/dist/styles/icon-button.css +1 -1
  84. package/dist/styles/list.css +1 -1
  85. package/dist/styles/loading-indicator.css +1 -1
  86. package/dist/styles/menu.css +1 -1
  87. package/dist/styles/navigation-bar.css +1 -1
  88. package/dist/styles/navigation-rail.css +1 -1
  89. package/dist/styles/progress.css +1 -1
  90. package/dist/styles/radios.css +1 -1
  91. package/dist/styles/search.css +1 -1
  92. package/dist/styles/select.css +1 -1
  93. package/dist/styles/side-sheet.css +1 -1
  94. package/dist/styles/slider.css +1 -1
  95. package/dist/styles/snackbar.css +1 -1
  96. package/dist/styles/split-button.css +1 -1
  97. package/dist/styles/switch.css +2 -2
  98. package/dist/styles/tabs.css +1 -1
  99. package/dist/styles/text-field.css +1 -1
  100. package/dist/styles/timepicker.css +1 -1
  101. package/dist/styles/toolbar.css +1 -1
  102. package/dist/styles/tooltip.css +1 -1
  103. package/dist/styles/top-app-bar.css +1 -1
  104. package/dist/styles/typography.css +1 -1
  105. package/dist/styles/utilities.css +1 -1
  106. package/dist/styles.css +2 -2
  107. package/dist/themes/autumn-contrast.css +1 -1
  108. package/dist/themes/autumn.css +1 -1
  109. package/dist/themes/baseline-contrast.css +1 -1
  110. package/dist/themes/baseline.css +1 -1
  111. package/dist/themes/brownbeige-contrast.css +1 -1
  112. package/dist/themes/brownbeige.css +1 -1
  113. package/dist/themes/content-contrast.css +1 -1
  114. package/dist/themes/content.css +1 -1
  115. package/dist/themes/desert-contrast.css +1 -1
  116. package/dist/themes/desert.css +1 -1
  117. package/dist/themes/expressive-contrast.css +1 -1
  118. package/dist/themes/expressive.css +1 -1
  119. package/dist/themes/fidelity-contrast.css +1 -1
  120. package/dist/themes/fidelity.css +1 -1
  121. package/dist/themes/forest-contrast.css +1 -1
  122. package/dist/themes/forest.css +1 -1
  123. package/dist/themes/fruit-salad-contrast.css +1 -1
  124. package/dist/themes/fruit-salad.css +1 -1
  125. package/dist/themes/highcontrast-contrast.css +1 -1
  126. package/dist/themes/highcontrast.css +1 -1
  127. package/dist/themes/monochrome-contrast.css +1 -1
  128. package/dist/themes/monochrome.css +1 -1
  129. package/dist/themes/neutral-contrast.css +1 -1
  130. package/dist/themes/neutral.css +1 -1
  131. package/dist/themes/ocean-contrast.css +1 -1
  132. package/dist/themes/ocean.css +1 -1
  133. package/dist/themes/rainbow-contrast.css +1 -1
  134. package/dist/themes/rainbow.css +1 -1
  135. package/dist/themes/sageivory-contrast.css +1 -1
  136. package/dist/themes/sageivory.css +1 -1
  137. package/dist/themes/spring-contrast.css +1 -1
  138. package/dist/themes/spring.css +1 -1
  139. package/dist/themes/summer-contrast.css +1 -1
  140. package/dist/themes/summer.css +1 -1
  141. package/dist/themes/sunset-contrast.css +1 -1
  142. package/dist/themes/sunset.css +1 -1
  143. package/dist/themes/tealcaramel-contrast.css +1 -1
  144. package/dist/themes/tealcaramel.css +1 -1
  145. package/dist/themes/vibrant-contrast.css +1 -1
  146. package/dist/themes/vibrant.css +1 -1
  147. package/package.json +1 -1
  148. package/src/styles/components/_switch.scss +15 -5
  149. package/dist/components/badge/types.js +0 -1
  150. package/dist/components/bottom-app-bar/types.js +0 -1
  151. package/dist/components/bottom-sheet/types.js +0 -1
  152. package/dist/components/button/types.js +0 -1
  153. package/dist/components/button-group/types.js +0 -1
  154. package/dist/components/card/types.js +0 -1
  155. package/dist/components/carousel/types.js +0 -1
  156. package/dist/components/checkbox/types.js +0 -1
  157. package/dist/components/chips/types.js +0 -1
  158. package/dist/components/dialog/types.js +0 -1
  159. package/dist/components/divider/types.js +0 -1
  160. package/dist/components/drawer/types.js +0 -1
  161. package/dist/components/extended-fab/types.js +0 -1
  162. package/dist/components/fab/types.js +0 -1
  163. package/dist/components/fab-menu/types.js +0 -1
  164. package/dist/components/icon-button/types.js +0 -1
  165. package/dist/components/list/types.js +0 -1
  166. package/dist/components/loading-indicator/types.js +0 -1
  167. package/dist/components/menu/types.js +0 -14
  168. package/dist/components/navigation-bar/types.js +0 -1
  169. package/dist/components/navigation-rail/types.js +0 -1
  170. package/dist/components/progress/types.js +0 -1
  171. package/dist/components/radios/types.js +0 -1
  172. package/dist/components/search/types.js +0 -1
  173. package/dist/components/select/types.js +0 -4
  174. package/dist/components/side-sheet/types.js +0 -1
  175. package/dist/components/snackbar/types.js +0 -1
  176. package/dist/components/split-button/types.js +0 -1
  177. package/dist/components/switch/types.js +0 -1
  178. package/dist/components/tabs/types.js +0 -1
  179. package/dist/components/text-field/types.js +0 -18
  180. package/dist/components/toolbar/types.js +0 -1
  181. package/dist/components/top-app-bar/types.js +0 -1
  182. package/dist/react/jsx.js +0 -2
  183. package/dist/solid/jsx.js +0 -1
package/README.md CHANGED
@@ -93,8 +93,8 @@ What a bundler keeps, minified and gzipped (kB is 1,000 bytes), measured from th
93
93
  |--------|-----:|
94
94
  | `createButton` from `material`, with code splitting: the initial chunks (the progress indicator loads on demand) | 7.6 kB |
95
95
  | `createButton`, without code splitting (the progress indicator included) | 13.9 kB |
96
- | `createButton`, `createTextField` and `createCheckbox`, without code splitting | 20.2 kB |
97
- | Everything the root exports (`export * from 'material'`), without code splitting | 126.7 kB |
96
+ | `createButton`, `createTextField` and `createCheckbox`, without code splitting | 20.4 kB |
97
+ | Everything the root exports (`export * from 'material'`), without code splitting | 127.4 kB |
98
98
  | `material/styles/base` | 2.9 kB |
99
99
  | `material/styles/base` and `material/styles/button` | 5.2 kB |
100
100
  | `material/styles`, the full stylesheet | 63.7 kB |
@@ -1,4 +1,4 @@
1
- import { isBadgeOverflow, isEmptyBadgeLabel } from "./config.js";
1
+ import { isBadgeOverflow, isEmptyBadgeLabel, smallBadgeLabel } from "./config.js";
2
2
  import { formatBadgeLabel } from "./config.js";
3
3
  const VARIANT_SMALL = "small";
4
4
  const ALL_COLORS = [
@@ -21,10 +21,12 @@ export const withAPI = ({ visibility, lifecycle }) => (component) => {
21
21
  component.config.label = label;
22
22
  if (component.config.variant === VARIANT_SMALL) {
23
23
  component.element.textContent = "";
24
+ component.element.setAttribute("aria-label", smallBadgeLabel(label));
24
25
  return badgeComponent;
25
26
  }
26
27
  const formattedLabel = formatBadgeLabel(label, component.config.max);
27
28
  component.element.textContent = formattedLabel;
29
+ component.element.removeAttribute("aria-label");
28
30
  component.element.classList.remove(`${component.getClass("badge")}--overflow`);
29
31
  if (isBadgeOverflow(label, component.config.max)) {
30
32
  component.element.classList.add(`${component.getClass("badge")}--overflow`);
@@ -81,14 +83,14 @@ export const withAPI = ({ visibility, lifecycle }) => (component) => {
81
83
  });
82
84
  component.element.classList.add(`${component.getClass("badge")}--${variant}`);
83
85
  component.config.variant = variant;
86
+ component.element.removeAttribute("aria-hidden");
87
+ component.element.setAttribute("role", "status");
84
88
  if (variant === VARIANT_SMALL) {
85
89
  component.element.textContent = "";
86
- component.element.removeAttribute("role");
87
- component.element.setAttribute("aria-hidden", "true");
90
+ component.element.setAttribute("aria-label", smallBadgeLabel(component.config.label));
88
91
  }
89
92
  else {
90
- component.element.removeAttribute("aria-hidden");
91
- component.element.setAttribute("role", "status");
93
+ component.element.removeAttribute("aria-label");
92
94
  if (component.config.label !== undefined) {
93
95
  badgeComponent.setLabel(component.config.label);
94
96
  }
@@ -7,22 +7,10 @@ export const defaultConfig = {
7
7
  label: "",
8
8
  visible: true,
9
9
  };
10
+ export const smallBadgeLabel = (label) => typeof label === "string" && isNaN(+label)
11
+ ? label.trim() : "New notification";
10
12
  export const createBaseConfig = (config = {}) => createComponentConfig(defaultConfig, config, "badge");
11
- export const getElementConfig = (config) => {
12
- const attributes = {};
13
- if (config.variant !== "small") {
14
- attributes.role = "status";
15
- }
16
- const formattedLabel = config.variant === "small"
17
- ? ""
18
- : formatBadgeLabel(config.label || "", config.max);
19
- return createElementConfig(config, {
20
- tag: "span",
21
- attributes,
22
- className: config.class,
23
- text: formattedLabel,
24
- });
25
- };
13
+ export const getElementConfig = (config) => createElementConfig(config, { tag: "span" });
26
14
  export const getApiConfig = (comp) => ({
27
15
  visibility: {
28
16
  show: () => comp.visibility.show(),
@@ -1,4 +1,4 @@
1
- import { formatBadgeLabel, isBadgeOverflow, isEmptyBadgeLabel } from "./config.js";
1
+ import { formatBadgeLabel, isBadgeOverflow, isEmptyBadgeLabel, smallBadgeLabel } from "./config.js";
2
2
  const VARIANT_SMALL = 'small';
3
3
  const VARIANT_LARGE = 'large';
4
4
  const COLOR_ERROR = 'error';
@@ -38,16 +38,14 @@ export const withVisibility = () => (component) => {
38
38
  export const withVariant = (config) => (component) => {
39
39
  const variant = config.variant || VARIANT_LARGE;
40
40
  component.element.classList.add(`${component.getClass('badge')}--${variant}`);
41
+ component.element.setAttribute('role', 'status');
41
42
  if (variant === VARIANT_SMALL) {
42
- component.element.textContent = '';
43
- component.element.setAttribute('aria-hidden', 'true');
43
+ component.element.setAttribute('aria-label', smallBadgeLabel(config.label));
44
+ component.element.removeAttribute('aria-hidden');
44
45
  }
45
46
  else {
46
- component.element.setAttribute('role', 'status');
47
- if (config.label !== undefined && config.label !== '') {
48
- const formattedLabel = formatBadgeLabel(config.label, config.max);
49
- component.element.textContent = formattedLabel;
50
- }
47
+ component.element.removeAttribute('aria-label');
48
+ component.element.textContent = formatBadgeLabel(config.label ?? '', config.max);
51
49
  }
52
50
  return component;
53
51
  };
@@ -70,8 +68,6 @@ export const withMax = (config) => (component) => {
70
68
  }
71
69
  component.config.max = config.max;
72
70
  if (config.label !== undefined) {
73
- const formattedLabel = formatBadgeLabel(config.label, config.max);
74
- component.element.textContent = formattedLabel;
75
71
  if (isBadgeOverflow(config.label, config.max)) {
76
72
  component.element.classList.add(`${component.getClass('badge')}--overflow`);
77
73
  }
@@ -90,6 +90,7 @@ const createChip = (config = {}) => {
90
90
  root.classList.toggle(base.getClass("chip--avatar"), !!avatar);
91
91
  action.disabled = disabled;
92
92
  if (oneActionCell) {
93
+ root.setAttribute("aria-label", label.textContent ?? "");
93
94
  if (selectable)
94
95
  root.setAttribute("aria-selected", String(selected));
95
96
  root.setAttribute("aria-disabled", String(disabled));
@@ -114,6 +115,8 @@ const createChip = (config = {}) => {
114
115
  if (!options.cell)
115
116
  root.setAttribute("aria-label", text);
116
117
  }
118
+ if (options.ariaLabel)
119
+ (oneActionCell ? root : action).setAttribute("aria-label", options.ariaLabel);
117
120
  };
118
121
  let made = false;
119
122
  const animateChanges = () => {
@@ -4,6 +4,7 @@ export type ChipType = "assist" | "filter" | "input" | "suggestion";
4
4
  export interface ChipConfig {
5
5
  type?: ChipType;
6
6
  label?: string;
7
+ ariaLabel?: string;
7
8
  value?: string;
8
9
  disabled?: boolean;
9
10
  selected?: boolean;
@@ -45,23 +45,11 @@ const withController = (config, getComponent) => (component) => {
45
45
  return eventData;
46
46
  },
47
47
  };
48
- const getOpenerElement = () => {
49
- if (component.opener && typeof component.opener.getOpener === "function") {
50
- return component.opener.getOpener();
51
- }
52
- const { opener } = config;
53
- if (typeof opener === "string") {
54
- const element = document.querySelector(opener);
55
- if (!element) {
56
- console.warn(`Menu opener not found: ${opener}`);
57
- return null;
58
- }
59
- return element;
60
- }
61
- if (typeof opener === "object" && opener !== null && "element" in opener) {
62
- return opener.element;
63
- }
64
- return opener;
48
+ const getOpenerElement = () => getComponent().getOpener() ?? null;
49
+ const reposition = () => {
50
+ const opener = getOpenerElement();
51
+ if (opener)
52
+ component.position?.positionMenu(opener);
65
53
  };
66
54
  const createMenuItem = (item, index) => {
67
55
  const itemElement = document.createElement("li");
@@ -287,10 +275,7 @@ const withController = (config, getComponent) => (component) => {
287
275
  if (topLayer)
288
276
  showInTopLayer(component.element, { kind: "popover-manual" });
289
277
  showTimer = tasks.setTimeout(() => {
290
- const openerElement = getOpenerElement();
291
- if (openerElement && component.position) {
292
- component.position.positionMenu(openerElement);
293
- }
278
+ reposition();
294
279
  component.element.setAttribute("aria-hidden", "false");
295
280
  component.element.style.transform = "";
296
281
  component.element.style.opacity = "";
@@ -298,24 +283,7 @@ const withController = (config, getComponent) => (component) => {
298
283
  component.element.classList.add(`${component.getClass("menu--visible")}`);
299
284
  if (!listbox)
300
285
  focusTimer = tasks.setTimeout(() => {
301
- if (component.keyboard && component.keyboard.handleInitialFocus) {
302
- component.keyboard.handleInitialFocus(component.element, interactionType);
303
- }
304
- else {
305
- const items = Array.from(component.element.querySelectorAll(`.${component.getClass("menu__item")}`));
306
- items.forEach((item) => {
307
- item.tabIndex = -1;
308
- });
309
- if (items.length > 0)
310
- items[0].tabIndex = 0;
311
- if (interactionType === "keyboard" && items.length > 0) {
312
- items[0].focus();
313
- }
314
- else {
315
- component.element.tabIndex = -1;
316
- component.element.focus();
317
- }
318
- }
286
+ component.keyboard?.handleInitialFocus(component.element, interactionType);
319
287
  }, 100);
320
288
  window.addEventListener("resize", handleWindowResize, {
321
289
  passive: true,
@@ -406,26 +374,18 @@ const withController = (config, getComponent) => (component) => {
406
374
  }
407
375
  }
408
376
  else if (isTargetInsideSubmenu) {
409
- if (component.keyboard && component.keyboard.handleMenuKeydown) {
410
- component.keyboard.handleMenuKeydown(e, state, {
411
- closeMenu,
412
- findItemById,
413
- ...submenuActions,
414
- });
415
- }
416
- else if (e.key === "Escape") {
417
- e.preventDefault();
418
- closeMenu(e, true);
419
- }
377
+ component.keyboard?.handleMenuKeydown(e, state, {
378
+ closeMenu,
379
+ findItemById,
380
+ ...submenuActions,
381
+ });
420
382
  }
421
383
  else {
422
- if (component.keyboard && component.keyboard.handleMenuKeydown) {
423
- component.keyboard.handleMenuKeydown(e, state, {
424
- closeMenu,
425
- findItemById,
426
- ...submenuActions,
427
- });
428
- }
384
+ component.keyboard?.handleMenuKeydown(e, state, {
385
+ closeMenu,
386
+ findItemById,
387
+ ...submenuActions,
388
+ });
429
389
  }
430
390
  }
431
391
  };
@@ -435,10 +395,7 @@ const withController = (config, getComponent) => (component) => {
435
395
  closeMenu(undefined, true);
436
396
  }
437
397
  else {
438
- const openerElement = getOpenerElement();
439
- if (openerElement && component.position) {
440
- component.position.positionMenu(openerElement);
441
- }
398
+ reposition();
442
399
  }
443
400
  }
444
401
  };
@@ -447,19 +404,13 @@ const withController = (config, getComponent) => (component) => {
447
404
  return;
448
405
  if (state.visible) {
449
406
  tasks.requestAnimationFrame(() => {
450
- const openerElement = getOpenerElement();
451
- if (openerElement && component.position) {
452
- component.position.positionMenu(openerElement);
453
- }
407
+ reposition();
454
408
  });
455
409
  }
456
410
  };
457
411
  const initMenu = () => {
458
412
  if (state.visible) {
459
- const openerElement = getOpenerElement();
460
- if (openerElement && component.position) {
461
- component.position.positionMenu(openerElement);
462
- }
413
+ reposition();
463
414
  component.element.classList.add(`${component.getClass("menu--visible")}`);
464
415
  if (config.closeOnClickOutside) {
465
416
  document.addEventListener("click", handleDocumentClick);
@@ -493,8 +444,8 @@ const withController = (config, getComponent) => (component) => {
493
444
  });
494
445
  });
495
446
  renderMenuItems();
496
- if (!listbox && component.keyboard && component.keyboard.setupKeyboardHandlers) {
497
- component.keyboard.setupKeyboardHandlers(component.element, state, {
447
+ if (!listbox) {
448
+ component.keyboard?.setupKeyboardHandlers(component.element, state, {
498
449
  closeMenu,
499
450
  findItemById,
500
451
  ...submenuActions,
@@ -550,10 +501,7 @@ const withController = (config, getComponent) => (component) => {
550
501
  setPosition: (position) => {
551
502
  state.position = position;
552
503
  if (state.visible) {
553
- const openerElement = getOpenerElement();
554
- if (openerElement && component.position) {
555
- component.position.positionMenu(openerElement);
556
- }
504
+ reposition();
557
505
  }
558
506
  return component;
559
507
  },
@@ -2,16 +2,7 @@ import { createMenuTasks } from "./tasks.js";
2
2
  import { focusWithin } from "./layer.js";
3
3
  const withOpener = (config) => (component) => {
4
4
  const tasks = createMenuTasks();
5
- let isTabNavigation = false;
6
- let tabTimeout = null;
7
- const handleTabKey = (e) => {
8
- isTabNavigation = e.key === "Tab";
9
- tasks.clearTimeout(tabTimeout);
10
- tabTimeout = tasks.setTimeout(() => {
11
- isTabNavigation = false;
12
- }, 100);
13
- };
14
- document.addEventListener("keydown", handleTabKey);
5
+ const isTabNavigationActive = () => component.keyboard?.isTabNavigationActive?.() ?? false;
15
6
  const state = {
16
7
  openerElement: null,
17
8
  openerComponent: null,
@@ -203,7 +194,7 @@ const withOpener = (config) => (component) => {
203
194
  if (!component.menu || !component.menu.isOpen())
204
195
  return;
205
196
  const relatedTarget = e.relatedTarget;
206
- if (isTabNavigation) {
197
+ if (isTabNavigationActive()) {
207
198
  tasks.setTimeout(() => {
208
199
  if (component.menu && component.menu.isOpen()) {
209
200
  component.menu.close(e, false);
@@ -228,8 +219,6 @@ const withOpener = (config) => (component) => {
228
219
  };
229
220
  const cleanup = () => {
230
221
  tasks.cancelAll();
231
- tabTimeout = null;
232
- isTabNavigation = false;
233
222
  if (state.openerElement) {
234
223
  if (!config.manualOpen) {
235
224
  state.openerElement.removeEventListener("click", handleOpenerClick);
@@ -254,7 +243,6 @@ const withOpener = (config) => (component) => {
254
243
  if (tasks.destroyed)
255
244
  return;
256
245
  tasks.destroy();
257
- document.removeEventListener("keydown", handleTabKey);
258
246
  cleanup();
259
247
  originalDestroy.call(component.lifecycle);
260
248
  };
@@ -269,7 +257,7 @@ const withOpener = (config) => (component) => {
269
257
  if (state.openerElement && !config.listbox) {
270
258
  state.openerElement.setAttribute("aria-expanded", "false");
271
259
  setOpenerActive(false);
272
- if (event.restoreFocus && !isTabNavigation) {
260
+ if (event.restoreFocus && !isTabNavigationActive()) {
273
261
  restoreFocusToOpener();
274
262
  }
275
263
  }
@@ -165,6 +165,7 @@ export interface MenuFeatureHost {
165
165
  removeKeyboardHandlers: (element: HTMLElement) => void;
166
166
  handleMenuKeydown: (e: KeyboardEvent, state: KeyboardMenuState, actions: KeyboardActions) => void;
167
167
  handleInitialFocus: (menuElement: HTMLElement, interactionType: "keyboard" | "mouse") => void;
168
+ isTabNavigationActive: () => boolean;
168
169
  };
169
170
  submenu?: {
170
171
  handleSubmenuClick: (item: MenuItem, index: number, itemElement: HTMLElement) => void;
@@ -76,7 +76,7 @@ export const withInput = (config, getComponent) => (component) => {
76
76
  const input = component.structure?.input;
77
77
  if (input) {
78
78
  input.placeholder = text;
79
- input.setAttribute("aria-label", text);
79
+ input.setAttribute("aria-label", config.ariaLabel || text);
80
80
  }
81
81
  };
82
82
  const getPlaceholder = () => {
@@ -77,7 +77,7 @@ export const withStructure = (config) => (component) => {
77
77
  const inputAttributes = {
78
78
  type: "text",
79
79
  placeholder,
80
- "aria-label": placeholder,
80
+ "aria-label": config.ariaLabel || placeholder,
81
81
  role: "combobox",
82
82
  "aria-autocomplete": "list",
83
83
  "aria-expanded": String(isViewState),
@@ -45,6 +45,7 @@ export interface SearchConfig {
45
45
  variant?: SearchVariant;
46
46
  disabled?: boolean;
47
47
  placeholder?: string;
48
+ ariaLabel?: string;
48
49
  value?: string;
49
50
  name?: string;
50
51
  leadingIcon?: string;
@@ -29,6 +29,8 @@ export const withTextField = (config) => (component) => {
29
29
  });
30
30
  textField.element.classList.add(`${config.prefix || "mtrl"}-select`);
31
31
  if (textField.input) {
32
+ if (config.ariaLabel)
33
+ textField.input.setAttribute("aria-label", config.ariaLabel);
32
34
  const input = textField.input;
33
35
  input.addEventListener("keydown", (e) => {
34
36
  const allowedKeys = [
@@ -21,6 +21,7 @@ export interface SelectConfig {
21
21
  variant?: SelectVariant | string;
22
22
  density?: "default" | "compact" | string;
23
23
  label?: string;
24
+ ariaLabel?: string;
24
25
  name?: string;
25
26
  required?: boolean;
26
27
  menu?: {
@@ -16,10 +16,8 @@ export const withDom = (config) => (component) => {
16
16
  setStyles(container, {
17
17
  position: "relative",
18
18
  });
19
- const label = config.label || "";
20
- const handleLabel = config.range && label ? `${label} minimum` : label;
21
- const secondHandleLabel = label ? `${label} maximum` : "";
22
- const handleElement = (index, handleValue, name) => {
19
+ const label = config.ariaLabel || config.label || "";
20
+ const handleElement = (index, handleValue) => {
23
21
  const attributes = {
24
22
  role: "slider",
25
23
  "aria-valuemin": String(min),
@@ -31,9 +29,6 @@ export const withDom = (config) => (component) => {
31
29
  "data-value": String(handleValue),
32
30
  "data-handle-index": String(index),
33
31
  };
34
- if (name) {
35
- attributes["aria-label"] = name;
36
- }
37
32
  const element = createElement({
38
33
  tag: "div",
39
34
  className: getClass("slider__handle"),
@@ -61,15 +56,38 @@ export const withDom = (config) => (component) => {
61
56
  });
62
57
  return element;
63
58
  };
64
- const handle = handleElement(0, value, handleLabel);
59
+ const handle = handleElement(0, value);
65
60
  const valueBubble = bubbleElement(0, value);
66
61
  let secondHandle;
67
62
  let secondValueBubble;
68
63
  if (config.range) {
69
64
  const secondValue = config.secondValue !== undefined ? config.secondValue : max;
70
- secondHandle = handleElement(1, secondValue, secondHandleLabel);
65
+ secondHandle = handleElement(1, secondValue);
71
66
  secondValueBubble = bubbleElement(1, secondValue);
72
67
  }
68
+ [handle, secondHandle].forEach((target, index) => {
69
+ if (!target)
70
+ return;
71
+ const endpoint = config.range ? index ? "maximum" : "minimum" : "";
72
+ if (label)
73
+ target.setAttribute("aria-label", endpoint ? `${label} ${endpoint}` : label);
74
+ if (config.ariaLabelledBy) {
75
+ let labelledBy = config.ariaLabelledBy;
76
+ if (endpoint) {
77
+ const suffix = createElement({
78
+ tag: "span",
79
+ text: endpoint,
80
+ attributes: {
81
+ id: `${getClass("slider")}-${Math.random().toString(36).slice(2, 9)}`,
82
+ hidden: true,
83
+ },
84
+ container: target,
85
+ });
86
+ labelledBy += ` ${suffix.id}`;
87
+ }
88
+ target.setAttribute("aria-labelledby", labelledBy);
89
+ }
90
+ });
73
91
  const name = config.name;
74
92
  const formFields = name
75
93
  ? [
@@ -0,0 +1,141 @@
1
+ export const createOnDemandMenu = (settings, items) => {
2
+ let menu = null;
3
+ let loading = false;
4
+ let destroyed = false;
5
+ let opened = false;
6
+ let asked = [undefined, undefined];
7
+ let current = items;
8
+ let selected;
9
+ let position = settings.position ?? "bottom-end";
10
+ let opener = settings.opener;
11
+ const openerElement = typeof settings.opener === "string"
12
+ ? null
13
+ : settings.opener.element ?? settings.opener;
14
+ const placeholder = document.createElement("div");
15
+ placeholder.id = `menu-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
16
+ openerElement?.setAttribute("aria-controls", placeholder.id);
17
+ const listeners = new Map();
18
+ const notify = (event, payload) => {
19
+ for (const listener of listeners.get(event) ?? [])
20
+ listener(payload);
21
+ };
22
+ const attach = (created) => {
23
+ menu = created;
24
+ if (selected !== undefined)
25
+ created.setSelected(selected);
26
+ if (position !== settings.position)
27
+ created.setPosition(position);
28
+ if (opener !== settings.opener)
29
+ created.setOpener(opener);
30
+ if (opened)
31
+ created.open(asked[0], asked[1]);
32
+ for (const [event, set] of listeners)
33
+ for (const listener of set)
34
+ created.on(event, listener);
35
+ listeners.clear();
36
+ };
37
+ const load = () => {
38
+ if (menu || loading || destroyed)
39
+ return;
40
+ loading = true;
41
+ import("../../menu/index.js").then(({ default: createMenu }) => {
42
+ loading = false;
43
+ if (destroyed)
44
+ return;
45
+ attach(createMenu({ ...settings, items: current }));
46
+ }, (error) => {
47
+ loading = false;
48
+ console.error("Split button menu failed to load:", error);
49
+ });
50
+ };
51
+ const api = {
52
+ get element() {
53
+ return menu?.element ?? placeholder;
54
+ },
55
+ open(event, interactionType) {
56
+ if (menu) {
57
+ menu.open(event, interactionType);
58
+ return api;
59
+ }
60
+ if (!opened) {
61
+ opened = true;
62
+ notify("open", { menu: api, originalEvent: event });
63
+ }
64
+ asked = [event, interactionType];
65
+ load();
66
+ return api;
67
+ },
68
+ close(event, restoreFocus = true, skipAnimation = false) {
69
+ if (menu) {
70
+ menu.close(event, restoreFocus, skipAnimation);
71
+ return api;
72
+ }
73
+ if (opened) {
74
+ opened = false;
75
+ notify("close", { menu: api, originalEvent: event });
76
+ }
77
+ return api;
78
+ },
79
+ toggle(event) {
80
+ if (api.isOpen())
81
+ api.close(event);
82
+ else
83
+ api.open(event);
84
+ return api;
85
+ },
86
+ isOpen: () => (menu ? menu.isOpen() : opened),
87
+ setItems(items) {
88
+ current = items;
89
+ menu?.setItems(items);
90
+ return api;
91
+ },
92
+ getItems: () => (menu ? menu.getItems() : current),
93
+ setOpener(value) {
94
+ if (menu)
95
+ menu.setOpener(value);
96
+ else
97
+ opener = value;
98
+ return api;
99
+ },
100
+ getOpener: () => (menu ? menu.getOpener() : opener),
101
+ setPosition(value) {
102
+ if (menu)
103
+ menu.setPosition(value);
104
+ else
105
+ position = value;
106
+ return api;
107
+ },
108
+ getPosition: () => (menu ? menu.getPosition() : position),
109
+ setSelected(value) {
110
+ if (menu)
111
+ menu.setSelected(value);
112
+ else
113
+ selected = value;
114
+ return api;
115
+ },
116
+ getSelected: () => (menu ? menu.getSelected() : (selected ?? null)),
117
+ on(event, handler) {
118
+ if (menu)
119
+ menu.on(event, handler);
120
+ else {
121
+ const set = listeners.get(event) ?? new Set();
122
+ set.add(handler);
123
+ listeners.set(event, set);
124
+ }
125
+ return api;
126
+ },
127
+ off(event, handler) {
128
+ if (menu)
129
+ menu.off(event, handler);
130
+ else
131
+ listeners.get(event)?.delete(handler);
132
+ return api;
133
+ },
134
+ destroy() {
135
+ destroyed = true;
136
+ listeners.clear();
137
+ menu?.destroy();
138
+ },
139
+ };
140
+ return api;
141
+ };
@@ -1,15 +1,14 @@
1
- import createMenu from "../../menu/index.js";
2
1
  import { MENU } from "../../menu/inner.js";
3
2
  import { SPLIT_BUTTON_DEFAULTS } from "../constants.js";
4
- export const makeMenu = (config, trailing, items) => createMenu({
3
+ import { createOnDemandMenu } from "./menu-on-demand.js";
4
+ export const makeMenu = (config, trailing, items) => createOnDemandMenu({
5
5
  opener: trailing,
6
- items,
7
6
  position: "bottom-end",
8
7
  offset: SPLIT_BUTTON_DEFAULTS.MENU_OFFSET,
9
8
  manualOpen: true,
10
9
  ...(config.layer ? { layer: config.layer } : {}),
11
10
  prefix: config.prefix,
12
- });
11
+ }, items);
13
12
  export const withMenu = (config) => (component) => {
14
13
  if (!config.items || config.items.length === 0)
15
14
  return component;
@@ -203,7 +203,8 @@ export const createTab = (config = {}) => {
203
203
  },
204
204
  };
205
205
  const announceBadge = () => {
206
- const count = tab.badge?.isVisible() ? tab.badge.getContent() : "";
206
+ const count = tab.badge?.isVisible()
207
+ ? tab.badge.element.getAttribute("aria-label") || tab.badge.getContent() : "";
207
208
  const label = baseConfig.ariaLabel || button.getText();
208
209
  const name = [label, count].filter(Boolean).join(", ");
209
210
  if (name !== label) {