@texuli/core 0.5.0 → 0.7.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/README.md +29 -5
- package/dist/editor/document.js +8 -1
- package/dist/editor/extensions.d.ts +64 -0
- package/dist/editor/extensions.js +116 -0
- package/dist/editor/inline.d.ts +5 -3
- package/dist/editor/inline.js +24 -12
- package/dist/editor/load.d.ts +10 -0
- package/dist/editor/load.js +16 -0
- package/dist/editor/markdown.d.ts +10 -1
- package/dist/editor/markdown.js +193 -83
- package/dist/editor/model.d.ts +3 -1
- package/dist/editor/model.js +3 -1
- package/dist/editor/transforms.d.ts +1 -1
- package/dist/editor/tree.d.ts +11 -1
- package/dist/editor/tree.js +11 -2
- package/dist/editor/types.d.ts +10 -0
- package/dist/editor/types.js +1 -0
- package/dist/inert/inert.d.ts +11 -0
- package/dist/inert/inert.js +5 -3
- package/dist/markup/index.d.ts +5 -0
- package/dist/markup/index.js +5 -0
- package/dist/markup/markup.d.ts +72 -0
- package/dist/markup/markup.js +233 -0
- package/dist/motion/animate.d.ts +109 -0
- package/dist/motion/animate.js +225 -0
- package/dist/motion/flip.d.ts +58 -0
- package/dist/motion/flip.js +167 -0
- package/dist/motion/index.d.ts +15 -0
- package/dist/motion/index.js +15 -0
- package/dist/motion/preferences.d.ts +68 -0
- package/dist/motion/preferences.js +111 -0
- package/dist/motion/presence.d.ts +61 -0
- package/dist/motion/presence.js +231 -0
- package/dist/motion/sequence.d.ts +70 -0
- package/dist/motion/sequence.js +159 -0
- package/dist/motion/shared.d.ts +45 -0
- package/dist/motion/shared.js +144 -0
- package/dist/motion/spring.d.ts +109 -0
- package/dist/motion/spring.js +166 -0
- package/dist/overlay/core.js +14 -3
- package/dist/zip/index.d.ts +5 -0
- package/dist/zip/index.js +5 -0
- package/dist/zip/zip.d.ts +35 -0
- package/dist/zip/zip.js +190 -0
- package/package.json +6 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLIP layout animation (ADR-013): measure (First), let the DOM change
|
|
3
|
+
* (Last), Invert with a transform, Play it back to identity.
|
|
4
|
+
*
|
|
5
|
+
* Reads and writes are batched: `measure` and `play` read every rectangle
|
|
6
|
+
* before they cancel or start any animation, so a list of any length lays out
|
|
7
|
+
* once per phase. A `measure` during a running play reads the in-flight
|
|
8
|
+
* (visual) positions, then interrupts the old run, so a re-reorder continues
|
|
9
|
+
* from where items are on screen.
|
|
10
|
+
*
|
|
11
|
+
* Deltas are measured in physical pixels, so reorders are correct in either
|
|
12
|
+
* direction; insert offsets are logical (`inline-start` follows `dir`).
|
|
13
|
+
* At level `subtle` moved items snap (no travel) and inserts only fade;
|
|
14
|
+
* at `none` or under reduced motion everything commits at once.
|
|
15
|
+
*/
|
|
16
|
+
import { type MotionKeyframes, type MotionOutcome, type MotionRun, type MotionTiming } from './animate.js';
|
|
17
|
+
/** Items keyed by a stable id (a `Map` works). */
|
|
18
|
+
export type FlipItems = Iterable<readonly [string, Element]>;
|
|
19
|
+
/** Where an inserted item comes from: a fade, or a short slide from a logical side. */
|
|
20
|
+
export type FlipEnter = false | 'fade' | 'inline-start' | 'inline-end' | 'block-start' | 'block-end' | MotionKeyframes;
|
|
21
|
+
export type FlipOptions = MotionTiming & {
|
|
22
|
+
/** Animate size changes with a scale as well. Default `true`. */
|
|
23
|
+
scale?: boolean;
|
|
24
|
+
/** Inserted items (keys `measure` did not see). Default `"fade"`. */
|
|
25
|
+
enter?: FlipEnter;
|
|
26
|
+
/** Slide distance for a logical `enter`, in px (clamped to [0, 64]). Default 8. */
|
|
27
|
+
enterDistance?: number;
|
|
28
|
+
/** Above this many animated items the change commits at once. Default 500. */
|
|
29
|
+
maxItems?: number;
|
|
30
|
+
onFinish?: (outcome: MotionOutcome) => void;
|
|
31
|
+
};
|
|
32
|
+
export type FlipController = {
|
|
33
|
+
/** First: record where every item is now (interrupts a running play). */
|
|
34
|
+
measure: (items: FlipItems) => void;
|
|
35
|
+
/** Last, Invert, Play: animate each item from its measured box to its current one. */
|
|
36
|
+
play: (items: FlipItems) => MotionRun;
|
|
37
|
+
/** Cancel the running play. */
|
|
38
|
+
cancel: () => void;
|
|
39
|
+
};
|
|
40
|
+
type Box = {
|
|
41
|
+
readonly left: number;
|
|
42
|
+
readonly top: number;
|
|
43
|
+
readonly width: number;
|
|
44
|
+
readonly height: number;
|
|
45
|
+
};
|
|
46
|
+
/** The inverted transform from `first` to `last` (`null` when nothing moved). */
|
|
47
|
+
export declare function flipTransform(first: Box, last: Box, scale?: boolean): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Create a FLIP controller for one list (or any set of keyed elements).
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const flip = createFlip({ duration: 200 });
|
|
53
|
+
* flip.measure(itemsByKey);
|
|
54
|
+
* reorder(); // the DOM changes
|
|
55
|
+
* await flip.play(itemsByKey).finished;
|
|
56
|
+
*/
|
|
57
|
+
export declare function createFlip(options?: FlipOptions): FlipController;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLIP layout animation (ADR-013): measure (First), let the DOM change
|
|
3
|
+
* (Last), Invert with a transform, Play it back to identity.
|
|
4
|
+
*
|
|
5
|
+
* Reads and writes are batched: `measure` and `play` read every rectangle
|
|
6
|
+
* before they cancel or start any animation, so a list of any length lays out
|
|
7
|
+
* once per phase. A `measure` during a running play reads the in-flight
|
|
8
|
+
* (visual) positions, then interrupts the old run, so a re-reorder continues
|
|
9
|
+
* from where items are on screen.
|
|
10
|
+
*
|
|
11
|
+
* Deltas are measured in physical pixels, so reorders are correct in either
|
|
12
|
+
* direction; insert offsets are logical (`inline-start` follows `dir`).
|
|
13
|
+
* At level `subtle` moved items snap (no travel) and inserts only fade;
|
|
14
|
+
* at `none` or under reduced motion everything commits at once.
|
|
15
|
+
*/
|
|
16
|
+
import { getOwnerWindow } from "../env/index.js";
|
|
17
|
+
import { createRun, resolveMotion, startAnimation, } from "./animate.js";
|
|
18
|
+
const MAX_DELTA = 100_000;
|
|
19
|
+
function boxOf(element) {
|
|
20
|
+
if (!element.isConnected || typeof element.getBoundingClientRect !== 'function')
|
|
21
|
+
return null;
|
|
22
|
+
const r = element.getBoundingClientRect();
|
|
23
|
+
const box = { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
24
|
+
return Number.isFinite(box.left) &&
|
|
25
|
+
Number.isFinite(box.top) &&
|
|
26
|
+
Number.isFinite(box.width) &&
|
|
27
|
+
Number.isFinite(box.height)
|
|
28
|
+
? box
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
function num(value) {
|
|
32
|
+
const clamped = Math.max(-MAX_DELTA, Math.min(MAX_DELTA, value));
|
|
33
|
+
const r = Math.round(clamped * 1000) / 1000;
|
|
34
|
+
return String(Object.is(r, -0) ? 0 : r);
|
|
35
|
+
}
|
|
36
|
+
/** The inverted transform from `first` to `last` (`null` when nothing moved). */
|
|
37
|
+
export function flipTransform(first, last, scale = true) {
|
|
38
|
+
const dx = first.left - last.left;
|
|
39
|
+
const dy = first.top - last.top;
|
|
40
|
+
const sx = scale && last.width > 0 && first.width > 0 ? first.width / last.width : 1;
|
|
41
|
+
const sy = scale && last.height > 0 && first.height > 0 ? first.height / last.height : 1;
|
|
42
|
+
if (Math.abs(dx) < 0.5 &&
|
|
43
|
+
Math.abs(dy) < 0.5 &&
|
|
44
|
+
Math.abs(sx - 1) < 0.001 &&
|
|
45
|
+
Math.abs(sy - 1) < 0.001) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const move = `translate(${num(dx)}px, ${num(dy)}px)`;
|
|
49
|
+
return sx === 1 && sy === 1 ? move : `${move} scale(${num(sx)}, ${num(sy)})`;
|
|
50
|
+
}
|
|
51
|
+
function isRtl(element) {
|
|
52
|
+
const win = getOwnerWindow(element);
|
|
53
|
+
try {
|
|
54
|
+
return win?.getComputedStyle(element).direction === 'rtl';
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function enterFrames(enter, distance, rtl) {
|
|
61
|
+
if (enter === false)
|
|
62
|
+
return null;
|
|
63
|
+
if (typeof enter === 'object')
|
|
64
|
+
return enter;
|
|
65
|
+
if (enter === 'fade')
|
|
66
|
+
return [{ opacity: 0 }, { opacity: 1 }];
|
|
67
|
+
const sign = enter === 'inline-start' || enter === 'block-start' ? -1 : 1;
|
|
68
|
+
const inline = enter.startsWith('inline');
|
|
69
|
+
const px = num(sign * distance * (inline && rtl ? -1 : 1));
|
|
70
|
+
const from = inline ? `translate(${px}px, 0px)` : `translate(0px, ${px}px)`;
|
|
71
|
+
return [
|
|
72
|
+
{ opacity: 0, transform: from },
|
|
73
|
+
{ opacity: 1, transform: 'none' },
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a FLIP controller for one list (or any set of keyed elements).
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const flip = createFlip({ duration: 200 });
|
|
81
|
+
* flip.measure(itemsByKey);
|
|
82
|
+
* reorder(); // the DOM changes
|
|
83
|
+
* await flip.play(itemsByKey).finished;
|
|
84
|
+
*/
|
|
85
|
+
export function createFlip(options = {}) {
|
|
86
|
+
const scale = options.scale !== false;
|
|
87
|
+
const enter = options.enter ?? 'fade';
|
|
88
|
+
const distance = Math.max(0, Math.min(64, Number.isFinite(options.enterDistance) ? options.enterDistance : 8));
|
|
89
|
+
const maxItems = Math.max(0, Math.floor(Number.isFinite(options.maxItems) ? options.maxItems : 500));
|
|
90
|
+
let first = new Map();
|
|
91
|
+
let running = null;
|
|
92
|
+
function interrupt() {
|
|
93
|
+
running?.interrupt();
|
|
94
|
+
running = null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
measure(items) {
|
|
98
|
+
// Read phase: every box (in-flight transforms included) before any write.
|
|
99
|
+
const next = new Map();
|
|
100
|
+
for (const [key, element] of items) {
|
|
101
|
+
const box = element ? boxOf(element) : null;
|
|
102
|
+
if (box)
|
|
103
|
+
next.set(String(key), box);
|
|
104
|
+
}
|
|
105
|
+
first = next;
|
|
106
|
+
// Write phase: stop the old run so `play` reads the new layout untransformed.
|
|
107
|
+
interrupt();
|
|
108
|
+
},
|
|
109
|
+
play(items) {
|
|
110
|
+
interrupt();
|
|
111
|
+
const before = first;
|
|
112
|
+
first = new Map();
|
|
113
|
+
const jobs = [];
|
|
114
|
+
let rtl = null;
|
|
115
|
+
for (const [rawKey, element] of items) {
|
|
116
|
+
if (!element)
|
|
117
|
+
continue;
|
|
118
|
+
const key = String(rawKey);
|
|
119
|
+
const last = boxOf(element);
|
|
120
|
+
if (!last)
|
|
121
|
+
continue;
|
|
122
|
+
const from = before.get(key);
|
|
123
|
+
let frames;
|
|
124
|
+
if (from) {
|
|
125
|
+
const transform = flipTransform(from, last, scale);
|
|
126
|
+
frames = transform
|
|
127
|
+
? [
|
|
128
|
+
{ transform, transformOrigin: '0 0' },
|
|
129
|
+
{ transform: 'none', transformOrigin: '0 0' },
|
|
130
|
+
]
|
|
131
|
+
: null;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
if (rtl === null && typeof enter === 'string' && enter.startsWith('inline'))
|
|
135
|
+
rtl = isRtl(element);
|
|
136
|
+
frames = enterFrames(enter, distance, rtl === true);
|
|
137
|
+
}
|
|
138
|
+
if (frames)
|
|
139
|
+
jobs.push({ element, frames, plan: resolveMotion(element, frames, options) });
|
|
140
|
+
}
|
|
141
|
+
if (jobs.length > maxItems) {
|
|
142
|
+
return createRun([], options.onFinish, 'limit');
|
|
143
|
+
}
|
|
144
|
+
// Write phase.
|
|
145
|
+
const animations = [];
|
|
146
|
+
let reason = null;
|
|
147
|
+
for (const job of jobs) {
|
|
148
|
+
if (job.plan.instant) {
|
|
149
|
+
reason ??= job.plan.reason;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const animation = startAnimation(job.element, job.plan);
|
|
153
|
+
if (animation)
|
|
154
|
+
animations.push(animation);
|
|
155
|
+
else
|
|
156
|
+
reason ??= 'error';
|
|
157
|
+
}
|
|
158
|
+
const run = createRun(animations, options.onFinish, reason ?? 'zero-duration');
|
|
159
|
+
running = animations.length > 0 ? run : null;
|
|
160
|
+
return run;
|
|
161
|
+
},
|
|
162
|
+
cancel() {
|
|
163
|
+
running?.cancel();
|
|
164
|
+
running = null;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/core/motion: the motion runtime (ADR-013). Web Animations with an
|
|
3
|
+
* owned spring solver, reduced motion instant by construction, and the
|
|
4
|
+
* `full` / `subtle` / `none` motion levels shared with the deck.
|
|
5
|
+
*
|
|
6
|
+
* Not re-exported from the `@texuli/core` root barrel: a component that does
|
|
7
|
+
* not animate pays nothing for it.
|
|
8
|
+
*/
|
|
9
|
+
export { type AnimateOptions, animate, createRun, DEFAULT_MOTION_DURATION, DEFAULT_MOTION_EASING, type InstantReason, type MotionKeyframes, type MotionOutcome, type MotionRun, type MotionTiming, type ReducedMotionInput, type ResolvedMotion, resolveMotion, startAnimation, withoutTravel, } from './animate.js';
|
|
10
|
+
export { createFlip, type FlipController, type FlipEnter, type FlipItems, type FlipOptions, flipTransform, } from './flip.js';
|
|
11
|
+
export { createReducedMotionSource, isMotionLevel, lowerMotionLevel, MAX_MOTION_MS, MOTION_LEVELS, type MotionLevel, type MotionLevelInput, motionDuration, REDUCED_FADE_MS, type ReducedMotionPolicy, type ReducedMotionSource, type ReducedMotionSourceOptions, resolveMotionLevel, SUBTLE_DURATION_SCALE, } from './preferences.js';
|
|
12
|
+
export { createMotionPresence, type MotionPresence, type MotionPresenceOptions, } from './presence.js';
|
|
13
|
+
export { MAX_STAGGER_SPAN, MAX_TIMELINE_STEPS, type ParallelGroup, parallel, type StaggerOptions, sequence, stagger, type Timeline, type TimelineEntry, type TimelineOptions, type TimelineStep, timeline, } from './sequence.js';
|
|
14
|
+
export { createSharedElements, type ScopedViewTransitionOptions, type SharedElements, type SharedElementsOptions, startScopedViewTransition, } from './shared.js';
|
|
15
|
+
export * from './spring.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/core/motion: the motion runtime (ADR-013). Web Animations with an
|
|
3
|
+
* owned spring solver, reduced motion instant by construction, and the
|
|
4
|
+
* `full` / `subtle` / `none` motion levels shared with the deck.
|
|
5
|
+
*
|
|
6
|
+
* Not re-exported from the `@texuli/core` root barrel: a component that does
|
|
7
|
+
* not animate pays nothing for it.
|
|
8
|
+
*/
|
|
9
|
+
export { animate, createRun, DEFAULT_MOTION_DURATION, DEFAULT_MOTION_EASING, resolveMotion, startAnimation, withoutTravel, } from "./animate.js";
|
|
10
|
+
export { createFlip, flipTransform, } from "./flip.js";
|
|
11
|
+
export { createReducedMotionSource, isMotionLevel, lowerMotionLevel, MAX_MOTION_MS, MOTION_LEVELS, motionDuration, REDUCED_FADE_MS, resolveMotionLevel, SUBTLE_DURATION_SCALE, } from "./preferences.js";
|
|
12
|
+
export { createMotionPresence, } from "./presence.js";
|
|
13
|
+
export { MAX_STAGGER_SPAN, MAX_TIMELINE_STEPS, parallel, sequence, stagger, timeline, } from "./sequence.js";
|
|
14
|
+
export { createSharedElements, startScopedViewTransition, } from "./shared.js";
|
|
15
|
+
export * from "./spring.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion preferences: the reduced-motion source and the motion-level
|
|
3
|
+
* resolver (ADR-013). Framework-free; no module-scope browser access.
|
|
4
|
+
*/
|
|
5
|
+
/** Motion levels shared with `@texuli/deck`: `subtle` shortens and drops travel, `none` is instant. */
|
|
6
|
+
export declare const MOTION_LEVELS: readonly ["full", "subtle", "none"];
|
|
7
|
+
export type MotionLevel = (typeof MOTION_LEVELS)[number];
|
|
8
|
+
/** `subtle` multiplies every duration and offset by this factor (the deck's value). */
|
|
9
|
+
export declare const SUBTLE_DURATION_SCALE = 0.6;
|
|
10
|
+
/** Longest duration or delay any motion texel plays, in milliseconds. */
|
|
11
|
+
export declare const MAX_MOTION_MS = 1000;
|
|
12
|
+
/** Duration of the opt-in `fade` reduced-motion policy (the deck stage's slide fade). */
|
|
13
|
+
export declare const REDUCED_FADE_MS = 150;
|
|
14
|
+
/** What reduced motion does: commit the end state at once (default), or keep an opacity-only fade. */
|
|
15
|
+
export type ReducedMotionPolicy = 'instant' | 'fade';
|
|
16
|
+
/** A live view of `prefers-reduced-motion` with a host override. */
|
|
17
|
+
export type ReducedMotionSource = {
|
|
18
|
+
/** Current value: the override when set, else the media query, else the SSR default. */
|
|
19
|
+
get: () => boolean;
|
|
20
|
+
/** Called with the new value whenever it changes; returns an unsubscribe function. */
|
|
21
|
+
subscribe: (listener: (reduced: boolean) => void) => () => void;
|
|
22
|
+
/** Force reduced motion on or off; `null` returns to the media query. */
|
|
23
|
+
setOverride: (value: boolean | null) => void;
|
|
24
|
+
/** Stop listening to the media query and drop every listener. */
|
|
25
|
+
dispose: () => void;
|
|
26
|
+
};
|
|
27
|
+
export type ReducedMotionSourceOptions = {
|
|
28
|
+
/** Window to read `matchMedia` from. Omit on the server. */
|
|
29
|
+
window?: Pick<Window, 'matchMedia'> | null;
|
|
30
|
+
/** Initial host override (`null` or omitted reads the media query). */
|
|
31
|
+
override?: boolean | null;
|
|
32
|
+
/** Value without a window. Default `false` (texels do not run on the server anyway). */
|
|
33
|
+
ssrDefault?: boolean;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Create a reduced-motion source. It reads `(prefers-reduced-motion: reduce)`
|
|
37
|
+
* from the given window, follows its `change` events, and lets the host
|
|
38
|
+
* override it (a settings toggle, a test, a print view).
|
|
39
|
+
*/
|
|
40
|
+
export declare function createReducedMotionSource(options?: ReducedMotionSourceOptions): ReducedMotionSource;
|
|
41
|
+
/** Whether a value is one of {@link MOTION_LEVELS}. */
|
|
42
|
+
export declare function isMotionLevel(value: unknown): value is MotionLevel;
|
|
43
|
+
/** The lower of two levels (`none` < `subtle` < `full`); invalid values are ignored. */
|
|
44
|
+
export declare function lowerMotionLevel(a: unknown, b: unknown): MotionLevel;
|
|
45
|
+
export type MotionLevelInput = {
|
|
46
|
+
/** The host's level (an app setting); wins over everything below it. */
|
|
47
|
+
host?: unknown;
|
|
48
|
+
/** The blueprint's or deck's level. */
|
|
49
|
+
document?: unknown;
|
|
50
|
+
/** The component default. */
|
|
51
|
+
component?: unknown;
|
|
52
|
+
/** The viewer's preference: can only lower the result. */
|
|
53
|
+
viewer?: unknown;
|
|
54
|
+
/** Reduced motion lowers the result to `none` (the texels commit the end state at once). */
|
|
55
|
+
reducedMotion?: boolean;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the motion level: host, then blueprint or deck, then the component
|
|
59
|
+
* default, then `full`; the viewer's preference and reduced motion only lower
|
|
60
|
+
* it. Invalid values are skipped.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveMotionLevel(input?: MotionLevelInput): MotionLevel;
|
|
63
|
+
/**
|
|
64
|
+
* Clamp a duration or delay to [0, {@link MAX_MOTION_MS}] and scale it for the
|
|
65
|
+
* level: `subtle` × {@link SUBTLE_DURATION_SCALE}, `none` → 0. NaN and
|
|
66
|
+
* negative values are 0; Infinity is the maximum.
|
|
67
|
+
*/
|
|
68
|
+
export declare function motionDuration(ms: unknown, level?: MotionLevel): number;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion preferences: the reduced-motion source and the motion-level
|
|
3
|
+
* resolver (ADR-013). Framework-free; no module-scope browser access.
|
|
4
|
+
*/
|
|
5
|
+
import { REDUCED_MOTION_QUERY } from "../presence/presence.js";
|
|
6
|
+
/** Motion levels shared with `@texuli/deck`: `subtle` shortens and drops travel, `none` is instant. */
|
|
7
|
+
export const MOTION_LEVELS = ['full', 'subtle', 'none'];
|
|
8
|
+
/** `subtle` multiplies every duration and offset by this factor (the deck's value). */
|
|
9
|
+
export const SUBTLE_DURATION_SCALE = 0.6;
|
|
10
|
+
/** Longest duration or delay any motion texel plays, in milliseconds. */
|
|
11
|
+
export const MAX_MOTION_MS = 1000;
|
|
12
|
+
/** Duration of the opt-in `fade` reduced-motion policy (the deck stage's slide fade). */
|
|
13
|
+
export const REDUCED_FADE_MS = 150;
|
|
14
|
+
/**
|
|
15
|
+
* Create a reduced-motion source. It reads `(prefers-reduced-motion: reduce)`
|
|
16
|
+
* from the given window, follows its `change` events, and lets the host
|
|
17
|
+
* override it (a settings toggle, a test, a print view).
|
|
18
|
+
*/
|
|
19
|
+
export function createReducedMotionSource(options = {}) {
|
|
20
|
+
const ssrDefault = options.ssrDefault === true;
|
|
21
|
+
let override = typeof options.override === 'boolean' ? options.override : null;
|
|
22
|
+
const listeners = new Set();
|
|
23
|
+
let query = null;
|
|
24
|
+
const win = options.window;
|
|
25
|
+
if (win && typeof win.matchMedia === 'function') {
|
|
26
|
+
try {
|
|
27
|
+
query = win.matchMedia(REDUCED_MOTION_QUERY);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
query = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let last = read();
|
|
34
|
+
function read() {
|
|
35
|
+
if (override !== null)
|
|
36
|
+
return override;
|
|
37
|
+
if (query)
|
|
38
|
+
return query.matches === true;
|
|
39
|
+
return ssrDefault;
|
|
40
|
+
}
|
|
41
|
+
function notify() {
|
|
42
|
+
const next = read();
|
|
43
|
+
if (next === last)
|
|
44
|
+
return;
|
|
45
|
+
last = next;
|
|
46
|
+
for (const listener of [...listeners])
|
|
47
|
+
listener(next);
|
|
48
|
+
}
|
|
49
|
+
const onChange = () => notify();
|
|
50
|
+
if (query?.addEventListener)
|
|
51
|
+
query.addEventListener('change', onChange);
|
|
52
|
+
else
|
|
53
|
+
query?.addListener?.(onChange);
|
|
54
|
+
return {
|
|
55
|
+
get: read,
|
|
56
|
+
subscribe(listener) {
|
|
57
|
+
listeners.add(listener);
|
|
58
|
+
return () => listeners.delete(listener);
|
|
59
|
+
},
|
|
60
|
+
setOverride(value) {
|
|
61
|
+
override = typeof value === 'boolean' ? value : null;
|
|
62
|
+
notify();
|
|
63
|
+
},
|
|
64
|
+
dispose() {
|
|
65
|
+
if (query?.removeEventListener)
|
|
66
|
+
query.removeEventListener('change', onChange);
|
|
67
|
+
else
|
|
68
|
+
query?.removeListener?.(onChange);
|
|
69
|
+
query = null;
|
|
70
|
+
listeners.clear();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Whether a value is one of {@link MOTION_LEVELS}. */
|
|
75
|
+
export function isMotionLevel(value) {
|
|
76
|
+
return typeof value === 'string' && MOTION_LEVELS.includes(value);
|
|
77
|
+
}
|
|
78
|
+
const RANK = { full: 2, subtle: 1, none: 0 };
|
|
79
|
+
/** The lower of two levels (`none` < `subtle` < `full`); invalid values are ignored. */
|
|
80
|
+
export function lowerMotionLevel(a, b) {
|
|
81
|
+
const left = isMotionLevel(a) ? a : 'full';
|
|
82
|
+
const right = isMotionLevel(b) ? b : 'full';
|
|
83
|
+
return RANK[left] <= RANK[right] ? left : right;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the motion level: host, then blueprint or deck, then the component
|
|
87
|
+
* default, then `full`; the viewer's preference and reduced motion only lower
|
|
88
|
+
* it. Invalid values are skipped.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveMotionLevel(input = {}) {
|
|
91
|
+
const chosen = isMotionLevel(input.host)
|
|
92
|
+
? input.host
|
|
93
|
+
: isMotionLevel(input.document)
|
|
94
|
+
? input.document
|
|
95
|
+
: isMotionLevel(input.component)
|
|
96
|
+
? input.component
|
|
97
|
+
: 'full';
|
|
98
|
+
const level = lowerMotionLevel(chosen, input.viewer);
|
|
99
|
+
return input.reducedMotion === true ? 'none' : level;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Clamp a duration or delay to [0, {@link MAX_MOTION_MS}] and scale it for the
|
|
103
|
+
* level: `subtle` × {@link SUBTLE_DURATION_SCALE}, `none` → 0. NaN and
|
|
104
|
+
* negative values are 0; Infinity is the maximum.
|
|
105
|
+
*/
|
|
106
|
+
export function motionDuration(ms, level = 'full') {
|
|
107
|
+
if (typeof ms !== 'number' || Number.isNaN(ms) || ms <= 0 || level === 'none')
|
|
108
|
+
return 0;
|
|
109
|
+
const clamped = Math.min(MAX_MOTION_MS, ms);
|
|
110
|
+
return Math.round(level === 'subtle' ? clamped * SUBTLE_DURATION_SCALE : clamped);
|
|
111
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion presence: enter and exit animations with interruptible reversal
|
|
3
|
+
* (ADR-013), on the `@texuli/core/presence` vocabulary (`PresenceSnapshot`,
|
|
4
|
+
* `data-state`).
|
|
5
|
+
*
|
|
6
|
+
* The core presence waits for CSS `transitionend` / `animationend`; Web
|
|
7
|
+
* Animations fire neither, and an unrelated CSS transition on the node would
|
|
8
|
+
* end the exit early, so this machine drives the same snapshot from its own
|
|
9
|
+
* animations instead. Toggling mid-flight reverses the running animation from
|
|
10
|
+
* where it is (no jump) and resolves the earlier transition `"interrupted"`.
|
|
11
|
+
*
|
|
12
|
+
* Nothing animates on the server or on hydration: the first attached node
|
|
13
|
+
* with `present: true` shows at once unless `appear` is set.
|
|
14
|
+
*/
|
|
15
|
+
import { type PresenceSnapshot } from '../presence/presence.js';
|
|
16
|
+
import { type MotionKeyframes, type MotionOutcome, type MotionTiming } from './animate.js';
|
|
17
|
+
export type MotionPresenceOptions = MotionTiming & {
|
|
18
|
+
/** Initial intent. Default `false`. */
|
|
19
|
+
present?: boolean;
|
|
20
|
+
/** Play the enter animation for a node that is present on first attach. Default `false`. */
|
|
21
|
+
appear?: boolean;
|
|
22
|
+
/** Keep the node mounted after exit (`data-state` still closes). Default `false`. */
|
|
23
|
+
forceMount?: boolean;
|
|
24
|
+
/** Enter keyframes (hidden → shown). Default a fade in. */
|
|
25
|
+
enter?: MotionKeyframes;
|
|
26
|
+
/** Exit keyframes (shown → hidden). Default the reverse of `enter`. */
|
|
27
|
+
exit?: MotionKeyframes;
|
|
28
|
+
/** Timing overrides for the exit (merged over the shared timing). */
|
|
29
|
+
exitTiming?: MotionTiming;
|
|
30
|
+
/** Snapshot changed. */
|
|
31
|
+
onSnapshot?: (snapshot: PresenceSnapshot) => void;
|
|
32
|
+
/** The enter path began (the node should be mounted). */
|
|
33
|
+
onEnter?: (snapshot: PresenceSnapshot) => void;
|
|
34
|
+
/** The enter animation ended (or committed instantly). */
|
|
35
|
+
onEnterComplete?: (snapshot: PresenceSnapshot) => void;
|
|
36
|
+
/** The exit ended; unmount when `snapshot.mounted` is false. */
|
|
37
|
+
onExitComplete?: (snapshot: PresenceSnapshot) => void;
|
|
38
|
+
};
|
|
39
|
+
export type MotionPresence = {
|
|
40
|
+
getSnapshot: () => PresenceSnapshot;
|
|
41
|
+
/** Drive the intent; resolves when this transition settles. */
|
|
42
|
+
setPresent: (present: boolean) => Promise<MotionOutcome>;
|
|
43
|
+
/** Attach (or detach with `null`) the animated element after it mounts. */
|
|
44
|
+
setNode: (node: Element | null) => void;
|
|
45
|
+
/** The current transition's outcome (resolved `"instant"` when idle). */
|
|
46
|
+
readonly finished: Promise<MotionOutcome>;
|
|
47
|
+
/** Cancel any animation and stop; pending transitions resolve `"cancelled"`. */
|
|
48
|
+
dispose: () => void;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Create a motion presence controller.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* const presence = createMotionPresence({
|
|
55
|
+
* enter: [{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'none' }],
|
|
56
|
+
* onExitComplete: (s) => { if (!s.mounted) unmount(); },
|
|
57
|
+
* });
|
|
58
|
+
* presence.setNode(panel);
|
|
59
|
+
* await presence.setPresent(false); // "finished", or "instant" under reduced motion
|
|
60
|
+
*/
|
|
61
|
+
export declare function createMotionPresence(options?: MotionPresenceOptions): MotionPresence;
|