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.
- package/README.md +20 -0
- package/dist/README.md +20 -0
- package/dist/components/bottom-app-bar/bottom-app-bar.js +2 -2
- package/dist/components/bottom-sheet/features/structure.js +4 -3
- package/dist/components/button/api.js +3 -2
- package/dist/components/button/features/progress.js +26 -21
- package/dist/components/button-group/config.d.ts +11 -3
- package/dist/components/button-group/config.js +1 -1
- package/dist/components/card/content.js +5 -4
- package/dist/components/carousel/features/slides.js +2 -1
- package/dist/components/checkbox/checkbox.js +22 -0
- package/dist/components/checkbox/config.d.ts +3 -3
- package/dist/components/checkbox/config.js +10 -9
- package/dist/components/checkbox/types.d.ts +19 -4
- package/dist/components/chips/api.js +3 -1
- package/dist/components/chips/chip/api.js +3 -2
- package/dist/components/chips/chip/chip.js +6 -4
- package/dist/components/chips/config.js +1 -1
- package/dist/components/datepicker/api.d.ts +8 -2
- package/dist/components/datepicker/api.js +1 -0
- package/dist/components/datepicker/config.d.ts +14 -4
- package/dist/components/datepicker/datepicker.js +5 -2
- package/dist/components/datepicker/types.d.ts +14 -0
- package/dist/components/datepicker/utils.js +4 -6
- package/dist/components/dialog/config.js +0 -1
- package/dist/components/dialog/features.js +10 -8
- package/dist/components/dialog/types.d.ts +3 -2
- package/dist/components/drawer/features/items.js +3 -2
- package/dist/components/list/features/renderer.js +4 -3
- package/dist/components/menu/api.d.ts +1 -1
- package/dist/components/menu/config.js +2 -2
- package/dist/components/menu/features/controller.js +41 -26
- package/dist/components/menu/features/keyboard.js +7 -5
- package/dist/components/menu/features/opener.js +19 -12
- package/dist/components/menu/features/position.js +1 -1
- package/dist/components/menu/features/submenu.js +7 -1
- package/dist/components/menu/types.d.ts +13 -2
- package/dist/components/navigation/nav-item.js +4 -3
- package/dist/components/navigation/system/mobile.js +3 -2
- package/dist/components/navigation-rail/navigation-rail.js +3 -2
- package/dist/components/progress/features/canvas.js +0 -2
- package/dist/components/search/features/states.js +3 -3
- package/dist/components/search/features/structure.js +3 -0
- package/dist/components/search/features/suggestions.js +2 -2
- package/dist/components/search/types.d.ts +6 -0
- package/dist/components/segmented-button/config.d.ts +4 -1
- package/dist/components/segmented-button/config.js +1 -1
- package/dist/components/segmented-button/segment.d.ts +2 -5
- package/dist/components/segmented-button/segment.js +7 -3
- package/dist/components/select/features.js +267 -44
- package/dist/components/side-sheet/features/structure.js +5 -4
- package/dist/components/slider/features/controller.js +7 -0
- package/dist/components/slider/features/dom.d.ts +1 -0
- package/dist/components/slider/features/dom.js +11 -0
- package/dist/components/slider/features/handlers.js +8 -3
- package/dist/components/slider/features/states.js +8 -0
- package/dist/components/slider/types.d.ts +7 -0
- package/dist/components/switch/config.d.ts +3 -3
- package/dist/components/switch/config.js +7 -7
- package/dist/components/switch/features.js +5 -5
- package/dist/components/switch/switch.js +1 -1
- package/dist/components/switch/types.d.ts +10 -0
- package/dist/components/tabs/features.d.ts +1 -0
- package/dist/components/tabs/scroll-indicators.js +3 -2
- package/dist/components/tabs/tab.d.ts +0 -5
- package/dist/components/tabs/tab.js +12 -3
- package/dist/components/tabs/tabs.js +10 -1
- package/dist/components/tabs/utils.d.ts +8 -0
- package/dist/components/tabs/utils.js +63 -18
- package/dist/components/textfield/api.js +12 -4
- package/dist/components/textfield/features/error.d.ts +1 -0
- package/dist/components/textfield/features/error.js +10 -0
- package/dist/components/textfield/features/leading-icon.js +32 -33
- package/dist/components/textfield/features/prefix-text.js +30 -18
- package/dist/components/textfield/features/suffix-text.js +30 -14
- package/dist/components/textfield/features/supporting-text.d.ts +1 -0
- package/dist/components/textfield/features/supporting-text.js +25 -0
- package/dist/components/textfield/features/trailing-icon.js +36 -19
- package/dist/components/timepicker/api.d.ts +1 -1
- package/dist/components/timepicker/api.js +16 -11
- package/dist/components/timepicker/render.js +27 -24
- package/dist/components/timepicker/timepicker.js +4 -2
- package/dist/components/timepicker/types.d.ts +7 -0
- package/dist/components/timepicker/utils.d.ts +14 -0
- package/dist/components/timepicker/utils.js +4 -0
- package/dist/components/tooltip/api.js +27 -2
- package/dist/components/top-app-bar/top-app-bar.js +3 -2
- package/dist/core/compose/component.d.ts +1 -1
- package/dist/core/compose/component.js +1 -1
- package/dist/core/compose/features/icon.js +3 -2
- package/dist/core/compose/features/input.js +1 -1
- package/dist/core/compose/features/lifecycle.js +10 -11
- package/dist/core/compose/features/textlabel.js +3 -0
- package/dist/core/compose/features/track.js +2 -1
- package/dist/core/config/component.d.ts +1 -1
- package/dist/core/config/component.js +1 -1
- package/dist/core/dom/create.js +5 -4
- package/dist/core/dom/form-value.d.ts +24 -0
- package/dist/core/dom/form-value.js +14 -0
- package/dist/core/dom/html.d.ts +34 -0
- package/dist/core/dom/html.js +13 -0
- package/dist/core/dom/index.d.ts +2 -0
- package/dist/core/dom/index.js +1 -0
- package/dist/core/dom/utils.js +1 -1
- package/dist/core/state/emitter.js +8 -1
- package/dist/core/state/index.d.ts +1 -1
- package/dist/core/state/store.d.ts +10 -2
- package/dist/core/state/store.js +2 -1
- package/dist/index.cjs +0 -0
- package/dist/package.json +5 -2
- package/dist/styles/badge.css +1 -1
- package/dist/styles/base.css +2 -2
- package/dist/styles/bottom-app-bar.css +1 -1
- package/dist/styles/bottom-sheet.css +2 -2
- package/dist/styles/button-group.css +1 -1
- package/dist/styles/button.css +2 -2
- package/dist/styles/card.css +2 -2
- package/dist/styles/carousel.css +1 -1
- package/dist/styles/checkbox.css +2 -2
- package/dist/styles/chips.css +2 -2
- package/dist/styles/dialog.css +2 -2
- package/dist/styles/divider.css +1 -1
- package/dist/styles/drawer.css +2 -2
- package/dist/styles/extended-fab.css +2 -2
- package/dist/styles/fab.css +2 -2
- package/dist/styles/icon-button.css +2 -2
- package/dist/styles/list.css +2 -2
- package/dist/styles/loading-indicator.css +1 -1
- package/dist/styles/menu.css +2 -2
- package/dist/styles/navigation-rail.css +1 -1
- package/dist/styles/navigation.css +2 -2
- package/dist/styles/progress.css +2 -2
- package/dist/styles/radios.css +1 -1
- package/dist/styles/search.css +1 -1
- package/dist/styles/segmented-button.css +2 -2
- package/dist/styles/select.css +2 -2
- package/dist/styles/side-sheet.css +2 -2
- package/dist/styles/slider.css +1 -1
- package/dist/styles/snackbar.css +1 -1
- package/dist/styles/split-button.css +2 -2
- package/dist/styles/switch.css +2 -2
- package/dist/styles/tabs.css +1 -1
- package/dist/styles/textfield.css +2 -2
- package/dist/styles/timepicker.css +1 -1
- package/dist/styles/tooltip.css +2 -2
- package/dist/styles/top-app-bar.css +1 -1
- package/dist/styles/utilities.css +1 -1
- package/dist/styles.css +2 -2
- package/dist/styles.d.ts +1 -0
- package/dist/themes/autumn.css +1 -1
- package/dist/themes/baseline.css +1 -1
- package/dist/themes/brownbeige.css +1 -1
- package/dist/themes/browngreen.css +1 -1
- package/dist/themes/desert.css +1 -1
- package/dist/themes/forest.css +1 -1
- package/dist/themes/highcontrast.css +1 -1
- package/dist/themes/legacy.css +1 -1
- package/dist/themes/material.css +1 -1
- package/dist/themes/ocean.css +1 -1
- package/dist/themes/sageivory.css +1 -1
- package/dist/themes/spring.css +1 -1
- package/dist/themes/summer.css +1 -1
- package/dist/themes/sunset.css +1 -1
- package/dist/themes/tealcaramel.css +1 -1
- package/dist/themes/winter.css +1 -1
- package/package.json +7 -3
- package/src/styles/abstract/_mixins.scss +26 -0
- package/src/styles/abstract/_variables.scss +25 -11
- package/src/styles/base/_reset.scss +9 -2
- package/src/styles/components/_bottom-sheet.scss +23 -7
- package/src/styles/components/_button.scss +1 -12
- package/src/styles/components/_dialog.scss +59 -26
- package/src/styles/components/_drawer.scss +60 -7
- package/src/styles/components/_menu.scss +12 -7
- package/src/styles/components/_side-sheet.scss +33 -4
- 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.
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
121
|
-
(maxDate && date > maxDate);
|
|
119
|
+
const isDisabled = outOfRange(date);
|
|
122
120
|
result.push({
|
|
123
121
|
date,
|
|
124
122
|
day: i,
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
|
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
|
-
*
|
|
168
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
28
|
+
listContainer.replaceChildren();
|
|
28
29
|
if (!items || items.length === 0) {
|
|
29
|
-
listContainer
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
component.keyboard.handleInitialFocus
|
|
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.
|
|
275
|
-
|
|
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 ??
|
|
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
|
-
|
|
130
|
-
|
|
131
|
+
const openerComponent = state.openerComponent;
|
|
132
|
+
if (openerComponent && typeof openerComponent.focus === "function") {
|
|
131
133
|
tasks.requestAnimationFrame(() => {
|
|
132
|
-
|
|
134
|
+
openerComponent.focus();
|
|
133
135
|
});
|
|
134
136
|
return;
|
|
135
137
|
}
|
|
136
|
-
if (
|
|
137
|
-
"input" in
|
|
138
|
-
|
|
138
|
+
if (openerComponent &&
|
|
139
|
+
"input" in openerComponent &&
|
|
140
|
+
openerComponent.input instanceof HTMLElement) {
|
|
141
|
+
const input = openerComponent.input;
|
|
139
142
|
tasks.requestAnimationFrame(() => {
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|