@tobiasoberpaul/mdk 0.2.0 → 0.3.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 CHANGED
@@ -23,6 +23,8 @@ toggle.addEventListener('click', () => motion.toggle());
23
23
 
24
24
  Your component manages layout, visibility, focus and accessibility. MDK handles the visual transition, including reversal and the system's reduced-motion preference.
25
25
 
26
+ For an existing popover or menu, use `createPresenceMotion` to connect its open and close signals. Use `resize: 'transform'` with `createGrowTransition` for local growth on a surface positioned by the host. [Integration guide](https://github.com/TOberpaul/animations/blob/main/docs/LIBRARY.md#animate-an-existing-component)
27
+
26
28
  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
29
 
28
30
  Coding assistants can access the MDK reference and integration examples through the public MCP server. See the [MCP setup](https://github.com/TOberpaul/animations/blob/main/docs/AI_INTEGRATION.md#hosted-mcp).
@@ -11,4 +11,4 @@ export interface ResizeOptions extends Options {
11
11
  overlay?: SVGSVGElement;
12
12
  }
13
13
  /** Shared physical resizing with a trigger edge or local origin. */
14
- export declare function createResizeTransition(options: ResizeOptions, pattern: 'morph' | 'scale'): Transition;
14
+ export declare function createResizeTransition(options: ResizeOptions, pattern: 'morph' | 'scale', transform?: boolean): Transition;
@@ -1,10 +1,12 @@
1
1
  import { createTransition } from './animation.js';
2
2
  import { growthAnchor, growthInsets, localScale, morphGeometry, resizeGeometry } from './geometry.js';
3
- import { overshootToken, numberToken, token } from './tokens.js';
3
+ import { lengthToken, overshootToken, numberToken, token } from './tokens.js';
4
4
  import { fixedContentTracks } from './content-layout.js';
5
5
  /** Shared physical resizing with a trigger edge or local origin. */
6
- export function createResizeTransition(options, pattern) {
6
+ export function createResizeTransition(options, pattern, transform = false) {
7
7
  const { content, trigger, overlay } = options;
8
+ if (transform && overlay)
9
+ throw new Error('Transform Grow does not support an SVG surface overlay.');
8
10
  const sameElement = trigger === content;
9
11
  if (sameElement && options.initiallyOpen)
10
12
  throw new Error('A same-element morph must initialize in its closed layout.');
@@ -32,6 +34,8 @@ export function createResizeTransition(options, pattern) {
32
34
  }
33
35
  options.onStateChange?.(state);
34
36
  } }, pattern, () => {
37
+ if (transform)
38
+ return transformGrowTracks(options);
35
39
  const edgeGeometry = !trigger || sameElement ? undefined : morphGeometry(trigger, content);
36
40
  const factor = trigger ? undefined : numberToken(content, 'scale-from');
37
41
  const collapseAxis = !trigger ? factor === 0 ? options.axis ?? 'both' : undefined : sameElement ? undefined : edgeGeometry.from.height === 0 ? 'height' : 'width';
@@ -39,7 +43,7 @@ export function createResizeTransition(options, pattern) {
39
43
  left: to.left + to.width * (options.axis === 'height' ? 0 : 1 - factor) * ax, top: to.top + to.height * (options.axis === 'width' ? 0 : 1 - factor) * ay,
40
44
  width: to.width * (options.axis === 'height' ? 1 : factor), height: to.height * (options.axis === 'width' ? 1 : factor)
41
45
  });
42
- const g = resizeGeometry(content, source, trigger ? edgeGeometry?.origin : token(content, 'scale-origin'), collapseAxis);
46
+ const g = resizeGeometry(content, source, trigger ? edgeGeometry?.origin : scaleOrigin(content), collapseAxis);
43
47
  const style = getComputedStyle(content);
44
48
  const base = style.transform;
45
49
  const shadow = style.boxShadow;
@@ -125,12 +129,85 @@ export function createResizeTransition(options, pattern) {
125
129
  result.push({ element: overlay, easing: 'linear', frames: frames.map(frame => ({
126
130
  ...frame, visibility: 'visible'
127
131
  })) });
128
- if (options.backdrop) {
129
- const native = options.backdrop === 'native';
130
- const element = native ? content : options.backdrop;
131
- const pseudoElement = native ? '::backdrop' : undefined;
132
- result.push({ element, pseudoElement, easing, frames: [{ opacity: 0 }, { opacity: getComputedStyle(element, pseudoElement).opacity }] });
133
- }
132
+ addBackdropTrack(result, options, easing);
134
133
  return result;
135
134
  }, { keepVisibleWhenClosed: sameElement });
136
135
  }
136
+ function addBackdropTrack(tracks, { content, backdrop }, easing) {
137
+ if (!backdrop)
138
+ return;
139
+ const element = backdrop === 'native' ? content : backdrop;
140
+ const pseudoElement = backdrop === 'native' ? '::backdrop' : undefined;
141
+ tracks.push({ element, pseudoElement, easing, frames: [{ opacity: 0 }, { opacity: getComputedStyle(element, pseudoElement).opacity }] });
142
+ }
143
+ function scaleOrigin(content) {
144
+ const origin = token(content, 'scale-origin');
145
+ if (!/\b(?:inline|block)-(?:start|end)\b/.test(origin))
146
+ return origin;
147
+ const { direction, writingMode } = getComputedStyle(content);
148
+ const horizontal = writingMode === 'horizontal-tb';
149
+ const inline = horizontal ? ['left', 'right'] : writingMode === 'sideways-lr' ? ['bottom', 'top'] : ['top', 'bottom'];
150
+ if (direction === 'rtl')
151
+ inline.reverse();
152
+ const block = horizontal ? ['top', 'bottom'] : writingMode.endsWith('-rl') ? ['right', 'left'] : ['left', 'right'];
153
+ return origin.replace(/\b(inline|block)-(start|end)\b/g, (_, axis, edge) => (axis === 'inline' ? inline : block)[edge === 'start' ? 0 : 1]);
154
+ }
155
+ function transformGrowTracks(options) {
156
+ const { content, axis } = options;
157
+ const style = getComputedStyle(content);
158
+ const hostOrigin = style.transformOrigin.split(' ').map(parseFloat);
159
+ const opacity = style.opacity;
160
+ const property = 'transform-origin';
161
+ const inlineOrigin = content.style.getPropertyValue(property);
162
+ const priority = content.style.getPropertyPriority(property);
163
+ const origin = scaleOrigin(content);
164
+ if (!CSS.supports(property, origin))
165
+ throw new Error('Invalid --motion-scale-origin.');
166
+ let x, y;
167
+ try {
168
+ content.style.setProperty(property, origin, 'important');
169
+ [x, y] = style.transformOrigin.split(' ').map(parseFloat);
170
+ }
171
+ finally {
172
+ if (inlineOrigin)
173
+ content.style.setProperty(property, inlineOrigin, priority);
174
+ else
175
+ content.style.removeProperty(property);
176
+ }
177
+ const { width, height } = localScale(content, content.getBoundingClientRect());
178
+ if (!width || !height)
179
+ throw new Error('Grow content must be measurable before animation.');
180
+ const dx = x - hostOrigin[0], dy = y - hostOrigin[1];
181
+ const end = numberToken(content, 'scale-geometry-end');
182
+ if (end <= 0 || end > 1)
183
+ throw new Error('Scale geometry end must satisfy 0 < end <= 1.');
184
+ const requested = token(content, 'easing-scale');
185
+ const easing = CSS.supports('animation-timing-function', requested) ? requested : token(content, 'easing-standard');
186
+ const from = numberToken(content, 'scale-from');
187
+ const frame = (offset, sx, sy) => ({
188
+ offset, easing,
189
+ // Append a local scale around the motion origin while preserving the host origin.
190
+ transform: `translate(${dx * (1 - sx)}px, ${dy * (1 - sy)}px) scale(${sx}, ${sy})`,
191
+ });
192
+ const frames = [frame(0, axis === 'height' ? 1 : from, axis === 'width' ? 1 : from)];
193
+ const overshoot = Math.min(overshootToken(content, 'scale-overshoot', width, height), lengthToken(content, 'overshoot-max'));
194
+ if (overshoot > 0) {
195
+ const peak = numberToken(content, 'scale-peak-offset');
196
+ if (peak <= 0 || peak >= end)
197
+ throw new Error('Scale peak must satisfy 0 < peak < geometry end.');
198
+ frames.push(frame(peak, axis === 'height' ? 1 : 1 + overshoot / Math.max(Math.abs(x), Math.abs(width - x)), axis === 'width' ? 1 : 1 + overshoot / Math.max(Math.abs(y), Math.abs(height - y))));
199
+ }
200
+ frames.push(frame(end, 1, 1));
201
+ if (end < 1)
202
+ frames.push(frame(1, 1, 1));
203
+ const tracks = [
204
+ { element: content, frames, easing: 'linear', composite: 'add' },
205
+ { element: content, easing: 'linear', frames: [
206
+ { offset: 0, easing, opacity: numberToken(content, 'scale-opacity') },
207
+ { offset: end, opacity },
208
+ ...(end < 1 ? [{ offset: 1, opacity }] : []),
209
+ ] },
210
+ ];
211
+ addBackdropTrack(tracks, options, easing);
212
+ return tracks;
213
+ }
@@ -45,9 +45,9 @@ export function lengthToken(element, name) {
45
45
  }
46
46
  /** Proportional overshoot per edge, capped by the pattern's CSS length budget. */
47
47
  export function overshootToken(element, name, width, height) {
48
- const percentage = token(element, 'overshoot-min');
48
+ const percentage = getComputedStyle(element).getPropertyValue('--motion-overshoot-ratio').trim() || token(element, 'overshoot-min');
49
49
  if (!/^\d+(?:\.\d+)?%$/.test(percentage))
50
- throw new Error('--motion-overshoot-min must be a non-negative percentage.');
50
+ throw new Error('--motion-overshoot-ratio must be a non-negative percentage.');
51
51
  const style = getComputedStyle(element);
52
52
  const w = width ?? parseFloat(style.width), h = height ?? parseFloat(style.height);
53
53
  return Math.min(Math.min(w, h) * parseFloat(percentage) / 100, lengthToken(element, name));
package/dist/layout.js CHANGED
@@ -10,6 +10,8 @@ export function captureMotionLayout(element) {
10
10
  /** Animate a component-owned CSS layout change from its captured visual state. */
11
11
  export function animateMotionLayout(element, from) {
12
12
  active.get(element)?.cancel();
13
+ if (typeof element.animate !== 'function')
14
+ return () => { };
13
15
  const to = captureMotionLayout(element);
14
16
  if (element.ownerDocument.defaultView.matchMedia('(prefers-reduced-motion: reduce)').matches || duration(element, 'morph') === 0)
15
17
  return () => { };
@@ -5,6 +5,8 @@ export interface PresenceMotionOptions extends Pick<Options, 'initiallyOpen'> {
5
5
  /** Supply only if the integration can synchronously show and hide the surface.
6
6
  * Without this callback, call setOpen(true) after the host has shown the surface. */
7
7
  setPresent?(present: boolean): void;
8
+ /** Reflect data-motion-state on this surface. Its CSS must keep the surface visible during closing. */
9
+ stateTarget?: HTMLElement;
8
10
  }
9
11
  export interface PresenceMotion {
10
12
  readonly state: MotionState;
package/dist/presence.js CHANGED
@@ -1,19 +1,25 @@
1
1
  /** Drive MDK from a component's existing open/close signals. */
2
2
  export function createPresenceMotion(options) {
3
- const { createTransition, setPresent } = options;
3
+ const { createTransition, setPresent, stateTarget } = options;
4
+ const stateAttribute = 'data-motion-state';
5
+ const originalState = stateTarget?.getAttribute(stateAttribute);
4
6
  let open = options.initiallyOpen ?? false;
5
7
  let destroyed = false;
6
8
  let controller;
7
- if (setPresent) {
9
+ if (setPresent || stateTarget) {
8
10
  controller = createTransition({
9
11
  initiallyOpen: open,
10
12
  onStateChange(state) {
13
+ if (state === 'destroyed')
14
+ return;
15
+ stateTarget?.setAttribute(stateAttribute, state);
11
16
  if (state === 'opening')
12
- setPresent(true);
17
+ setPresent?.(true);
13
18
  if (state === 'closed')
14
- setPresent(false);
19
+ setPresent?.(false);
15
20
  }
16
21
  });
22
+ stateTarget?.setAttribute(stateAttribute, controller.state);
17
23
  }
18
24
  return {
19
25
  get state() {
@@ -29,7 +35,7 @@ export function createPresenceMotion(options) {
29
35
  controller ?? (controller = createTransition({ initiallyOpen: false }));
30
36
  controller.open();
31
37
  }
32
- else if (setPresent) {
38
+ else if (setPresent || stateTarget) {
33
39
  controller?.close();
34
40
  }
35
41
  else {
@@ -44,6 +50,10 @@ export function createPresenceMotion(options) {
44
50
  destroyed = true;
45
51
  const pendingExit = controller?.state === 'closing';
46
52
  controller?.destroy();
53
+ if (originalState == null)
54
+ stateTarget?.removeAttribute(stateAttribute);
55
+ else
56
+ stateTarget?.setAttribute(stateAttribute, originalState);
47
57
  if (pendingExit)
48
58
  setPresent?.(false);
49
59
  }
@@ -72,5 +72,5 @@
72
72
  --motion-overshoot-max: .75rem;
73
73
  }
74
74
  @media (prefers-reduced-motion: reduce) {
75
- :root { --motion-duration-morph: 0ms; --motion-duration-slide: 0ms; --motion-duration-scale: 0ms; --motion-duration-backdrop: 0ms; }
75
+ :root { --motion-duration-morph: 0ms; --motion-duration-slide: 0ms; --motion-duration-scale: 0ms; --motion-duration-backdrop: 0ms; --motion-duration-content: 0ms; --motion-duration-liquid: 0ms; }
76
76
  }
@@ -1,6 +1,8 @@
1
1
  import { type ResizeOptions } from '../core/resize.js';
2
2
  import type { Transition } from '../core/lifecycle.js';
3
3
  export interface GrowOptions extends ResizeOptions {
4
+ /** Use visual scaling for local Grow. Trigger-edge Grow always uses physical resizing. */
5
+ resize?: 'metrics' | 'transform';
4
6
  }
5
7
  /** Grow from a trigger edge, or from the configured local origin without a trigger. */
6
8
  export declare function createGrowTransition(options: GrowOptions): Transition;
@@ -1,5 +1,5 @@
1
1
  import { createResizeTransition } from '../core/resize.js';
2
2
  /** Grow from a trigger edge, or from the configured local origin without a trigger. */
3
3
  export function createGrowTransition(options) {
4
- return createResizeTransition(options, options.trigger ? 'morph' : 'scale');
4
+ return createResizeTransition(options, options.trigger ? 'morph' : 'scale', !options.trigger && options.resize === 'transform');
5
5
  }
@@ -3,5 +3,5 @@ export interface ScaleOptions extends Options {
3
3
  backdrop?: HTMLElement | 'native';
4
4
  axis?: 'width' | 'height';
5
5
  }
6
- /** The same resize behavior, starting at the component's configured origin. */
6
+ /** @deprecated Use createGrowTransition for local growth. */
7
7
  export declare function createScaleTransition(options: ScaleOptions): Transition;
@@ -1,5 +1,5 @@
1
1
  import { createGrowTransition } from './grow.js';
2
- /** The same resize behavior, starting at the component's configured origin. */
2
+ /** @deprecated Use createGrowTransition for local growth. */
3
3
  export function createScaleTransition(options) {
4
4
  return createGrowTransition(options);
5
5
  }
package/docs/LIBRARY.md CHANGED
@@ -23,14 +23,14 @@ content, unless reflow is intentional (the three-column layout).
23
23
  The package name is `@tobiasoberpaul/mdk`.
24
24
 
25
25
  ```ts
26
- import { createScaleTransition } from '@tobiasoberpaul/mdk';
26
+ import { createGrowTransition } from '@tobiasoberpaul/mdk';
27
27
  import '@tobiasoberpaul/mdk/motion.css';
28
28
 
29
29
  const surface = document.querySelector<HTMLElement>('#dialog')!;
30
30
  const trigger = document.querySelector<HTMLButtonElement>('#open')!;
31
31
  surface.inert = true;
32
32
 
33
- const transition = createScaleTransition({
33
+ const transition = createGrowTransition({
34
34
  content: surface,
35
35
  reveal: [...surface.children].filter((node): node is HTMLElement => node instanceof HTMLElement),
36
36
  onStateChange(state) {
@@ -104,18 +104,47 @@ mechanism in this callback. The same surface must remain mounted through exit.
104
104
  Keep requested open state separate from visual presence so a new open request
105
105
  can reverse an in-progress exit.
106
106
 
107
- If the host hides itself immediately, omit `setPresent`. Call `setOpen(true)`
107
+ If the host can hold presence through CSS, pass `stateTarget: surface`. The
108
+ adapter reflects `opening`, `open`, `closing` and `closed` in
109
+ `data-motion-state` and restores any previous value on teardown. Let the
110
+ host's CSS keep the surface rendered at its normal open opacity during
111
+ `closing`, so the animation can measure and fade it.
112
+ Forward the host's requested state, independently of the held visibility,
113
+ so reopening can reverse the exit. Keep focus and interaction rules in the host.
114
+
115
+ If the host hides itself immediately, omit both `setPresent` and `stateTarget`. Call `setOpen(true)`
108
116
  after the host has made its surface visible and positioned it, then call
109
117
  `setOpen(false)` when it hides. This mode plays entry and releases the effect
110
118
  on close. It requires a reliable signal from the host integration. Disable any
111
119
  competing host animation on the same surface.
112
120
 
121
+ ## Grow on a host-positioned surface
122
+
123
+ For a popover positioned by its host, opt into transform-based local Grow:
124
+
125
+ ```ts
126
+ const motion = createGrowTransition({ content: surface, resize: 'transform' });
127
+ ```
128
+
129
+ Use `--motion-scale-origin: inline-end block-start` for logical placement in
130
+ either text direction or writing mode. Use `right top` for a physical anchor.
131
+ Omit `trigger` when the component owns positioning and you want local growth.
132
+ Pass `trigger` only to grow from the trigger's edge. Use
133
+ `--motion-scale-from` for its starting scale. MDK adds the animation to the
134
+ host transform and leaves layout dimensions and insets alone. The whole surface
135
+ scales visually, including its border and content. Set `--motion-overshoot-max: 0`
136
+ to turn off overshoot. This mode works with `createPresenceMotion`.
137
+
138
+ The default `resize: 'metrics'` still resizes the actual surface. Grow with a
139
+ `trigger` continues to use trigger-edge geometry. The local transform mode
140
+ supports HTML surfaces without an SVG overlay.
141
+
113
142
  ## Public behavior factories
114
143
 
115
144
  | Factory | Behavior | Required additions to shared options |
116
145
  | --- | --- | --- |
117
- | `createGrowTransition` | Grow from a configured origin or trigger edge | Optional `trigger`, `axis`, `backdrop` |
118
- | `createScaleTransition` | Origin-based Grow compatibility factory | Optional `axis`, `backdrop` |
146
+ | `createGrowTransition` | Grow from a configured origin or trigger edge | Optional `trigger`, `axis`, `backdrop`, `resize` |
147
+ | `createScaleTransition` | Deprecated alias for local Grow | Optional `axis`, `backdrop` |
119
148
  | `createExtractTransition` | Extract a new surface while retaining the trigger | `trigger` |
120
149
  | `createMorphTransition` | Transform the trigger into the target surface | `trigger` |
121
150
  | `createLiquidMorphTransition` | Source volume → encompassing surface → target | `trigger`; optional `replaceTrigger` |
@@ -132,7 +161,7 @@ opacity and blur. Explicit `items` for Content must not be nested.
132
161
 
133
162
  ### Grow
134
163
 
135
- `createScaleTransition` preserves the component's base transform and reads its
164
+ `createGrowTransition` preserves the component's base transform and reads its
136
165
  origin from `--motion-scale-origin`. The library default is `.92` for
137
166
  `--motion-scale-from`; the demos set `0` for fully collapsed opening.
138
167
 
@@ -238,7 +267,7 @@ const transition = createMotion({
238
267
 
239
268
  An explicit `data-motion` takes precedence over classes. Without it, use exactly
240
269
  one matching class. Declarative `morph` selects the replacing Morph factory directly.
241
- The old `liquid` / `motion-liquid` spelling remains a compatibility path; its
270
+ The deprecated `liquid` / `motion-liquid` spelling remains a compatibility path; its
242
271
  `data-motion-replace-trigger` flag retains its original meaning. Recreate the controller to change behavior after
243
272
  initialization. Change CSS tokens for per-trip visual parameters.
244
273
 
@@ -335,7 +364,7 @@ can be overridden locally on a surface or globally.
335
364
  | --- | --- |
336
365
  | `--motion-duration` | `300ms`; aliases for morph, scale, liquid, slide, content, backdrop |
337
366
  | `--motion-easing-standard` | `cubic-bezier(.42, 0, .58, 1)`; symmetric open/close |
338
- | `--motion-overshoot-min` | `15%` of the surface's shorter side |
367
+ | `--motion-overshoot-ratio` | `15%` of the surface's shorter side by default. When unset, reads the legacy `--motion-overshoot-min` token. |
339
368
  | `--motion-overshoot-max` | `.75rem` cap shared by trigger and target; morph/scale alias this |
340
369
  | `--motion-content-progressive` | `1` (enabled by default); set to `0` to disable |
341
370
  | `--motion-content-stagger` | `.08`, fraction of base duration for Content |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tobiasoberpaul/mdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",