mtrl 0.8.0-next.37 → 0.8.0-next.39

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 (70) hide show
  1. package/dist/components/drawer/config.js +2 -3
  2. package/dist/components/drawer/features/headline.js +3 -0
  3. package/dist/components/drawer/features/items.js +19 -42
  4. package/dist/components/drawer/features/state.d.ts +1 -9
  5. package/dist/components/drawer/features/state.js +143 -68
  6. package/dist/components/textfield/api.d.ts +1 -0
  7. package/dist/components/textfield/api.js +10 -10
  8. package/dist/components/textfield/features/density.js +1 -1
  9. package/dist/components/textfield/features/leading-icon.js +2 -1
  10. package/dist/components/textfield/features/placement.d.ts +2 -0
  11. package/dist/components/textfield/features/placement.js +25 -3
  12. package/dist/components/textfield/textfield.js +1 -1
  13. package/dist/core/compose/features/textinput.js +2 -1
  14. package/dist/index.cjs +12 -12
  15. package/dist/package.json +1 -1
  16. package/dist/styles/badge.css +1 -1
  17. package/dist/styles/base.css +1 -1
  18. package/dist/styles/bottom-app-bar.css +1 -1
  19. package/dist/styles/bottom-sheet.css +1 -1
  20. package/dist/styles/button-group.css +1 -1
  21. package/dist/styles/button.css +1 -1
  22. package/dist/styles/card.css +1 -1
  23. package/dist/styles/carousel.css +1 -1
  24. package/dist/styles/checkbox.css +1 -1
  25. package/dist/styles/chips.css +1 -1
  26. package/dist/styles/dialog.css +1 -1
  27. package/dist/styles/divider.css +1 -1
  28. package/dist/styles/drawer.css +2 -2
  29. package/dist/styles/extended-fab.css +1 -1
  30. package/dist/styles/fab.css +1 -1
  31. package/dist/styles/icon-button.css +1 -1
  32. package/dist/styles/list.css +1 -1
  33. package/dist/styles/loading-indicator.css +1 -1
  34. package/dist/styles/menu.css +1 -1
  35. package/dist/styles/navigation.css +1 -1
  36. package/dist/styles/progress.css +1 -1
  37. package/dist/styles/radios.css +1 -1
  38. package/dist/styles/search.css +1 -1
  39. package/dist/styles/segmented-button.css +1 -1
  40. package/dist/styles/select.css +1 -1
  41. package/dist/styles/side-sheet.css +1 -1
  42. package/dist/styles/slider.css +1 -1
  43. package/dist/styles/snackbar.css +1 -1
  44. package/dist/styles/split-button.css +1 -1
  45. package/dist/styles/switch.css +1 -1
  46. package/dist/styles/tabs.css +1 -1
  47. package/dist/styles/textfield.css +1 -1
  48. package/dist/styles/timepicker.css +1 -1
  49. package/dist/styles/top-app-bar.css +1 -1
  50. package/dist/styles/utilities.css +1 -1
  51. package/dist/styles.css +2 -2
  52. package/dist/themes/autumn.css +1 -1
  53. package/dist/themes/baseline.css +1 -1
  54. package/dist/themes/brownbeige.css +1 -1
  55. package/dist/themes/browngreen.css +1 -1
  56. package/dist/themes/desert.css +1 -1
  57. package/dist/themes/forest.css +1 -1
  58. package/dist/themes/highcontrast.css +1 -1
  59. package/dist/themes/legacy.css +1 -1
  60. package/dist/themes/material.css +1 -1
  61. package/dist/themes/ocean.css +1 -1
  62. package/dist/themes/sageivory.css +1 -1
  63. package/dist/themes/spring.css +1 -1
  64. package/dist/themes/summer.css +1 -1
  65. package/dist/themes/sunset.css +1 -1
  66. package/dist/themes/tealcaramel.css +1 -1
  67. package/dist/themes/winter.css +1 -1
  68. package/package.json +2 -1
  69. package/src/styles/components/_drawer.scss +5 -1
  70. package/src/styles/components/_slider.scss +7 -6
@@ -19,9 +19,8 @@ export const getElementConfig = (config) => {
19
19
  else {
20
20
  attributes.role = "navigation";
21
21
  }
22
- if (config.ariaLabel) {
23
- attributes["aria-label"] = config.ariaLabel;
24
- }
22
+ attributes["aria-label"] = config.ariaLabel || config.headline || "Navigation";
23
+ attributes.tabindex = "-1";
25
24
  return createElementConfig(config, {
26
25
  tag: "aside",
27
26
  attributes,
@@ -8,12 +8,15 @@ export const withHeadline = (config) => (component) => {
8
8
  }
9
9
  const setHeadline = (text) => {
10
10
  headlineText = text;
11
+ if (!config.ariaLabel)
12
+ component.element.setAttribute("aria-label", text || "Navigation");
11
13
  if (text) {
12
14
  if (!headlineElement) {
13
15
  headlineElement = document.createElement("div");
14
16
  headlineElement.className = component.getClass("drawer__headline");
15
17
  }
16
18
  headlineElement.textContent = text;
19
+ component.element.querySelector(`.${component.getClass("drawer__sheet")}`)?.prepend(headlineElement);
17
20
  }
18
21
  else if (headlineElement && headlineElement.parentNode) {
19
22
  headlineElement.parentNode.removeChild(headlineElement);
@@ -2,8 +2,8 @@ import { DRAWER_EVENTS } from "../constants.js";
2
2
  const createItemElement = (item, getClass) => {
3
3
  const el = document.createElement("button");
4
4
  el.className = getClass("drawer__item");
5
- el.setAttribute("role", "tab");
6
- el.setAttribute("tabindex", "-1");
5
+ el.type = "button";
6
+ el.setAttribute("tabindex", item.disabled ? "-1" : "0");
7
7
  if (item.id) {
8
8
  el.dataset.id = item.id;
9
9
  }
@@ -18,6 +18,7 @@ const createItemElement = (item, getClass) => {
18
18
  const iconEl = document.createElement("span");
19
19
  iconEl.className = getClass("drawer__item-icon");
20
20
  iconEl.innerHTML = item.icon;
21
+ iconEl.setAttribute("aria-hidden", "true");
21
22
  el.appendChild(iconEl);
22
23
  }
23
24
  if (item.label) {
@@ -34,11 +35,7 @@ const createItemElement = (item, getClass) => {
34
35
  }
35
36
  if (item.active) {
36
37
  el.classList.add(`${getClass("drawer__item")}--active`);
37
- el.setAttribute("aria-selected", "true");
38
- el.setAttribute("tabindex", "0");
39
- }
40
- else {
41
- el.setAttribute("aria-selected", "false");
38
+ el.setAttribute("aria-current", "page");
42
39
  }
43
40
  return el;
44
41
  };
@@ -58,7 +55,7 @@ export const withItems = (config) => (component) => {
58
55
  let currentItems = config.items || [];
59
56
  let activeId = null;
60
57
  let itemsContainer = null;
61
- const initialActive = currentItems.find((item) => item.type !== "divider" && item.type !== "section" && item.active);
58
+ const initialActive = currentItems.find((item) => item.type !== "divider" && item.type !== "section" && item.active && !item.disabled);
62
59
  if (initialActive?.id) {
63
60
  activeId = initialActive.id;
64
61
  }
@@ -77,7 +74,7 @@ export const withItems = (config) => (component) => {
77
74
  itemsContainer.appendChild(createSectionElement(item, component.getClass));
78
75
  return;
79
76
  }
80
- const itemEl = createItemElement(item, component.getClass);
77
+ const itemEl = createItemElement({ ...item, active: !!activeId && item.id === activeId }, component.getClass);
81
78
  itemEl.dataset.navIndex = String(navIndex);
82
79
  navIndex++;
83
80
  itemEl.addEventListener("click", (e) => {
@@ -97,39 +94,23 @@ export const withItems = (config) => (component) => {
97
94
  });
98
95
  itemsContainer.appendChild(itemEl);
99
96
  });
100
- if (!activeId) {
101
- const firstItem = itemsContainer.querySelector(`.${component.getClass("drawer__item")}`);
102
- if (firstItem) {
103
- firstItem.setAttribute("tabindex", "0");
104
- }
105
- }
106
97
  };
107
98
  const setActive = (id) => {
108
99
  if (!itemsContainer)
109
100
  return;
110
101
  const activeClass = `${component.getClass("drawer__item")}--active`;
111
- const allItems = itemsContainer.querySelectorAll(`.${component.getClass("drawer__item")}`);
112
- allItems.forEach((el) => {
113
- el.classList.remove(activeClass);
114
- el.setAttribute("aria-selected", "false");
115
- el.setAttribute("tabindex", "-1");
102
+ const allItems = [...itemsContainer.querySelectorAll(`.${component.getClass("drawer__item")}`)];
103
+ const target = allItems.find(element => element.dataset.id === id && !element.hasAttribute("disabled"));
104
+ if (id && !target)
105
+ return;
106
+ allItems.forEach(element => {
107
+ element.classList.toggle(activeClass, element === target);
108
+ if (element === target)
109
+ element.setAttribute("aria-current", "page");
110
+ else
111
+ element.removeAttribute("aria-current");
116
112
  });
117
- if (!id) {
118
- activeId = null;
119
- const firstItem = itemsContainer.querySelector(`.${component.getClass("drawer__item")}`);
120
- if (firstItem) {
121
- firstItem.setAttribute("tabindex", "0");
122
- }
123
- }
124
- else {
125
- const targetItem = itemsContainer.querySelector(`[data-id="${id}"]`);
126
- if (targetItem) {
127
- targetItem.classList.add(activeClass);
128
- targetItem.setAttribute("aria-selected", "true");
129
- targetItem.setAttribute("tabindex", "0");
130
- activeId = id;
131
- }
132
- }
113
+ activeId = target ? id : null;
133
114
  currentItems = currentItems.map((item) => ({
134
115
  ...item,
135
116
  active: item.id === id,
@@ -138,7 +119,7 @@ export const withItems = (config) => (component) => {
138
119
  const getActive = () => activeId;
139
120
  const setItems = (items) => {
140
121
  currentItems = items;
141
- const active = items.find((item) => item.type !== "divider" && item.type !== "section" && item.active);
122
+ const active = items.find((item) => item.type !== "divider" && item.type !== "section" && item.active && !item.disabled);
142
123
  activeId = active?.id || null;
143
124
  renderItems();
144
125
  };
@@ -146,7 +127,7 @@ export const withItems = (config) => (component) => {
146
127
  const setBadge = (id, badge) => {
147
128
  if (!itemsContainer)
148
129
  return;
149
- const itemEl = itemsContainer.querySelector(`[data-id="${id}"]`);
130
+ const itemEl = [...itemsContainer.querySelectorAll("[data-id]")].find(element => element.dataset.id === id);
150
131
  if (!itemEl)
151
132
  return;
152
133
  let badgeEl = itemEl.querySelector(`.${component.getClass("drawer__item-badge")}`);
@@ -199,15 +180,11 @@ export const withItems = (config) => (component) => {
199
180
  return;
200
181
  }
201
182
  if (nextIndex >= 0 && nextIndex < items.length) {
202
- items.forEach((item) => item.setAttribute("tabindex", "-1"));
203
- items[nextIndex].setAttribute("tabindex", "0");
204
183
  items[nextIndex].focus();
205
184
  }
206
185
  };
207
186
  itemsContainer = document.createElement("div");
208
187
  itemsContainer.className = component.getClass("drawer__items");
209
- itemsContainer.setAttribute("role", "tablist");
210
- itemsContainer.setAttribute("aria-orientation", "vertical");
211
188
  itemsContainer.addEventListener("keydown", handleKeydown);
212
189
  renderItems();
213
190
  return {
@@ -1,18 +1,10 @@
1
1
  import { DrawerConfig } from "../types.js";
2
- /**
3
- * Component shape expected by withState
4
- */
5
2
  interface StateBaseComponent {
6
3
  element: HTMLElement;
7
4
  getClass: (name: string) => string;
8
5
  emit: (event: string, data?: unknown) => void;
9
6
  [key: string]: unknown;
10
7
  }
11
- /**
12
- * Adds open/close state management, scrim, and keyboard dismiss to the drawer
13
- *
14
- * @param config - Drawer configuration
15
- * @returns Component enhancer function
16
- */
8
+ /** Standard drawers remain in the page; modal drawers own focus and background interaction. */
17
9
  export declare const withState: (config: DrawerConfig) => (component: StateBaseComponent) => StateBaseComponent;
18
10
  export {};
@@ -1,95 +1,170 @@
1
1
  import { DRAWER_EVENTS } from "../constants.js";
2
+ const modals = new WeakMap();
3
+ function updateBackground(doc, state) {
4
+ state.inerted.forEach(element => element.removeAttribute("inert"));
5
+ state.inerted = [];
6
+ let branch = state.roots.at(-1);
7
+ while (branch?.parentElement) {
8
+ for (const sibling of branch.parentElement.children) {
9
+ if (sibling !== branch && !sibling.hasAttribute("inert")) {
10
+ sibling.setAttribute("inert", "");
11
+ state.inerted.push(sibling);
12
+ }
13
+ }
14
+ branch = branch.parentElement;
15
+ if (branch === doc.body)
16
+ break;
17
+ }
18
+ }
19
+ function acquireModal(root) {
20
+ const doc = root.ownerDocument;
21
+ let state = modals.get(doc);
22
+ if (!state) {
23
+ state = { roots: [], inerted: [], overflow: doc.body.style.overflow, priority: doc.body.style.getPropertyPriority("overflow") };
24
+ modals.set(doc, state);
25
+ doc.body.style.setProperty("overflow", "hidden");
26
+ }
27
+ if (!state.roots.includes(root))
28
+ state.roots.push(root);
29
+ updateBackground(doc, state);
30
+ }
31
+ function releaseModal(root) {
32
+ const doc = root.ownerDocument;
33
+ const state = modals.get(doc);
34
+ if (!state || !state.roots.includes(root))
35
+ return;
36
+ state.roots = state.roots.filter(element => element !== root);
37
+ updateBackground(doc, state);
38
+ if (!state.roots.length) {
39
+ doc.body.style.setProperty("overflow", state.overflow, state.priority);
40
+ modals.delete(doc);
41
+ }
42
+ }
2
43
  export const withState = (config) => (component) => {
44
+ const root = component.element;
45
+ const doc = root.ownerDocument;
3
46
  const isModal = config.variant === "modal";
4
47
  const dismissible = config.dismissible !== false;
5
48
  let isOpen = config.open === true;
6
- let scrimElement = null;
7
- if (isModal) {
8
- scrimElement = document.createElement("div");
49
+ let destroyed = false;
50
+ let frame = null;
51
+ let previousFocus = null;
52
+ const scrimElement = isModal ? doc.createElement("div") : null;
53
+ if (scrimElement) {
9
54
  scrimElement.className = component.getClass("drawer__scrim");
10
- if (dismissible) {
11
- scrimElement.addEventListener("click", () => {
12
- close();
13
- });
14
- }
55
+ scrimElement.setAttribute("aria-hidden", "true");
56
+ if (dismissible)
57
+ scrimElement.addEventListener("click", close);
15
58
  }
16
- const handleKeydown = (e) => {
17
- if (e.key === "Escape" && isOpen && isModal && dismissible) {
18
- e.preventDefault();
19
- close();
20
- }
59
+ const cancelFocus = () => {
60
+ if (frame !== null)
61
+ cancelAnimationFrame(frame);
62
+ frame = null;
21
63
  };
22
- if (isModal) {
23
- document.addEventListener("keydown", handleKeydown);
24
- }
25
- function open() {
26
- if (isOpen)
64
+ const focusable = () => [...root.querySelectorAll('button, a[href], input, select, textarea, [tabindex]')].filter(element => {
65
+ const style = doc.defaultView.getComputedStyle(element);
66
+ return element.tabIndex >= 0 && !element.matches(':disabled, [aria-disabled="true"]') && !element.closest('[inert], [hidden]') && style.display !== 'none' && style.visibility !== 'hidden';
67
+ });
68
+ const focusInside = () => {
69
+ const items = focusable();
70
+ (items.find(element => element.getAttribute("aria-current") === "page") || items[0] || root).focus();
71
+ };
72
+ const isTopModal = () => modals.get(doc)?.roots.at(-1) === root;
73
+ function handleKeydown(event) {
74
+ if (!isOpen || !isTopModal() || event.defaultPrevented)
27
75
  return;
28
- isOpen = true;
29
- component.element.classList.add(`${component.getClass("drawer")}--open`);
30
- if (isModal && scrimElement) {
31
- scrimElement.classList.add(`${component.getClass("drawer__scrim")}--visible`);
32
- document.body.style.overflow = "hidden";
76
+ if (event.key === "Escape" && dismissible) {
77
+ event.preventDefault();
78
+ close();
33
79
  }
34
- requestAnimationFrame(() => {
35
- const firstItem = component.element.querySelector(`.${component.getClass("drawer__item")}[tabindex="0"]`);
36
- if (firstItem) {
37
- firstItem.focus();
80
+ else if (event.key === "Tab") {
81
+ const items = focusable();
82
+ const first = items[0], last = items.at(-1);
83
+ if (!first) {
84
+ event.preventDefault();
85
+ root.focus();
86
+ }
87
+ else if (event.shiftKey && (doc.activeElement === first || doc.activeElement === root || !root.contains(doc.activeElement))) {
88
+ event.preventDefault();
89
+ last.focus();
38
90
  }
39
- else {
40
- component.element.focus();
91
+ else if (!event.shiftKey && (doc.activeElement === last || doc.activeElement === root || !root.contains(doc.activeElement))) {
92
+ event.preventDefault();
93
+ first.focus();
41
94
  }
95
+ }
96
+ }
97
+ function handleFocus(event) {
98
+ if (isOpen && isTopModal() && !root.contains(event.target))
99
+ focusInside();
100
+ }
101
+ const synchronize = () => {
102
+ root.classList.toggle(`${component.getClass("drawer")}--open`, isOpen);
103
+ root.toggleAttribute("inert", !isOpen);
104
+ root.setAttribute("aria-hidden", String(!isOpen));
105
+ scrimElement?.classList.toggle(`${component.getClass("drawer__scrim")}--visible`, isOpen);
106
+ };
107
+ const activate = () => {
108
+ if (!isModal)
109
+ return;
110
+ previousFocus = doc.activeElement;
111
+ if (root.isConnected)
112
+ acquireModal(root);
113
+ doc.addEventListener("keydown", handleKeydown);
114
+ doc.addEventListener("focusin", handleFocus);
115
+ frame = requestAnimationFrame(() => {
116
+ frame = null;
117
+ if (destroyed || !isOpen || !root.isConnected)
118
+ return;
119
+ acquireModal(root);
120
+ if (isTopModal())
121
+ focusInside();
42
122
  });
123
+ };
124
+ const deactivate = () => {
125
+ cancelFocus();
126
+ doc.removeEventListener("keydown", handleKeydown);
127
+ doc.removeEventListener("focusin", handleFocus);
128
+ const restore = isTopModal();
129
+ releaseModal(root);
130
+ if (restore && previousFocus?.isConnected && !previousFocus.closest('[inert]'))
131
+ previousFocus.focus();
132
+ previousFocus = null;
133
+ };
134
+ function open() {
135
+ if (destroyed || isOpen)
136
+ return;
137
+ isOpen = true;
138
+ synchronize();
139
+ activate();
43
140
  component.emit(DRAWER_EVENTS.OPEN);
44
- if (config.onOpen) {
45
- config.onOpen();
46
- }
141
+ config.onOpen?.();
47
142
  }
48
143
  function close() {
49
- if (!isOpen)
144
+ if (destroyed || !isOpen)
50
145
  return;
51
146
  isOpen = false;
52
- component.element.classList.remove(`${component.getClass("drawer")}--open`);
53
- if (isModal && scrimElement) {
54
- scrimElement.classList.remove(`${component.getClass("drawer__scrim")}--visible`);
55
- document.body.style.overflow = "";
56
- }
147
+ deactivate();
148
+ synchronize();
57
149
  component.emit(DRAWER_EVENTS.CLOSE);
58
- if (config.onClose) {
59
- config.onClose();
60
- }
61
- }
62
- function toggle() {
63
- if (isOpen) {
64
- close();
65
- }
66
- else {
67
- open();
68
- }
69
- }
70
- if (isOpen) {
71
- component.element.classList.add(`${component.getClass("drawer")}--open`);
72
- if (isModal && scrimElement) {
73
- scrimElement.classList.add(`${component.getClass("drawer__scrim")}--visible`);
74
- }
150
+ config.onClose?.();
75
151
  }
152
+ synchronize();
153
+ if (isOpen)
154
+ activate();
76
155
  return {
77
156
  ...component,
78
157
  scrimElement,
79
- drawerState: {
80
- open,
81
- close,
82
- toggle,
83
- isOpen: () => isOpen,
84
- },
158
+ drawerState: { open, close, toggle: () => isOpen ? close() : open(), isOpen: () => isOpen },
85
159
  _stateCleanup: () => {
86
- if (isModal) {
87
- document.removeEventListener("keydown", handleKeydown);
88
- if (scrimElement && scrimElement.parentNode) {
89
- scrimElement.parentNode.removeChild(scrimElement);
90
- }
91
- document.body.style.overflow = "";
92
- }
160
+ if (destroyed)
161
+ return;
162
+ destroyed = true;
163
+ isOpen = false;
164
+ deactivate();
165
+ synchronize();
166
+ scrimElement?.removeEventListener("click", close);
167
+ scrimElement?.remove();
93
168
  },
94
169
  };
95
170
  };
@@ -8,6 +8,7 @@ type ComponentWithDensity = BaseComponent & {
8
8
  set: (density: string) => void;
9
9
  };
10
10
  updateElementPositions?: () => void;
11
+ schedulePositionUpdate?: () => void;
11
12
  setError?: (error: boolean, message?: string) => any;
12
13
  isError?: () => boolean;
13
14
  };
@@ -23,7 +23,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
23
23
  component.element.classList.remove(`${PREFIX}-${COMPONENT}--filled`, `${PREFIX}-${COMPONENT}--outlined`);
24
24
  component.element.classList.add(`${PREFIX}-${COMPONENT}--${variant}`);
25
25
  if (component.updateElementPositions) {
26
- setTimeout(() => component.updateElementPositions(), 10);
26
+ component.schedulePositionUpdate?.();
27
27
  }
28
28
  return this;
29
29
  },
@@ -38,7 +38,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
38
38
  setLabel(text) {
39
39
  component.label?.setText(text);
40
40
  if (component.updateElementPositions) {
41
- setTimeout(() => component.updateElementPositions(), 10);
41
+ component.schedulePositionUpdate?.();
42
42
  }
43
43
  return this;
44
44
  },
@@ -50,7 +50,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
50
50
  if (component.setLeadingIcon) {
51
51
  component.setLeadingIcon(html);
52
52
  if (component.updateElementPositions) {
53
- setTimeout(() => component.updateElementPositions(), 10);
53
+ component.schedulePositionUpdate?.();
54
54
  }
55
55
  }
56
56
  return this;
@@ -59,7 +59,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
59
59
  if (component.removeLeadingIcon) {
60
60
  component.removeLeadingIcon();
61
61
  if (component.updateElementPositions) {
62
- setTimeout(() => component.updateElementPositions(), 10);
62
+ component.schedulePositionUpdate?.();
63
63
  }
64
64
  }
65
65
  return this;
@@ -69,7 +69,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
69
69
  if (component.setTrailingIcon) {
70
70
  component.setTrailingIcon(html);
71
71
  if (component.updateElementPositions) {
72
- setTimeout(() => component.updateElementPositions(), 10);
72
+ component.schedulePositionUpdate?.();
73
73
  }
74
74
  }
75
75
  return this;
@@ -78,7 +78,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
78
78
  if (component.removeTrailingIcon) {
79
79
  component.removeTrailingIcon();
80
80
  if (component.updateElementPositions) {
81
- setTimeout(() => component.updateElementPositions(), 10);
81
+ component.schedulePositionUpdate?.();
82
82
  }
83
83
  }
84
84
  return this;
@@ -101,7 +101,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
101
101
  if (component.setPrefixText) {
102
102
  component.setPrefixText(text);
103
103
  if (component.updateElementPositions) {
104
- setTimeout(() => component.updateElementPositions(), 10);
104
+ component.schedulePositionUpdate?.();
105
105
  }
106
106
  }
107
107
  return this;
@@ -110,7 +110,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
110
110
  if (component.removePrefixText) {
111
111
  component.removePrefixText();
112
112
  if (component.updateElementPositions) {
113
- setTimeout(() => component.updateElementPositions(), 10);
113
+ component.schedulePositionUpdate?.();
114
114
  }
115
115
  }
116
116
  return this;
@@ -120,7 +120,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
120
120
  if (component.setSuffixText) {
121
121
  component.setSuffixText(text);
122
122
  if (component.updateElementPositions) {
123
- setTimeout(() => component.updateElementPositions(), 10);
123
+ component.schedulePositionUpdate?.();
124
124
  }
125
125
  }
126
126
  return this;
@@ -129,7 +129,7 @@ export const withAPI = ({ disabled, lifecycle }) => (component) => ({
129
129
  if (component.removeSuffixText) {
130
130
  component.removeSuffixText();
131
131
  if (component.updateElementPositions) {
132
- setTimeout(() => component.updateElementPositions(), 10);
132
+ component.schedulePositionUpdate?.();
133
133
  }
134
134
  }
135
135
  return this;
@@ -27,7 +27,7 @@ export const withDensity = (config) => (component) => {
27
27
  component.density.current = newDensity;
28
28
  }
29
29
  if (component.updateElementPositions) {
30
- setTimeout(() => component.updateElementPositions(), 10);
30
+ component.updateElementPositions();
31
31
  }
32
32
  };
33
33
  component.density = {
@@ -14,11 +14,12 @@ export const withLeadingIcon = (config) => (component) => {
14
14
  if ('lifecycle' in component && component.lifecycle?.destroy) {
15
15
  const originalDestroy = component.lifecycle.destroy;
16
16
  component.lifecycle.destroy = () => {
17
+ clearTimeout(initialPosition);
17
18
  iconElement.remove();
18
19
  originalDestroy.call(component.lifecycle);
19
20
  };
20
21
  }
21
- setTimeout(() => {
22
+ const initialPosition = setTimeout(() => {
22
23
  const labelEl = component.element.querySelector(`.${PREFIX}-${config.componentName || 'textfield'}-label`);
23
24
  if (labelEl) {
24
25
  if (!component.element.classList.contains(`${PREFIX}-${config.componentName || 'textfield'}--with-prefix`)) {
@@ -17,6 +17,8 @@ export interface PlacementComponent extends BaseComponent {
17
17
  * @returns The component instance for chaining
18
18
  */
19
19
  updateElementPositions: () => PlacementComponent;
20
+ /** Queue a placement update owned by this component lifecycle. */
21
+ schedulePositionUpdate: () => void;
20
22
  }
21
23
  /**
22
24
  * Handles dynamic positioning of textfield elements (label, prefix, suffix)
@@ -2,12 +2,24 @@ import { getInheritedBackground } from "../../../core/utils/background.js";
2
2
  export const withPlacement = () => (component) => {
3
3
  const PREFIX = component.config.prefix || "mtrl";
4
4
  const COMPONENT = component.config.componentName || "textfield";
5
+ let destroyed = false;
6
+ let updateTimer = null;
7
+ const schedulePositionUpdate = () => {
8
+ if (destroyed)
9
+ return;
10
+ if (updateTimer !== null)
11
+ clearTimeout(updateTimer);
12
+ updateTimer = setTimeout(() => {
13
+ updateTimer = null;
14
+ updateElementPositions();
15
+ }, 10);
16
+ };
5
17
  const bgSourceObservers = new WeakMap();
6
18
  const parentObservers = new WeakMap();
7
19
  const themeChangeHandlers = new WeakMap();
8
20
  const classObservers = new WeakMap();
9
21
  const updateElementPositions = () => {
10
- if (!component.element || !component.element.isConnected)
22
+ if (destroyed || !component.element || !component.element.isConnected)
11
23
  return component;
12
24
  const labelEl = component.element.querySelector(`.${PREFIX}-${COMPONENT}-label`);
13
25
  const prefixEl = component.element.querySelector(`.${PREFIX}-${COMPONENT}-prefix`);
@@ -110,7 +122,7 @@ export const withPlacement = () => (component) => {
110
122
  mutations.forEach((mutation) => {
111
123
  if (mutation.type === "attributes" &&
112
124
  mutation.attributeName === "class") {
113
- setTimeout(updateElementPositions, 10);
125
+ schedulePositionUpdate();
114
126
  }
115
127
  });
116
128
  });
@@ -121,13 +133,22 @@ export const withPlacement = () => (component) => {
121
133
  window.addEventListener("resize", updateElementPositions);
122
134
  classObservers.set(component.element, classObserver);
123
135
  };
124
- setTimeout(() => {
136
+ const initialization = setTimeout(() => {
137
+ if (destroyed)
138
+ return;
125
139
  setupEventListeners();
126
140
  updateElementPositions();
127
141
  }, 0);
128
142
  if ("lifecycle" in component && component.lifecycle?.destroy) {
129
143
  const originalDestroy = component.lifecycle.destroy;
130
144
  component.lifecycle.destroy = () => {
145
+ if (destroyed)
146
+ return;
147
+ destroyed = true;
148
+ clearTimeout(initialization);
149
+ if (updateTimer !== null)
150
+ clearTimeout(updateTimer);
151
+ updateTimer = null;
131
152
  window.removeEventListener("resize", updateElementPositions);
132
153
  const classObserver = classObservers.get(component.element);
133
154
  if (classObserver) {
@@ -154,6 +175,7 @@ export const withPlacement = () => (component) => {
154
175
  }
155
176
  return {
156
177
  ...component,
178
+ schedulePositionUpdate,
157
179
  updateElementPositions: () => {
158
180
  updateElementPositions();
159
181
  return component;
@@ -7,7 +7,7 @@ import { createBaseConfig, getElementConfig, getApiConfig } from "./config.js";
7
7
  const createTextfield = (config = {}) => {
8
8
  const baseConfig = createBaseConfig(config);
9
9
  try {
10
- const textfield = pipe(createBase, withEvents(), withElement(getElementConfig(baseConfig)), withVariant(baseConfig), withDensity(baseConfig), withTextInput(baseConfig), withTextLabel(baseConfig), withLeadingIcon(baseConfig), withTrailingIcon(baseConfig), withPrefixText(baseConfig), withSuffixText(baseConfig), withSupportingText(baseConfig), withError(baseConfig), withDisabled(baseConfig), withLifecycle(), withPlacement(), (comp) => withAPI(getApiConfig(comp))(comp))(baseConfig);
10
+ const textfield = pipe(createBase, withEvents(), withElement(getElementConfig(baseConfig)), withLifecycle(), withVariant(baseConfig), withDensity(baseConfig), withTextInput(baseConfig), withTextLabel(baseConfig), withLeadingIcon(baseConfig), withTrailingIcon(baseConfig), withPrefixText(baseConfig), withSuffixText(baseConfig), withSupportingText(baseConfig), withError(baseConfig), withDisabled(baseConfig), withPlacement(), (comp) => withAPI(getApiConfig(comp))(comp))(baseConfig);
11
11
  return textfield;
12
12
  }
13
13
  catch (error) {
@@ -118,7 +118,7 @@ export const withTextInput = (config = {}) => (component) => {
118
118
  }
119
119
  });
120
120
  updateInputState();
121
- setTimeout(() => {
121
+ const initialAutofillCheck = setTimeout(() => {
122
122
  checkForAutofill(false);
123
123
  }, 0);
124
124
  if (isMultiline) {
@@ -128,6 +128,7 @@ export const withTextInput = (config = {}) => (component) => {
128
128
  if (hasLifecycle(component)) {
129
129
  const originalDestroy = component.lifecycle.destroy;
130
130
  component.lifecycle.destroy = () => {
131
+ clearTimeout(initialAutofillCheck);
131
132
  if (autofillObserver) {
132
133
  autofillObserver.disconnect();
133
134
  }