vanilla-jui 1.4.4 → 1.4.6

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