vanilla-jui 1.3.10 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2927 -0
- package/dist/index.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/style.css +1 -1
- package/package.json +4 -3
- package/src/components/accordion.ts +522 -0
- package/src/components/drop.ts +497 -0
- package/src/components/flow.ts +1579 -0
- package/src/components/form.ts +953 -0
- package/src/components/{icons.js → icons.ts} +24 -11
- package/src/components/menu.ts +615 -0
- package/src/components/{modal.js → modal.ts} +560 -237
- package/src/components/offcanvas.ts +496 -0
- package/src/components/{pagination.js → pagination.ts} +251 -76
- package/src/components/parabola.ts +335 -0
- package/src/components/{sticky.js → sticky.ts} +123 -98
- package/src/components/{swiper.js → swiper.ts} +510 -232
- package/src/components/tabs.ts +984 -0
- package/src/components/theme.ts +437 -0
- package/src/components/toast.ts +371 -0
- package/src/components/toc.ts +385 -0
- package/src/components/tooltip.ts +179 -0
- package/src/components/validator.ts +596 -0
- package/src/core/{Component.js → Component.ts} +134 -27
- package/src/css/accordion.css +2 -2
- package/src/css/animation.css +31 -1
- package/src/css/breadcrumb.css +1 -1
- package/src/css/button.css +5 -5
- package/src/css/card.css +7 -7
- package/src/css/color.css +865 -1028
- package/src/css/color_p3.css +871 -1036
- package/src/css/components.css +1 -0
- package/src/css/drop.css +1 -1
- package/src/css/flow.css +1 -1
- package/src/css/form.css +21 -21
- package/src/css/gradient.css +17 -4
- package/src/css/icon.css +17 -1
- package/src/css/index.css +3 -3
- package/src/css/layout.css +1 -1
- package/src/css/menu.css +27 -27
- package/src/css/modal.css +10 -27
- package/src/css/offcanvas.css +2 -2
- package/src/css/pagination.css +1 -1
- package/src/css/popup.css +2 -2
- package/src/css/swiper.css +12 -12
- package/src/css/tabs.css +3 -2
- package/src/css/tag.css +3 -4
- package/src/css/theme-palette.css +6 -6
- package/src/css/tip.css +1 -1
- package/src/css/toast.css +8 -8
- package/src/css/tokens.css +197 -226
- package/src/css/toolbar.css +3 -3
- package/src/css/tooltip.css +1 -1
- package/src/index.ts +37 -0
- package/src/locales/{index.js → index.ts} +1 -2
- package/src/types.d.ts +1 -0
- package/src/utilities/browser.ts +131 -0
- package/src/utilities/cache.ts +57 -0
- package/src/utilities/core.ts +614 -0
- package/src/utilities/dom.ts +468 -0
- package/src/utilities/events.ts +106 -0
- package/src/utilities/http.ts +35 -0
- package/src/utilities/storage.ts +46 -0
- package/src/components/accordion.js +0 -373
- package/src/components/drop.js +0 -437
- package/src/components/flow.js +0 -1133
- package/src/components/form.js +0 -585
- package/src/components/menu.js +0 -422
- package/src/components/offcanvas.js +0 -332
- package/src/components/parabola.js +0 -295
- package/src/components/tabs.js +0 -746
- package/src/components/theme.js +0 -353
- package/src/components/toast.js +0 -329
- package/src/components/toc.js +0 -226
- package/src/components/tooltip.js +0 -86
- package/src/components/validator.js +0 -586
- package/src/css/toast.min.css +0 -1
- package/src/index.js +0 -45
- package/src/locales/index.min.js +0 -1
- package/src/utilities/browser.js +0 -111
- package/src/utilities/cache.js +0 -159
- package/src/utilities/core.js +0 -502
- package/src/utilities/debug.js +0 -103
- package/src/utilities/dom.js +0 -313
- package/src/utilities/events.js +0 -69
- package/src/utilities/http.js +0 -30
- package/src/utilities/legacy-dom.js +0 -179
- package/src/utilities/storage.js +0 -46
- /package/src/{components/components.js → css/themes/index.css} +0 -0
- /package/src/icons/{index.js → index.ts} +0 -0
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
import iconPath from '../icons/index.
|
|
2
|
-
import { requireRenderDOM } from '../utilities/dom.js';
|
|
1
|
+
import iconPath from '../icons/index.ts';
|
|
3
2
|
|
|
4
3
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
export type IconPathMap = Record<string, string>;
|
|
6
|
+
export type IconName = string;
|
|
7
|
+
export type IconAttributeValue = string | number | boolean | null | undefined;
|
|
8
|
+
export interface IconProps {
|
|
9
|
+
className?: IconAttributeValue;
|
|
10
|
+
[attribute: string]: IconAttributeValue;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const defaultSvgObjects: IconPathMap = iconPath;
|
|
7
14
|
|
|
8
|
-
function assertIconName(name) {
|
|
15
|
+
function assertIconName(name: IconName): void {
|
|
9
16
|
if (!(name in defaultSvgObjects)) {
|
|
10
17
|
throw new Error(`Icon "${name}" not found.`);
|
|
11
18
|
}
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
function svgMarkup(name) {
|
|
21
|
+
function svgMarkup(name: IconName): string {
|
|
15
22
|
assertIconName(name);
|
|
16
23
|
return `<svg xmlns="${SVG_NS}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">${defaultSvgObjects[name]}</svg>`;
|
|
17
24
|
}
|
|
18
25
|
|
|
26
|
+
function isSVGElement(value: Element | null): value is SVGElement {
|
|
27
|
+
return value !== null && value.namespaceURI === SVG_NS;
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* 获取内置 SVG 图标节点。
|
|
21
32
|
*
|
|
@@ -25,13 +36,15 @@ function svgMarkup(name) {
|
|
|
25
36
|
* @returns {SVGElement}
|
|
26
37
|
* @throws {Error} 图标不存在或非 DOM 环境时抛出。
|
|
27
38
|
*/
|
|
28
|
-
export function icon(name, props = {}) {
|
|
29
|
-
requireRenderDOM('icon()');
|
|
30
|
-
|
|
39
|
+
export function icon(name: IconName, props: IconProps = {}): SVGElement {
|
|
31
40
|
const template = document.createElement('template');
|
|
32
41
|
template.innerHTML = svgMarkup(name);
|
|
33
42
|
const svg = template.content.firstElementChild;
|
|
34
43
|
|
|
44
|
+
if (!isSVGElement(svg)) {
|
|
45
|
+
throw new Error(`Icon "${name}" cannot be rendered.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
for (const [key, value] of Object.entries(props || {})) {
|
|
36
49
|
if (value == null || value === false) continue;
|
|
37
50
|
const attr = key === 'className' ? 'class' : key;
|
|
@@ -48,7 +61,7 @@ export function icon(name, props = {}) {
|
|
|
48
61
|
* @param {string} name 图标名称。
|
|
49
62
|
* @returns {string}
|
|
50
63
|
*/
|
|
51
|
-
export function iconHtml(name) {
|
|
64
|
+
export function iconHtml(name: IconName): string {
|
|
52
65
|
return svgMarkup(name);
|
|
53
66
|
}
|
|
54
67
|
|
|
@@ -58,7 +71,7 @@ export const iconMarkup = iconHtml;
|
|
|
58
71
|
* 获取当前已注册图标的 path 片段浅拷贝。
|
|
59
72
|
* @returns {Record<string, string>}
|
|
60
73
|
*/
|
|
61
|
-
export function getRegistedIconPath() {
|
|
74
|
+
export function getRegistedIconPath(): IconPathMap {
|
|
62
75
|
return { ...defaultSvgObjects };
|
|
63
76
|
}
|
|
64
77
|
|
|
@@ -69,7 +82,7 @@ export function getRegistedIconPath() {
|
|
|
69
82
|
* @param {Record<string, string>} svgPathObjects 图标名称到 SVG path 的映射。
|
|
70
83
|
* @returns {void}
|
|
71
84
|
*/
|
|
72
|
-
export function addIcons(svgPathObjects) {
|
|
85
|
+
export function addIcons(svgPathObjects: IconPathMap): void {
|
|
73
86
|
if (typeof svgPathObjects !== 'object' || svgPathObjects === null) {
|
|
74
87
|
throw new Error('Icons must be a valid object.');
|
|
75
88
|
}
|
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { render, jsx } from 'vanilla-signal';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ResolveSchema,
|
|
5
|
+
randomId,
|
|
6
|
+
resolveProps,
|
|
7
|
+
validateParam,
|
|
8
|
+
} from '../utilities/core.ts';
|
|
9
|
+
import { all, type DOMReference, q, resolveElement } from '../utilities/dom.ts';
|
|
10
|
+
import { createEventManager, type IEventManager } from '../utilities/events.ts';
|
|
11
|
+
import { icon } from './icons.ts';
|
|
12
|
+
|
|
13
|
+
export type MenuType = string;
|
|
14
|
+
export type MenuItemId = string | number;
|
|
15
|
+
|
|
16
|
+
export interface MenuClassNames {
|
|
17
|
+
root: string;
|
|
18
|
+
list: string;
|
|
19
|
+
item: string;
|
|
20
|
+
hasChildren: string;
|
|
21
|
+
link: string;
|
|
22
|
+
subMenu: string;
|
|
23
|
+
back: string;
|
|
24
|
+
active: string;
|
|
25
|
+
icon: string;
|
|
26
|
+
iconPrefix: string;
|
|
27
|
+
text: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type MenuClassNameConfig = Partial<MenuClassNames>;
|
|
31
|
+
|
|
32
|
+
export interface MenuItem extends Record<string, unknown> {
|
|
33
|
+
id?: MenuItemId;
|
|
34
|
+
title: string | number;
|
|
35
|
+
url?: string;
|
|
36
|
+
target?: string;
|
|
37
|
+
classes?: string[];
|
|
38
|
+
children?: MenuItem[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MenuOptions extends Record<string, unknown> {
|
|
42
|
+
type?: MenuType;
|
|
43
|
+
id?: string;
|
|
44
|
+
items?: MenuItem[];
|
|
45
|
+
backText?: string;
|
|
46
|
+
className?: MenuClassNameConfig;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ResolvedMenuOptions extends Record<string, unknown> {
|
|
50
|
+
type: MenuType;
|
|
51
|
+
id: string;
|
|
52
|
+
items: MenuItem[];
|
|
53
|
+
backText: string;
|
|
54
|
+
className: MenuClassNames;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface MenuDOM {
|
|
58
|
+
root: HTMLElement | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface MenuCleanup {
|
|
62
|
+
events: IEventManager;
|
|
63
|
+
items?: (() => void) | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DEFAULT_CLASS_NAMES: MenuClassNames = {
|
|
67
|
+
root: '',
|
|
68
|
+
list: 'menu',
|
|
69
|
+
item: 'menu-item',
|
|
70
|
+
hasChildren: 'menu-item-has-children',
|
|
71
|
+
link: 'menu-link',
|
|
72
|
+
subMenu: 'sub-menu',
|
|
73
|
+
back: 'back',
|
|
74
|
+
active: 'is-active',
|
|
75
|
+
icon: 'el-icon',
|
|
76
|
+
iconPrefix: 'el-prefix',
|
|
77
|
+
text: 'menu-text',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const MENU_OPTIONS_SCHEMA = {
|
|
81
|
+
type: { default: 'mobile', type: 'string' },
|
|
82
|
+
id: {
|
|
83
|
+
default: '',
|
|
84
|
+
type: 'string',
|
|
85
|
+
normalize: (value: unknown) => {
|
|
86
|
+
if (typeof value !== 'string') return value;
|
|
87
|
+
return value.trim() === '' ? randomId() : value.trim();
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
items: { default: [], type: 'array' },
|
|
91
|
+
backText: { default: 'Back', type: 'string' },
|
|
92
|
+
className: {
|
|
93
|
+
default: DEFAULT_CLASS_NAMES,
|
|
94
|
+
type: 'object',
|
|
95
|
+
normalize: (value: unknown) => ({
|
|
96
|
+
...DEFAULT_CLASS_NAMES,
|
|
97
|
+
...(value && typeof value === 'object' ? value : {}),
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
} satisfies ResolveSchema<MenuOptions>;
|
|
101
|
+
|
|
102
|
+
const MENU_ITEMS_RULE = { type: 'array' };
|
|
103
|
+
|
|
104
|
+
function normalizeOptions(options: MenuOptions = {}): ResolvedMenuOptions {
|
|
105
|
+
const props = resolveProps(
|
|
106
|
+
options,
|
|
107
|
+
MENU_OPTIONS_SCHEMA,
|
|
108
|
+
'Menu.options'
|
|
109
|
+
) as ResolvedMenuOptions;
|
|
110
|
+
return {
|
|
111
|
+
...props,
|
|
112
|
+
items: props.items.slice(),
|
|
113
|
+
className: { ...props.className },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function classList(...tokens: Array<string | null | undefined>): string {
|
|
118
|
+
return tokens.filter(Boolean).join(' ');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function itemId(id: MenuItemId | undefined): string {
|
|
122
|
+
return `menu-item-${id ?? randomId()}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isListElement(element: Element): element is HTMLUListElement {
|
|
126
|
+
const tag = element.tagName.toLowerCase();
|
|
127
|
+
return tag === 'ul' || tag === 'ol';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isMenuItem(value: unknown): value is MenuItem {
|
|
131
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const item = value as Partial<MenuItem>;
|
|
136
|
+
return (
|
|
137
|
+
(typeof item.title === 'string' || typeof item.title === 'number') &&
|
|
138
|
+
(item.id == null ||
|
|
139
|
+
typeof item.id === 'string' ||
|
|
140
|
+
typeof item.id === 'number') &&
|
|
141
|
+
(item.url == null || typeof item.url === 'string') &&
|
|
142
|
+
(item.target == null || typeof item.target === 'string') &&
|
|
143
|
+
(item.classes == null ||
|
|
144
|
+
(Array.isArray(item.classes) &&
|
|
145
|
+
item.classes.every((className) => typeof className === 'string'))) &&
|
|
146
|
+
(item.children == null ||
|
|
147
|
+
(Array.isArray(item.children) && item.children.every(isMenuItem)))
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findMenuList(root: HTMLElement): HTMLElement | null {
|
|
152
|
+
if (root.hasAttribute('data-menu-list')) return root;
|
|
153
|
+
if (isListElement(root)) return root;
|
|
154
|
+
return q<HTMLElement>('[data-menu-list]', root);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function findDirectMenuLink(menuItem: HTMLElement): HTMLElement | null {
|
|
158
|
+
return (
|
|
159
|
+
all<HTMLElement>('[data-menu-link]', menuItem).find(
|
|
160
|
+
(link) => link.parentElement === menuItem
|
|
161
|
+
) || null
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function findDirectSubMenu(menuItem: HTMLElement): HTMLElement | null {
|
|
166
|
+
return (
|
|
167
|
+
all<HTMLElement>('[data-menu-list="sub"]', menuItem).find(
|
|
168
|
+
(list) => list.parentElement === menuItem
|
|
169
|
+
) || null
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findDirectBackItem(subMenu: HTMLElement): HTMLElement | null {
|
|
174
|
+
return (
|
|
175
|
+
all<HTMLElement>('[data-menu-back]', subMenu).find(
|
|
176
|
+
(item) => item.parentElement === subMenu
|
|
177
|
+
) || null
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 菜单组件。
|
|
183
|
+
*
|
|
184
|
+
* 支持绑定已有菜单 DOM,也支持通过配置动态创建移动菜单或底部菜单。
|
|
185
|
+
*/
|
|
186
|
+
export class Menu {
|
|
187
|
+
options: ResolvedMenuOptions | null;
|
|
188
|
+
dom: MenuDOM;
|
|
189
|
+
cleanup: MenuCleanup | null;
|
|
190
|
+
private _element: DOMReference;
|
|
191
|
+
private _bound: boolean;
|
|
192
|
+
private _destroyed: boolean;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 创建菜单实例。
|
|
196
|
+
* @param {MenuOptions} [options={}] 菜单配置。
|
|
197
|
+
* @param {Element|Node|string|Array|false} [element=false] 已有菜单节点、选择器或 JSX/h 返回节点;默认 `false` 按 items 动态创建。
|
|
198
|
+
*/
|
|
199
|
+
constructor(options: MenuOptions = {}, element: DOMReference = false) {
|
|
200
|
+
this.options = normalizeOptions(options);
|
|
201
|
+
this._element = element;
|
|
202
|
+
this.dom = {
|
|
203
|
+
root: null,
|
|
204
|
+
};
|
|
205
|
+
this.cleanup = {
|
|
206
|
+
events: createEventManager(),
|
|
207
|
+
};
|
|
208
|
+
this._bound = false;
|
|
209
|
+
this._destroyed = false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
get root(): HTMLElement | null {
|
|
213
|
+
return this.dom.root;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 校验菜单数据。
|
|
218
|
+
* @private
|
|
219
|
+
* @param {MenuItem[]} items 菜单数据。
|
|
220
|
+
* @returns {void}
|
|
221
|
+
*/
|
|
222
|
+
_verifyItems(items: MenuItem[]): void {
|
|
223
|
+
validateParam('items', items, MENU_ITEMS_RULE, 'Menu');
|
|
224
|
+
items.forEach((item, index) => {
|
|
225
|
+
validateParam(
|
|
226
|
+
String(index),
|
|
227
|
+
item,
|
|
228
|
+
{
|
|
229
|
+
validate: isMenuItem,
|
|
230
|
+
message:
|
|
231
|
+
'expects { title, id?, url?, target?, classes?, children? }.',
|
|
232
|
+
},
|
|
233
|
+
'Menu.items'
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 构建菜单。
|
|
240
|
+
*
|
|
241
|
+
* element 为 false 时动态创建 DOM;否则绑定已有节点。
|
|
242
|
+
* @returns {Menu}
|
|
243
|
+
*/
|
|
244
|
+
build(): this {
|
|
245
|
+
if (this._bound) return this;
|
|
246
|
+
if (!this.options) throw new Error('Menu.build: instance destroyed.');
|
|
247
|
+
|
|
248
|
+
if (this._element === false) {
|
|
249
|
+
this._verifyItems(this.options.items);
|
|
250
|
+
this.dom.root = this._buildRoot();
|
|
251
|
+
this._bound = true;
|
|
252
|
+
} else {
|
|
253
|
+
const root = resolveElement(this._element);
|
|
254
|
+
if (!root) throw new Error('Menu.element: container not found.');
|
|
255
|
+
this.dom.root = root as HTMLElement;
|
|
256
|
+
this._bound = true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this._bindEvents();
|
|
260
|
+
|
|
261
|
+
return this;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 根据 items 创建菜单根节点。
|
|
266
|
+
* @private
|
|
267
|
+
* @returns {HTMLElement}
|
|
268
|
+
*/
|
|
269
|
+
_buildRoot(): HTMLElement {
|
|
270
|
+
if (!this.options) throw new Error('Menu._buildRoot: instance destroyed.');
|
|
271
|
+
const { items, id, type, className } = this.options;
|
|
272
|
+
const rootClass = className.root || `j-${type}-menu`;
|
|
273
|
+
|
|
274
|
+
return jsx('nav', {
|
|
275
|
+
className: rootClass,
|
|
276
|
+
'data-menu': 'root',
|
|
277
|
+
'data-menu-type': type,
|
|
278
|
+
children: jsx('ul', {
|
|
279
|
+
className: className.list,
|
|
280
|
+
id: id,
|
|
281
|
+
'data-menu-list': 'root',
|
|
282
|
+
children: items.map((item) => this._buildItem(item)),
|
|
283
|
+
}),
|
|
284
|
+
}) as HTMLElement;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 递归创建菜单项。
|
|
289
|
+
* @private
|
|
290
|
+
* @param {MenuItem} item 菜单项配置。
|
|
291
|
+
* @returns {HTMLElement}
|
|
292
|
+
*/
|
|
293
|
+
_buildItem(item: MenuItem): HTMLElement {
|
|
294
|
+
if (!this.options) throw new Error('Menu._buildItem: instance destroyed.');
|
|
295
|
+
const { className } = this.options;
|
|
296
|
+
const childrenItems = item.children || [];
|
|
297
|
+
const hasChildren = childrenItems.length > 0;
|
|
298
|
+
const classes = [className.item];
|
|
299
|
+
|
|
300
|
+
if (hasChildren) {
|
|
301
|
+
classes.push(className.hasChildren);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (item.classes) {
|
|
305
|
+
classes.push(...item.classes);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const children: Node[] = [
|
|
309
|
+
jsx('a', {
|
|
310
|
+
className: className.link,
|
|
311
|
+
href: item.url || '',
|
|
312
|
+
...(item.target && { target: item.target }),
|
|
313
|
+
'data-menu-link': '',
|
|
314
|
+
children: item.title,
|
|
315
|
+
}) as HTMLElement,
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
if (hasChildren) {
|
|
319
|
+
children.push(
|
|
320
|
+
jsx('ul', {
|
|
321
|
+
className: className.subMenu,
|
|
322
|
+
'data-menu-list': 'sub',
|
|
323
|
+
children: childrenItems.map((child) => this._buildItem(child)),
|
|
324
|
+
}) as HTMLElement
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return jsx('li', {
|
|
329
|
+
className: classList(...classes),
|
|
330
|
+
id: itemId(item.id),
|
|
331
|
+
'data-menu-item': item.id == null ? '' : String(item.id),
|
|
332
|
+
...(hasChildren && { 'data-menu-has-children': '' }),
|
|
333
|
+
children,
|
|
334
|
+
}) as HTMLElement;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 根据菜单类型绑定交互事件。
|
|
339
|
+
* @private
|
|
340
|
+
* @returns {void}
|
|
341
|
+
*/
|
|
342
|
+
_bindEvents(): void {
|
|
343
|
+
if (!this.dom.root || !this.options || !this.cleanup) return;
|
|
344
|
+
|
|
345
|
+
if (this.options.type === 'mobile') {
|
|
346
|
+
this.cleanup.events.on('mobile', this.dom.root, 'click', (event) => {
|
|
347
|
+
const target = event.target;
|
|
348
|
+
if (!(target instanceof Element)) return;
|
|
349
|
+
|
|
350
|
+
const backItem = target.closest<HTMLElement>('[data-menu-back]');
|
|
351
|
+
if (backItem && this.dom.root?.contains(backItem)) {
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
this._handleBack(backItem);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const menuItem = target.closest<HTMLElement>(
|
|
358
|
+
'[data-menu-has-children]'
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
if (menuItem && this.dom.root?.contains(menuItem)) {
|
|
362
|
+
const directLink = findDirectMenuLink(menuItem);
|
|
363
|
+
if (
|
|
364
|
+
directLink &&
|
|
365
|
+
(target === directLink || directLink.contains(target))
|
|
366
|
+
) {
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
this._handleMenuClick(menuItem);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
} else if (this.options.type === 'bottom') {
|
|
373
|
+
this.cleanup.events.on('bottom', document, 'click', (event) => {
|
|
374
|
+
const target = event.target;
|
|
375
|
+
if (!(target instanceof Element) || !this.dom.root) return;
|
|
376
|
+
|
|
377
|
+
const submenuLink = target.closest(
|
|
378
|
+
'[data-menu-list="sub"] [data-menu-link]'
|
|
379
|
+
);
|
|
380
|
+
if (submenuLink && this.dom.root.contains(submenuLink)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const firstLevelMenuItem = target.closest<HTMLElement>(
|
|
385
|
+
'[data-menu-list="root"] > [data-menu-has-children]'
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
if (firstLevelMenuItem && this.dom.root.contains(firstLevelMenuItem)) {
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
this._toggleActive(firstLevelMenuItem);
|
|
391
|
+
} else {
|
|
392
|
+
this._clearActive();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 解绑当前菜单实例绑定的事件。
|
|
400
|
+
* @private
|
|
401
|
+
* @returns {void}
|
|
402
|
+
*/
|
|
403
|
+
_unbindEvents(): void {
|
|
404
|
+
this.cleanup?.events.clear();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 清理当前构建出的 DOM 与事件,可选择保留实例引用用于重建。
|
|
409
|
+
* @private
|
|
410
|
+
* @param {object} [options={}] 清理选项。
|
|
411
|
+
* @param {boolean} [options.keepElement=false] 是否保留初始 element 引用。
|
|
412
|
+
* @returns {void}
|
|
413
|
+
*/
|
|
414
|
+
_teardown({ keepElement = false }: { keepElement?: boolean } = {}): void {
|
|
415
|
+
this._unbindEvents();
|
|
416
|
+
this.cleanup?.items?.();
|
|
417
|
+
if (this.cleanup) this.cleanup.items = null;
|
|
418
|
+
|
|
419
|
+
if (this._element === false && this.dom.root?.parentElement) {
|
|
420
|
+
this.dom.root.remove();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!keepElement) {
|
|
424
|
+
this._element = null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.dom.root = null;
|
|
428
|
+
this._bound = false;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* 处理移动端有子菜单项的进入操作。
|
|
433
|
+
* @private
|
|
434
|
+
* @param {HTMLElement} menuItem 菜单项节点。
|
|
435
|
+
* @returns {void}
|
|
436
|
+
*/
|
|
437
|
+
_handleMenuClick(menuItem: HTMLElement): void {
|
|
438
|
+
if (!this.options) return;
|
|
439
|
+
menuItem.classList.add(this.options.className.active);
|
|
440
|
+
|
|
441
|
+
const subMenu = findDirectSubMenu(menuItem);
|
|
442
|
+
if (!subMenu) return;
|
|
443
|
+
|
|
444
|
+
const existingBack = findDirectBackItem(subMenu);
|
|
445
|
+
if (existingBack) return;
|
|
446
|
+
|
|
447
|
+
const { className } = this.options;
|
|
448
|
+
const backButton = jsx('li', {
|
|
449
|
+
className: classList(className.item, className.back),
|
|
450
|
+
'data-menu-back': '',
|
|
451
|
+
children: jsx('a', {
|
|
452
|
+
className: className.link,
|
|
453
|
+
href: '',
|
|
454
|
+
'data-menu-link': '',
|
|
455
|
+
children: [
|
|
456
|
+
jsx('icon', {
|
|
457
|
+
className: classList(className.icon, className.iconPrefix),
|
|
458
|
+
children: icon('arrow-left'),
|
|
459
|
+
}),
|
|
460
|
+
jsx('span', {
|
|
461
|
+
className: className.text,
|
|
462
|
+
children: this.options.backText,
|
|
463
|
+
}),
|
|
464
|
+
],
|
|
465
|
+
}),
|
|
466
|
+
}) as HTMLElement;
|
|
467
|
+
|
|
468
|
+
subMenu.insertBefore(backButton, subMenu.firstChild);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 处理移动端子菜单返回操作。
|
|
473
|
+
* @private
|
|
474
|
+
* @param {Element} target 点击目标。
|
|
475
|
+
* @returns {void}
|
|
476
|
+
*/
|
|
477
|
+
_handleBack(target: Element): void {
|
|
478
|
+
if (!this.options) return;
|
|
479
|
+
|
|
480
|
+
const backItem = target.closest<HTMLElement>('[data-menu-back]');
|
|
481
|
+
if (!backItem) return;
|
|
482
|
+
|
|
483
|
+
const subMenu = backItem.parentElement;
|
|
484
|
+
const parentMenuItem = subMenu?.parentElement;
|
|
485
|
+
|
|
486
|
+
if (parentMenuItem?.hasAttribute('data-menu-has-children')) {
|
|
487
|
+
parentMenuItem.classList.remove(this.options.className.active);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
backItem.remove();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 切换底部菜单激活状态。
|
|
495
|
+
* @private
|
|
496
|
+
* @param {HTMLElement} menuItem 菜单项节点。
|
|
497
|
+
* @returns {void}
|
|
498
|
+
*/
|
|
499
|
+
_toggleActive(menuItem: HTMLElement): void {
|
|
500
|
+
if (!this.options) return;
|
|
501
|
+
const isActive = menuItem.classList.contains(this.options.className.active);
|
|
502
|
+
|
|
503
|
+
this._clearActive();
|
|
504
|
+
|
|
505
|
+
if (!isActive) {
|
|
506
|
+
menuItem.classList.add(this.options.className.active);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
_clearActive(): void {
|
|
511
|
+
if (!this.dom.root || !this.options) return;
|
|
512
|
+
all<HTMLElement>('[data-menu-item]', this.dom.root).forEach((item) => {
|
|
513
|
+
item.classList.remove(this.options?.className.active || 'is-active');
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* 替换菜单数据;动态创建的菜单会在已构建时重建 DOM。
|
|
519
|
+
* @param {MenuItem[]} items 新菜单数据。
|
|
520
|
+
* @returns {Menu}
|
|
521
|
+
*/
|
|
522
|
+
setItems(items: MenuItem[]): this {
|
|
523
|
+
if (!this.options) throw new Error('Menu.setItems: instance destroyed.');
|
|
524
|
+
this._verifyItems(items);
|
|
525
|
+
|
|
526
|
+
this.options.items = items;
|
|
527
|
+
|
|
528
|
+
if (this._bound) {
|
|
529
|
+
if (this._element === false) {
|
|
530
|
+
const element = this._element;
|
|
531
|
+
this._teardown({ keepElement: true });
|
|
532
|
+
this._element = element;
|
|
533
|
+
this.build();
|
|
534
|
+
} else if (this.dom.root) {
|
|
535
|
+
const list = findMenuList(this.dom.root);
|
|
536
|
+
|
|
537
|
+
if (!list) {
|
|
538
|
+
throw new Error('Menu: [data-menu-list] element not found.');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
this._unbindEvents();
|
|
542
|
+
this.cleanup?.items?.();
|
|
543
|
+
if (this.cleanup) {
|
|
544
|
+
this.cleanup.items = render(
|
|
545
|
+
() => items.map((item) => this._buildItem(item)),
|
|
546
|
+
list
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
this._bindEvents();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return this;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* 根据 id 移除菜单项。
|
|
558
|
+
* @param {string|number} id 菜单项 id。
|
|
559
|
+
* @returns {Menu}
|
|
560
|
+
*/
|
|
561
|
+
removeItem(id: MenuItemId): this {
|
|
562
|
+
if (!this.dom.root) return this;
|
|
563
|
+
|
|
564
|
+
const item = all<HTMLElement>('[data-menu-item]', this.dom.root).find(
|
|
565
|
+
(node) => node.dataset.menuItem === String(id)
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
if (item) {
|
|
569
|
+
item.remove();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (this.options?.items) {
|
|
573
|
+
const removeFromArray = (arr: MenuItem[]): void => {
|
|
574
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
575
|
+
if (arr[i].id === id) {
|
|
576
|
+
arr.splice(i, 1);
|
|
577
|
+
} else {
|
|
578
|
+
const children = arr[i].children;
|
|
579
|
+
if (children) removeFromArray(children);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
removeFromArray(this.options.items);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return this;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 销毁当前菜单实例并解绑事件。
|
|
591
|
+
* @returns {void}
|
|
592
|
+
*/
|
|
593
|
+
destroy(): void {
|
|
594
|
+
if (this._destroyed) return;
|
|
595
|
+
this._destroyed = true;
|
|
596
|
+
|
|
597
|
+
if (this._bound) {
|
|
598
|
+
this._teardown();
|
|
599
|
+
} else {
|
|
600
|
+
this._element = null;
|
|
601
|
+
this.dom.root = null;
|
|
602
|
+
this.cleanup?.events.clear();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
this.cleanup = null;
|
|
606
|
+
this.options = null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export function createMenu(
|
|
611
|
+
options: MenuOptions = {},
|
|
612
|
+
element: DOMReference = false
|
|
613
|
+
): Menu {
|
|
614
|
+
return new Menu(options, element);
|
|
615
|
+
}
|