@tobiasoberpaul/mdk 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +27 -2
- package/dist/behaviors.d.ts +25 -0
- package/dist/behaviors.js +20 -0
- package/dist/core/animation.d.ts +16 -0
- package/dist/core/animation.js +150 -0
- package/dist/core/content-frames.d.ts +13 -0
- package/dist/core/content-frames.js +27 -0
- package/dist/core/content-groups.d.ts +18 -0
- package/dist/core/content-groups.js +34 -0
- package/dist/core/content-layout.d.ts +4 -0
- package/dist/core/content-layout.js +84 -0
- package/dist/core/content-timing.d.ts +16 -0
- package/dist/core/content-timing.js +20 -0
- package/dist/core/geometry.d.ts +69 -0
- package/dist/core/geometry.js +127 -0
- package/dist/core/lifecycle.d.ts +21 -0
- package/dist/core/lifecycle.js +1 -0
- package/dist/core/resize.d.ts +14 -0
- package/dist/core/resize.js +136 -0
- package/dist/core/rounded-shape.d.ts +2 -0
- package/dist/core/rounded-shape.js +6 -0
- package/dist/core/tokens.d.ts +7 -0
- package/dist/core/tokens.js +54 -0
- package/dist/declarative.d.ts +9 -0
- package/dist/declarative.js +83 -0
- package/dist/dismiss.d.ts +8 -0
- package/dist/dismiss.js +14 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +15 -0
- package/dist/interactions.d.ts +25 -0
- package/dist/interactions.js +115 -0
- package/dist/layout.d.ts +3 -0
- package/dist/layout.js +22 -0
- package/dist/observe.d.ts +19 -0
- package/dist/observe.js +44 -0
- package/dist/position.d.ts +15 -0
- package/dist/position.js +60 -0
- package/dist/scroll.d.ts +12 -0
- package/dist/scroll.js +39 -0
- package/dist/styles/motion.css +76 -0
- package/dist/transitions/content.d.ts +8 -0
- package/dist/transitions/content.js +38 -0
- package/dist/transitions/extract.d.ts +5 -0
- package/dist/transitions/extract.js +5 -0
- package/dist/transitions/grow.d.ts +6 -0
- package/dist/transitions/grow.js +5 -0
- package/dist/transitions/liquid.d.ts +13 -0
- package/dist/transitions/liquid.js +215 -0
- package/dist/transitions/morph.d.ts +5 -0
- package/dist/transitions/morph.js +5 -0
- package/dist/transitions/scale.d.ts +7 -0
- package/dist/transitions/scale.js +5 -0
- package/dist/transitions/slide.d.ts +11 -0
- package/dist/transitions/slide.js +68 -0
- package/docs/LIBRARY.md +440 -0
- package/package.json +80 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TOberpaul
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MDK · UI Motion Library
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MDK is a framework-independent TypeScript library for reversible UI transitions. It uses the Web Animations API and CSS custom properties.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @tobiasoberpaul/mdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { createGrowTransition } from '@tobiasoberpaul/mdk';
|
|
15
|
+
import '@tobiasoberpaul/mdk/motion.css';
|
|
16
|
+
|
|
17
|
+
const panel = document.querySelector('#panel');
|
|
18
|
+
const toggle = document.querySelector('#toggle');
|
|
19
|
+
const motion = createGrowTransition({ content: panel });
|
|
20
|
+
|
|
21
|
+
toggle.addEventListener('click', () => motion.toggle());
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Your component manages layout, visibility, focus and accessibility. MDK handles the visual transition, including reversal and the system's reduced-motion preference.
|
|
25
|
+
|
|
26
|
+
The library includes Grow, Extract, Morph, Slide and Content transitions. See the [library guide](https://github.com/TOberpaul/animations/blob/main/docs/LIBRARY.md) for the API, CSS tokens and integration patterns.
|
|
27
|
+
|
|
28
|
+
Licensed under [MIT](LICENSE).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const motionBehaviors: {
|
|
2
|
+
readonly grow: {
|
|
3
|
+
readonly replacesTrigger: false;
|
|
4
|
+
readonly contentEffect: "fade-blur";
|
|
5
|
+
};
|
|
6
|
+
readonly extract: {
|
|
7
|
+
readonly replacesTrigger: false;
|
|
8
|
+
readonly contentEffect: "fade-blur";
|
|
9
|
+
};
|
|
10
|
+
readonly morph: {
|
|
11
|
+
readonly replacesTrigger: true;
|
|
12
|
+
readonly contentEffect: "fade-blur";
|
|
13
|
+
};
|
|
14
|
+
readonly slide: {
|
|
15
|
+
readonly replacesTrigger: false;
|
|
16
|
+
readonly contentEffect: "fade-blur";
|
|
17
|
+
};
|
|
18
|
+
readonly fade: {
|
|
19
|
+
readonly replacesTrigger: false;
|
|
20
|
+
readonly contentEffect: "slide-blur-fade";
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export type MotionKind = keyof typeof motionBehaviors;
|
|
24
|
+
/** Canonical naming shared by declarative integration and consuming components. */
|
|
25
|
+
export declare function getMotionKind(content: HTMLElement): MotionKind;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const motionBehaviors = {
|
|
2
|
+
grow: { replacesTrigger: false, contentEffect: 'fade-blur' },
|
|
3
|
+
extract: { replacesTrigger: false, contentEffect: 'fade-blur' },
|
|
4
|
+
morph: { replacesTrigger: true, contentEffect: 'fade-blur' },
|
|
5
|
+
slide: { replacesTrigger: false, contentEffect: 'fade-blur' },
|
|
6
|
+
fade: { replacesTrigger: false, contentEffect: 'slide-blur-fade' },
|
|
7
|
+
};
|
|
8
|
+
/** Canonical naming shared by declarative integration and consuming components. */
|
|
9
|
+
export function getMotionKind(content) {
|
|
10
|
+
const names = [...Object.keys(motionBehaviors), 'liquid', 'content'];
|
|
11
|
+
const classes = names.filter(name => content.classList.contains(`motion-${name}`));
|
|
12
|
+
const raw = content.dataset.motion ?? (classes.length === 1 ? classes[0] : undefined);
|
|
13
|
+
if (raw === 'content')
|
|
14
|
+
return 'fade';
|
|
15
|
+
if (raw === 'liquid')
|
|
16
|
+
return content.hasAttribute('data-motion-replace-trigger') ? 'morph' : 'extract';
|
|
17
|
+
if (!raw || !Object.prototype.hasOwnProperty.call(motionBehaviors, raw))
|
|
18
|
+
throw new Error('Set data-motion to grow, extract, morph, slide or fade, or use one matching motion-* class.');
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Options, Transition } from './lifecycle.js';
|
|
2
|
+
export interface Track {
|
|
3
|
+
element: Element;
|
|
4
|
+
frames: Keyframe[];
|
|
5
|
+
easing?: string;
|
|
6
|
+
pseudoElement?: string;
|
|
7
|
+
composite?: CompositeOperation;
|
|
8
|
+
}
|
|
9
|
+
export declare function createTransition(options: Options, pattern: string, tracks: () => Track[], behavior?: {
|
|
10
|
+
keepVisibleWhenClosed?: boolean;
|
|
11
|
+
durationScale?: () => number;
|
|
12
|
+
revealTiming?: (count: number, index: number) => {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
};
|
|
16
|
+
}): Transition;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { motionBehaviors } from '../behaviors.js';
|
|
2
|
+
import { contentTimeline } from './content-timing.js';
|
|
3
|
+
import { contentTracks } from './content-frames.js';
|
|
4
|
+
import { revealGroups, hasSurfaceSequence, sequenceTiming } from './content-groups.js';
|
|
5
|
+
import { duration, numberToken, token } from './tokens.js';
|
|
6
|
+
export function createTransition(options, pattern, tracks, behavior = {}) {
|
|
7
|
+
const { content } = options;
|
|
8
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
|
9
|
+
let target = options.initiallyOpen ?? false;
|
|
10
|
+
let state = target ? 'open' : 'closed';
|
|
11
|
+
let animations = [];
|
|
12
|
+
let generation = 0;
|
|
13
|
+
const opacity = content.style.getPropertyValue('opacity');
|
|
14
|
+
const priority = content.style.getPropertyPriority('opacity');
|
|
15
|
+
function restore() {
|
|
16
|
+
if (opacity)
|
|
17
|
+
content.style.setProperty('opacity', opacity, priority);
|
|
18
|
+
else
|
|
19
|
+
content.style.removeProperty('opacity');
|
|
20
|
+
}
|
|
21
|
+
function endpoint() { restore(); if (!target && !behavior.keepVisibleWhenClosed)
|
|
22
|
+
content.style.opacity = '0'; }
|
|
23
|
+
function notify(next) { state = next; options.onStateChange?.(next); }
|
|
24
|
+
function cancel() { generation++; animations.forEach(a => a.cancel()); animations = []; }
|
|
25
|
+
function settle() {
|
|
26
|
+
cancel();
|
|
27
|
+
endpoint();
|
|
28
|
+
notify(target ? 'open' : 'closed');
|
|
29
|
+
}
|
|
30
|
+
endpoint();
|
|
31
|
+
function set(next) {
|
|
32
|
+
if (state === 'destroyed' || next === target)
|
|
33
|
+
return;
|
|
34
|
+
target = next;
|
|
35
|
+
// Let adapters settle native effects before the visual-state snapshot.
|
|
36
|
+
if (!animations.length)
|
|
37
|
+
restore();
|
|
38
|
+
// Notify before measurement so the component can unhide and position itself.
|
|
39
|
+
notify(target ? 'opening' : 'closing');
|
|
40
|
+
if (state === 'destroyed')
|
|
41
|
+
return;
|
|
42
|
+
if (reduced.matches || typeof content.animate !== 'function') {
|
|
43
|
+
settle();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!animations.length) {
|
|
47
|
+
try {
|
|
48
|
+
let time = duration(content, pattern);
|
|
49
|
+
if (!time) {
|
|
50
|
+
settle();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const requestedEasing = token(content, `easing-${pattern}`);
|
|
54
|
+
const easing = CSS.supports('animation-timing-function', requestedEasing) ? requestedEasing : token(content, 'easing-standard');
|
|
55
|
+
const recipe = motionBehaviors[(pattern === 'scale' ? 'grow' : pattern === 'liquid' ? 'extract' : pattern === 'content' ? 'fade' : pattern)];
|
|
56
|
+
const descriptions = tracks();
|
|
57
|
+
time *= behavior.durationScale?.() ?? 1;
|
|
58
|
+
const reveal = options.reveal ? (Array.isArray(options.reveal) ? options.reveal : [options.reveal]) : [];
|
|
59
|
+
const progressive = numberToken(content, 'content-progressive') > 0;
|
|
60
|
+
const surfaceSequence = progressive && hasSurfaceSequence(content);
|
|
61
|
+
const { targets: revealTargets, orders } = revealGroups(content, reveal, options.revealPseudoElements ?? [], progressive);
|
|
62
|
+
if (revealTargets.length) {
|
|
63
|
+
const start = numberToken(content, progressive ? 'component-content-offset' : `${pattern}-reveal-offset`);
|
|
64
|
+
const end = numberToken(content, progressive ? 'component-content-end' : `${pattern}-reveal-end`);
|
|
65
|
+
const blur = token(content, `${pattern}-reveal-blur`);
|
|
66
|
+
const plan = contentTimeline(content, orders.length, { start, end, stepToken: 'component-content-stagger', capToken: 'component-content-stagger-span' });
|
|
67
|
+
for (const { element, pseudoElement, order } of revealTargets) {
|
|
68
|
+
const index = orders.indexOf(order);
|
|
69
|
+
const sequence = surfaceSequence ? (behavior.revealTiming ?? ((count, index) => sequenceTiming(content, count, index)))(orders.length + 1, index + 1) : undefined;
|
|
70
|
+
const range = sequence ?? plan.at(index);
|
|
71
|
+
if (!pseudoElement && (element === content || !content.contains(element)))
|
|
72
|
+
throw new Error('Reveal targets must be inner elements, separate from the surface.');
|
|
73
|
+
descriptions.push(...contentTracks({ element, pseudoElement, ...range, blur, easing: token(content, 'easing-reveal'),
|
|
74
|
+
distance: recipe?.contentEffect === 'slide-blur-fade' ? token(content, 'reveal-distance') : undefined }));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const track of descriptions) {
|
|
78
|
+
const timing = { duration: time, easing: track.easing ?? easing, fill: 'both', composite: track.composite ?? 'replace' };
|
|
79
|
+
const a = track.pseudoElement
|
|
80
|
+
? new Animation(new KeyframeEffect(track.element, track.frames, { ...timing, pseudoElement: track.pseudoElement }), document.timeline)
|
|
81
|
+
: track.element.animate(track.frames, timing);
|
|
82
|
+
a.pause();
|
|
83
|
+
a.currentTime = target ? 0 : time;
|
|
84
|
+
animations.push(a);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
settle();
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Reversing at an exact endpoint must settle, rather than letting play()
|
|
93
|
+
// auto-rewind the effect to the opposite end of the timeline.
|
|
94
|
+
if (animations.every(a => target ? Number(a.currentTime) >= Number(a.effect.getTiming().duration) : Number(a.currentTime) <= 0)) {
|
|
95
|
+
settle();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const version = ++generation;
|
|
99
|
+
animations.forEach(a => { a.playbackRate = target ? 1 : -1; a.play(); });
|
|
100
|
+
Promise.all(animations.map(a => a.finished)).then(() => {
|
|
101
|
+
if (version === generation && state !== 'destroyed')
|
|
102
|
+
settle();
|
|
103
|
+
}).catch(() => { });
|
|
104
|
+
}
|
|
105
|
+
function seek(progress) {
|
|
106
|
+
if (!Number.isFinite(progress))
|
|
107
|
+
throw new Error('Motion progress must be finite.');
|
|
108
|
+
if (state === 'destroyed')
|
|
109
|
+
return;
|
|
110
|
+
const p = Math.max(0, Math.min(1, progress));
|
|
111
|
+
if (reduced.matches) {
|
|
112
|
+
const next = p >= .5;
|
|
113
|
+
if (next !== target)
|
|
114
|
+
set(next);
|
|
115
|
+
else if (animations.length)
|
|
116
|
+
settle();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!animations.length) {
|
|
120
|
+
if ((p === 0 && state === 'closed') || (p === 1 && state === 'open'))
|
|
121
|
+
return;
|
|
122
|
+
set(state !== 'open');
|
|
123
|
+
}
|
|
124
|
+
if (!animations.length)
|
|
125
|
+
return;
|
|
126
|
+
generation++;
|
|
127
|
+
target = p > 0;
|
|
128
|
+
animations.forEach(a => { a.pause(); a.currentTime = p * Number(a.effect.getTiming().duration); });
|
|
129
|
+
if (p === 0 || p === 1)
|
|
130
|
+
settle();
|
|
131
|
+
else if (state !== 'opening')
|
|
132
|
+
notify('opening');
|
|
133
|
+
}
|
|
134
|
+
function preferenceChanged() { if (reduced.matches && animations.length)
|
|
135
|
+
settle(); }
|
|
136
|
+
reduced.addEventListener('change', preferenceChanged);
|
|
137
|
+
return {
|
|
138
|
+
get state() { return state; }, get isOpen() { return target; },
|
|
139
|
+
seek,
|
|
140
|
+
open: () => set(true), close: () => set(false), toggle: () => set(!target),
|
|
141
|
+
destroy() {
|
|
142
|
+
if (state === 'destroyed')
|
|
143
|
+
return;
|
|
144
|
+
cancel();
|
|
145
|
+
restore();
|
|
146
|
+
reduced.removeEventListener('change', preferenceChanged);
|
|
147
|
+
notify('destroyed');
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Track } from './animation.js';
|
|
2
|
+
export interface ContentTrackOptions {
|
|
3
|
+
element: HTMLElement;
|
|
4
|
+
pseudoElement?: string;
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
blur: string;
|
|
8
|
+
easing: string;
|
|
9
|
+
distance?: string;
|
|
10
|
+
blurEnd?: number;
|
|
11
|
+
}
|
|
12
|
+
/** The single fade/blur recipe; Content adds an additive vertical translation. */
|
|
13
|
+
export declare function contentTracks(options: ContentTrackOptions): Track[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The single fade/blur recipe; Content adds an additive vertical translation. */
|
|
2
|
+
export function contentTracks(options) {
|
|
3
|
+
const { element, pseudoElement, start, end, blur, easing, distance, blurEnd } = options;
|
|
4
|
+
if (!CSS.supports('filter', `blur(${blur})`))
|
|
5
|
+
throw new Error('Content blur must be a CSS length.');
|
|
6
|
+
const style = getComputedStyle(element, pseudoElement), filter = style.filter, opacity = style.opacity;
|
|
7
|
+
const blurred = `${filter === 'none' ? '' : `${filter} `}blur(${blur})`;
|
|
8
|
+
const display = style.display === 'inline' ? { display: 'inline-block' } : {};
|
|
9
|
+
const from = { ...display, opacity: 0, ...(blurEnd === undefined ? { filter: blurred } : {}) };
|
|
10
|
+
const to = { ...display, opacity, ...(blurEnd === undefined ? { filter } : {}) };
|
|
11
|
+
const tracks = [{ element, pseudoElement, easing: 'linear', frames: [
|
|
12
|
+
{ ...from, offset: 0 }, { ...from, offset: start, easing }, { ...to, offset: end }, { ...to, offset: 1 }
|
|
13
|
+
] }];
|
|
14
|
+
if (distance) {
|
|
15
|
+
if (!CSS.supports('translate', `0 ${distance}`))
|
|
16
|
+
throw new Error('Content distance must be a CSS length.');
|
|
17
|
+
tracks.push({ element, pseudoElement, easing: 'linear', composite: 'add', frames: [
|
|
18
|
+
{ translate: `0 ${distance}`, offset: 0 }, { translate: `0 ${distance}`, offset: start, easing },
|
|
19
|
+
{ translate: '0 0', offset: end }, { translate: '0 0', offset: 1 }
|
|
20
|
+
] });
|
|
21
|
+
}
|
|
22
|
+
if (blurEnd !== undefined)
|
|
23
|
+
tracks.push({ element, pseudoElement, easing: 'linear', frames: [
|
|
24
|
+
{ filter: blurred, offset: 0, easing }, { filter, offset: blurEnd }, { filter, offset: 1 }
|
|
25
|
+
] });
|
|
26
|
+
return tracks;
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Keep icon/label pairs and semantic sections together; never reveal nested groups twice. */
|
|
2
|
+
export declare function contentGroups(roots: HTMLElement[]): HTMLElement[];
|
|
3
|
+
/** Bound the stagger so any number of groups still finishes inside the same timeline. */
|
|
4
|
+
export declare function contentStagger(content: HTMLElement, count: number, span?: number): number;
|
|
5
|
+
/** Opt in to a shared overlapping sequence: surface first, then its groups. */
|
|
6
|
+
export declare function hasSurfaceSequence(content: HTMLElement): boolean;
|
|
7
|
+
export declare function sequenceTiming(content: HTMLElement, count: number, index: number): {
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function revealGroups(content: HTMLElement, roots: HTMLElement[], pseudos: string[], progressive: boolean): {
|
|
12
|
+
targets: {
|
|
13
|
+
element: HTMLElement;
|
|
14
|
+
pseudoElement: string | undefined;
|
|
15
|
+
order: number;
|
|
16
|
+
}[];
|
|
17
|
+
orders: number[];
|
|
18
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { contentTimeline } from './content-timing.js';
|
|
2
|
+
/** Keep icon/label pairs and semantic sections together; never reveal nested groups twice. */
|
|
3
|
+
export function contentGroups(roots) {
|
|
4
|
+
const selector = '[data-motion-order],[data-motion-content-group],header,footer,[role="menuitem"],button,h1,h2,h3,h4,h5,h6,p,small,figure,figcaption,label,input,textarea,select';
|
|
5
|
+
const groups = roots.flatMap(root => {
|
|
6
|
+
if (root.matches(selector))
|
|
7
|
+
return [root];
|
|
8
|
+
const candidates = Array.from(root.querySelectorAll(selector));
|
|
9
|
+
return candidates.length ? candidates.filter(item => !candidates.some(other => other !== item && other.contains(item))) : [root];
|
|
10
|
+
});
|
|
11
|
+
return Array.from(new Set(groups)).filter(item => !groups.some(other => other !== item && other.contains(item))).map((element, index) => ({ element, index, order: Number(element.dataset.motionOrder ?? index) })).sort((a, b) => a.order - b.order || a.index - b.index).map(item => item.element);
|
|
12
|
+
}
|
|
13
|
+
/** Bound the stagger so any number of groups still finishes inside the same timeline. */
|
|
14
|
+
export function contentStagger(content, count, span = 1) {
|
|
15
|
+
return contentTimeline(content, count, { end: span }).step;
|
|
16
|
+
}
|
|
17
|
+
/** Opt in to a shared overlapping sequence: surface first, then its groups. */
|
|
18
|
+
export function hasSurfaceSequence(content) {
|
|
19
|
+
return content.matches('[data-motion-sequence="surface-first"],.motion-sequence');
|
|
20
|
+
}
|
|
21
|
+
export function sequenceTiming(content, count, index) {
|
|
22
|
+
return contentTimeline(content, count).at(index);
|
|
23
|
+
}
|
|
24
|
+
export function revealGroups(content, roots, pseudos, progressive) {
|
|
25
|
+
const grouped = progressive ? contentGroups(roots) : roots;
|
|
26
|
+
const generated = Array.from(new Set(pseudos));
|
|
27
|
+
const targets = [
|
|
28
|
+
...Array.from(new Set(grouped)).map((element, index) => ({ element, pseudoElement: undefined, order: Number(element.dataset.motionOrder ?? (index + generated.length)) })),
|
|
29
|
+
...generated.map((pseudoElement, index) => ({ element: content, pseudoElement, order: Number(content.getAttribute(pseudoElement === '::before' ? 'data-motion-before-order' : 'data-motion-after-order') ?? index) })),
|
|
30
|
+
].sort((a, b) => a.order - b.order);
|
|
31
|
+
if (targets.some(target => !Number.isFinite(target.order)))
|
|
32
|
+
throw new Error('Motion content order must be numeric.');
|
|
33
|
+
return { targets, orders: Array.from(new Set(targets.map(target => target.order))) };
|
|
34
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Track } from './animation.js';
|
|
2
|
+
/** Hold the existing inner layout in its final position while the surface
|
|
3
|
+
* resizes around it. Measurements happen once; reversal reuses native tracks. */
|
|
4
|
+
export declare function fixedContentTracks(content: HTMLElement, frames: Keyframe[], pseudoElements?: ReadonlyArray<'::before' | '::after'>): Track[];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Hold the existing inner layout in its final position while the surface
|
|
2
|
+
* resizes around it. Measurements happen once; reversal reuses native tracks. */
|
|
3
|
+
export function fixedContentTracks(content, frames, pseudoElements = []) {
|
|
4
|
+
const px = (value) => parseFloat(value) || 0;
|
|
5
|
+
const borderBox = (style, axis) => px(style[axis]) + (style.boxSizing === 'border-box' ? 0 : axis === 'width'
|
|
6
|
+
? px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth)
|
|
7
|
+
: px(style.paddingTop) + px(style.paddingBottom) + px(style.borderTopWidth) + px(style.borderBottomWidth));
|
|
8
|
+
const final = content.getBoundingClientRect(), style = getComputedStyle(content);
|
|
9
|
+
const sx = final.width / borderBox(style, 'width'), sy = final.height / borderBox(style, 'height');
|
|
10
|
+
const borderLeft = px(style.borderLeftWidth), borderTop = px(style.borderTopWidth);
|
|
11
|
+
const children = Array.from(content.children).filter((node) => node instanceof HTMLElement || node instanceof SVGSVGElement);
|
|
12
|
+
const snapshots = children.flatMap(element => {
|
|
13
|
+
const css = getComputedStyle(element);
|
|
14
|
+
if (css.display === 'none' || css.position === 'fixed')
|
|
15
|
+
return [];
|
|
16
|
+
const rect = element.getBoundingClientRect(), width = borderBox(css, 'width'), height = borderBox(css, 'height');
|
|
17
|
+
if (!width || !height)
|
|
18
|
+
return [];
|
|
19
|
+
// Preserve a child's existing transform while converting its visual rect
|
|
20
|
+
// back to its layout origin (including translated/scaled/rotated icons).
|
|
21
|
+
const matrix = new DOMMatrixReadOnly(css.transform === 'none' ? undefined : css.transform);
|
|
22
|
+
const [ox, oy] = css.transformOrigin.split(' ').map(px);
|
|
23
|
+
const points = [[0, 0], [width, 0], [0, height], [width, height]].map(([x, y]) => ({
|
|
24
|
+
x: matrix.a * (x - ox) + matrix.c * (y - oy) + matrix.e + ox,
|
|
25
|
+
y: matrix.b * (x - ox) + matrix.d * (y - oy) + matrix.f + oy,
|
|
26
|
+
}));
|
|
27
|
+
return [{ element, width, height,
|
|
28
|
+
left: (rect.left - final.left) / sx - Math.min(...points.map(p => p.x)) - borderLeft,
|
|
29
|
+
top: (rect.top - final.top) / sy - Math.min(...points.map(p => p.y)) - borderTop }];
|
|
30
|
+
});
|
|
31
|
+
const pseudos = Array.from(new Set(pseudoElements)).flatMap(pseudoElement => {
|
|
32
|
+
const css = getComputedStyle(content, pseudoElement);
|
|
33
|
+
if (css.display === 'none' || css.content === 'none' || css.content === 'normal')
|
|
34
|
+
return [];
|
|
35
|
+
return [{ pseudoElement, transform: css.transform === 'none' ? '' : css.transform }];
|
|
36
|
+
});
|
|
37
|
+
if (!snapshots.length && !pseudos.length)
|
|
38
|
+
return [];
|
|
39
|
+
// A positioned surface provides the containing block for its existing nodes.
|
|
40
|
+
if (style.position === 'static')
|
|
41
|
+
frames.forEach(frame => { frame.position = 'relative'; });
|
|
42
|
+
const properties = ['width', 'height', 'transform', 'min-width', 'min-height', 'max-width', 'max-height', 'overflow', 'position',
|
|
43
|
+
'padding-top', 'padding-bottom', 'padding-left', 'padding-right', 'border-top-width', 'border-bottom-width', 'border-left-width', 'border-right-width'];
|
|
44
|
+
const saved = properties.map(name => [name, content.style.getPropertyValue(name), content.style.getPropertyPriority(name)]);
|
|
45
|
+
const positions = [];
|
|
46
|
+
try {
|
|
47
|
+
// Snapshot the small, finite set of poses once. An overshoot is not a
|
|
48
|
+
// linear extrapolation of the collapsed pose on both axes.
|
|
49
|
+
for (const frame of frames) {
|
|
50
|
+
for (const name of properties) {
|
|
51
|
+
const key = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
52
|
+
if (frame[key] !== undefined)
|
|
53
|
+
content.style.setProperty(name, String(frame[key]));
|
|
54
|
+
}
|
|
55
|
+
const rect = content.getBoundingClientRect();
|
|
56
|
+
positions.push({ dx: (final.left - rect.left) / sx, dy: (final.top - rect.top) / sy });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
for (const [name, value, priority] of saved) {
|
|
61
|
+
if (value)
|
|
62
|
+
content.style.setProperty(name, value, priority);
|
|
63
|
+
else
|
|
64
|
+
content.style.removeProperty(name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const tracks = snapshots.map(snapshot => ({ element: snapshot.element, easing: 'linear', frames: frames.map((frame, index) => {
|
|
68
|
+
const { dx, dy } = positions[index];
|
|
69
|
+
// An absolutely positioned grid item otherwise uses its named grid area
|
|
70
|
+
// as the containing block, adding that area's offset a second time.
|
|
71
|
+
return { offset: frame.offset, easing: frame.easing, position: 'absolute', gridArea: 'auto', margin: '0px',
|
|
72
|
+
right: 'auto', bottom: 'auto', boxSizing: 'border-box', minWidth: '0px', minHeight: '0px', maxWidth: 'none', maxHeight: 'none',
|
|
73
|
+
width: `${snapshot.width}px`, height: `${snapshot.height}px`,
|
|
74
|
+
left: `${snapshot.left + dx + borderLeft - (frame.borderLeftWidth === undefined ? borderLeft : px(String(frame.borderLeftWidth)))}px`,
|
|
75
|
+
top: `${snapshot.top + dy + borderTop - (frame.borderTopWidth === undefined ? borderTop : px(String(frame.borderTopWidth)))}px` };
|
|
76
|
+
}) }));
|
|
77
|
+
// Generated icons are content too. Counter only the changing surface origin,
|
|
78
|
+
// leaving their component-owned grid placement and the slide itself intact.
|
|
79
|
+
for (const { pseudoElement, transform } of pseudos)
|
|
80
|
+
tracks.push({ element: content, pseudoElement, easing: 'linear', frames: frames.map((frame, index) => ({
|
|
81
|
+
offset: frame.offset, easing: frame.easing, transform: `${transform} translate(${positions[index].dx}px,${positions[index].dy}px)`
|
|
82
|
+
})) });
|
|
83
|
+
return tracks;
|
|
84
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ContentTimelineOptions {
|
|
2
|
+
perItem?: boolean;
|
|
3
|
+
start?: number;
|
|
4
|
+
end?: number;
|
|
5
|
+
stepToken?: string;
|
|
6
|
+
capToken?: string;
|
|
7
|
+
}
|
|
8
|
+
/** One timing model: fixed component window or full-duration content items. */
|
|
9
|
+
export declare function contentTimeline(content: HTMLElement, count: number, options?: ContentTimelineOptions): {
|
|
10
|
+
durationScale: number;
|
|
11
|
+
step: number;
|
|
12
|
+
at(index: number): {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { numberToken } from './tokens.js';
|
|
2
|
+
/** One timing model: fixed component window or full-duration content items. */
|
|
3
|
+
export function contentTimeline(content, count, options = {}) {
|
|
4
|
+
const start = options.start ?? 0, end = options.end ?? 1, intervals = Math.max(0, count - 1);
|
|
5
|
+
if (start < 0 || start >= end || end > 1)
|
|
6
|
+
throw new Error('Content timing must satisfy 0 <= start < end <= 1.');
|
|
7
|
+
const requested = numberToken(content, 'content-progressive') && intervals ? numberToken(content, options.stepToken ?? 'content-stagger') : 0;
|
|
8
|
+
if (requested < 0)
|
|
9
|
+
throw new Error('Content stagger must be non-negative.');
|
|
10
|
+
const cap = options.capToken ? numberToken(content, options.capToken) : Infinity;
|
|
11
|
+
if (cap < 0)
|
|
12
|
+
throw new Error('Content stagger cap must be non-negative.');
|
|
13
|
+
const step = options.perItem ? requested : intervals ? Math.min(requested, cap / intervals, (end - start) / (2 * intervals)) : 0;
|
|
14
|
+
const durationScale = options.perItem ? 1 + step * intervals : 1;
|
|
15
|
+
return { durationScale, step, at(index) {
|
|
16
|
+
return options.perItem
|
|
17
|
+
? { start: index * step / durationScale, end: (index * step + 1) / durationScale }
|
|
18
|
+
: { start: start + index * step, end: end - (intervals - index) * step };
|
|
19
|
+
} };
|
|
20
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Read only at the start of a new trip; reversal reuses the same geometry. */
|
|
2
|
+
export declare function morphGeometry(trigger: HTMLElement, content: HTMLElement): {
|
|
3
|
+
side: string;
|
|
4
|
+
origin: string;
|
|
5
|
+
anchor: {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
};
|
|
9
|
+
dx: number;
|
|
10
|
+
dy: number;
|
|
11
|
+
from: {
|
|
12
|
+
left: number;
|
|
13
|
+
top: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
17
|
+
sx: number;
|
|
18
|
+
sy: number;
|
|
19
|
+
};
|
|
20
|
+
export declare function localScale(content: HTMLElement, to: DOMRectReadOnly): {
|
|
21
|
+
xScale: number;
|
|
22
|
+
yScale: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
};
|
|
26
|
+
type Rectangle = Pick<DOMRectReadOnly, 'left' | 'top' | 'width' | 'height'>;
|
|
27
|
+
export declare const motionAnchors: Record<string, {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function explicitMotionAnchor(content: HTMLElement): {
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
} | undefined;
|
|
35
|
+
/** Read the component's growth anchor from its existing alignment/placement.
|
|
36
|
+
* Matching centers stay centered; matching edges stay fixed during growth. */
|
|
37
|
+
export declare function growthAnchor(source: Rectangle, target: Rectangle): {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
};
|
|
41
|
+
/** Grow away from the anchor: a top-right anchor expands left and down. */
|
|
42
|
+
export declare function growthInsets(anchor: {
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
}, pad: number): {
|
|
46
|
+
left: number;
|
|
47
|
+
right: number;
|
|
48
|
+
top: number;
|
|
49
|
+
bottom: number;
|
|
50
|
+
};
|
|
51
|
+
/** Measure a resized box once, including its component-owned centering/insets.
|
|
52
|
+
* Temporary preparation styles are restored synchronously, before any paint. */
|
|
53
|
+
export declare function resizeGeometry(content: HTMLElement, source: Rectangle | ((to: DOMRectReadOnly, ax: number, ay: number) => Rectangle), origin?: string, collapseAxis?: 'width' | 'height' | 'both'): {
|
|
54
|
+
anchor: {
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
};
|
|
58
|
+
width: {
|
|
59
|
+
from: number;
|
|
60
|
+
to: number;
|
|
61
|
+
};
|
|
62
|
+
height: {
|
|
63
|
+
from: number;
|
|
64
|
+
to: number;
|
|
65
|
+
};
|
|
66
|
+
dx: number;
|
|
67
|
+
dy: number;
|
|
68
|
+
};
|
|
69
|
+
export {};
|