@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.
Files changed (45) hide show
  1. package/README.md +29 -5
  2. package/dist/editor/document.js +8 -1
  3. package/dist/editor/extensions.d.ts +64 -0
  4. package/dist/editor/extensions.js +116 -0
  5. package/dist/editor/inline.d.ts +5 -3
  6. package/dist/editor/inline.js +24 -12
  7. package/dist/editor/load.d.ts +10 -0
  8. package/dist/editor/load.js +16 -0
  9. package/dist/editor/markdown.d.ts +10 -1
  10. package/dist/editor/markdown.js +193 -83
  11. package/dist/editor/model.d.ts +3 -1
  12. package/dist/editor/model.js +3 -1
  13. package/dist/editor/transforms.d.ts +1 -1
  14. package/dist/editor/tree.d.ts +11 -1
  15. package/dist/editor/tree.js +11 -2
  16. package/dist/editor/types.d.ts +10 -0
  17. package/dist/editor/types.js +1 -0
  18. package/dist/inert/inert.d.ts +11 -0
  19. package/dist/inert/inert.js +5 -3
  20. package/dist/markup/index.d.ts +5 -0
  21. package/dist/markup/index.js +5 -0
  22. package/dist/markup/markup.d.ts +72 -0
  23. package/dist/markup/markup.js +233 -0
  24. package/dist/motion/animate.d.ts +109 -0
  25. package/dist/motion/animate.js +225 -0
  26. package/dist/motion/flip.d.ts +58 -0
  27. package/dist/motion/flip.js +167 -0
  28. package/dist/motion/index.d.ts +15 -0
  29. package/dist/motion/index.js +15 -0
  30. package/dist/motion/preferences.d.ts +68 -0
  31. package/dist/motion/preferences.js +111 -0
  32. package/dist/motion/presence.d.ts +61 -0
  33. package/dist/motion/presence.js +231 -0
  34. package/dist/motion/sequence.d.ts +70 -0
  35. package/dist/motion/sequence.js +159 -0
  36. package/dist/motion/shared.d.ts +45 -0
  37. package/dist/motion/shared.js +144 -0
  38. package/dist/motion/spring.d.ts +109 -0
  39. package/dist/motion/spring.js +166 -0
  40. package/dist/overlay/core.js +14 -3
  41. package/dist/zip/index.d.ts +5 -0
  42. package/dist/zip/index.js +5 -0
  43. package/dist/zip/zip.d.ts +35 -0
  44. package/dist/zip/zip.js +190 -0
  45. 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;