vanilla-jui 1.4.8 → 1.5.2
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/README.md +10 -4
- package/dist/index.d.ts +402 -977
- package/dist/index.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/components/accordion.ts +467 -349
- package/src/components/flow.ts +779 -1133
- package/src/components/form.ts +666 -509
- package/src/components/menu.ts +199 -326
- package/src/components/modal.ts +510 -747
- package/src/components/offcanvas.ts +281 -361
- package/src/components/pagination.ts +207 -374
- package/src/components/swiper.ts +688 -1168
- package/src/components/tabs.ts +468 -788
- package/src/components/toc.ts +191 -244
- package/src/core/component.ts +332 -0
- package/src/core/motion.ts +298 -0
- package/src/core/presence.ts +233 -0
- package/src/core/scheduler.ts +91 -0
- package/src/core/view.ts +31 -0
- package/src/css/accordion.css +94 -3
- package/src/css/color.css +4 -5
- package/src/css/color_p3.css +6 -5
- package/src/css/content.css +18 -24
- package/src/css/drop.css +4 -1
- package/src/css/form.css +8 -0
- package/src/css/gradient.css +64 -131
- package/src/css/layout.css +9 -23
- package/src/css/menu.css +10 -5
- package/src/css/modal.css +2 -1
- package/src/css/offcanvas.css +1 -17
- package/src/css/swiper.css +2 -1
- package/src/css/tabs.css +8 -17
- package/src/css/themes/default/accordion.css +0 -48
- package/src/css/themes/default/swiper.css +1 -1
- package/src/css/themes/default/tabs.css +4 -6
- package/src/css/toast.css +1 -17
- package/src/css/tokens.css +18 -35
- package/src/css/tooltip.css +2 -1
- package/src/index.ts +8 -3
- package/src/locales/index.ts +3 -0
- package/src/primitives/drop.ts +253 -262
- package/src/primitives/loading.ts +22 -4
- package/src/primitives/parabola.ts +123 -164
- package/src/primitives/sticky.ts +297 -0
- package/src/primitives/theme.ts +164 -222
- package/src/primitives/toast.ts +309 -284
- package/src/primitives/tooltip.ts +65 -66
- package/src/utilities/browser.ts +38 -0
- package/src/utilities/dom.ts +0 -54
- package/src/utilities/refs.ts +61 -0
- package/src/utilities/state.ts +73 -0
- package/src/utilities/timer.ts +11 -31
- package/src/validation/validator.ts +289 -389
- package/src/components/sticky.ts +0 -297
- package/src/core/Component.ts +0 -377
- package/src/utilities/scheduler.ts +0 -156
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx } from 'vanilla-signal';
|
|
1
|
+
import { insert, jsx } from 'vanilla-signal';
|
|
2
2
|
|
|
3
3
|
import { type DOMReference, resolveElement } from '../utilities/dom.ts';
|
|
4
4
|
import { randomId } from '../utilities/id.ts';
|
|
@@ -45,21 +45,10 @@ interface ResolvedParabolaProps extends Record<string, unknown> {
|
|
|
45
45
|
onHidden: ((parabola: ParabolaInstance) => void) | null;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
interface ParabolaDOM {
|
|
49
|
-
root: HTMLElement | null;
|
|
50
|
-
from: Element | null;
|
|
51
|
-
to: Element | null;
|
|
52
|
-
balls: Set<HTMLElement>;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
48
|
interface ParabolaRuntime {
|
|
56
49
|
destroyed: boolean;
|
|
57
50
|
}
|
|
58
51
|
|
|
59
|
-
interface ParabolaCache {
|
|
60
|
-
delays: Map<string, () => void>;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
52
|
interface ParabolaPath {
|
|
64
53
|
startX: number;
|
|
65
54
|
startY: number;
|
|
@@ -67,10 +56,10 @@ interface ParabolaPath {
|
|
|
67
56
|
endY: number;
|
|
68
57
|
}
|
|
69
58
|
|
|
70
|
-
interface ParabolaInstance {
|
|
71
|
-
props: ResolvedParabolaProps;
|
|
72
|
-
|
|
73
|
-
runtime: ParabolaRuntime;
|
|
59
|
+
export interface ParabolaInstance {
|
|
60
|
+
readonly props: ResolvedParabolaProps;
|
|
61
|
+
readonly element: HTMLElement | null;
|
|
62
|
+
readonly runtime: ParabolaRuntime;
|
|
74
63
|
show(): Promise<boolean>;
|
|
75
64
|
destroy(): void;
|
|
76
65
|
}
|
|
@@ -135,39 +124,24 @@ function normalizeProps(input: ParabolaProps): ResolvedParabolaProps {
|
|
|
135
124
|
};
|
|
136
125
|
}
|
|
137
126
|
|
|
138
|
-
|
|
139
|
-
props
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
destroyed: false,
|
|
154
|
-
};
|
|
155
|
-
this.cache = {
|
|
156
|
-
delays: new Map(),
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
this.createRoot();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
private createRoot(): void {
|
|
163
|
-
if (this.runtime.destroyed) return;
|
|
164
|
-
|
|
165
|
-
if (this.dom.root) {
|
|
166
|
-
if (!this.dom.root.isConnected) document.body.appendChild(this.dom.root);
|
|
127
|
+
export function createParabola(input: ParabolaProps = {}): ParabolaInstance {
|
|
128
|
+
const props = normalizeProps(input);
|
|
129
|
+
const runtime: ParabolaRuntime = { destroyed: false };
|
|
130
|
+
const balls = new Set<HTMLElement>();
|
|
131
|
+
const delays = new Map<string, () => void>();
|
|
132
|
+
const frames = new Map<HTMLElement, number>();
|
|
133
|
+
let root: HTMLElement | null = null;
|
|
134
|
+
let from = resolveElement(props.from);
|
|
135
|
+
let to = resolveElement(props.to);
|
|
136
|
+
let parabola!: ParabolaInstance;
|
|
137
|
+
|
|
138
|
+
const createRoot = (): void => {
|
|
139
|
+
if (runtime.destroyed) return;
|
|
140
|
+
if (root) {
|
|
141
|
+
if (!root.isConnected) insert(document.body, root);
|
|
167
142
|
return;
|
|
168
143
|
}
|
|
169
|
-
|
|
170
|
-
this.dom.root = jsx('div', {
|
|
144
|
+
root = jsx('div', {
|
|
171
145
|
'data-parabola': 'root',
|
|
172
146
|
style: {
|
|
173
147
|
position: 'fixed',
|
|
@@ -176,19 +150,31 @@ class Parabola implements ParabolaInstance {
|
|
|
176
150
|
zIndex: '9999',
|
|
177
151
|
},
|
|
178
152
|
}) as HTMLElement;
|
|
179
|
-
document.body
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
153
|
+
insert(document.body, root);
|
|
154
|
+
};
|
|
155
|
+
const removeRootIfIdle = (): void => {
|
|
156
|
+
if (!runtime.destroyed || balls.size > 0) return;
|
|
157
|
+
root?.remove();
|
|
158
|
+
root = null;
|
|
159
|
+
};
|
|
160
|
+
const removeBall = (ball: HTMLElement, notify: boolean): void => {
|
|
161
|
+
if (!balls.has(ball)) return;
|
|
162
|
+
const frame = frames.get(ball);
|
|
163
|
+
if (frame) cancelAnimationFrame(frame);
|
|
164
|
+
frames.delete(ball);
|
|
165
|
+
ball.remove();
|
|
166
|
+
balls.delete(ball);
|
|
167
|
+
if (notify) props.onHidden?.(parabola);
|
|
168
|
+
removeRootIfIdle();
|
|
169
|
+
};
|
|
170
|
+
const createBall = (path: ParabolaPath): HTMLElement | null => {
|
|
171
|
+
if (runtime.destroyed) return null;
|
|
172
|
+
createRoot();
|
|
173
|
+
if (!root) return null;
|
|
174
|
+
const { color, size } = props.ball;
|
|
189
175
|
|
|
190
176
|
const ball = jsx('div', {
|
|
191
|
-
className:
|
|
177
|
+
className: props.className.ball,
|
|
192
178
|
'data-parabola': 'ball',
|
|
193
179
|
style: {
|
|
194
180
|
backgroundColor: color,
|
|
@@ -204,20 +190,13 @@ class Parabola implements ParabolaInstance {
|
|
|
204
190
|
top: `${path.startY}px`,
|
|
205
191
|
},
|
|
206
192
|
}) as HTMLElement;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
193
|
+
insert(root, ball);
|
|
194
|
+
balls.add(ball);
|
|
210
195
|
return ball;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
this.dom.to = resolveElement(this.props.to);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
private calculatePath(): ParabolaPath | null {
|
|
219
|
-
const fromRect = this.dom.from?.getBoundingClientRect();
|
|
220
|
-
const toRect = this.dom.to?.getBoundingClientRect();
|
|
196
|
+
};
|
|
197
|
+
const calculatePath = (): ParabolaPath | null => {
|
|
198
|
+
const fromRect = from?.getBoundingClientRect();
|
|
199
|
+
const toRect = to?.getBoundingClientRect();
|
|
221
200
|
|
|
222
201
|
if (!fromRect || !toRect) return null;
|
|
223
202
|
|
|
@@ -226,7 +205,7 @@ class Parabola implements ParabolaInstance {
|
|
|
226
205
|
|
|
227
206
|
let startX: number;
|
|
228
207
|
let startY: number;
|
|
229
|
-
switch (
|
|
208
|
+
switch (props.direction) {
|
|
230
209
|
case 'top-left':
|
|
231
210
|
startX = fL + fW * 0.2;
|
|
232
211
|
startY = fT + fH * 0.2;
|
|
@@ -252,20 +231,15 @@ class Parabola implements ParabolaInstance {
|
|
|
252
231
|
const endY = tT + tH / 2;
|
|
253
232
|
|
|
254
233
|
return { startX, startY, endX, endY };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
private easeOutCubic(t: number): number {
|
|
258
|
-
return 1 - (1 - t) ** 3;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
private animate(
|
|
234
|
+
};
|
|
235
|
+
const animate = (
|
|
262
236
|
ball: HTMLElement,
|
|
263
237
|
startX: number,
|
|
264
238
|
startY: number,
|
|
265
239
|
endX: number,
|
|
266
240
|
endY: number,
|
|
267
241
|
duration = 800
|
|
268
|
-
): void {
|
|
242
|
+
): void => {
|
|
269
243
|
if (!ball.isConnected) return;
|
|
270
244
|
|
|
271
245
|
const startTime = performance.now();
|
|
@@ -274,11 +248,14 @@ class Parabola implements ParabolaInstance {
|
|
|
274
248
|
const peakOffset = -100;
|
|
275
249
|
|
|
276
250
|
const step = (currentTime: number): void => {
|
|
277
|
-
if (!ball.isConnected)
|
|
251
|
+
if (runtime.destroyed || !ball.isConnected) {
|
|
252
|
+
frames.delete(ball);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
278
255
|
|
|
279
256
|
const elapsed = currentTime - startTime;
|
|
280
257
|
const progress = Math.min(elapsed / duration, 1);
|
|
281
|
-
const eased =
|
|
258
|
+
const eased = 1 - (1 - progress) ** 3;
|
|
282
259
|
|
|
283
260
|
const currentX = startX + deltaX * eased;
|
|
284
261
|
const currentY =
|
|
@@ -292,97 +269,79 @@ class Parabola implements ParabolaInstance {
|
|
|
292
269
|
ball.style.transform = `translate(-50%, -50%) scale(${1 - eased * 0.3})`;
|
|
293
270
|
|
|
294
271
|
if (progress < 1) {
|
|
295
|
-
requestAnimationFrame(step);
|
|
272
|
+
frames.set(ball, requestAnimationFrame(step));
|
|
296
273
|
} else {
|
|
297
|
-
|
|
274
|
+
frames.delete(ball);
|
|
275
|
+
removeBall(ball, true);
|
|
298
276
|
}
|
|
299
277
|
};
|
|
300
278
|
|
|
301
|
-
requestAnimationFrame(step);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
private startDelay(callback: () => void, onCancel: () => void): void {
|
|
279
|
+
frames.set(ball, requestAnimationFrame(step));
|
|
280
|
+
};
|
|
281
|
+
const startDelay = (callback: () => void, onCancel: () => void): void => {
|
|
305
282
|
const key = `parabola-show-${randomId()}`;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
this.cache.delays.delete(key);
|
|
283
|
+
delays.set(key, onCancel);
|
|
284
|
+
timer.start(key, props.showDelay, () => {
|
|
285
|
+
delays.delete(key);
|
|
310
286
|
callback();
|
|
311
287
|
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
for (const [key, onCancel] of this.cache.delays) {
|
|
288
|
+
};
|
|
289
|
+
const cancelDelays = (): void => {
|
|
290
|
+
for (const [key, onCancel] of delays) {
|
|
316
291
|
timer.cancel(key);
|
|
317
292
|
onCancel();
|
|
318
293
|
}
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
private removeBall(ball: HTMLElement, notify: boolean): void {
|
|
323
|
-
if (!this.dom.balls.has(ball)) return;
|
|
324
|
-
|
|
325
|
-
ball.remove();
|
|
326
|
-
this.dom.balls.delete(ball);
|
|
327
|
-
|
|
328
|
-
if (notify) this.props.onHidden?.(this);
|
|
329
|
-
this.removeRootIfIdle();
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
private removeRootIfIdle(): void {
|
|
333
|
-
if (!this.runtime.destroyed || this.dom.balls.size > 0) return;
|
|
334
|
-
|
|
335
|
-
this.dom.root?.remove();
|
|
336
|
-
this.dom.root = null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
show(): Promise<boolean> {
|
|
340
|
-
if (this.runtime.destroyed) return Promise.resolve(false);
|
|
341
|
-
|
|
342
|
-
return new Promise((resolve) => {
|
|
343
|
-
this.startDelay(
|
|
344
|
-
() => {
|
|
345
|
-
if (this.runtime.destroyed) {
|
|
346
|
-
resolve(false);
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
this.resolveTargets();
|
|
351
|
-
|
|
352
|
-
const path = this.calculatePath();
|
|
353
|
-
if (!path) {
|
|
354
|
-
resolve(false);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const ball = this.createBall(path);
|
|
359
|
-
if (!ball) {
|
|
360
|
-
resolve(false);
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
this.props.onShow?.(this);
|
|
365
|
-
|
|
366
|
-
this.animate(ball, path.startX, path.startY, path.endX, path.endY);
|
|
367
|
-
resolve(true);
|
|
368
|
-
},
|
|
369
|
-
() => resolve(false)
|
|
370
|
-
);
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
destroy(): void {
|
|
375
|
-
if (this.runtime.destroyed) return;
|
|
376
|
-
|
|
377
|
-
this.cancelDelays();
|
|
378
|
-
|
|
379
|
-
this.dom.from = null;
|
|
380
|
-
this.dom.to = null;
|
|
381
|
-
this.runtime.destroyed = true;
|
|
382
|
-
this.removeRootIfIdle();
|
|
383
|
-
}
|
|
384
|
-
}
|
|
294
|
+
delays.clear();
|
|
295
|
+
};
|
|
385
296
|
|
|
386
|
-
|
|
387
|
-
|
|
297
|
+
parabola = {
|
|
298
|
+
props,
|
|
299
|
+
runtime,
|
|
300
|
+
get element() {
|
|
301
|
+
return root;
|
|
302
|
+
},
|
|
303
|
+
show() {
|
|
304
|
+
if (runtime.destroyed) return Promise.resolve(false);
|
|
305
|
+
return new Promise((resolve) => {
|
|
306
|
+
startDelay(
|
|
307
|
+
() => {
|
|
308
|
+
if (runtime.destroyed) {
|
|
309
|
+
resolve(false);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
from = resolveElement(props.from);
|
|
313
|
+
to = resolveElement(props.to);
|
|
314
|
+
const path = calculatePath();
|
|
315
|
+
if (!path) {
|
|
316
|
+
resolve(false);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const ball = createBall(path);
|
|
321
|
+
if (!ball) {
|
|
322
|
+
resolve(false);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
props.onShow?.(parabola);
|
|
327
|
+
animate(ball, path.startX, path.startY, path.endX, path.endY);
|
|
328
|
+
resolve(true);
|
|
329
|
+
},
|
|
330
|
+
() => resolve(false)
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
destroy() {
|
|
335
|
+
if (runtime.destroyed) return;
|
|
336
|
+
cancelDelays();
|
|
337
|
+
from = null;
|
|
338
|
+
to = null;
|
|
339
|
+
runtime.destroyed = true;
|
|
340
|
+
for (const ball of balls) removeBall(ball, false);
|
|
341
|
+
frames.clear();
|
|
342
|
+
removeRootIfIdle();
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
createRoot();
|
|
346
|
+
return parabola;
|
|
388
347
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createDeepStore, flushSync } from 'vanilla-signal';
|
|
2
|
+
import { randomId } from '../utilities/id.ts';
|
|
3
|
+
import { type DOMReference, all, requireContainer } from '../utilities/dom.ts';
|
|
4
|
+
import { type ResolveSchema, resolveProps } from '../utilities/types.ts';
|
|
5
|
+
|
|
6
|
+
type StickyOverflow = 'destroy' | 'ignore';
|
|
7
|
+
|
|
8
|
+
interface StickyProps extends Record<string, unknown> {
|
|
9
|
+
target?: DOMReference;
|
|
10
|
+
parent?: DOMReference;
|
|
11
|
+
max?: number;
|
|
12
|
+
top?: number;
|
|
13
|
+
gap?: number;
|
|
14
|
+
overflow?: StickyOverflow;
|
|
15
|
+
reactive?: boolean;
|
|
16
|
+
onReBuild?: ((sticky: StickyInstance) => void) | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ResolvedStickyProps extends Record<string, unknown> {
|
|
20
|
+
target: DOMReference;
|
|
21
|
+
parent: DOMReference;
|
|
22
|
+
max: number;
|
|
23
|
+
top: number;
|
|
24
|
+
gap: number;
|
|
25
|
+
overflow: StickyOverflow;
|
|
26
|
+
reactive: boolean;
|
|
27
|
+
onReBuild: ((sticky: StickyInstance) => void) | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface StickyStateItem {
|
|
31
|
+
key: string;
|
|
32
|
+
index: number;
|
|
33
|
+
top: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface StickyState extends Record<string, unknown> {
|
|
37
|
+
items: StickyStateItem[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface StickyOriginalStyle {
|
|
41
|
+
element: HTMLElement;
|
|
42
|
+
originalPosition: string;
|
|
43
|
+
originalTop: string;
|
|
44
|
+
originalZIndex: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface StickyRuntime {
|
|
48
|
+
built: boolean;
|
|
49
|
+
destroyed: boolean;
|
|
50
|
+
reBuilding: boolean;
|
|
51
|
+
reBuildFrameId: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface StickyCache {
|
|
55
|
+
originalStyles: Map<HTMLElement, StickyOriginalStyle>;
|
|
56
|
+
keys: WeakMap<HTMLElement, string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface StickyInstance {
|
|
60
|
+
readonly props: ResolvedStickyProps;
|
|
61
|
+
readonly state: StickyState;
|
|
62
|
+
readonly runtime: StickyRuntime;
|
|
63
|
+
build(): StickyInstance;
|
|
64
|
+
reBuild(): StickyInstance;
|
|
65
|
+
destroy(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const STICKY_PROPS_SCHEMA = {
|
|
69
|
+
target: { default: null },
|
|
70
|
+
parent: { default: null },
|
|
71
|
+
max: {
|
|
72
|
+
default: 10,
|
|
73
|
+
type: 'number',
|
|
74
|
+
integer: true,
|
|
75
|
+
greaterThan: 0,
|
|
76
|
+
},
|
|
77
|
+
top: {
|
|
78
|
+
default: 16,
|
|
79
|
+
type: 'number',
|
|
80
|
+
min: 0,
|
|
81
|
+
},
|
|
82
|
+
gap: {
|
|
83
|
+
default: 16,
|
|
84
|
+
type: 'number',
|
|
85
|
+
min: 0,
|
|
86
|
+
},
|
|
87
|
+
overflow: {
|
|
88
|
+
default: 'destroy',
|
|
89
|
+
type: 'string',
|
|
90
|
+
enum: ['destroy', 'ignore'],
|
|
91
|
+
},
|
|
92
|
+
reactive: { default: false, type: 'boolean' },
|
|
93
|
+
onReBuild: { default: null, types: ['function', 'null'] },
|
|
94
|
+
} satisfies ResolveSchema<StickyProps>;
|
|
95
|
+
|
|
96
|
+
function normalizeProps(input: StickyProps): ResolvedStickyProps {
|
|
97
|
+
const props = resolveProps(input, STICKY_PROPS_SCHEMA, 'Sticky.props');
|
|
98
|
+
return {
|
|
99
|
+
target: props.target as DOMReference,
|
|
100
|
+
parent: props.parent as DOMReference,
|
|
101
|
+
max: props.max as number,
|
|
102
|
+
top: props.top as number,
|
|
103
|
+
gap: props.gap as number,
|
|
104
|
+
overflow: props.overflow as StickyOverflow,
|
|
105
|
+
reactive: props.reactive as boolean,
|
|
106
|
+
onReBuild: props.onReBuild as ResolvedStickyProps['onReBuild'],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function uniqueElements(elements: readonly Node[]): HTMLElement[] {
|
|
111
|
+
const htmlElements = elements.filter(
|
|
112
|
+
(element): element is HTMLElement => element instanceof HTMLElement
|
|
113
|
+
);
|
|
114
|
+
return Array.from(new Set(htmlElements));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveParent(parent: DOMReference): Element | null {
|
|
118
|
+
if (parent === false || parent == null) return null;
|
|
119
|
+
return requireContainer(parent, 'Sticky.parent', 'element');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveTarget(
|
|
123
|
+
target: DOMReference,
|
|
124
|
+
parent: Element | null
|
|
125
|
+
): HTMLElement[] {
|
|
126
|
+
if (target === false || target == null) return [];
|
|
127
|
+
|
|
128
|
+
if (typeof target === 'string') {
|
|
129
|
+
if (!parent) {
|
|
130
|
+
return uniqueElements(requireContainer(target, 'Sticky.target', 'array'));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const elements = uniqueElements(all<HTMLElement>(target, parent));
|
|
134
|
+
if (elements.length === 0) {
|
|
135
|
+
throw new Error('Sticky.target: target not found.');
|
|
136
|
+
}
|
|
137
|
+
return elements;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const elements = uniqueElements(
|
|
141
|
+
requireContainer(target, 'Sticky.target', 'array')
|
|
142
|
+
);
|
|
143
|
+
if (!parent) return elements;
|
|
144
|
+
|
|
145
|
+
const scopedElements = elements.filter(
|
|
146
|
+
(element) => element === parent || parent.contains(element)
|
|
147
|
+
);
|
|
148
|
+
if (scopedElements.length === 0) {
|
|
149
|
+
throw new Error('Sticky.target: target not found in parent.');
|
|
150
|
+
}
|
|
151
|
+
return scopedElements;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createSticky(props: StickyProps = {}): StickyInstance {
|
|
155
|
+
const settings = normalizeProps(props);
|
|
156
|
+
const state = createDeepStore({ items: [] }) as StickyState;
|
|
157
|
+
const runtime: StickyRuntime = {
|
|
158
|
+
built: false,
|
|
159
|
+
destroyed: false,
|
|
160
|
+
reBuilding: false,
|
|
161
|
+
reBuildFrameId: 0,
|
|
162
|
+
};
|
|
163
|
+
const cache: StickyCache = {
|
|
164
|
+
originalStyles: new Map(),
|
|
165
|
+
keys: new WeakMap(),
|
|
166
|
+
};
|
|
167
|
+
let parent: Element | null = null;
|
|
168
|
+
let targets: HTMLElement[] = [];
|
|
169
|
+
let observer: MutationObserver | null = null;
|
|
170
|
+
let sticky: StickyInstance;
|
|
171
|
+
|
|
172
|
+
const resolveOverflow = (elements: HTMLElement[]): HTMLElement[] => {
|
|
173
|
+
if (elements.length <= settings.max) return elements;
|
|
174
|
+
return settings.overflow === 'ignore' ? [] : elements.slice(-settings.max);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const restore = (): void => {
|
|
178
|
+
for (const item of cache.originalStyles.values()) restoreItem(item);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const restoreItem = (item: StickyOriginalStyle): void => {
|
|
182
|
+
item.element.style.position = item.originalPosition;
|
|
183
|
+
item.element.style.top = item.originalTop;
|
|
184
|
+
item.element.style.zIndex = item.originalZIndex;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const itemKey = (element: HTMLElement, index: number): string => {
|
|
188
|
+
if (element.id) return element.id;
|
|
189
|
+
const cached = cache.keys.get(element);
|
|
190
|
+
if (cached) return cached;
|
|
191
|
+
const key = `sticky-${randomId()}-${index}`;
|
|
192
|
+
cache.keys.set(element, key);
|
|
193
|
+
return key;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const rememberOriginalStyle = (element: HTMLElement): void => {
|
|
197
|
+
if (cache.originalStyles.has(element)) return;
|
|
198
|
+
cache.originalStyles.set(element, {
|
|
199
|
+
element,
|
|
200
|
+
originalPosition: element.style.position,
|
|
201
|
+
originalTop: element.style.top,
|
|
202
|
+
originalZIndex: element.style.zIndex,
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const resolveTargets = (): HTMLElement[] => {
|
|
207
|
+
return resolveOverflow(resolveTarget(settings.target, parent));
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const apply = (startTop = settings.top): number => {
|
|
211
|
+
let nextTop = startTop;
|
|
212
|
+
const items: StickyStateItem[] = [];
|
|
213
|
+
const activeTargets = new Set(targets);
|
|
214
|
+
|
|
215
|
+
for (const [element, item] of cache.originalStyles) {
|
|
216
|
+
if (!activeTargets.has(element)) {
|
|
217
|
+
restoreItem(item);
|
|
218
|
+
cache.originalStyles.delete(element);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
targets.forEach((element, index) => {
|
|
223
|
+
rememberOriginalStyle(element);
|
|
224
|
+
element.style.position = 'sticky';
|
|
225
|
+
element.style.top = `${nextTop}px`;
|
|
226
|
+
items.push({ key: itemKey(element, index), index, top: nextTop });
|
|
227
|
+
nextTop += element.offsetHeight + settings.gap;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
flushSync(() => {
|
|
231
|
+
state.items = items;
|
|
232
|
+
});
|
|
233
|
+
settings.onReBuild?.(sticky);
|
|
234
|
+
return nextTop;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const observeParent = (): void => {
|
|
238
|
+
observer?.disconnect();
|
|
239
|
+
observer = null;
|
|
240
|
+
if (!settings.reactive) return;
|
|
241
|
+
const root = parent || document.body;
|
|
242
|
+
observer = new MutationObserver(() => {
|
|
243
|
+
if (runtime.reBuilding || runtime.destroyed || !runtime.built) return;
|
|
244
|
+
runtime.reBuilding = true;
|
|
245
|
+
runtime.reBuildFrameId = requestAnimationFrame(() => {
|
|
246
|
+
runtime.reBuilding = false;
|
|
247
|
+
runtime.reBuildFrameId = 0;
|
|
248
|
+
sticky.reBuild();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const build = (): StickyInstance => {
|
|
255
|
+
if (runtime.destroyed) throw new Error('Sticky.build: instance destroyed');
|
|
256
|
+
if (runtime.built) return sticky;
|
|
257
|
+
|
|
258
|
+
parent = resolveParent(settings.parent);
|
|
259
|
+
targets = resolveTargets();
|
|
260
|
+
runtime.built = true;
|
|
261
|
+
observeParent();
|
|
262
|
+
if (targets.length === 0) {
|
|
263
|
+
flushSync(() => {
|
|
264
|
+
state.items = [];
|
|
265
|
+
});
|
|
266
|
+
return sticky;
|
|
267
|
+
}
|
|
268
|
+
apply();
|
|
269
|
+
return sticky;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const reBuild = (): StickyInstance => {
|
|
273
|
+
if (!runtime.destroyed && runtime.built) {
|
|
274
|
+
targets = resolveTargets();
|
|
275
|
+
apply();
|
|
276
|
+
}
|
|
277
|
+
return sticky;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const destroy = (): void => {
|
|
281
|
+
if (runtime.destroyed) return;
|
|
282
|
+
if (runtime.reBuildFrameId) cancelAnimationFrame(runtime.reBuildFrameId);
|
|
283
|
+
observer?.disconnect();
|
|
284
|
+
observer = null;
|
|
285
|
+
restore();
|
|
286
|
+
runtime.destroyed = true;
|
|
287
|
+
runtime.built = false;
|
|
288
|
+
runtime.reBuilding = false;
|
|
289
|
+
runtime.reBuildFrameId = 0;
|
|
290
|
+
cache.originalStyles.clear();
|
|
291
|
+
targets = [];
|
|
292
|
+
parent = null;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
sticky = { props: settings, state, runtime, build, reBuild, destroy };
|
|
296
|
+
return sticky;
|
|
297
|
+
}
|