mtrl 0.9.5 → 0.9.8

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 (176) hide show
  1. package/README.md +20 -0
  2. package/dist/README.md +20 -0
  3. package/dist/components/bottom-app-bar/bottom-app-bar.js +2 -2
  4. package/dist/components/bottom-sheet/features/structure.js +4 -3
  5. package/dist/components/button/api.js +3 -2
  6. package/dist/components/button/features/progress.js +26 -21
  7. package/dist/components/button-group/config.d.ts +11 -3
  8. package/dist/components/button-group/config.js +1 -1
  9. package/dist/components/card/content.js +5 -4
  10. package/dist/components/carousel/features/slides.js +2 -1
  11. package/dist/components/checkbox/checkbox.js +22 -0
  12. package/dist/components/checkbox/config.d.ts +3 -3
  13. package/dist/components/checkbox/config.js +10 -9
  14. package/dist/components/checkbox/types.d.ts +19 -4
  15. package/dist/components/chips/api.js +3 -1
  16. package/dist/components/chips/chip/api.js +3 -2
  17. package/dist/components/chips/chip/chip.js +6 -4
  18. package/dist/components/chips/config.js +1 -1
  19. package/dist/components/datepicker/api.d.ts +8 -2
  20. package/dist/components/datepicker/api.js +1 -0
  21. package/dist/components/datepicker/config.d.ts +14 -4
  22. package/dist/components/datepicker/datepicker.js +5 -2
  23. package/dist/components/datepicker/types.d.ts +14 -0
  24. package/dist/components/datepicker/utils.js +4 -6
  25. package/dist/components/dialog/config.js +0 -1
  26. package/dist/components/dialog/features.js +10 -8
  27. package/dist/components/dialog/types.d.ts +3 -2
  28. package/dist/components/drawer/features/items.js +3 -2
  29. package/dist/components/list/features/renderer.js +4 -3
  30. package/dist/components/menu/api.d.ts +1 -1
  31. package/dist/components/menu/config.js +2 -2
  32. package/dist/components/menu/features/controller.js +41 -26
  33. package/dist/components/menu/features/keyboard.js +7 -5
  34. package/dist/components/menu/features/opener.js +19 -12
  35. package/dist/components/menu/features/position.js +1 -1
  36. package/dist/components/menu/features/submenu.js +7 -1
  37. package/dist/components/menu/types.d.ts +13 -2
  38. package/dist/components/navigation/nav-item.js +4 -3
  39. package/dist/components/navigation/system/mobile.js +3 -2
  40. package/dist/components/navigation-rail/navigation-rail.js +3 -2
  41. package/dist/components/progress/features/canvas.js +0 -2
  42. package/dist/components/search/features/states.js +3 -3
  43. package/dist/components/search/features/structure.js +3 -0
  44. package/dist/components/search/features/suggestions.js +2 -2
  45. package/dist/components/search/types.d.ts +6 -0
  46. package/dist/components/segmented-button/config.d.ts +4 -1
  47. package/dist/components/segmented-button/config.js +1 -1
  48. package/dist/components/segmented-button/segment.d.ts +2 -5
  49. package/dist/components/segmented-button/segment.js +7 -3
  50. package/dist/components/select/features.js +267 -44
  51. package/dist/components/side-sheet/features/structure.js +5 -4
  52. package/dist/components/slider/features/controller.js +7 -0
  53. package/dist/components/slider/features/dom.d.ts +1 -0
  54. package/dist/components/slider/features/dom.js +11 -0
  55. package/dist/components/slider/features/handlers.js +8 -3
  56. package/dist/components/slider/features/states.js +8 -0
  57. package/dist/components/slider/types.d.ts +7 -0
  58. package/dist/components/switch/config.d.ts +3 -3
  59. package/dist/components/switch/config.js +7 -7
  60. package/dist/components/switch/features.js +5 -5
  61. package/dist/components/switch/switch.js +1 -1
  62. package/dist/components/switch/types.d.ts +10 -0
  63. package/dist/components/tabs/features.d.ts +1 -0
  64. package/dist/components/tabs/scroll-indicators.js +3 -2
  65. package/dist/components/tabs/tab.d.ts +0 -5
  66. package/dist/components/tabs/tab.js +12 -3
  67. package/dist/components/tabs/tabs.js +10 -1
  68. package/dist/components/tabs/utils.d.ts +8 -0
  69. package/dist/components/tabs/utils.js +63 -18
  70. package/dist/components/textfield/api.js +12 -4
  71. package/dist/components/textfield/features/error.d.ts +1 -0
  72. package/dist/components/textfield/features/error.js +10 -0
  73. package/dist/components/textfield/features/leading-icon.js +32 -33
  74. package/dist/components/textfield/features/prefix-text.js +30 -18
  75. package/dist/components/textfield/features/suffix-text.js +30 -14
  76. package/dist/components/textfield/features/supporting-text.d.ts +1 -0
  77. package/dist/components/textfield/features/supporting-text.js +25 -0
  78. package/dist/components/textfield/features/trailing-icon.js +36 -19
  79. package/dist/components/timepicker/api.d.ts +1 -1
  80. package/dist/components/timepicker/api.js +16 -11
  81. package/dist/components/timepicker/render.js +27 -24
  82. package/dist/components/timepicker/timepicker.js +4 -2
  83. package/dist/components/timepicker/types.d.ts +7 -0
  84. package/dist/components/timepicker/utils.d.ts +14 -0
  85. package/dist/components/timepicker/utils.js +4 -0
  86. package/dist/components/tooltip/api.js +27 -2
  87. package/dist/components/top-app-bar/top-app-bar.js +3 -2
  88. package/dist/core/compose/component.d.ts +1 -1
  89. package/dist/core/compose/component.js +1 -1
  90. package/dist/core/compose/features/icon.js +3 -2
  91. package/dist/core/compose/features/input.js +1 -1
  92. package/dist/core/compose/features/lifecycle.js +10 -11
  93. package/dist/core/compose/features/textlabel.js +3 -0
  94. package/dist/core/compose/features/track.js +2 -1
  95. package/dist/core/config/component.d.ts +1 -1
  96. package/dist/core/config/component.js +1 -1
  97. package/dist/core/dom/create.js +5 -4
  98. package/dist/core/dom/form-value.d.ts +24 -0
  99. package/dist/core/dom/form-value.js +14 -0
  100. package/dist/core/dom/html.d.ts +34 -0
  101. package/dist/core/dom/html.js +13 -0
  102. package/dist/core/dom/index.d.ts +2 -0
  103. package/dist/core/dom/index.js +1 -0
  104. package/dist/core/dom/utils.js +1 -1
  105. package/dist/core/state/emitter.js +8 -1
  106. package/dist/core/state/index.d.ts +1 -1
  107. package/dist/core/state/store.d.ts +10 -2
  108. package/dist/core/state/store.js +2 -1
  109. package/dist/index.cjs +0 -0
  110. package/dist/package.json +5 -2
  111. package/dist/styles/badge.css +1 -1
  112. package/dist/styles/base.css +2 -2
  113. package/dist/styles/bottom-app-bar.css +1 -1
  114. package/dist/styles/bottom-sheet.css +2 -2
  115. package/dist/styles/button-group.css +1 -1
  116. package/dist/styles/button.css +2 -2
  117. package/dist/styles/card.css +2 -2
  118. package/dist/styles/carousel.css +1 -1
  119. package/dist/styles/checkbox.css +2 -2
  120. package/dist/styles/chips.css +2 -2
  121. package/dist/styles/dialog.css +2 -2
  122. package/dist/styles/divider.css +1 -1
  123. package/dist/styles/drawer.css +2 -2
  124. package/dist/styles/extended-fab.css +2 -2
  125. package/dist/styles/fab.css +2 -2
  126. package/dist/styles/icon-button.css +2 -2
  127. package/dist/styles/list.css +2 -2
  128. package/dist/styles/loading-indicator.css +1 -1
  129. package/dist/styles/menu.css +2 -2
  130. package/dist/styles/navigation-rail.css +1 -1
  131. package/dist/styles/navigation.css +2 -2
  132. package/dist/styles/progress.css +2 -2
  133. package/dist/styles/radios.css +1 -1
  134. package/dist/styles/search.css +1 -1
  135. package/dist/styles/segmented-button.css +2 -2
  136. package/dist/styles/select.css +2 -2
  137. package/dist/styles/side-sheet.css +2 -2
  138. package/dist/styles/slider.css +1 -1
  139. package/dist/styles/snackbar.css +1 -1
  140. package/dist/styles/split-button.css +2 -2
  141. package/dist/styles/switch.css +2 -2
  142. package/dist/styles/tabs.css +1 -1
  143. package/dist/styles/textfield.css +2 -2
  144. package/dist/styles/timepicker.css +1 -1
  145. package/dist/styles/tooltip.css +2 -2
  146. package/dist/styles/top-app-bar.css +1 -1
  147. package/dist/styles/utilities.css +1 -1
  148. package/dist/styles.css +2 -2
  149. package/dist/styles.d.ts +1 -0
  150. package/dist/themes/autumn.css +1 -1
  151. package/dist/themes/baseline.css +1 -1
  152. package/dist/themes/brownbeige.css +1 -1
  153. package/dist/themes/browngreen.css +1 -1
  154. package/dist/themes/desert.css +1 -1
  155. package/dist/themes/forest.css +1 -1
  156. package/dist/themes/highcontrast.css +1 -1
  157. package/dist/themes/legacy.css +1 -1
  158. package/dist/themes/material.css +1 -1
  159. package/dist/themes/ocean.css +1 -1
  160. package/dist/themes/sageivory.css +1 -1
  161. package/dist/themes/spring.css +1 -1
  162. package/dist/themes/summer.css +1 -1
  163. package/dist/themes/sunset.css +1 -1
  164. package/dist/themes/tealcaramel.css +1 -1
  165. package/dist/themes/winter.css +1 -1
  166. package/package.json +7 -3
  167. package/src/styles/abstract/_mixins.scss +26 -0
  168. package/src/styles/abstract/_variables.scss +25 -11
  169. package/src/styles/base/_reset.scss +9 -2
  170. package/src/styles/components/_bottom-sheet.scss +23 -7
  171. package/src/styles/components/_button.scss +1 -12
  172. package/src/styles/components/_dialog.scss +59 -26
  173. package/src/styles/components/_drawer.scss +60 -7
  174. package/src/styles/components/_menu.scss +12 -7
  175. package/src/styles/components/_side-sheet.scss +33 -4
  176. package/src/styles/components/_tooltip.scss +3 -0
@@ -2,7 +2,7 @@ import { PREFIX } from "../../core/config.js";
2
2
  import { pipe } from "../../core/compose/index.js";
3
3
  import { createBase, withElement } from "../../core/compose/component.js";
4
4
  import { withEvents, withDisabled, withLifecycle, } from "../../core/compose/features/index.js";
5
- import { withAPI } from "./api.js";
5
+ import { withAPI, isReadyForApi } from "./api.js";
6
6
  import { createBaseConfig, getContainerConfig, getInputConfig, getCalendarConfig, getApiConfig, } from "./config.js";
7
7
  import { formatDate, parseDate } from "./utils.js";
8
8
  import { renderCalendar } from "./render.js";
@@ -45,7 +45,7 @@ const createDatePicker = (config = {}) => {
45
45
  updateCalendar() {
46
46
  if (!this.isOpen || !this.calendarElement)
47
47
  return;
48
- this.calendarElement.innerHTML = "";
48
+ this.calendarElement.replaceChildren();
49
49
  const calendar = renderCalendar(this, (...[event, data]) => {
50
50
  switch (event) {
51
51
  case "dateSelected":
@@ -234,6 +234,9 @@ const createDatePicker = (config = {}) => {
234
234
  }
235
235
  };
236
236
  document.addEventListener("click", state.outsideClickHandler);
237
+ if (!isReadyForApi(state)) {
238
+ throw new Error("DatePicker input or outside-click handler is missing");
239
+ }
237
240
  return withAPI(state, getApiConfig(component))(component);
238
241
  }
239
242
  catch (error) {
@@ -349,6 +349,20 @@ export interface DatePickerState {
349
349
  nextYear(): void;
350
350
  render(): void;
351
351
  }
352
+ /**
353
+ * State as it reaches the API step.
354
+ *
355
+ * `DatePickerState` marks these loose because the state object is built before
356
+ * the DOM is. By the time `withAPI` runs, the factory has created the input
357
+ * (throwing if it is not one) and installed the outside-click handler, so both
358
+ * are there — and the public `input` is declared non-optional.
359
+ * @category Components
360
+ * @internal
361
+ */
362
+ export type DatePickerApiState = DatePickerState & {
363
+ input: HTMLInputElement;
364
+ outsideClickHandler: EventListener;
365
+ };
352
366
  /**
353
367
  * Events the calendar renderer emits, each with its payload
354
368
  * @internal
@@ -75,13 +75,13 @@ export const isDateInRange = (date, startDate, endDate) => {
75
75
  export const generateCalendarDates = (year, month, selectedDate = null, rangeEndDate = null, minDate = null, maxDate = null) => {
76
76
  const today = new Date();
77
77
  today.setHours(0, 0, 0, 0);
78
+ const outOfRange = (date) => Boolean((minDate && date < minDate) || (maxDate && date > maxDate));
78
79
  const result = [];
79
80
  const firstDay = getFirstDayOfMonth(year, month);
80
81
  const daysInPrevMonth = getDaysInMonth(year, month - 1);
81
82
  for (let i = firstDay - 1; i >= 0; i--) {
82
83
  const date = new Date(year, month - 1, daysInPrevMonth - i);
83
- const isDisabled = (minDate && date < minDate) ||
84
- (maxDate && date > maxDate);
84
+ const isDisabled = outOfRange(date);
85
85
  result.push({
86
86
  date,
87
87
  day: date.getDate(),
@@ -95,8 +95,7 @@ export const generateCalendarDates = (year, month, selectedDate = null, rangeEnd
95
95
  for (let i = 1; i <= daysInMonth; i++) {
96
96
  const date = new Date(year, month, i);
97
97
  const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
98
- const isDisabled = (minDate && date < minDate) ||
99
- (maxDate && date > maxDate);
98
+ const isDisabled = outOfRange(date);
100
99
  const calendarDate = {
101
100
  date,
102
101
  day: i,
@@ -117,8 +116,7 @@ export const generateCalendarDates = (year, month, selectedDate = null, rangeEnd
117
116
  const remainingDays = totalDaysNeeded - result.length;
118
117
  for (let i = 1; i <= remainingDays; i++) {
119
118
  const date = new Date(year, month + 1, i);
120
- const isDisabled = (minDate && date < minDate) ||
121
- (maxDate && date > maxDate);
119
+ const isDisabled = outOfRange(date);
122
120
  result.push({
123
121
  date,
124
122
  day: i,
@@ -10,7 +10,6 @@ export const defaultConfig = {
10
10
  autofocus: true,
11
11
  trapFocus: true,
12
12
  divider: false,
13
- animationDuration: 150,
14
13
  buttons: []
15
14
  };
16
15
  export const createBaseConfig = (config = {}) => createComponentConfig(defaultConfig, config, 'dialog');
@@ -2,6 +2,7 @@ import { getOverlayConfig } from "./config.js";
2
2
  import createButton from "../button/index.js";
3
3
  import { createDivider } from "../divider/index.js";
4
4
  import { addClass, removeClass } from "../../core/dom/classes.js";
5
+ import { setHTML } from "../../core/dom/html.js";
5
6
  const DIALOG_EVENTS = {
6
7
  OPEN: "open",
7
8
  CLOSE: "close",
@@ -53,12 +54,12 @@ export const withStructure = (config) => (component) => {
53
54
  const closeButton = document.createElement("button");
54
55
  closeButton.classList.add(component.getClass("dialog-header-close"));
55
56
  closeButton.setAttribute("aria-label", "Close dialog");
56
- closeButton.innerHTML = `
57
+ setHTML(closeButton, `
57
58
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
58
59
  <line x1="18" y1="6" x2="6" y2="18"></line>
59
60
  <line x1="6" y1="6" x2="18" y2="18"></line>
60
61
  </svg>
61
- `;
62
+ `);
62
63
  closeButton.addEventListener("click", (e) => {
63
64
  e.preventDefault();
64
65
  e.stopPropagation();
@@ -75,7 +76,7 @@ export const withStructure = (config) => (component) => {
75
76
  content.classList.add(component.getClass("dialog-content"));
76
77
  content.id = contentId;
77
78
  if (config.content) {
78
- content.innerHTML = config.content;
79
+ setHTML(content, config.content);
79
80
  }
80
81
  return content;
81
82
  };
@@ -236,7 +237,8 @@ const addButton = (footer, buttonConfig, component) => {
236
237
  };
237
238
  export const withVisibility = () => (component) => {
238
239
  const isOpen = component.config.open === true;
239
- const animationDuration = component.config.animationDuration || 150;
240
+ const openDuration = component.config.animationDuration ?? 500;
241
+ const closeDuration = component.config.animationDuration ?? 150;
240
242
  const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
241
243
  let previouslyFocusedElement = null;
242
244
  let mouseDownOnOverlay = false;
@@ -370,8 +372,8 @@ export const withVisibility = () => (component) => {
370
372
  const container = component.structure.container || document.body;
371
373
  container.appendChild(component.overlay);
372
374
  }
373
- addClass(component.overlay, `${component.getClass("dialog-overlay")}--visible`);
374
375
  setTimeout(() => {
376
+ addClass(component.overlay, `${component.getClass("dialog-overlay")}--visible`);
375
377
  addClass(component.element, `${component.getClass("dialog")}--visible`);
376
378
  trapFocus();
377
379
  setupEvents();
@@ -379,7 +381,7 @@ export const withVisibility = () => (component) => {
379
381
  component.emit(DIALOG_EVENTS.OPEN, { dialog: component });
380
382
  setTimeout(() => {
381
383
  component.emit(DIALOG_EVENTS.AFTER_OPEN, { dialog: component });
382
- }, animationDuration);
384
+ }, openDuration);
383
385
  }
384
386
  }, 10);
385
387
  },
@@ -414,7 +416,7 @@ export const withVisibility = () => (component) => {
414
416
  if (typeof component.emit === "function") {
415
417
  component.emit(DIALOG_EVENTS.AFTER_CLOSE, { dialog: component });
416
418
  }
417
- }, animationDuration);
419
+ }, closeDuration);
418
420
  },
419
421
  toggle(open) {
420
422
  if (open === undefined) {
@@ -487,7 +489,7 @@ export const withContent = () => (component) => {
487
489
  return subtitleElement ? subtitleElement.textContent || "" : "";
488
490
  },
489
491
  setContent(content) {
490
- contentElement.innerHTML = content;
492
+ setHTML(contentElement, content);
491
493
  },
492
494
  getContent() {
493
495
  return contentElement.innerHTML;
@@ -164,8 +164,9 @@ export interface DialogConfig {
164
164
  */
165
165
  zIndex?: number;
166
166
  /**
167
- * Duration of open/close animations in milliseconds
168
- * @default 150
167
+ * How long `afteropen` and `afterclose` wait after opening and closing, in
168
+ * milliseconds, and when a closed dialog leaves the DOM. By default they
169
+ * follow the stylesheet: 450 after opening, 200 after closing.
169
170
  */
170
171
  animationDuration?: number;
171
172
  /**
@@ -1,5 +1,6 @@
1
1
  import { DRAWER_EVENTS } from "../constants.js";
2
2
  import { createRipple, } from "../../../core/compose/features/ripple.js";
3
+ import { setHTML } from "../../../core/dom/html.js";
3
4
  const createItemElement = (item, getClass) => {
4
5
  const el = document.createElement("button");
5
6
  el.className = getClass("drawer__item");
@@ -18,7 +19,7 @@ const createItemElement = (item, getClass) => {
18
19
  if (item.icon) {
19
20
  const iconEl = document.createElement("span");
20
21
  iconEl.className = getClass("drawer__item-icon");
21
- iconEl.innerHTML = item.icon;
22
+ setHTML(iconEl, item.icon);
22
23
  iconEl.setAttribute("aria-hidden", "true");
23
24
  el.appendChild(iconEl);
24
25
  }
@@ -70,7 +71,7 @@ export const withItems = (config) => (component) => {
70
71
  if (!itemsContainer)
71
72
  return;
72
73
  unmountRipples();
73
- itemsContainer.innerHTML = "";
74
+ itemsContainer.replaceChildren();
74
75
  let navIndex = 0;
75
76
  currentItems.forEach((item) => {
76
77
  const type = item.type || "item";
@@ -1,4 +1,5 @@
1
1
  import { LIST_CLASSES, LIST_EVENTS } from "../constants.js";
2
+ import { setHTML } from "../../../core/dom/html.js";
2
3
  export const withRenderer = (config) => component => {
3
4
  if (!component.element) {
4
5
  console.warn('Cannot initialize list renderer: missing element');
@@ -24,9 +25,9 @@ export const withRenderer = (config) => component => {
24
25
  return element;
25
26
  }
26
27
  const renderAllItems = () => {
27
- listContainer.innerHTML = '';
28
+ listContainer.replaceChildren();
28
29
  if (!items || items.length === 0) {
29
- listContainer.innerHTML = '<div class="mtrl-list-empty">No items</div>';
30
+ setHTML(listContainer, '<div class="mtrl-list-empty">No items</div>');
30
31
  return;
31
32
  }
32
33
  const fragment = document.createDocumentFragment();
@@ -84,7 +85,7 @@ export const withRenderer = (config) => component => {
84
85
  if (component.lifecycle?.destroy) {
85
86
  const originalDestroy = component.lifecycle.destroy;
86
87
  component.lifecycle.destroy = () => {
87
- listContainer.innerHTML = '';
88
+ listContainer.replaceChildren();
88
89
  originalDestroy();
89
90
  };
90
91
  }
@@ -15,7 +15,7 @@ interface ApiOptions {
15
15
  getItems: () => MenuContent[];
16
16
  setPosition: (position: MenuPosition) => void;
17
17
  getPosition: () => MenuPosition;
18
- setSelected: (itemId: string) => void;
18
+ setSelected: (itemId: string | null) => void;
19
19
  getSelected: () => string | null;
20
20
  };
21
21
  opener: {
@@ -26,8 +26,8 @@ export const getElementConfig = (config) => {
26
26
  styles.maxHeight = config.maxHeight;
27
27
  }
28
28
  const attributes = {
29
- role: "menu",
30
- tabindex: "-1",
29
+ role: config.listbox ? "presentation" : "menu",
30
+ ...(config.listbox ? {} : { tabindex: "-1" }),
31
31
  "aria-hidden": (!config.visible).toString(),
32
32
  style: Object.entries(styles)
33
33
  .map(([key, value]) => `${key}: ${value}`)
@@ -1,11 +1,17 @@
1
1
  import { createMenuTasks } from "./tasks.js";
2
2
  import { menuOpened, menuClosed } from "./registry.js";
3
+ import { setHTML } from "../../../core/dom/html.js";
3
4
  const withController = (config) => (component) => {
4
5
  if (!component.element) {
5
6
  console.warn("Cannot initialize menu controller: missing element");
6
7
  return component;
7
8
  }
8
9
  const tasks = createMenuTasks();
10
+ const listbox = config.listbox === true;
11
+ const optionIdPrefix = `${component.getClass("menu")}-${Math.random().toString(36).slice(2, 9)}`;
12
+ if (listbox) {
13
+ component.element.addEventListener("mousedown", (e) => e.preventDefault());
14
+ }
9
15
  const state = {
10
16
  visible: config.visible || false,
11
17
  items: config.items || [],
@@ -51,8 +57,14 @@ const withController = (config) => (component) => {
51
57
  const itemElement = document.createElement("li");
52
58
  const itemClass = `${component.getClass("menu-item")}`;
53
59
  itemElement.className = itemClass;
54
- itemElement.setAttribute("role", "menuitem");
55
- itemElement.setAttribute("tabindex", "-1");
60
+ if (listbox) {
61
+ itemElement.setAttribute("role", "option");
62
+ itemElement.id = `${optionIdPrefix}-option-${index}`;
63
+ }
64
+ else {
65
+ itemElement.setAttribute("role", "menuitem");
66
+ itemElement.setAttribute("tabindex", "-1");
67
+ }
56
68
  itemElement.setAttribute("data-id", item.id);
57
69
  itemElement.setAttribute("data-index", index.toString());
58
70
  if (item.disabled) {
@@ -79,7 +91,7 @@ const withController = (config) => (component) => {
79
91
  if (item.icon) {
80
92
  const iconElement = document.createElement("span");
81
93
  iconElement.className = `${component.getClass("menu-item-icon")}`;
82
- iconElement.innerHTML = item.icon;
94
+ setHTML(iconElement, item.icon);
83
95
  contentContainer.appendChild(iconElement);
84
96
  }
85
97
  const textElement = document.createElement("span");
@@ -144,7 +156,9 @@ const withController = (config) => (component) => {
144
156
  const renderMenuItems = () => {
145
157
  const menuList = document.createElement("ul");
146
158
  menuList.className = `${component.getClass("menu-list")}`;
147
- menuList.setAttribute("role", "menu");
159
+ menuList.setAttribute("role", listbox ? "listbox" : "menu");
160
+ if (listbox)
161
+ menuList.id = `${optionIdPrefix}-listbox`;
148
162
  let target = menuList;
149
163
  const startGroup = () => {
150
164
  const group = document.createElement("li");
@@ -170,7 +184,7 @@ const withController = (config) => (component) => {
170
184
  target.appendChild(createMenuItem(item, index));
171
185
  }
172
186
  });
173
- component.element.innerHTML = "";
187
+ component.element.replaceChildren();
174
188
  component.element.appendChild(menuList);
175
189
  };
176
190
  const findItemById = (id) => {
@@ -179,7 +193,7 @@ const withController = (config) => (component) => {
179
193
  return item;
180
194
  }
181
195
  if ("submenu" in item && Array.isArray(item.submenu)) {
182
- for (const subItem of item.submenu) {
196
+ for (const subItem of item.submenu ?? []) {
183
197
  if ("id" in subItem && subItem.id === id) {
184
198
  return subItem;
185
199
  }
@@ -256,31 +270,32 @@ const withController = (config) => (component) => {
256
270
  component.element.style.opacity = "";
257
271
  void component.element.getBoundingClientRect();
258
272
  component.element.classList.add(`${component.getClass("menu--visible")}`);
259
- tasks.setTimeout(() => {
260
- if (component.keyboard && component.keyboard.handleInitialFocus) {
261
- component.keyboard.handleInitialFocus(component.element, interactionType);
262
- }
263
- else {
264
- const items = Array.from(component.element.querySelectorAll(`.${component.getClass("menu-item")}`));
265
- items.forEach((item) => {
266
- item.tabIndex = -1;
267
- });
268
- if (items.length > 0)
269
- items[0].tabIndex = 0;
270
- if (interactionType === "keyboard" && items.length > 0) {
271
- items[0].focus();
273
+ if (!listbox)
274
+ tasks.setTimeout(() => {
275
+ if (component.keyboard && component.keyboard.handleInitialFocus) {
276
+ component.keyboard.handleInitialFocus(component.element, interactionType);
272
277
  }
273
278
  else {
274
- component.element.tabIndex = -1;
275
- component.element.focus();
279
+ const items = Array.from(component.element.querySelectorAll(`.${component.getClass("menu-item")}`));
280
+ items.forEach((item) => {
281
+ item.tabIndex = -1;
282
+ });
283
+ if (items.length > 0)
284
+ items[0].tabIndex = 0;
285
+ if (interactionType === "keyboard" && items.length > 0) {
286
+ items[0].focus();
287
+ }
288
+ else {
289
+ component.element.tabIndex = -1;
290
+ component.element.focus();
291
+ }
276
292
  }
277
- }
278
- }, 100);
293
+ }, 100);
279
294
  tasks.setTimeout(() => {
280
295
  if (config.closeOnClickOutside && state.visible) {
281
296
  document.addEventListener("click", handleDocumentClick);
282
297
  }
283
- if (config.closeOnEscape) {
298
+ if (config.closeOnEscape && !listbox) {
284
299
  document.addEventListener("keydown", handleDocumentKeydown);
285
300
  }
286
301
  window.addEventListener("resize", handleWindowResize, {
@@ -432,7 +447,7 @@ const withController = (config) => (component) => {
432
447
  };
433
448
  const initMenu = () => {
434
449
  renderMenuItems();
435
- if (component.keyboard && component.keyboard.setupKeyboardHandlers) {
450
+ if (!listbox && component.keyboard && component.keyboard.setupKeyboardHandlers) {
436
451
  component.keyboard.setupKeyboardHandlers(component.element, state, {
437
452
  closeMenu,
438
453
  closeSubmenu: component.submenu ? component.submenu.closeSubmenu : null,
@@ -454,7 +469,7 @@ const withController = (config) => (component) => {
454
469
  if (config.closeOnClickOutside) {
455
470
  document.addEventListener("click", handleDocumentClick);
456
471
  }
457
- if (config.closeOnEscape) {
472
+ if (config.closeOnEscape && !listbox) {
458
473
  document.addEventListener("keydown", handleDocumentKeydown);
459
474
  }
460
475
  window.addEventListener("resize", handleWindowResize);
@@ -39,10 +39,12 @@ export const createKeyboardNavigation = (component) => {
39
39
  if (!parentId)
40
40
  return null;
41
41
  const level = parseInt(menuElement.getAttribute("data-level") || "1", 10);
42
+ const owner = component.element.id;
43
+ const ownerFilter = owner ? `[data-owner="${owner}"]` : "";
42
44
  const scope = level <= 1
43
45
  ? component.element
44
- : document.querySelector(`.${component.getClass("menu--submenu")}[data-level="${level - 1}"]`);
45
- return ((scope ?? document).querySelector(`.${component.getClass("menu-item")}[data-id="${parentId}"]`) ?? null);
46
+ : document.querySelector(`.${component.getClass("menu--submenu")}${ownerFilter}[data-level="${level - 1}"]`);
47
+ return ((scope ?? component.element).querySelector(`.${component.getClass("menu-item")}[data-id="${parentId}"]`) ?? null);
46
48
  };
47
49
  const menuItems = (menuElement) => Array.from(menuElement.querySelectorAll(`.${component.getClass("menu-item")}`));
48
50
  const resetTypeahead = () => {
@@ -173,10 +175,10 @@ export const createKeyboardNavigation = (component) => {
173
175
  if (focusedItemIndex >= 0 &&
174
176
  items[focusedItemIndex].classList.contains(`${component.getClass("menu-item--submenu")}`)) {
175
177
  const itemElement = items[focusedItemIndex];
176
- const itemIndex = parseInt(itemElement.getAttribute("data-index"), 10);
178
+ const itemIndex = parseInt(itemElement.getAttribute("data-index") ?? "0", 10);
177
179
  const parentMenu = itemElement.closest(`.${component.getClass("menu--submenu")}`);
178
180
  const parentItemId = parentMenu?.getAttribute("data-parent-item");
179
- const parentItem = actions.findItemById(parentItemId);
181
+ const parentItem = parentItemId ? actions.findItemById(parentItemId) : null;
180
182
  if (parentItem && parentItem.submenu) {
181
183
  const itemData = parentItem.submenu[itemIndex];
182
184
  actions.handleNestedSubmenuClick(itemData, itemIndex, itemElement);
@@ -187,7 +189,7 @@ export const createKeyboardNavigation = (component) => {
187
189
  if (focusedItemIndex >= 0 &&
188
190
  items[focusedItemIndex].classList.contains(`${component.getClass("menu-item--submenu")}`)) {
189
191
  const itemElement = items[focusedItemIndex];
190
- const itemIndex = parseInt(itemElement.getAttribute("data-index"), 10);
192
+ const itemIndex = parseInt(itemElement.getAttribute("data-index") ?? "0", 10);
191
193
  const itemData = state.items[itemIndex];
192
194
  actions.handleSubmenuClick(itemData, itemIndex, itemElement);
193
195
  }
@@ -82,6 +82,8 @@ const withOpener = (config) => (component) => {
82
82
  openerElement.addEventListener("keydown", handleOpenerKeydown);
83
83
  openerElement.addEventListener("blur", handleOpenerBlur);
84
84
  }
85
+ if (config.listbox)
86
+ return;
85
87
  openerElement.setAttribute("aria-haspopup", "true");
86
88
  openerElement.setAttribute("aria-expanded", "false");
87
89
  let menuId = component.element.id;
@@ -126,23 +128,27 @@ const withOpener = (config) => (component) => {
126
128
  const restoreFocusToOpener = () => {
127
129
  if (!state.openerElement)
128
130
  return;
129
- if (state.openerComponent &&
130
- typeof state.openerComponent.focus === "function") {
131
+ const openerComponent = state.openerComponent;
132
+ if (openerComponent && typeof openerComponent.focus === "function") {
131
133
  tasks.requestAnimationFrame(() => {
132
- state.openerComponent.focus();
134
+ openerComponent.focus();
133
135
  });
134
136
  return;
135
137
  }
136
- if (state.openerComponent &&
137
- "input" in state.openerComponent &&
138
- state.openerComponent.input instanceof HTMLElement) {
138
+ if (openerComponent &&
139
+ "input" in openerComponent &&
140
+ openerComponent.input instanceof HTMLElement) {
141
+ const input = openerComponent.input;
139
142
  tasks.requestAnimationFrame(() => {
140
- state.openerComponent.input.focus();
143
+ input.focus();
141
144
  });
142
145
  return;
143
146
  }
147
+ const openerElement = state.openerElement;
148
+ if (!openerElement)
149
+ return;
144
150
  tasks.requestAnimationFrame(() => {
145
- state.openerElement.focus();
151
+ openerElement.focus();
146
152
  });
147
153
  };
148
154
  const handleOpenerClick = (e) => {
@@ -243,7 +249,8 @@ const withOpener = (config) => (component) => {
243
249
  state.activeClass = "";
244
250
  };
245
251
  const { element, component: openerComponent } = resolveOpener(config.opener);
246
- setupOpenerEvents({ element, component: openerComponent });
252
+ if (element)
253
+ setupOpenerEvents({ element, component: openerComponent });
247
254
  if (component.lifecycle) {
248
255
  const originalDestroy = component.lifecycle.destroy || (() => { });
249
256
  component.lifecycle.destroy = () => {
@@ -256,13 +263,13 @@ const withOpener = (config) => (component) => {
256
263
  };
257
264
  }
258
265
  component.on("open", () => {
259
- if (state.openerElement) {
266
+ if (state.openerElement && !config.listbox) {
260
267
  state.openerElement.setAttribute("aria-expanded", "true");
261
268
  setOpenerActive(true);
262
269
  }
263
270
  });
264
271
  component.on("close", (event) => {
265
- if (state.openerElement) {
272
+ if (state.openerElement && !config.listbox) {
266
273
  state.openerElement.setAttribute("aria-expanded", "false");
267
274
  setOpenerActive(false);
268
275
  if (event.restoreFocus && !isTabNavigation) {
@@ -276,7 +283,7 @@ const withOpener = (config) => (component) => {
276
283
  setOpener(opener) {
277
284
  const resolved = resolveOpener(opener);
278
285
  if (resolved.element) {
279
- setupOpenerEvents(resolved);
286
+ setupOpenerEvents({ element: resolved.element, component: resolved.component });
280
287
  }
281
288
  return component;
282
289
  },
@@ -248,7 +248,7 @@ export const createPositioner = (component, config) => {
248
248
  const positionMenu = (openerElement) => {
249
249
  if (!openerElement || !component.element)
250
250
  return;
251
- positionElement(component.element, openerElement, config.position, false);
251
+ positionElement(component.element, openerElement, config.position ?? "bottom-start", false);
252
252
  };
253
253
  const positionSubmenu = (submenuElement, parentItemElement, level = 1) => {
254
254
  if (!submenuElement || !parentItemElement)
@@ -145,7 +145,7 @@ const withSubmenu = (config) => (component) => {
145
145
  const currentLevel = itemElement.closest(`.${component.getClass("menu--submenu")}`)
146
146
  ? parseInt(itemElement
147
147
  .closest(`.${component.getClass("menu--submenu")}`)
148
- .getAttribute("data-level") || "0", 10) + 1
148
+ ?.getAttribute("data-level") || "0", 10) + 1
149
149
  : 1;
150
150
  closeSubmenu(currentLevel);
151
151
  const existingSubmenuIndex = state.activeSubmenus.findIndex((s) => s.menuItem === itemElement && s.isOpening);
@@ -165,6 +165,12 @@ const withSubmenu = (config) => (component) => {
165
165
  submenuElement.setAttribute("tabindex", "-1");
166
166
  submenuElement.setAttribute("data-level", currentLevel.toString());
167
167
  submenuElement.setAttribute("data-parent-item", item.id);
168
+ let ownerId = component.element.id;
169
+ if (!ownerId) {
170
+ ownerId = `menu-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
171
+ component.element.id = ownerId;
172
+ }
173
+ submenuElement.setAttribute("data-owner", ownerId);
168
174
  submenuElement.style.zIndex = `${1000 + currentLevel * 10}`;
169
175
  const submenuList = document.createElement("ul");
170
176
  submenuList.className = `${component.getClass("menu-list")}`;
@@ -232,6 +232,15 @@ export interface MenuConfig {
232
232
  * @default false
233
233
  */
234
234
  manualOpen?: boolean;
235
+ /**
236
+ * Renders the menu as the listbox popup of a combobox (WAI-ARIA select-only
237
+ * combobox): the list is `role="listbox"` and each item `role="option"` with
238
+ * its own id, focus stays with the combobox, which owns the keyboard and
239
+ * points at the active option with `aria-activedescendant`, and the opener
240
+ * gets no ARIA from the menu. Pair it with `manualOpen`.
241
+ * @default false
242
+ */
243
+ listbox?: boolean;
235
244
  /**
236
245
  * When true, renders a compact menu with smaller items and tighter spacing.
237
246
  * Useful in dense UIs like toolbars and action bars where the standard
@@ -317,9 +326,11 @@ export interface MenuComponent {
317
326
  /**
318
327
  * Closes the menu
319
328
  * @param event - Optional event that triggered the close
329
+ * @param restoreFocus - Whether focus returns to the opener (default true)
330
+ * @param skipAnimation - Whether to close without the exit animation
320
331
  * @returns The menu component for chaining
321
332
  */
322
- close: (event?: Event) => MenuComponent;
333
+ close: (event?: Event, restoreFocus?: boolean, skipAnimation?: boolean) => MenuComponent;
323
334
  /**
324
335
  * Toggles the menu's open state
325
336
  * @param event - Optional event that triggered the toggle
@@ -369,7 +380,7 @@ export interface MenuComponent {
369
380
  * @param itemId - ID of the menu item to mark as selected
370
381
  * @returns The menu component for chaining
371
382
  */
372
- setSelected: (itemId: string) => MenuComponent;
383
+ setSelected: (itemId: string | null) => MenuComponent;
373
384
  /**
374
385
  * Gets the currently selected menu item's ID
375
386
  * @returns ID of the selected menu item or null if none is selected
@@ -1,12 +1,13 @@
1
1
  import { NAV_CLASSES } from "./constants.js";
2
+ import { setHTML } from "../../core/dom/html.js";
2
3
  export const createExpandIcon = (prefix) => {
3
4
  const icon = document.createElement('span');
4
5
  icon.className = `${prefix}-${NAV_CLASSES.EXPAND_ICON}`;
5
- icon.innerHTML = `
6
+ setHTML(icon, `
6
7
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
7
8
  <polyline points="9 18 15 12 9 6"></polyline>
8
9
  </svg>
9
- `;
10
+ `);
10
11
  return icon;
11
12
  };
12
13
  export const createNestedContainer = (items, prefix, createItem) => {
@@ -56,7 +57,7 @@ export const createNavItem = (config, container, prefix) => {
56
57
  if (config.icon) {
57
58
  const icon = document.createElement('span');
58
59
  icon.className = `${prefix}-${NAV_CLASSES.ICON}`;
59
- icon.innerHTML = config.icon;
60
+ setHTML(icon, config.icon);
60
61
  itemElement.appendChild(icon);
61
62
  }
62
63
  if (config.label) {
@@ -1,4 +1,5 @@
1
1
  import { hasTouchSupport, normalizeEvent, TOUCH_CONFIG, TOUCH_TARGETS } from "../../../core/utils/mobile.js";
2
+ import { setHTML } from "../../../core/dom/html.js";
2
3
  export const createOverlay = (state, mobileConfig, hideDrawer) => {
3
4
  if (state.overlayElement)
4
5
  return state.overlayElement;
@@ -19,12 +20,12 @@ export const createCloseButton = (state, mobileConfig, hideDrawer) => {
19
20
  state.closeButtonElement = document.createElement('button');
20
21
  state.closeButtonElement.className = mobileConfig.closeButtonClass;
21
22
  state.closeButtonElement.setAttribute('aria-label', 'Close navigation');
22
- state.closeButtonElement.innerHTML = `
23
+ setHTML(state.closeButtonElement, `
23
24
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
24
25
  <line x1="18" y1="6" x2="6" y2="18"></line>
25
26
  <line x1="6" y1="6" x2="18" y2="18"></line>
26
27
  </svg>
27
- `;
28
+ `);
28
29
  state.closeButtonElement.addEventListener('click', () => {
29
30
  hideDrawer();
30
31
  });