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
|
@@ -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
|
+
}
|