mtrl 0.8.0-next.40 → 0.8.0-next.42
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/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +1 -0
- package/dist/components/navigation-rail/config.d.ts +2 -0
- package/dist/components/navigation-rail/config.js +14 -0
- package/dist/components/navigation-rail/constants.d.ts +9 -0
- package/dist/components/navigation-rail/constants.js +8 -0
- package/dist/components/navigation-rail/index.d.ts +2 -0
- package/dist/components/navigation-rail/index.js +1 -0
- package/dist/components/navigation-rail/navigation-rail.d.ts +7 -0
- package/dist/components/navigation-rail/navigation-rail.js +323 -0
- package/dist/components/navigation-rail/ripple.d.ts +2 -0
- package/dist/components/navigation-rail/ripple.js +41 -0
- package/dist/components/navigation-rail/types.d.ts +68 -0
- package/dist/components/navigation-rail/types.js +1 -0
- package/dist/core/compose/features/textlabel.js +6 -0
- package/dist/core/config/global.d.ts +2 -0
- package/dist/core/utils/theme.js +1 -1
- package/dist/index.cjs +12 -12
- package/dist/package.json +1 -1
- package/dist/styles/badge.css +2 -2
- package/dist/styles/base.css +2 -2
- package/dist/styles/bottom-app-bar.css +2 -2
- package/dist/styles/bottom-sheet.css +2 -2
- package/dist/styles/button-group.css +2 -2
- package/dist/styles/button.css +2 -2
- package/dist/styles/card.css +2 -2
- package/dist/styles/carousel.css +2 -2
- 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 +2 -2
- 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 +2 -2
- package/dist/styles/menu.css +2 -2
- package/dist/styles/navigation-rail.css +2 -0
- package/dist/styles/navigation-rail.d.ts +1 -0
- package/dist/styles/navigation-rail.js +2 -0
- package/dist/styles/navigation.css +2 -2
- package/dist/styles/progress.css +2 -2
- package/dist/styles/radios.css +2 -2
- package/dist/styles/search.css +2 -2
- 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 +2 -2
- package/dist/styles/snackbar.css +2 -2
- package/dist/styles/split-button.css +2 -2
- package/dist/styles/switch.css +2 -2
- package/dist/styles/tabs.css +2 -2
- package/dist/styles/textfield.css +2 -2
- package/dist/styles/timepicker.css +2 -2
- package/dist/styles/top-app-bar.css +2 -2
- package/dist/styles/utilities.css +2 -2
- package/dist/styles.css +2 -2
- package/dist/themes/autumn.css +1 -1
- package/dist/themes/baseline.css +2 -2
- 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 +2 -1
- package/src/styles/components/_navigation-rail.scss +179 -0
- package/src/styles/components/_textfield.scss +4 -1
- package/src/styles/main.scss +1 -0
- package/src/styles/themes/_baseline.scss +8 -0
|
@@ -26,6 +26,8 @@ export { default as createExtendedFab } from "./extended-fab/index.js";
|
|
|
26
26
|
export { default as createIconButton } from "./icon-button/index.js";
|
|
27
27
|
export { default as createList } from "./list/index.js";
|
|
28
28
|
export { default as createMenu } from "./menu/index.js";
|
|
29
|
+
export { default as createNavigationRail } from "./navigation-rail/index.js";
|
|
30
|
+
export type { NavigationRailConfig, NavigationRailComponent, NavigationRailItemConfig, NavigationRailSelectEvent, NavigationRailEvents } from "./navigation-rail/types.js";
|
|
29
31
|
export { default as createNavigation } from "./navigation/index.js";
|
|
30
32
|
export { default as createNavigationSystem } from "./navigation/system/index.js";
|
|
31
33
|
export { default as createProgress } from "./progress/index.js";
|
package/dist/components/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { default as createExtendedFab } from "./extended-fab/index.js";
|
|
|
17
17
|
export { default as createIconButton } from "./icon-button/index.js";
|
|
18
18
|
export { default as createList } from "./list/index.js";
|
|
19
19
|
export { default as createMenu } from "./menu/index.js";
|
|
20
|
+
export { default as createNavigationRail } from "./navigation-rail/index.js";
|
|
20
21
|
export { default as createNavigation } from "./navigation/index.js";
|
|
21
22
|
export { default as createNavigationSystem } from "./navigation/system/index.js";
|
|
22
23
|
export { default as createProgress } from "./progress/index.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createComponentConfig } from "../../core/config/component.js";
|
|
2
|
+
import { NAVIGATION_RAIL_DEFAULTS as defaults } from "./constants.js";
|
|
3
|
+
export const createBaseConfig = (config) => {
|
|
4
|
+
const result = createComponentConfig({
|
|
5
|
+
expanded: defaults.EXPANDED, expandedWidth: defaults.EXPANDED_WIDTH,
|
|
6
|
+
showToggle: defaults.SHOW_TOGGLE, ripple: true, items: [],
|
|
7
|
+
expandLabel: 'Expand navigation', collapseLabel: 'Collapse navigation',
|
|
8
|
+
}, config, 'navigation-rail');
|
|
9
|
+
const width = result.expandedWidth;
|
|
10
|
+
result.expandedWidth = Number.isFinite(width)
|
|
11
|
+
? Math.min(defaults.MAX_EXPANDED_WIDTH, Math.max(defaults.MIN_EXPANDED_WIDTH, width))
|
|
12
|
+
: defaults.EXPANDED_WIDTH;
|
|
13
|
+
return result;
|
|
14
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** M3 Expressive Android navigationrail/res/values/tokens.xml, token version 34.0.0. */
|
|
2
|
+
export declare const NAVIGATION_RAIL_DEFAULTS: {
|
|
3
|
+
readonly EXPANDED: false;
|
|
4
|
+
readonly COLLAPSED_WIDTH: 96;
|
|
5
|
+
readonly EXPANDED_WIDTH: 280;
|
|
6
|
+
readonly MIN_EXPANDED_WIDTH: 220;
|
|
7
|
+
readonly MAX_EXPANDED_WIDTH: 360;
|
|
8
|
+
readonly SHOW_TOGGLE: true;
|
|
9
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, default as createNavigationRail } from "./navigation-rail.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { NavigationRailConfig, NavigationRailComponent } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Creates an M3 Expressive navigation rail with stable destinations during expansion.
|
|
4
|
+
* Import its styles separately with `mtrl/styles/navigation-rail`.
|
|
5
|
+
* Links retain native browser navigation; onSelect can preventDefault for a router.
|
|
6
|
+
*/
|
|
7
|
+
export default function createNavigationRail(config?: NavigationRailConfig): NavigationRailComponent;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { pipe } from "../../core/compose/pipe.js";
|
|
2
|
+
import { createBase, withElement } from "../../core/compose/component.js";
|
|
3
|
+
import { withLifecycle } from "../../core/compose/features/lifecycle.js";
|
|
4
|
+
import { createElementConfig } from "../../core/config/component.js";
|
|
5
|
+
import { createEmitter } from "../../core/state/emitter.js";
|
|
6
|
+
import { mountRailRipple } from "./ripple.js";
|
|
7
|
+
import { createBaseConfig } from "./config.js";
|
|
8
|
+
const copyItems = (items) => {
|
|
9
|
+
const ids = new Set();
|
|
10
|
+
let selected = false;
|
|
11
|
+
return items.map(item => {
|
|
12
|
+
if (!item.id || !item.label || !item.icon || ids.has(item.id))
|
|
13
|
+
throw new Error('NavigationRail destinations require unique IDs, labels, and icons');
|
|
14
|
+
ids.add(item.id);
|
|
15
|
+
const active = !!item.active && !item.disabled && !selected;
|
|
16
|
+
selected || (selected = active);
|
|
17
|
+
return { ...item, active };
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
export default function createNavigationRail(config = {}) {
|
|
21
|
+
const options = createBaseConfig(config);
|
|
22
|
+
let items = copyItems(options.items || []);
|
|
23
|
+
const component = pipe(createBase, withElement(createElementConfig(options, { tag: options.layout === 'modal' ? 'dialog' : 'nav', attributes: { 'aria-label': options.ariaLabel || 'Primary navigation' } })), withLifecycle())(options);
|
|
24
|
+
const root = component.element;
|
|
25
|
+
const getClass = component.getClass;
|
|
26
|
+
const cls = (part) => getClass(`navigation-rail${part}`);
|
|
27
|
+
const emitter = createEmitter();
|
|
28
|
+
let expanded = !!options.expanded;
|
|
29
|
+
let destroyed = false;
|
|
30
|
+
let connectionObserver = null;
|
|
31
|
+
const dialog = options.layout === 'modal' ? root : null;
|
|
32
|
+
if (dialog)
|
|
33
|
+
root.classList.add(cls('--modal'));
|
|
34
|
+
if (options.hideWhenCollapsed)
|
|
35
|
+
root.classList.add(cls('--hide-collapsed'));
|
|
36
|
+
const nodes = new Map();
|
|
37
|
+
const destinations = document.createElement('div');
|
|
38
|
+
destinations.className = cls('__items');
|
|
39
|
+
const header = document.createElement('div');
|
|
40
|
+
header.className = cls('__header');
|
|
41
|
+
let toggle = null;
|
|
42
|
+
if (options.showToggle) {
|
|
43
|
+
toggle = document.createElement('button');
|
|
44
|
+
toggle.type = 'button';
|
|
45
|
+
toggle.className = cls('__toggle');
|
|
46
|
+
toggle.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>';
|
|
47
|
+
header.append(toggle);
|
|
48
|
+
}
|
|
49
|
+
if (options.header)
|
|
50
|
+
header.append(options.header);
|
|
51
|
+
if (header.childElementCount)
|
|
52
|
+
root.append(header);
|
|
53
|
+
root.append(destinations);
|
|
54
|
+
root.style.setProperty(`--${options.prefix}-navigation-rail-expanded-width`, `${options.expandedWidth}px`);
|
|
55
|
+
const updateBadge = (item, element) => {
|
|
56
|
+
let badge = element.querySelector(`.${cls('__badge')}`);
|
|
57
|
+
const visible = item.badge !== undefined && item.badge !== false && item.badge !== '';
|
|
58
|
+
if (!visible) {
|
|
59
|
+
badge?.remove();
|
|
60
|
+
element.setAttribute('aria-label', item.label);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!badge) {
|
|
64
|
+
badge = document.createElement('span');
|
|
65
|
+
badge.className = cls('__badge');
|
|
66
|
+
badge.setAttribute('aria-hidden', 'true');
|
|
67
|
+
element.querySelector(`.${cls("__content")}`).append(badge);
|
|
68
|
+
}
|
|
69
|
+
badge.classList.toggle(cls('__badge--dot'), item.badge === true);
|
|
70
|
+
badge.textContent = item.badge === true ? '' : String(item.badge);
|
|
71
|
+
element.setAttribute('aria-label', `${item.label}, ${item.badgeLabel || (item.badge === true ? 'New activity' : String(item.badge))}`);
|
|
72
|
+
};
|
|
73
|
+
const updateSelection = () => {
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
const element = nodes.get(item.id);
|
|
76
|
+
element.classList.toggle(cls('__item--active'), !!item.active);
|
|
77
|
+
if (item.active)
|
|
78
|
+
element.setAttribute('aria-current', 'page');
|
|
79
|
+
else
|
|
80
|
+
element.removeAttribute('aria-current');
|
|
81
|
+
const icon = element.querySelector(`.${cls('__icon')}`);
|
|
82
|
+
icon.innerHTML = item.active && item.activeIcon ? item.activeIcon : item.icon;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const render = () => {
|
|
86
|
+
const focusedId = [...nodes].find(([, element]) => element === document.activeElement)?.[0];
|
|
87
|
+
nodes.clear();
|
|
88
|
+
destinations.replaceChildren();
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
const element = document.createElement(item.href ? 'a' : 'button');
|
|
91
|
+
if (item.href) {
|
|
92
|
+
if (!item.disabled)
|
|
93
|
+
element.setAttribute("href", item.href);
|
|
94
|
+
else {
|
|
95
|
+
element.setAttribute('role', 'link');
|
|
96
|
+
element.tabIndex = -1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
element.setAttribute('type', 'button');
|
|
101
|
+
if (item.disabled)
|
|
102
|
+
element.setAttribute('disabled', '');
|
|
103
|
+
}
|
|
104
|
+
if (item.disabled)
|
|
105
|
+
element.setAttribute('aria-disabled', 'true');
|
|
106
|
+
element.className = cls('__item');
|
|
107
|
+
element.dataset.id = item.id;
|
|
108
|
+
const indicator = document.createElement('span');
|
|
109
|
+
indicator.className = cls('__indicator');
|
|
110
|
+
indicator.setAttribute('aria-hidden', 'true');
|
|
111
|
+
const icon = document.createElement('span');
|
|
112
|
+
icon.className = cls('__icon');
|
|
113
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
114
|
+
const label = document.createElement('span');
|
|
115
|
+
label.className = cls('__label');
|
|
116
|
+
label.textContent = item.label;
|
|
117
|
+
const content = document.createElement('span');
|
|
118
|
+
content.className = cls('__content');
|
|
119
|
+
content.append(indicator, icon, label);
|
|
120
|
+
element.append(content);
|
|
121
|
+
updateBadge(item, element);
|
|
122
|
+
nodes.set(item.id, element);
|
|
123
|
+
destinations.append(element);
|
|
124
|
+
}
|
|
125
|
+
updateSelection();
|
|
126
|
+
if (focusedId) {
|
|
127
|
+
const previous = nodes.get(focusedId);
|
|
128
|
+
const target = previous && !previous.hasAttribute('aria-disabled') ? previous : [...nodes.values()].find(element => !element.hasAttribute('aria-disabled')) || toggle;
|
|
129
|
+
target?.focus();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const showModal = () => {
|
|
133
|
+
if (!dialog || destroyed || !expanded)
|
|
134
|
+
return;
|
|
135
|
+
if (root.isConnected) {
|
|
136
|
+
connectionObserver?.disconnect();
|
|
137
|
+
connectionObserver = null;
|
|
138
|
+
if (!dialog.open)
|
|
139
|
+
dialog.showModal();
|
|
140
|
+
}
|
|
141
|
+
else if (!connectionObserver) {
|
|
142
|
+
connectionObserver = new MutationObserver(showModal);
|
|
143
|
+
connectionObserver.observe(root.ownerDocument, { childList: true, subtree: true });
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const synchronize = () => {
|
|
147
|
+
root.classList.toggle(cls('--expanded'), expanded);
|
|
148
|
+
toggle?.setAttribute('aria-expanded', String(expanded));
|
|
149
|
+
toggle?.setAttribute('aria-label', (expanded ? options.collapseLabel : options.expandLabel));
|
|
150
|
+
if (dialog) {
|
|
151
|
+
if (expanded)
|
|
152
|
+
showModal();
|
|
153
|
+
else {
|
|
154
|
+
connectionObserver?.disconnect();
|
|
155
|
+
connectionObserver = null;
|
|
156
|
+
if (dialog.open)
|
|
157
|
+
dialog.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (options.hideWhenCollapsed) {
|
|
161
|
+
root.toggleAttribute('inert', !expanded);
|
|
162
|
+
root.setAttribute('aria-hidden', String(!expanded));
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const setExpanded = (value) => {
|
|
166
|
+
if (destroyed || expanded === value)
|
|
167
|
+
return api;
|
|
168
|
+
expanded = value;
|
|
169
|
+
synchronize();
|
|
170
|
+
emitter.emit(value ? 'expand' : 'collapse', { expanded: value });
|
|
171
|
+
if (!destroyed) {
|
|
172
|
+
if (value)
|
|
173
|
+
options.onExpand?.();
|
|
174
|
+
else
|
|
175
|
+
options.onCollapse?.();
|
|
176
|
+
}
|
|
177
|
+
return api;
|
|
178
|
+
};
|
|
179
|
+
const handleClick = (event) => {
|
|
180
|
+
if (destroyed || event.defaultPrevented)
|
|
181
|
+
return;
|
|
182
|
+
const target = event.target;
|
|
183
|
+
if (dialog && target === root) {
|
|
184
|
+
const rect = root.getBoundingClientRect();
|
|
185
|
+
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)
|
|
186
|
+
api.collapse();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (toggle?.contains(target)) {
|
|
190
|
+
api.toggle();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const element = target.closest(`.${cls('__item')}`);
|
|
194
|
+
if (!element || !destinations.contains(element))
|
|
195
|
+
return;
|
|
196
|
+
const index = items.findIndex(item => nodes.get(item.id) === element), item = items[index];
|
|
197
|
+
if (!item || item.disabled) {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (item.href && (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0))
|
|
202
|
+
return;
|
|
203
|
+
api.setActive(item.id);
|
|
204
|
+
const detail = { id: item.id, index, originalEvent: event };
|
|
205
|
+
emitter.emit('select', detail);
|
|
206
|
+
if (!destroyed)
|
|
207
|
+
options.onSelect?.(detail);
|
|
208
|
+
};
|
|
209
|
+
const handleKeydown = (event) => {
|
|
210
|
+
const enabled = [...nodes.values()].filter(element => !element.hasAttribute('aria-disabled'));
|
|
211
|
+
const index = enabled.indexOf(document.activeElement);
|
|
212
|
+
if (index < 0 || event.altKey || event.ctrlKey || event.metaKey)
|
|
213
|
+
return;
|
|
214
|
+
let next;
|
|
215
|
+
switch (event.key) {
|
|
216
|
+
case 'ArrowDown':
|
|
217
|
+
next = (index + 1) % enabled.length;
|
|
218
|
+
break;
|
|
219
|
+
case 'ArrowUp':
|
|
220
|
+
next = (index + enabled.length - 1) % enabled.length;
|
|
221
|
+
break;
|
|
222
|
+
case 'Home':
|
|
223
|
+
next = 0;
|
|
224
|
+
break;
|
|
225
|
+
case 'End':
|
|
226
|
+
next = enabled.length - 1;
|
|
227
|
+
break;
|
|
228
|
+
default: return;
|
|
229
|
+
}
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
enabled[next].focus();
|
|
232
|
+
};
|
|
233
|
+
const modalTab = (event) => {
|
|
234
|
+
if (event.key !== 'Tab' || event.defaultPrevented)
|
|
235
|
+
return;
|
|
236
|
+
const focusable = [...root.querySelectorAll('button, a[href], input, select, textarea, [tabindex]')].filter(element => element.tabIndex >= 0 && !element.matches(':disabled, [aria-disabled="true"]') && element.getClientRects().length > 0);
|
|
237
|
+
const first = focusable[0], last = focusable.at(-1), focused = root.ownerDocument.activeElement;
|
|
238
|
+
if (!first) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
root.focus();
|
|
241
|
+
}
|
|
242
|
+
else if (event.shiftKey && (focused === first || focused === root)) {
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
last.focus();
|
|
245
|
+
}
|
|
246
|
+
else if (!event.shiftKey && (focused === last || focused === root)) {
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
first.focus();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
dialog?.addEventListener('keydown', modalTab);
|
|
252
|
+
const dismiss = (event) => { event.preventDefault(); api.collapse(); };
|
|
253
|
+
dialog?.addEventListener('cancel', dismiss);
|
|
254
|
+
const closed = () => {
|
|
255
|
+
if (!dialog?.open)
|
|
256
|
+
api.collapse();
|
|
257
|
+
};
|
|
258
|
+
dialog?.addEventListener('close', closed);
|
|
259
|
+
root.addEventListener('click', handleClick);
|
|
260
|
+
destinations.addEventListener('keydown', handleKeydown);
|
|
261
|
+
const removeRipple = options.ripple ? mountRailRipple(root, cls) : undefined;
|
|
262
|
+
const originalDestroy = component.lifecycle.destroy.bind(component.lifecycle);
|
|
263
|
+
const destroy = () => {
|
|
264
|
+
if (destroyed)
|
|
265
|
+
return;
|
|
266
|
+
destroyed = true;
|
|
267
|
+
connectionObserver?.disconnect();
|
|
268
|
+
connectionObserver = null;
|
|
269
|
+
dialog?.removeEventListener('keydown', modalTab);
|
|
270
|
+
dialog?.removeEventListener('cancel', dismiss);
|
|
271
|
+
dialog?.removeEventListener('close', closed);
|
|
272
|
+
if (dialog?.open)
|
|
273
|
+
dialog.close();
|
|
274
|
+
root.removeEventListener('click', handleClick);
|
|
275
|
+
destinations.removeEventListener('keydown', handleKeydown);
|
|
276
|
+
removeRipple?.();
|
|
277
|
+
nodes.clear();
|
|
278
|
+
emitter.clear();
|
|
279
|
+
originalDestroy();
|
|
280
|
+
};
|
|
281
|
+
component.lifecycle.destroy = destroy;
|
|
282
|
+
const api = {
|
|
283
|
+
element: root, getClass, lifecycle: component.lifecycle, destroy,
|
|
284
|
+
expand: () => setExpanded(true), collapse: () => setExpanded(false), toggle: () => setExpanded(!expanded), isExpanded: () => expanded,
|
|
285
|
+
getActive: () => items.find(item => item.active)?.id || null,
|
|
286
|
+
setActive(id) {
|
|
287
|
+
if (destroyed || (id !== null && !items.some(item => item.id === id && !item.disabled)))
|
|
288
|
+
return api;
|
|
289
|
+
items = items.map(item => ({ ...item, active: item.id === id }));
|
|
290
|
+
updateSelection();
|
|
291
|
+
return api;
|
|
292
|
+
},
|
|
293
|
+
getItems: () => items.map(item => ({ ...item })),
|
|
294
|
+
setItems(value) {
|
|
295
|
+
if (!destroyed) {
|
|
296
|
+
const next = copyItems(value);
|
|
297
|
+
items = next;
|
|
298
|
+
render();
|
|
299
|
+
}
|
|
300
|
+
return api;
|
|
301
|
+
},
|
|
302
|
+
setBadge(id, badge, label) {
|
|
303
|
+
if (destroyed)
|
|
304
|
+
return api;
|
|
305
|
+
const item = items.find(item => item.id === id);
|
|
306
|
+
if (item) {
|
|
307
|
+
item.badge = badge;
|
|
308
|
+
item.badgeLabel = label;
|
|
309
|
+
updateBadge(item, nodes.get(id));
|
|
310
|
+
}
|
|
311
|
+
return api;
|
|
312
|
+
},
|
|
313
|
+
on(event, handler) {
|
|
314
|
+
if (!destroyed)
|
|
315
|
+
emitter.on(event, handler);
|
|
316
|
+
return api;
|
|
317
|
+
},
|
|
318
|
+
off(event, handler) { emitter.off(event, handler); return api; },
|
|
319
|
+
};
|
|
320
|
+
render();
|
|
321
|
+
synchronize();
|
|
322
|
+
return api;
|
|
323
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function mountRailRipple(root, className) {
|
|
2
|
+
const waveClass = className('__ripple');
|
|
3
|
+
const press = (event) => {
|
|
4
|
+
const target = event.target.closest(`.${className('__item')}, .${className('__toggle')}`);
|
|
5
|
+
if (!target || !root.contains(target) || target.hasAttribute('aria-disabled'))
|
|
6
|
+
return;
|
|
7
|
+
if (event instanceof KeyboardEvent && (event.repeat || !['Enter', ' '].includes(event.key)))
|
|
8
|
+
return;
|
|
9
|
+
if (event.type === 'pointerdown' && event.button !== 0)
|
|
10
|
+
return;
|
|
11
|
+
if (root.ownerDocument.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
|
12
|
+
return;
|
|
13
|
+
const host = target.querySelector(`.${className('__indicator')}`) || target;
|
|
14
|
+
const bounds = host.getBoundingClientRect();
|
|
15
|
+
const wave = document.createElement('span');
|
|
16
|
+
wave.className = waveClass;
|
|
17
|
+
wave.setAttribute('aria-hidden', 'true');
|
|
18
|
+
const size = Math.max(bounds.width, bounds.height) * 2;
|
|
19
|
+
const pointer = event instanceof MouseEvent;
|
|
20
|
+
wave.style.width = wave.style.height = `${size}px`;
|
|
21
|
+
wave.style.left = `${(pointer ? event.clientX - bounds.left : bounds.width / 2) - size / 2}px`;
|
|
22
|
+
wave.style.top = `${(pointer ? event.clientY - bounds.top : bounds.height / 2) - size / 2}px`;
|
|
23
|
+
host.append(wave);
|
|
24
|
+
};
|
|
25
|
+
const finish = (event) => {
|
|
26
|
+
const target = event.target;
|
|
27
|
+
if (target.classList.contains(waveClass))
|
|
28
|
+
target.remove();
|
|
29
|
+
};
|
|
30
|
+
root.addEventListener('pointerdown', press);
|
|
31
|
+
root.addEventListener('keydown', press);
|
|
32
|
+
root.addEventListener('animationend', finish);
|
|
33
|
+
root.addEventListener('animationcancel', finish);
|
|
34
|
+
return () => {
|
|
35
|
+
root.removeEventListener('pointerdown', press);
|
|
36
|
+
root.removeEventListener('keydown', press);
|
|
37
|
+
root.removeEventListener('animationend', finish);
|
|
38
|
+
root.removeEventListener('animationcancel', finish);
|
|
39
|
+
root.querySelectorAll(`.${waveClass}`).forEach(wave => wave.remove());
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { BaseComponentConfig } from "../../core/config/component.js";
|
|
2
|
+
/** A primary destination. Icons are trusted SVG/HTML supplied by the application. */
|
|
3
|
+
export interface NavigationRailItemConfig {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon: string;
|
|
7
|
+
activeIcon?: string;
|
|
8
|
+
href?: string;
|
|
9
|
+
badge?: string | number | boolean;
|
|
10
|
+
/** Accessible description for a dot badge or count, e.g. "3 unread messages". */
|
|
11
|
+
badgeLabel?: string;
|
|
12
|
+
active?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface NavigationRailSelectEvent {
|
|
16
|
+
id: string;
|
|
17
|
+
index: number;
|
|
18
|
+
originalEvent: MouseEvent;
|
|
19
|
+
}
|
|
20
|
+
export interface NavigationRailEvents {
|
|
21
|
+
select: NavigationRailSelectEvent;
|
|
22
|
+
expand: {
|
|
23
|
+
expanded: true;
|
|
24
|
+
};
|
|
25
|
+
collapse: {
|
|
26
|
+
expanded: false;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Standalone, non-modal M3 Expressive navigation rail. */
|
|
30
|
+
export interface NavigationRailConfig extends BaseComponentConfig {
|
|
31
|
+
items?: NavigationRailItemConfig[];
|
|
32
|
+
expanded?: boolean;
|
|
33
|
+
/** Standard rail occupies layout space; modal expansion uses a native modal dialog. */
|
|
34
|
+
layout?: 'standard' | 'modal';
|
|
35
|
+
/** Hide the standard rail when collapsed. Modal rails always hide when collapsed. */
|
|
36
|
+
hideWhenCollapsed?: boolean;
|
|
37
|
+
/** Expanded width in CSS pixels, clamped to the Android token range 220–360. Default 280. */
|
|
38
|
+
expandedWidth?: number;
|
|
39
|
+
/** Show the accessible expand/collapse menu button. Default true. */
|
|
40
|
+
showToggle?: boolean;
|
|
41
|
+
expandLabel?: string;
|
|
42
|
+
collapseLabel?: string;
|
|
43
|
+
/** Optional application-owned header, such as a FAB. Its lifecycle stays with its owner. */
|
|
44
|
+
header?: HTMLElement;
|
|
45
|
+
ripple?: boolean;
|
|
46
|
+
onSelect?: (event: NavigationRailSelectEvent) => void;
|
|
47
|
+
onExpand?: () => void;
|
|
48
|
+
onCollapse?: () => void;
|
|
49
|
+
}
|
|
50
|
+
export interface NavigationRailComponent {
|
|
51
|
+
element: HTMLElement;
|
|
52
|
+
getClass: (name: string) => string;
|
|
53
|
+
expand: () => NavigationRailComponent;
|
|
54
|
+
collapse: () => NavigationRailComponent;
|
|
55
|
+
toggle: () => NavigationRailComponent;
|
|
56
|
+
isExpanded: () => boolean;
|
|
57
|
+
setActive: (id: string | null) => NavigationRailComponent;
|
|
58
|
+
getActive: () => string | null;
|
|
59
|
+
setItems: (items: NavigationRailItemConfig[]) => NavigationRailComponent;
|
|
60
|
+
getItems: () => NavigationRailItemConfig[];
|
|
61
|
+
setBadge: (id: string, badge: NavigationRailItemConfig['badge'], label?: string) => NavigationRailComponent;
|
|
62
|
+
on: <K extends keyof NavigationRailEvents>(event: K, handler: (detail: NavigationRailEvents[K]) => void) => NavigationRailComponent;
|
|
63
|
+
off: <K extends keyof NavigationRailEvents>(event: K, handler: (detail: NavigationRailEvents[K]) => void) => NavigationRailComponent;
|
|
64
|
+
destroy: () => void;
|
|
65
|
+
lifecycle: {
|
|
66
|
+
destroy: () => void;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -5,6 +5,12 @@ export const withTextLabel = (config = {}) => (component) => {
|
|
|
5
5
|
const position = config.labelPosition || "start";
|
|
6
6
|
labelElement.className = `${config.prefix}-${config.componentName}-label ${config.prefix}-${config.componentName}-label--${position}`;
|
|
7
7
|
labelElement.textContent = config.label;
|
|
8
|
+
const input = component.input;
|
|
9
|
+
if (input instanceof HTMLElement) {
|
|
10
|
+
if (!input.id)
|
|
11
|
+
input.id = `${config.prefix}-${config.componentName}-${Math.random().toString(36).slice(2, 9)}`;
|
|
12
|
+
labelElement.htmlFor = input.id;
|
|
13
|
+
}
|
|
8
14
|
if (position === "start") {
|
|
9
15
|
if (component.element.firstChild) {
|
|
10
16
|
component.element.insertBefore(labelElement, component.element.firstChild);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* without adding complexity or performance overhead.
|
|
6
6
|
*/
|
|
7
7
|
import { ButtonConfig } from "../../components/button/types.js";
|
|
8
|
+
import type { NavigationRailConfig } from "../../components/navigation-rail/types.js";
|
|
8
9
|
import { TextfieldConfig } from "../../components/textfield/types.js";
|
|
9
10
|
/**
|
|
10
11
|
* Component configuration map
|
|
@@ -13,6 +14,7 @@ import { TextfieldConfig } from "../../components/textfield/types.js";
|
|
|
13
14
|
* For components not yet integrated, use Partial<any> as a placeholder
|
|
14
15
|
*/
|
|
15
16
|
export interface ComponentConfigMap {
|
|
17
|
+
"navigation-rail"?: Partial<NavigationRailConfig>;
|
|
16
18
|
button?: Partial<ButtonConfig>;
|
|
17
19
|
textfield?: Partial<TextfieldConfig>;
|
|
18
20
|
checkbox?: Partial<any>;
|
package/dist/core/utils/theme.js
CHANGED
|
@@ -68,7 +68,7 @@ export function getThemeColor(varName, options) {
|
|
|
68
68
|
return value;
|
|
69
69
|
}
|
|
70
70
|
if (typeof window !== 'undefined') {
|
|
71
|
-
window.addEventListener('
|
|
71
|
+
window.addEventListener('pagehide', () => {
|
|
72
72
|
if (themeObserver) {
|
|
73
73
|
themeObserver.disconnect();
|
|
74
74
|
themeObserver = null;
|