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
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { flushSync } from 'vanilla-signal';
|
|
2
|
+
|
|
3
|
+
import { isPlainObject } from '../utilities/object.ts';
|
|
4
|
+
import { createOwnedView } from './view.ts';
|
|
5
|
+
|
|
6
|
+
export type ComponentProps = Record<string, unknown>;
|
|
7
|
+
export type ComponentState = Record<string, unknown>;
|
|
8
|
+
export type ComponentCleanup =
|
|
9
|
+
| void
|
|
10
|
+
| (() => void)
|
|
11
|
+
| {
|
|
12
|
+
destroy: () => void;
|
|
13
|
+
};
|
|
14
|
+
export type ComponentPluginOptions = Record<string, unknown> | undefined;
|
|
15
|
+
|
|
16
|
+
export interface ComponentRuntime {
|
|
17
|
+
built: boolean;
|
|
18
|
+
mounted: boolean;
|
|
19
|
+
destroyed: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ComponentLifecycleEvent = 'build' | 'mount' | 'unmount' | 'destroy';
|
|
23
|
+
export type ComponentListener = (...args: unknown[]) => void;
|
|
24
|
+
|
|
25
|
+
export interface ComponentController<
|
|
26
|
+
TProps extends ComponentProps = ComponentProps,
|
|
27
|
+
TState extends ComponentState = ComponentState,
|
|
28
|
+
TElement extends Element = HTMLElement,
|
|
29
|
+
> {
|
|
30
|
+
readonly props: TProps;
|
|
31
|
+
readonly state: TState;
|
|
32
|
+
readonly runtime: ComponentRuntime;
|
|
33
|
+
readonly element: TElement | null;
|
|
34
|
+
build(): this;
|
|
35
|
+
mount(container: Element | DocumentFragment): this;
|
|
36
|
+
unmount(): this;
|
|
37
|
+
setState(patch?: Partial<TState> | null): this;
|
|
38
|
+
setState<TKey extends keyof TState>(key: TKey, value: TState[TKey]): this;
|
|
39
|
+
own(cleanup: ComponentCleanup): this;
|
|
40
|
+
use(
|
|
41
|
+
plugin: ComponentPlugin<this> | null | undefined,
|
|
42
|
+
options?: ComponentPluginOptions
|
|
43
|
+
): this;
|
|
44
|
+
on(event: string, listener: ComponentListener): this;
|
|
45
|
+
off(event: string, listener: ComponentListener): this;
|
|
46
|
+
emit(event: string, ...args: unknown[]): this;
|
|
47
|
+
destroy(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ComponentPlugin<TComponent = ComponentController> =
|
|
51
|
+
| ((
|
|
52
|
+
component: TComponent,
|
|
53
|
+
options?: ComponentPluginOptions
|
|
54
|
+
) => ComponentCleanup)
|
|
55
|
+
| {
|
|
56
|
+
install: (
|
|
57
|
+
component: TComponent,
|
|
58
|
+
options?: ComponentPluginOptions
|
|
59
|
+
) => ComponentCleanup;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface ComponentContext<
|
|
63
|
+
TProps extends ComponentProps,
|
|
64
|
+
TState extends ComponentState,
|
|
65
|
+
TElement extends Element,
|
|
66
|
+
> {
|
|
67
|
+
readonly props: TProps;
|
|
68
|
+
readonly state: TState;
|
|
69
|
+
readonly runtime: ComponentRuntime;
|
|
70
|
+
readonly element: TElement | null;
|
|
71
|
+
own: (cleanup: ComponentCleanup) => void;
|
|
72
|
+
assertActive: (operation: string) => void;
|
|
73
|
+
emit: (event: string, ...args: unknown[]) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ComponentDefinition<
|
|
77
|
+
TProps extends ComponentProps,
|
|
78
|
+
TState extends ComponentState,
|
|
79
|
+
TElement extends Element,
|
|
80
|
+
TActions extends object,
|
|
81
|
+
> {
|
|
82
|
+
name: string;
|
|
83
|
+
ownsElement?: boolean;
|
|
84
|
+
props: TProps;
|
|
85
|
+
state: TState;
|
|
86
|
+
view: (context: ComponentContext<TProps, TState, TElement>) => TElement;
|
|
87
|
+
actions?: TActions;
|
|
88
|
+
normalizeStatePatch?: (patch: Partial<TState>) => Partial<TState>;
|
|
89
|
+
validateStatePatch?: (patch: Partial<TState>, state: TState) => void;
|
|
90
|
+
onBuild?: (context: ComponentContext<TProps, TState, TElement>) => void;
|
|
91
|
+
onMount?: (context: ComponentContext<TProps, TState, TElement>) => void;
|
|
92
|
+
onUnmount?: (context: ComponentContext<TProps, TState, TElement>) => void;
|
|
93
|
+
onDestroy?: (context: ComponentContext<TProps, TState, TElement>) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type FunctionalComponent<
|
|
97
|
+
TProps extends ComponentProps,
|
|
98
|
+
TState extends ComponentState,
|
|
99
|
+
TElement extends Element,
|
|
100
|
+
TActions extends object = object,
|
|
101
|
+
> = ComponentController<TProps, TState, TElement> & TActions;
|
|
102
|
+
|
|
103
|
+
const globalPlugins = new Map<string, ComponentPlugin>();
|
|
104
|
+
|
|
105
|
+
export function useComponentPlugin(
|
|
106
|
+
name: string,
|
|
107
|
+
plugin: ComponentPlugin | null | undefined
|
|
108
|
+
): void {
|
|
109
|
+
if (!name || !plugin) return;
|
|
110
|
+
globalPlugins.set(name, plugin);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function removeComponentPlugin(name: string): void {
|
|
114
|
+
globalPlugins.delete(name);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function runCleanup(cleanup: ComponentCleanup): void {
|
|
118
|
+
if (typeof cleanup === 'function') cleanup();
|
|
119
|
+
else if (cleanup && typeof cleanup.destroy === 'function') cleanup.destroy();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function defineComponent<
|
|
123
|
+
TProps extends ComponentProps,
|
|
124
|
+
TState extends ComponentState,
|
|
125
|
+
TElement extends Element,
|
|
126
|
+
TActions extends object = object,
|
|
127
|
+
>(
|
|
128
|
+
definition: ComponentDefinition<TProps, TState, TElement, TActions>
|
|
129
|
+
): FunctionalComponent<TProps, TState, TElement, TActions> {
|
|
130
|
+
const runtime: ComponentRuntime = {
|
|
131
|
+
built: false,
|
|
132
|
+
mounted: false,
|
|
133
|
+
destroyed: false,
|
|
134
|
+
};
|
|
135
|
+
const listeners = new Map<string, Set<ComponentListener>>();
|
|
136
|
+
const resources = new Set<ComponentCleanup>();
|
|
137
|
+
const plugins = new Map<ComponentPlugin<unknown>, ComponentCleanup>();
|
|
138
|
+
let element: TElement | null = null;
|
|
139
|
+
let disposeView: (() => void) | null = null;
|
|
140
|
+
let component: FunctionalComponent<TProps, TState, TElement, TActions>;
|
|
141
|
+
|
|
142
|
+
const assertActive = (operation: string): void => {
|
|
143
|
+
if (runtime.destroyed) {
|
|
144
|
+
throw new Error(`${definition.name}.${operation}: instance destroyed`);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const emit = (event: string, ...args: unknown[]): void => {
|
|
149
|
+
for (const listener of listeners.get(event) || []) {
|
|
150
|
+
try {
|
|
151
|
+
listener(...args);
|
|
152
|
+
} catch {
|
|
153
|
+
// Listener failures must not interrupt the component lifecycle.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const context: ComponentContext<TProps, TState, TElement> = {
|
|
159
|
+
props: definition.props,
|
|
160
|
+
state: definition.state,
|
|
161
|
+
runtime,
|
|
162
|
+
get element() {
|
|
163
|
+
return element;
|
|
164
|
+
},
|
|
165
|
+
own(cleanup) {
|
|
166
|
+
if (cleanup) resources.add(cleanup);
|
|
167
|
+
},
|
|
168
|
+
assertActive,
|
|
169
|
+
emit,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const controller: ComponentController<TProps, TState, TElement> = {
|
|
173
|
+
props: definition.props,
|
|
174
|
+
state: definition.state,
|
|
175
|
+
runtime,
|
|
176
|
+
get element() {
|
|
177
|
+
return element;
|
|
178
|
+
},
|
|
179
|
+
build() {
|
|
180
|
+
assertActive('build');
|
|
181
|
+
if (runtime.built) return component;
|
|
182
|
+
|
|
183
|
+
const ownedView = createOwnedView(() => definition.view(context), {
|
|
184
|
+
removeOnDispose: definition.ownsElement,
|
|
185
|
+
});
|
|
186
|
+
element = ownedView.element;
|
|
187
|
+
disposeView = ownedView.dispose;
|
|
188
|
+
runtime.built = true;
|
|
189
|
+
definition.onBuild?.(context);
|
|
190
|
+
emit('build', component);
|
|
191
|
+
return component;
|
|
192
|
+
},
|
|
193
|
+
mount(container) {
|
|
194
|
+
assertActive('mount');
|
|
195
|
+
if (!runtime.built) component.build();
|
|
196
|
+
if (!element) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`${definition.name}.mount: view did not create an element.`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (element.parentNode !== container) container.appendChild(element);
|
|
202
|
+
runtime.mounted = true;
|
|
203
|
+
definition.onMount?.(context);
|
|
204
|
+
emit('mount', component, container);
|
|
205
|
+
return component;
|
|
206
|
+
},
|
|
207
|
+
unmount() {
|
|
208
|
+
assertActive('unmount');
|
|
209
|
+
if (!runtime.mounted && !element?.parentNode) return component;
|
|
210
|
+
definition.onUnmount?.(context);
|
|
211
|
+
if (definition.ownsElement !== false) element?.remove();
|
|
212
|
+
runtime.mounted = false;
|
|
213
|
+
emit('unmount', component);
|
|
214
|
+
return component;
|
|
215
|
+
},
|
|
216
|
+
setState(
|
|
217
|
+
keyOrPatch: Partial<TState> | keyof TState | null | undefined = {},
|
|
218
|
+
value?: unknown
|
|
219
|
+
) {
|
|
220
|
+
assertActive('setState');
|
|
221
|
+
const patch =
|
|
222
|
+
typeof keyOrPatch === 'string' && arguments.length > 1
|
|
223
|
+
? { [keyOrPatch]: value }
|
|
224
|
+
: keyOrPatch;
|
|
225
|
+
if (!isPlainObject(patch)) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`${definition.name}.setState: expects a plain object patch.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const normalized = definition.normalizeStatePatch
|
|
232
|
+
? definition.normalizeStatePatch(patch as Partial<TState>)
|
|
233
|
+
: (patch as Partial<TState>);
|
|
234
|
+
if (!isPlainObject(normalized)) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`${definition.name}.setState: expects a plain object patch.`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (definition.validateStatePatch) {
|
|
241
|
+
definition.validateStatePatch(normalized, definition.state);
|
|
242
|
+
} else {
|
|
243
|
+
for (const key of Object.keys(normalized)) {
|
|
244
|
+
if (!Object.hasOwn(definition.state, key)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`${definition.name}.setState: "${key}" is not a supported state key.`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
flushSync(() => {
|
|
253
|
+
Object.assign(definition.state, normalized);
|
|
254
|
+
});
|
|
255
|
+
return component;
|
|
256
|
+
},
|
|
257
|
+
own(cleanup) {
|
|
258
|
+
if (cleanup) resources.add(cleanup);
|
|
259
|
+
return component;
|
|
260
|
+
},
|
|
261
|
+
use(plugin, options) {
|
|
262
|
+
if (!plugin) return component;
|
|
263
|
+
const cleanup =
|
|
264
|
+
typeof plugin === 'function'
|
|
265
|
+
? plugin(component, options)
|
|
266
|
+
: plugin.install(component, options);
|
|
267
|
+
plugins.set(plugin as ComponentPlugin<unknown>, cleanup);
|
|
268
|
+
return component;
|
|
269
|
+
},
|
|
270
|
+
on(event, listener) {
|
|
271
|
+
if (!event || typeof listener !== 'function') return component;
|
|
272
|
+
let eventListeners = listeners.get(event);
|
|
273
|
+
if (!eventListeners) {
|
|
274
|
+
eventListeners = new Set();
|
|
275
|
+
listeners.set(event, eventListeners);
|
|
276
|
+
}
|
|
277
|
+
eventListeners.add(listener);
|
|
278
|
+
return component;
|
|
279
|
+
},
|
|
280
|
+
off(event, listener) {
|
|
281
|
+
listeners.get(event)?.delete(listener);
|
|
282
|
+
return component;
|
|
283
|
+
},
|
|
284
|
+
emit(event, ...args) {
|
|
285
|
+
emit(event, ...args);
|
|
286
|
+
return component;
|
|
287
|
+
},
|
|
288
|
+
destroy() {
|
|
289
|
+
if (runtime.destroyed) return;
|
|
290
|
+
runtime.destroyed = true;
|
|
291
|
+
definition.onDestroy?.(context);
|
|
292
|
+
emit('destroy', component);
|
|
293
|
+
|
|
294
|
+
for (const cleanup of resources) {
|
|
295
|
+
try {
|
|
296
|
+
runCleanup(cleanup);
|
|
297
|
+
} catch {
|
|
298
|
+
// Cleanup is best-effort; remaining resources must still be released.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
resources.clear();
|
|
302
|
+
|
|
303
|
+
for (const cleanup of plugins.values()) {
|
|
304
|
+
try {
|
|
305
|
+
runCleanup(cleanup);
|
|
306
|
+
} catch {
|
|
307
|
+
// Plugin cleanup must not prevent view disposal.
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
plugins.clear();
|
|
311
|
+
|
|
312
|
+
disposeView?.();
|
|
313
|
+
disposeView = null;
|
|
314
|
+
if (definition.ownsElement !== false) element?.remove();
|
|
315
|
+
element = null;
|
|
316
|
+
runtime.built = false;
|
|
317
|
+
runtime.mounted = false;
|
|
318
|
+
listeners.clear();
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
component = Object.assign(
|
|
323
|
+
controller,
|
|
324
|
+
definition.actions || {}
|
|
325
|
+
) as FunctionalComponent<TProps, TState, TElement, TActions>;
|
|
326
|
+
|
|
327
|
+
for (const plugin of globalPlugins.values()) {
|
|
328
|
+
component.use(plugin as ComponentPlugin<typeof component>);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return component;
|
|
332
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
export type TransitionTarget = () => Element | null | undefined;
|
|
2
|
+
|
|
3
|
+
export interface TransitionDefinition {
|
|
4
|
+
keyframes: Keyframe[] | PropertyIndexedKeyframes;
|
|
5
|
+
options?: KeyframeAnimationOptions;
|
|
6
|
+
respectReducedMotion?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface MotionController {
|
|
10
|
+
enter: (signal?: AbortSignal) => Promise<void>;
|
|
11
|
+
leave: (signal?: AbortSignal) => Promise<void>;
|
|
12
|
+
cancel: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CollapseTransitionDefinition {
|
|
16
|
+
axis?: 'vertical' | 'horizontal';
|
|
17
|
+
options?: KeyframeAnimationOptions;
|
|
18
|
+
fade?: boolean;
|
|
19
|
+
respectReducedMotion?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CollapseMotionController extends MotionController {
|
|
23
|
+
setExpanded: (expanded: boolean) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createMotionGroup(
|
|
27
|
+
...motions: readonly MotionController[]
|
|
28
|
+
): MotionController {
|
|
29
|
+
return {
|
|
30
|
+
async enter(signal) {
|
|
31
|
+
await Promise.all(motions.map((motion) => motion.enter(signal)));
|
|
32
|
+
},
|
|
33
|
+
async leave(signal) {
|
|
34
|
+
await Promise.all(motions.map((motion) => motion.leave(signal)));
|
|
35
|
+
},
|
|
36
|
+
cancel() {
|
|
37
|
+
for (const motion of motions) motion.cancel();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function waitForAnimation(
|
|
43
|
+
animation: Animation,
|
|
44
|
+
signal?: AbortSignal
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const finished = animation.finished.then(
|
|
47
|
+
() => undefined,
|
|
48
|
+
() => undefined
|
|
49
|
+
);
|
|
50
|
+
if (!signal) return finished;
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const complete = (): void => {
|
|
53
|
+
signal.removeEventListener('abort', complete);
|
|
54
|
+
resolve();
|
|
55
|
+
};
|
|
56
|
+
if (signal.aborted) {
|
|
57
|
+
complete();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
signal.addEventListener('abort', complete, { once: true });
|
|
61
|
+
void finished.then(complete);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolvedOptions(
|
|
66
|
+
element: Element,
|
|
67
|
+
definition: TransitionDefinition
|
|
68
|
+
): KeyframeAnimationOptions {
|
|
69
|
+
const options: KeyframeAnimationOptions = {
|
|
70
|
+
fill: 'both',
|
|
71
|
+
...definition.options,
|
|
72
|
+
};
|
|
73
|
+
const view = element.ownerDocument.defaultView;
|
|
74
|
+
if (
|
|
75
|
+
definition.respectReducedMotion !== false &&
|
|
76
|
+
view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
77
|
+
) {
|
|
78
|
+
options.duration = 0;
|
|
79
|
+
options.delay = 0;
|
|
80
|
+
options.endDelay = 0;
|
|
81
|
+
}
|
|
82
|
+
return options;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createTransition(
|
|
86
|
+
target: TransitionTarget,
|
|
87
|
+
definition: TransitionDefinition
|
|
88
|
+
): MotionController {
|
|
89
|
+
let animation: Animation | null = null;
|
|
90
|
+
|
|
91
|
+
const resolveElement = (): Element | null => {
|
|
92
|
+
const nextElement = target() || null;
|
|
93
|
+
if (!nextElement || typeof nextElement.animate !== 'function') return null;
|
|
94
|
+
return nextElement;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const keyframesFor = (
|
|
98
|
+
direction: 1 | -1
|
|
99
|
+
): Keyframe[] | PropertyIndexedKeyframes => {
|
|
100
|
+
if (direction === 1 || !Array.isArray(definition.keyframes)) {
|
|
101
|
+
return definition.keyframes;
|
|
102
|
+
}
|
|
103
|
+
return definition.keyframes.slice().reverse();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const play = async (
|
|
107
|
+
direction: 1 | -1,
|
|
108
|
+
signal?: AbortSignal
|
|
109
|
+
): Promise<void> => {
|
|
110
|
+
if (signal?.aborted) return;
|
|
111
|
+
const currentElement = resolveElement();
|
|
112
|
+
if (!currentElement) return;
|
|
113
|
+
animation?.cancel();
|
|
114
|
+
animation = currentElement.animate(
|
|
115
|
+
keyframesFor(direction),
|
|
116
|
+
resolvedOptions(currentElement, definition)
|
|
117
|
+
);
|
|
118
|
+
const currentAnimation = animation;
|
|
119
|
+
await waitForAnimation(currentAnimation, signal);
|
|
120
|
+
if (signal?.aborted || animation !== currentAnimation) return;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
enter: (signal) => play(1, signal),
|
|
125
|
+
leave: (signal) => play(-1, signal),
|
|
126
|
+
cancel() {
|
|
127
|
+
animation?.cancel();
|
|
128
|
+
animation = null;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface CollapseInlineStyles {
|
|
134
|
+
size: string;
|
|
135
|
+
opacity: string;
|
|
136
|
+
overflow: string;
|
|
137
|
+
visibility: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createCollapseTransition(
|
|
141
|
+
target: () => HTMLElement | null | undefined,
|
|
142
|
+
definition: CollapseTransitionDefinition = {}
|
|
143
|
+
): CollapseMotionController {
|
|
144
|
+
const axis = definition.axis || 'vertical';
|
|
145
|
+
let element: HTMLElement | null = null;
|
|
146
|
+
let animation: Animation | null = null;
|
|
147
|
+
let original: CollapseInlineStyles | null = null;
|
|
148
|
+
let visibleOpacity = '1';
|
|
149
|
+
let operation = 0;
|
|
150
|
+
|
|
151
|
+
const readSize = (current: HTMLElement): string =>
|
|
152
|
+
axis === 'vertical' ? current.style.height : current.style.width;
|
|
153
|
+
|
|
154
|
+
const writeSize = (current: HTMLElement, value: string): void => {
|
|
155
|
+
if (axis === 'vertical') current.style.height = value;
|
|
156
|
+
else current.style.width = value;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const restore = (): void => {
|
|
160
|
+
if (!element || !original) return;
|
|
161
|
+
writeSize(element, original.size);
|
|
162
|
+
element.style.opacity = original.opacity;
|
|
163
|
+
element.style.overflow = original.overflow;
|
|
164
|
+
element.style.visibility = original.visibility;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const resolveElement = (): HTMLElement | null => {
|
|
168
|
+
const next = target() || null;
|
|
169
|
+
if (next === element) return element;
|
|
170
|
+
|
|
171
|
+
animation?.cancel();
|
|
172
|
+
restore();
|
|
173
|
+
animation = null;
|
|
174
|
+
element = next;
|
|
175
|
+
original = next
|
|
176
|
+
? {
|
|
177
|
+
size: readSize(next),
|
|
178
|
+
opacity: next.style.opacity,
|
|
179
|
+
overflow: next.style.overflow,
|
|
180
|
+
visibility: next.style.visibility,
|
|
181
|
+
}
|
|
182
|
+
: null;
|
|
183
|
+
visibleOpacity =
|
|
184
|
+
next?.ownerDocument.defaultView?.getComputedStyle(next).opacity || '1';
|
|
185
|
+
return element;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const applyBoundary = (expanded: boolean): void => {
|
|
189
|
+
const current = resolveElement();
|
|
190
|
+
if (!current || !original) return;
|
|
191
|
+
if (expanded) {
|
|
192
|
+
restore();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
writeSize(current, '0px');
|
|
196
|
+
current.style.overflow = 'hidden';
|
|
197
|
+
current.style.visibility = 'hidden';
|
|
198
|
+
if (definition.fade !== false) current.style.opacity = '0';
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const measureExpandedSize = (current: HTMLElement): number => {
|
|
202
|
+
if (!original) return 0;
|
|
203
|
+
const size = readSize(current);
|
|
204
|
+
const visibility = current.style.visibility;
|
|
205
|
+
writeSize(current, original.size);
|
|
206
|
+
current.style.visibility = original.visibility;
|
|
207
|
+
const bounds = current.getBoundingClientRect();
|
|
208
|
+
const measured =
|
|
209
|
+
axis === 'vertical'
|
|
210
|
+
? Math.max(bounds.height, current.scrollHeight)
|
|
211
|
+
: Math.max(bounds.width, current.scrollWidth);
|
|
212
|
+
writeSize(current, size);
|
|
213
|
+
current.style.visibility = visibility;
|
|
214
|
+
return measured;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const play = async (
|
|
218
|
+
expanded: boolean,
|
|
219
|
+
signal?: AbortSignal
|
|
220
|
+
): Promise<void> => {
|
|
221
|
+
if (signal?.aborted) return;
|
|
222
|
+
const current = resolveElement();
|
|
223
|
+
if (!current || !original) return;
|
|
224
|
+
|
|
225
|
+
const bounds = current.getBoundingClientRect();
|
|
226
|
+
const currentSize = axis === 'vertical' ? bounds.height : bounds.width;
|
|
227
|
+
const computedOpacity =
|
|
228
|
+
current.ownerDocument.defaultView?.getComputedStyle(current).opacity;
|
|
229
|
+
const currentOpacity = animation
|
|
230
|
+
? computedOpacity || (expanded ? '0' : visibleOpacity)
|
|
231
|
+
: current.style.opacity ||
|
|
232
|
+
computedOpacity ||
|
|
233
|
+
(expanded ? '0' : visibleOpacity);
|
|
234
|
+
const token = ++operation;
|
|
235
|
+
|
|
236
|
+
animation?.cancel();
|
|
237
|
+
animation = null;
|
|
238
|
+
const targetSize = expanded ? measureExpandedSize(current) : 0;
|
|
239
|
+
current.style.overflow = 'hidden';
|
|
240
|
+
current.style.visibility = original.visibility;
|
|
241
|
+
|
|
242
|
+
if (typeof current.animate !== 'function') {
|
|
243
|
+
applyBoundary(expanded);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const options = resolvedOptions(current, {
|
|
248
|
+
keyframes: [],
|
|
249
|
+
options: {
|
|
250
|
+
duration: 250,
|
|
251
|
+
easing: 'ease',
|
|
252
|
+
...definition.options,
|
|
253
|
+
fill: 'both',
|
|
254
|
+
},
|
|
255
|
+
respectReducedMotion: definition.respectReducedMotion,
|
|
256
|
+
});
|
|
257
|
+
animation = current.animate(
|
|
258
|
+
[
|
|
259
|
+
{
|
|
260
|
+
[axis === 'vertical' ? 'height' : 'width']: `${currentSize}px`,
|
|
261
|
+
...(definition.fade === false ? {} : { opacity: currentOpacity }),
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
[axis === 'vertical' ? 'height' : 'width']: `${targetSize}px`,
|
|
265
|
+
...(definition.fade === false
|
|
266
|
+
? {}
|
|
267
|
+
: { opacity: expanded ? visibleOpacity : '0' }),
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
options
|
|
271
|
+
);
|
|
272
|
+
const currentAnimation = animation;
|
|
273
|
+
await waitForAnimation(currentAnimation, signal);
|
|
274
|
+
if (token !== operation || signal?.aborted) return;
|
|
275
|
+
currentAnimation.cancel();
|
|
276
|
+
if (animation === currentAnimation) animation = null;
|
|
277
|
+
applyBoundary(expanded);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
enter: (signal) => play(true, signal),
|
|
282
|
+
leave: (signal) => play(false, signal),
|
|
283
|
+
setExpanded(expanded) {
|
|
284
|
+
operation += 1;
|
|
285
|
+
animation?.cancel();
|
|
286
|
+
animation = null;
|
|
287
|
+
applyBoundary(expanded);
|
|
288
|
+
},
|
|
289
|
+
cancel() {
|
|
290
|
+
operation += 1;
|
|
291
|
+
animation?.cancel();
|
|
292
|
+
animation = null;
|
|
293
|
+
restore();
|
|
294
|
+
element = null;
|
|
295
|
+
original = null;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|