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
@@ -0,0 +1,468 @@
1
+ import { jsx } from 'vanilla-signal';
2
+
3
+ import { icon } from '../components/icons';
4
+
5
+ export type ContainerExpect = 'node' | 'element' | 'array';
6
+ export type ResolveContainerResult<
7
+ TExpect extends ContainerExpect = 'element',
8
+ > = TExpect extends 'array' ? Node[] : TExpect extends 'node' ? Node : Element;
9
+ export type RequireContainerResult<
10
+ TExpect extends ContainerExpect = 'element',
11
+ > = NonNullable<ResolveContainerResult<TExpect>>;
12
+ export type DOMReference =
13
+ | Node
14
+ | string
15
+ | readonly DOMReference[]
16
+ | false
17
+ | null
18
+ | undefined;
19
+ export type QueryContext = Document | DocumentFragment | Element;
20
+ export type CleanupFunction = () => void;
21
+ export type LazyRenderTarget = string | Element;
22
+ export type LazyRenderCallback = () => void;
23
+ export type RenderableContent<TContext = unknown> =
24
+ | Node
25
+ | string
26
+ | number
27
+ | boolean
28
+ | null
29
+ | undefined
30
+ | readonly RenderableContent<TContext>[]
31
+ | ((context: TContext) => RenderableContent<TContext>);
32
+
33
+ export interface LazyRenderOptions {
34
+ threshold?: number | number[];
35
+ rootMargin?: string;
36
+ root?: Element | Document | null;
37
+ waitForDOM?: boolean;
38
+ }
39
+
40
+ const CONTAINER_EXPECTS = ['node', 'element', 'array'] as const;
41
+
42
+ function isContainerExpect(value: unknown): value is ContainerExpect {
43
+ return CONTAINER_EXPECTS.includes(value as ContainerExpect);
44
+ }
45
+
46
+ /**
47
+ * 判断是否为 DOM Node。
48
+ * @param {*} value 需要判断的值。
49
+ * @returns {boolean}
50
+ */
51
+ export function isNode(value: unknown): value is Node {
52
+ return value instanceof Node;
53
+ }
54
+
55
+ /**
56
+ * 判断是否为 DOM Element。
57
+ * @param {*} value 需要判断的值。
58
+ * @returns {boolean}
59
+ */
60
+ export function isElement(value: unknown): value is Element {
61
+ return value instanceof Element;
62
+ }
63
+
64
+ /**
65
+ * 判断值是否为可接受的容器输入。
66
+ * @param {*} value 需要判断的值。
67
+ * @returns {boolean}
68
+ */
69
+ function isContainerLike(
70
+ value: unknown
71
+ ): value is string | Node | readonly DOMReference[] {
72
+ return (
73
+ typeof value === 'string' ||
74
+ isElement(value) ||
75
+ isNode(value) ||
76
+ Array.isArray(value)
77
+ );
78
+ }
79
+
80
+ function firstElement(nodes: readonly Node[]): Element | null {
81
+ return nodes.find(isElement) || null;
82
+ }
83
+
84
+ function flattenNodeArray(
85
+ value: readonly DOMReference[],
86
+ out: Node[] = []
87
+ ): Node[] | null {
88
+ for (const item of value) {
89
+ if (Array.isArray(item)) {
90
+ if (!flattenNodeArray(item, out)) return null;
91
+ continue;
92
+ }
93
+
94
+ if (isNode(item)) {
95
+ out.push(item);
96
+ continue;
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * 将可渲染内容归一化为 DOM 节点数组。
107
+ *
108
+ * 支持的输入类型:
109
+ * - **Node/Element**: 包装为单元素数组
110
+ * - **string**: 按 HTML 片段解析为节点数组
111
+ * - **number/boolean**: 转换为文本节点
112
+ * - **Array**: 递归扁平化处理
113
+ * - **Function**: 调用后递归处理返回值
114
+ * - **null/undefined/false/true**: 返回空数组
115
+ *
116
+ * @param {RenderableContent<TContext>} content 可渲染内容。
117
+ * @param {TContext} [context] 传递给函数类型内容的上下文。
118
+ * @returns {Node[]} 归一化后的节点数组。
119
+ */
120
+ export function normalizeContentNodes<TContext = unknown>(
121
+ content: RenderableContent<TContext>,
122
+ context?: TContext
123
+ ): Node[] {
124
+ if (typeof content === 'function') {
125
+ return normalizeContentNodes(content(context as TContext), context);
126
+ }
127
+
128
+ const value = content;
129
+
130
+ if (value == null || value === false || value === true) return [];
131
+
132
+ if (Array.isArray(value)) {
133
+ return value.flatMap((item) => normalizeContentNodes(item, context));
134
+ }
135
+
136
+ if (isNode(value)) return [value];
137
+
138
+ if (typeof value === 'string') {
139
+ const template = document.createElement('template');
140
+ template.innerHTML = value;
141
+ return Array.from(template.content.childNodes);
142
+ }
143
+
144
+ return [
145
+ document.createTextNode(
146
+ String(value as string | number | boolean | bigint | symbol)
147
+ ),
148
+ ];
149
+ }
150
+
151
+ /**
152
+ * 将 DOM 引用解析为节点列表。
153
+ *
154
+ * 支持的输入类型:
155
+ * - **Node**: 包装为单元素数组,如 `document.body`
156
+ * - **Element**: 包装为单元素数组,如 `div`
157
+ * - **string**: CSS 选择器,如 `'div'`, `'.class'`, `'#id'`
158
+ * - **Array**: 扁平化为节点数组,如 `[el1, [el2, el3]]`
159
+ * - **false/null/undefined**: 返回 null
160
+ *
161
+ * @param {Element|Node|string|Array|false|null|undefined} ref 元素引用、选择器、节点或空值。
162
+ * @returns {Node[]|null}
163
+ */
164
+ export function resolveNodeList(ref: DOMReference): Node[] | null {
165
+ if (ref === false || ref == null) return null;
166
+
167
+ if (typeof ref === 'string') {
168
+ const nodes = all(ref);
169
+ return nodes.length > 0 ? nodes : null;
170
+ }
171
+
172
+ if (Array.isArray(ref)) {
173
+ const nodes = flattenNodeArray(ref, []);
174
+ return nodes && nodes.length > 0 ? nodes : null;
175
+ }
176
+
177
+ if (isNode(ref)) return [ref];
178
+
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * 将 DOM 引用解析为节点。
184
+ *
185
+ * 支持的输入类型:
186
+ * - **Node/Element**: 直接返回,如 `document.body`, `div`
187
+ * - **string**: CSS 选择器,如 `'div'`, `'.class'`, `'#id'`
188
+ * - **Array**: 返回第一个节点,如 `[el1, el2]`
189
+ * - **false/null/undefined**: 返回 null
190
+ *
191
+ * @param {Element|Node|string|Array|false|null|undefined} ref 元素引用、选择器、节点或空值。
192
+ * @returns {Node|null}
193
+ */
194
+ export function resolveNode(ref: DOMReference): Node | null {
195
+ if (isElement(ref) || isNode(ref)) return ref;
196
+
197
+ if (typeof ref === 'string') {
198
+ return document.querySelector(ref);
199
+ }
200
+
201
+ if (Array.isArray(ref)) {
202
+ const nodes = resolveNodeList(ref) || [];
203
+ return nodes[0] || null;
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * 将 DOM 引用解析为元素。
211
+ *
212
+ * 支持的输入类型:
213
+ * - **Element**: 直接返回,如 `document.querySelector('#app')`
214
+ * - **string**: CSS 选择器,如 `'div'`, `'.class'`, `'#id'`
215
+ * - **Array**: 返回第一个 Element,如 `[el1, el2]`
216
+ * - **false/null/undefined**: 返回 null
217
+ *
218
+ * @param {Element|Node|string|Array|false|null|undefined} ref 元素引用、选择器、节点或空值。
219
+ * @returns {Element|null}
220
+ */
221
+ export function resolveElement(ref: DOMReference): Element | null {
222
+ if (!isContainerLike(ref)) return null;
223
+
224
+ if (typeof ref === 'string') {
225
+ const element = document.querySelector(ref);
226
+ return isElement(element) ? element : null;
227
+ }
228
+
229
+ if (Array.isArray(ref)) {
230
+ const nodes = resolveNodeList(ref);
231
+ return Array.isArray(nodes) ? firstElement(nodes) : null;
232
+ }
233
+
234
+ return isElement(ref) ? ref : null;
235
+ }
236
+
237
+ /**
238
+ * 统一解析容器引用。
239
+ *
240
+ * @param {Element|Node|string|Array|false|null|undefined} container 容器引用、选择器、节点或数组。
241
+ * @param {string} [namespace='Component'] 错误命名空间。
242
+ * @param {'node'|'element'|'array'} [expect='element'] 期望返回类型。
243
+ * @returns {Node|Element|Node[]|null}
244
+ */
245
+ export function resolveContainer<TExpect extends ContainerExpect = 'element'>(
246
+ container: DOMReference,
247
+ namespace: string = 'Component',
248
+ expect: TExpect = 'element' as TExpect
249
+ ): ResolveContainerResult<TExpect> | null {
250
+ if (!isContainerExpect(expect)) {
251
+ throw new Error(
252
+ `${namespace}: expect must be one of 'node', 'element', 'array'.`
253
+ );
254
+ }
255
+
256
+ if (!isContainerLike(container)) return null;
257
+
258
+ if (expect === 'array') {
259
+ return resolveNodeList(container) as ResolveContainerResult<TExpect> | null;
260
+ }
261
+
262
+ if (expect === 'node') {
263
+ return resolveNode(container) as ResolveContainerResult<TExpect> | null;
264
+ }
265
+
266
+ return resolveElement(container) as ResolveContainerResult<TExpect> | null;
267
+ }
268
+
269
+ /**
270
+ * 强制解析容器并要求返回值存在。
271
+ *
272
+ * @param {Element|Node|string|Array|false|null|undefined} container 容器引用、选择器、节点或数组。
273
+ * @param {string} [namespace='Component'] 错误命名空间。
274
+ * @param {'node'|'element'|'array'} [expect='element'] 期望返回类型。
275
+ * @returns {Node|Element|Node[]}
276
+ */
277
+ export function requireContainer<TExpect extends ContainerExpect = 'element'>(
278
+ container: DOMReference,
279
+ namespace: string = 'Component',
280
+ expect: TExpect = 'element' as TExpect
281
+ ): RequireContainerResult<TExpect> {
282
+ const resolved = resolveContainer(container, namespace, expect);
283
+ if (resolved == null) {
284
+ throw new Error(`${namespace}: container not found.`);
285
+ }
286
+ return resolved as RequireContainerResult<TExpect>;
287
+ }
288
+
289
+ /**
290
+ * 判断是否为组件可渲染内容。
291
+ * @param {*} value 需要判断的值。
292
+ * @returns {boolean}
293
+ */
294
+ export function isRenderableContent(
295
+ value: unknown
296
+ ): value is RenderableContent {
297
+ return (
298
+ value == null ||
299
+ typeof value === 'string' ||
300
+ typeof value === 'number' ||
301
+ typeof value === 'boolean' ||
302
+ typeof value === 'function' ||
303
+ Array.isArray(value) ||
304
+ isNode(value)
305
+ );
306
+ }
307
+
308
+ /**
309
+ * 创建通用加载状态节点。
310
+ * @returns {HTMLElement}
311
+ */
312
+ export function createLoading(): HTMLDivElement {
313
+ return jsx('div', {
314
+ 'aria-live': 'polite',
315
+ style: {
316
+ display: 'flex',
317
+ justifyContent: 'center',
318
+ alignItems: 'center',
319
+ position: 'absolute',
320
+ width: '100%',
321
+ height: '100%',
322
+ backdropFilter: 'blur(4px)',
323
+ },
324
+ children: icon('loader', { width: 24, className: 'animate-spin' }),
325
+ });
326
+ }
327
+
328
+ /**
329
+ * 根据 CSS 选择器获取第一个匹配的元素。
330
+ * @param {string} selector CSS 选择器。
331
+ * @param {Document|Element} [context=document] 查询范围。
332
+ * @returns {Element|null}
333
+ */
334
+ export function q<TElement extends Element = Element>(
335
+ selector: string,
336
+ context: QueryContext = document
337
+ ): TElement | null {
338
+ return context.querySelector<TElement>(selector);
339
+ }
340
+
341
+ /**
342
+ * 根据 CSS 选择器获取所有匹配的元素。
343
+ * @param {string} selector CSS 选择器。
344
+ * @param {Document|Element} [context=document] 查询范围。
345
+ * @returns {Element[]}
346
+ */
347
+ export function all<TElement extends Element = Element>(
348
+ selector: string,
349
+ context: QueryContext = document
350
+ ): TElement[] {
351
+ return Array.from(context.querySelectorAll<TElement>(selector));
352
+ }
353
+
354
+ /**
355
+ * 当目标元素进入可视区域时执行渲染回调,仅执行一次后自动清理。
356
+ *
357
+ * 支持选择器字符串或 Element。目标尚未挂载时默认等待 DOM 变化;不支持
358
+ * IntersectionObserver 时会立即执行回调,保证降级环境也能渲染。
359
+ * @param {string|Element} target CSS 选择器或 DOM 元素。
360
+ * @param {Function} renderCallback 渲染回调函数,仅执行一次。
361
+ * @param {Object} [options] 配置项。
362
+ * @returns {Function} 停止观察的清理函数。
363
+ */
364
+ export function lazyRender(
365
+ target: LazyRenderTarget,
366
+ renderCallback: LazyRenderCallback,
367
+ options?: LazyRenderOptions
368
+ ): CleanupFunction;
369
+ export function lazyRender(
370
+ target: unknown,
371
+ renderCallback: LazyRenderCallback,
372
+ options: LazyRenderOptions = {}
373
+ ): CleanupFunction {
374
+ if (typeof renderCallback !== 'function') {
375
+ throw new TypeError('lazyRender: renderCallback expects function.');
376
+ }
377
+
378
+ const {
379
+ threshold = 0.1,
380
+ rootMargin = '0px',
381
+ root = null,
382
+ waitForDOM = true,
383
+ } = options;
384
+ const observerOptions: IntersectionObserverInit = {
385
+ threshold,
386
+ rootMargin,
387
+ root,
388
+ };
389
+
390
+ if (!isLazyRenderTarget(target)) {
391
+ console.warn('lazyRender: target 必须是 CSS 选择器字符串或 DOM 元素');
392
+ return noop;
393
+ }
394
+
395
+ let cleanup: CleanupFunction = noop;
396
+ let element = resolveLazyRenderTarget(target);
397
+
398
+ if (element && document.body.contains(element)) {
399
+ cleanup = observeAndRender(element, renderCallback, observerOptions);
400
+ return cleanup;
401
+ }
402
+
403
+ if (!waitForDOM || typeof MutationObserver === 'undefined') {
404
+ renderCallback();
405
+ return cleanup;
406
+ }
407
+
408
+ const observer = new MutationObserver(() => {
409
+ const current = resolveLazyRenderTarget(target);
410
+ if (!current || !document.body.contains(current)) return;
411
+
412
+ element = current;
413
+ observer.disconnect();
414
+ cleanup = observeAndRender(element, renderCallback, observerOptions);
415
+ });
416
+
417
+ observer.observe(document.documentElement, {
418
+ childList: true,
419
+ subtree: true,
420
+ });
421
+
422
+ return () => {
423
+ observer.disconnect();
424
+ cleanup();
425
+ };
426
+ }
427
+
428
+ function noop(): void {}
429
+
430
+ function isLazyRenderTarget(value: unknown): value is LazyRenderTarget {
431
+ return typeof value === 'string' || isElement(value);
432
+ }
433
+
434
+ function resolveLazyRenderTarget(target: LazyRenderTarget): Element | null {
435
+ return typeof target === 'string' ? q(target) : target;
436
+ }
437
+
438
+ function observeAndRender(
439
+ element: Element,
440
+ renderCallback: LazyRenderCallback,
441
+ observerOptions: IntersectionObserverInit
442
+ ): CleanupFunction {
443
+ if (
444
+ typeof window === 'undefined' ||
445
+ typeof window.IntersectionObserver === 'undefined'
446
+ ) {
447
+ renderCallback();
448
+ return noop;
449
+ }
450
+
451
+ const observer = new window.IntersectionObserver((entries) => {
452
+ for (const entry of entries) {
453
+ if (!entry.isIntersecting) continue;
454
+
455
+ observer.unobserve(element);
456
+ observer.disconnect();
457
+ renderCallback();
458
+ break;
459
+ }
460
+ }, observerOptions);
461
+
462
+ observer.observe(element);
463
+
464
+ return () => {
465
+ observer.unobserve(element);
466
+ observer.disconnect();
467
+ };
468
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 绑定 DOM 事件并返回一次性解绑函数。
3
+ *
4
+ * @template T - 事件目标类型(如 HTMLElement, Window 等)
5
+ * @template K - 事件类型字符串(如 'click', 'scroll')
6
+ * @param {T} target - 事件目标。
7
+ * @param {K} type - 事件类型。
8
+ * @param {(this: T, ev: HTMLElementEventMap[K extends keyof HTMLElementEventMap ? K : never]) => any} handler - 事件处理器。
9
+ * @param {boolean | AddEventListenerOptions} [options] - 事件选项。
10
+ * @returns {() => void} 解绑函数,可重复调用。
11
+ */
12
+ export function listen<T extends EventTarget, K extends string>(
13
+ target: T,
14
+ type: K,
15
+ handler: (this: T, ev: Event) => any,
16
+ options?: boolean | AddEventListenerOptions
17
+ ): () => void {
18
+ let active = true;
19
+ target.addEventListener(type, handler as EventListener, options);
20
+
21
+ return () => {
22
+ if (!active) return;
23
+ active = false;
24
+ target.removeEventListener(type, handler as EventListener, options);
25
+ };
26
+ }
27
+
28
+ /**
29
+ * 事件管理器实例的接口定义
30
+ */
31
+ export interface IEventManager {
32
+ /**
33
+ * 绑定事件并自动记录解绑函数。
34
+ * 同一个 key 重复绑定时会先解绑旧事件,避免组件重渲染时重复监听。
35
+ */
36
+ on<T extends EventTarget, K extends string>(
37
+ key: string,
38
+ target: T,
39
+ type: K,
40
+ handler: (this: T, ev: Event) => any,
41
+ options?: boolean | AddEventListenerOptions
42
+ ): () => void;
43
+
44
+ /** 根据 key 解绑特定事件 */
45
+ off(key: string): boolean;
46
+
47
+ /** 清除所有已绑定的事件 */
48
+ clear(): void;
49
+
50
+ /** 获取当前绑定的事件数量 */
51
+ size(): number;
52
+ }
53
+
54
+ /**
55
+ * 创建实例级事件管理器。
56
+ *
57
+ * - `on()` 绑定事件并自动记录解绑函数。
58
+ * - 同一个 key 重复绑定时会先解绑旧事件,避免组件重渲染时重复监听。
59
+ * - `off(key)` 和 `clear()` 用于跨作用域精确释放。
60
+ */
61
+ export function createEventManager(): IEventManager {
62
+ const records = new Map<string, () => void>();
63
+
64
+ const off = (key: string): boolean => {
65
+ const dispose = records.get(key);
66
+ if (!dispose) return false;
67
+ dispose();
68
+ records.delete(key);
69
+ return true;
70
+ };
71
+
72
+ return {
73
+ on<T extends EventTarget, K extends string>(
74
+ key: string,
75
+ target: T,
76
+ type: K,
77
+ handler: (this: T, ev: Event) => any,
78
+ options?: boolean | AddEventListenerOptions
79
+ ): () => void {
80
+ if (typeof key !== 'string' || key.trim() === '') {
81
+ throw new TypeError('EventManager.on: key expects a non-empty string.');
82
+ }
83
+
84
+ // 先解绑旧事件
85
+ off(key);
86
+
87
+ // 防御性编程:如果 target 为空,返回空函数
88
+ if (target == null) return () => {};
89
+
90
+ const dispose = listen(target, type, handler, options);
91
+ records.set(key, dispose);
92
+ return dispose;
93
+ },
94
+
95
+ off,
96
+
97
+ clear(): void {
98
+ for (const dispose of records.values()) dispose();
99
+ records.clear();
100
+ },
101
+
102
+ size(): number {
103
+ return records.size;
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 发送 JSON POST 请求并解析 JSON 响应。
3
+ * @template T - 期望返回的 JSON 数据类型
4
+ * @param {string} url - 请求地址
5
+ * @param {unknown} body - 请求体,会被 JSON.stringify
6
+ * @param {RequestInit} [options] - 透传给 fetch 的请求配置(排除 method 和 body)
7
+ * @returns {Promise<T>} 解析后的 JSON 响应体
8
+ */
9
+ export async function postJson<T = unknown>(
10
+ url: string,
11
+ body: unknown,
12
+ options: Omit<RequestInit, 'method' | 'body'> = {}
13
+ ): Promise<T> {
14
+ const headers = new Headers(options.headers);
15
+
16
+ if (!headers.has('Content-Type')) {
17
+ headers.set('Content-Type', 'application/json');
18
+ }
19
+
20
+ const res = await fetch(url, {
21
+ ...options,
22
+ method: 'POST',
23
+ headers,
24
+ body: JSON.stringify(body),
25
+ });
26
+
27
+ return res.json() as Promise<T>;
28
+ }
29
+
30
+ /**
31
+ * 当前站点 WordPress REST API 根地址。
32
+ * @type {string}
33
+ */
34
+ export const restUrl: string =
35
+ typeof window !== 'undefined' ? `${window.location.origin}/wp-json` : '';
@@ -0,0 +1,46 @@
1
+ export function getCookie(name: string): string | null {
2
+ if (typeof document === 'undefined') return null;
3
+
4
+ const escapedName = escapeRegExp(name);
5
+ const match = document.cookie.match(
6
+ new RegExp('(?:^|;\\s*)' + escapedName + '=([^;]*)')
7
+ );
8
+ return match ? decodeURIComponent(match[1]) : null;
9
+ }
10
+
11
+ function escapeRegExp(string: string): string {
12
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+
15
+ export function setCookie(
16
+ name: string,
17
+ value: string,
18
+ seconds: number = 60 * 60 * 24 // 默认有效期为 24 小时
19
+ ): boolean {
20
+ if (typeof document === 'undefined') return false;
21
+
22
+ const encodedName = encodeURIComponent(name);
23
+ const encodedValue = encodeURIComponent(value);
24
+
25
+ let cookieStr = `${encodedName}=${encodedValue}; path=/; SameSite=Lax`;
26
+
27
+ if (seconds > 0) {
28
+ const expires = new Date(Date.now() + seconds * 1000).toUTCString();
29
+ cookieStr += `; expires=${expires}`;
30
+ }
31
+
32
+ if (location.protocol === 'https:') {
33
+ cookieStr += '; Secure';
34
+ }
35
+
36
+ document.cookie = cookieStr;
37
+
38
+ return getCookie(name) === value;
39
+ }
40
+
41
+ export function removeCookie(name: string): boolean {
42
+ if (typeof document === 'undefined') return true;
43
+
44
+ document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
45
+ return !getCookie(name);
46
+ }