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.
Files changed (90) hide show
  1. package/dist/index.d.ts +2927 -0
  2. package/dist/index.js +1 -1
  3. package/dist/index.umd.js +1 -1
  4. package/dist/style.css +1 -1
  5. package/package.json +4 -3
  6. package/src/components/accordion.ts +522 -0
  7. package/src/components/drop.ts +497 -0
  8. package/src/components/flow.ts +1579 -0
  9. package/src/components/form.ts +953 -0
  10. package/src/components/{icons.js → icons.ts} +24 -11
  11. package/src/components/menu.ts +615 -0
  12. package/src/components/{modal.js → modal.ts} +560 -237
  13. package/src/components/offcanvas.ts +496 -0
  14. package/src/components/{pagination.js → pagination.ts} +251 -76
  15. package/src/components/parabola.ts +335 -0
  16. package/src/components/{sticky.js → sticky.ts} +123 -98
  17. package/src/components/{swiper.js → swiper.ts} +510 -232
  18. package/src/components/tabs.ts +984 -0
  19. package/src/components/theme.ts +437 -0
  20. package/src/components/toast.ts +371 -0
  21. package/src/components/toc.ts +385 -0
  22. package/src/components/tooltip.ts +179 -0
  23. package/src/components/validator.ts +596 -0
  24. package/src/core/{Component.js → Component.ts} +134 -27
  25. package/src/css/accordion.css +2 -2
  26. package/src/css/animation.css +31 -1
  27. package/src/css/breadcrumb.css +1 -1
  28. package/src/css/button.css +5 -5
  29. package/src/css/card.css +7 -7
  30. package/src/css/color.css +865 -1028
  31. package/src/css/color_p3.css +871 -1036
  32. package/src/css/components.css +1 -0
  33. package/src/css/drop.css +1 -1
  34. package/src/css/flow.css +1 -1
  35. package/src/css/form.css +21 -21
  36. package/src/css/gradient.css +17 -4
  37. package/src/css/icon.css +17 -1
  38. package/src/css/index.css +3 -3
  39. package/src/css/layout.css +1 -1
  40. package/src/css/menu.css +27 -27
  41. package/src/css/modal.css +10 -27
  42. package/src/css/offcanvas.css +2 -2
  43. package/src/css/pagination.css +1 -1
  44. package/src/css/popup.css +2 -2
  45. package/src/css/swiper.css +12 -12
  46. package/src/css/tabs.css +3 -2
  47. package/src/css/tag.css +3 -4
  48. package/src/css/theme-palette.css +6 -6
  49. package/src/css/tip.css +1 -1
  50. package/src/css/toast.css +8 -8
  51. package/src/css/tokens.css +197 -226
  52. package/src/css/toolbar.css +3 -3
  53. package/src/css/tooltip.css +1 -1
  54. package/src/index.ts +37 -0
  55. package/src/locales/{index.js → index.ts} +1 -2
  56. package/src/types.d.ts +1 -0
  57. package/src/utilities/browser.ts +131 -0
  58. package/src/utilities/cache.ts +57 -0
  59. package/src/utilities/core.ts +614 -0
  60. package/src/utilities/dom.ts +468 -0
  61. package/src/utilities/events.ts +106 -0
  62. package/src/utilities/http.ts +35 -0
  63. package/src/utilities/storage.ts +46 -0
  64. package/src/components/accordion.js +0 -373
  65. package/src/components/drop.js +0 -437
  66. package/src/components/flow.js +0 -1133
  67. package/src/components/form.js +0 -585
  68. package/src/components/menu.js +0 -422
  69. package/src/components/offcanvas.js +0 -332
  70. package/src/components/parabola.js +0 -295
  71. package/src/components/tabs.js +0 -746
  72. package/src/components/theme.js +0 -353
  73. package/src/components/toast.js +0 -329
  74. package/src/components/toc.js +0 -226
  75. package/src/components/tooltip.js +0 -86
  76. package/src/components/validator.js +0 -586
  77. package/src/css/toast.min.css +0 -1
  78. package/src/index.js +0 -45
  79. package/src/locales/index.min.js +0 -1
  80. package/src/utilities/browser.js +0 -111
  81. package/src/utilities/cache.js +0 -159
  82. package/src/utilities/core.js +0 -502
  83. package/src/utilities/debug.js +0 -103
  84. package/src/utilities/dom.js +0 -313
  85. package/src/utilities/events.js +0 -69
  86. package/src/utilities/http.js +0 -30
  87. package/src/utilities/legacy-dom.js +0 -179
  88. package/src/utilities/storage.js +0 -46
  89. /package/src/{components/components.js → css/themes/index.css} +0 -0
  90. /package/src/icons/{index.js → index.ts} +0 -0
@@ -1,21 +1,32 @@
1
- import iconPath from '../icons/index.js';
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
- const defaultSvgObjects = iconPath;
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
+ }