@noxlovette/material 0.9.1 → 0.9.3

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.
@@ -15,7 +15,7 @@ Source: [m3.material.io → Transitions → Applying transitions](https://m3.mat
15
15
 
16
16
  | Quality | M3 rule | What it means here |
17
17
  | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
18
- | **Follows accessibility settings** | With reduced motion on, use subtle fades instead of intense slides or scales, and turn off decorative effects such as parallax and shape morphing. | This is not implemented yet (see [Reduced motion](#reduced-motion)). Don't add new parallax or morph effects that you couldn't easily switch off. |
18
+ | **Follows accessibility settings** | With reduced motion on, use subtle fades instead of intense slides or scales, and turn off decorative effects such as parallax and shape morphing. | The OS preference switches the transition primitives and CSS spatial tokens automatically (see [Reduced motion](#reduced-motion)). |
19
19
  | **Consistent** | Use the same transition type for the same kind of change everywhere, so the app feels like one cohesive family. | Always use one primitive for one relationship. Every hierarchy step uses `sharedAxis` on the same axis, and every navbar/rail destination uses `fadeThrough`. Never mix patterns for the same kind of navigation. |
20
20
  | **Stable layouts** | Use skeleton loaders so the layout holds still during a transition. Content shouldn't pop in or move around. | Reserve the loaded content's footprint with `{@attach skeleton}` placeholders, then reveal the content with `presence(…, enterExit.fade)`. The content shouldn't be conditionally inserted in a way that shifts its siblings. |
21
21
  | **No jarring jump cuts** | By default, avoid instant screen swaps because they disorient users. A jump cut is fine only when pure efficiency matters most, such as opening a menu in a productivity app. | Surfaces and navigation always animate, which is why the ownership table below is mandatory. A jump cut has to be an explicit, documented choice, never the result of leaving the animation out. |
@@ -54,31 +54,31 @@ Spatial springs overshoot by design (fast spatial most, at damping ratio 0.6); e
54
54
 
55
55
  ## JS: picking a primitive
56
56
 
57
- | M3 pattern | Primitive | Notes |
58
- | ----------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
- | **Enter and exit** — a surface appears/leaves within the screen | `presence(() => open, enterExit.<preset>)` | Attachment. Presets: `fade`, `scale` (menus/popovers/tooltips — sets `transform-origin` to bits-ui's anchor side, so never add an `origin-*` class), `slideUp` (snackbar), `dialog`, `sideSheet`, `bottomSheet`. Interruptible: reopening mid-exit retargets from the current value with velocity. |
60
- | Same, outside bits-ui (element must stay mounted during its exit) | `new Presence(() => open)` | `{#if p.mounted}<div {@attach p.attach(enterExit.x)}>`. Construct during component init. Used by Snackbar, SideSheet, BottomSheet. |
61
- | **Container transform** — card → detail, search bar → view | `containerTransform(update, { from, to })` | Motion `animateView()` (View Transition API). `update` swaps the DOM (`async () => { open = true; await tick(); }`); `to` may be a selector for an element that only exists after the update. |
62
- | **Forward and backward** — hierarchy levels, wizard steps | `sharedAxis(update, { target, axis, direction })` | `axis: 'x' \| 'y' \| 'z'`, `direction: 'forward' \| 'backward'`. `target` is the persistent region whose content changes; omit for the whole page. |
63
- | **Lateral** — peer screens (tabs, carousels) | `lateral(update, { target, direction, axis })` | Edge-to-edge slide, no fade, along the axis the peers are laid out on: `axis: 'y'` for vertical tabs, a vertical carousel or a top-to-bottom stepper (`forward` = next, pushing up from below). Never on a vertical nav list: that's a drawer, so top level. |
64
- | **Top level** — unrelated destinations (navigation bar) | `fadeThrough(update, { target })` | Old fades out, new fades in scaling from 92%. The `target` region swaps its box instantly (no slide or resize, so a scroll reset doesn't glide). In SvelteKit, call it from `onNavigate` in the root layout, skip hash-only changes, and fade only the region whose content changed: `main` between rail/navbar destinations, a section's content pane between pages of a nav that stays on screen (the showcase site's root layout does both). |
65
- | **Skeleton loaders** | `{@attach skeleton}` on the placeholder | Pulses until replaced; reveal the content with `presence(…, enterExit.fade)`. |
66
- | **Direct manipulation** — dragging a pane, reordering chips | `drag()` attachment + `resist()` + a spring on release | The attachment reports offsets and release velocity; the caller moves things. Release on `fastSpatial` with `velocity` so the throw carries (DraggablePane, ChipGroup). Neighbours making room play FLIP: measure, reorder, `flushSync()`, spring each from its old offset to 0. |
67
- | **Several parts moving as one state change** — rail expand | One number spring written to a CSS variable | `animate(from, to, { ...spring, onUpdate })` writes the progress (`--rail-p`, 0–1) and every part interpolates on it in CSS `calc()`: widths, gaps, heights, positions. One spring keeps them in step and an interruption reverses them together. Things that must not be seen mid-change (the rail label's side and typescale) switch at the halfway point while faded out. |
68
- | Anything custom | `animate(node, keyframes, springTransition(springTokens.x))` | `springTransition` converts a token to Motion's physics spring (`stiffness`/`damping`), which inherits velocity on interruption. |
57
+ | M3 pattern | Primitive | Notes |
58
+ | ----------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | **Enter and exit** — a surface appears/leaves within the screen | `presence(() => open, enterExit.<preset>)` | Attachment. Presets: `fade`, `scale` (menus/popovers/tooltips — sets `transform-origin` to bits-ui's anchor side, so never add an `origin-*` class), `slideUp` (snackbar), `dialog`, `sideSheet`, `bottomSheet`. Interruptible: reopening mid-exit retargets from the current value with velocity. |
60
+ | Same, outside bits-ui (element must stay mounted during its exit) | `new Presence(() => open)` | `{#if p.mounted}<div {@attach p.attach(enterExit.x)}>`. Construct during component init. Used by Snackbar, SideSheet, BottomSheet. |
61
+ | **Container transform** — card → detail, search bar → view | `containerTransform(update, { from, to })` | Motion `animateView()` (View Transition API). `update` swaps the DOM (`async () => { open = true; await tick(); }`); `to` may be a selector for an element that only exists after the update. |
62
+ | **Forward and backward** — hierarchy levels, wizard steps | `sharedAxis(update, { target, axis, direction })` | `axis: 'x' \| 'y' \| 'z'`, `direction: 'forward' \| 'backward'`. `target` is the persistent region whose content changes; omit for the whole page. |
63
+ | **Lateral** — peer screens (tabs, carousels) | `lateral(update, { target, direction, axis })` | Edge-to-edge slide, no fade, along the axis the peers are laid out on: `axis: 'y'` for vertical tabs, a vertical carousel or a top-to-bottom stepper (`forward` = next, pushing up from below). Never on a vertical nav list: that's a drawer, so top level. |
64
+ | **Top level** — unrelated destinations (navigation bar) | `fadeThrough(update, { target })` | Old fades out, new fades in scaling from 92%. The `target` region swaps its box instantly (no slide or resize, so a scroll reset doesn't glide). In SvelteKit, call it from `onNavigate` in the root layout, skip hash-only changes, and fade only the region whose content changed: `main` between rail/navbar destinations, a section's content pane between pages of a nav that stays on screen (the showcase site's root layout does both). |
65
+ | **Skeleton loaders** | `{@attach skeleton}` on the placeholder | Pulses until replaced; reveal the content with `presence(…, enterExit.fade)`. |
66
+ | **Direct manipulation** — dragging a pane, reordering chips | `drag()` attachment + `resist()` + a spring on release | The attachment reports offsets and release velocity; the caller moves things. Release on `fastSpatial` with `velocity` so the throw carries (DraggablePane, ChipGroup): scale the pointer's velocity by `resistSlope()` when released past an edge, and aim at `position + project(velocity)` (clamped), not the release point, or a flick springs back to where it was let go. Move with `transform`/`translate`, not `left`/`top`, and commit the resting position once on release. Neighbours making room play FLIP: measure, reorder, `flushSync()`, spring each from its old offset to 0. |
67
+ | **Several parts moving as one state change** — rail expand | One number spring written to a CSS variable | `animate(from, to, { ...spring, onUpdate })` writes the progress (`--rail-p`, 0–1) and every part interpolates on it in CSS `calc()`: widths, gaps, heights, positions. One spring keeps them in step and an interruption reverses them together. Things that must not be seen mid-change (the rail label's side and typescale) switch at the halfway point while faded out. |
68
+ | Anything custom | `animate(node, keyframes, springTransition(springTokens.x))` | `springTransition` converts a token to Motion's physics spring (`stiffness`/`damping`), which inherits velocity on interruption. |
69
69
 
70
70
  ### Which components must use which pattern
71
71
 
72
72
  A pattern belongs in a component only when that component owns both states of the change. When the content that changes lives outside the component (a route, an app screen), the app applies the pattern, not the library. Keep this table in sync when a component gains or loses one of these patterns; each primitive's JSDoc in `src/lib/animation/` repeats its own row.
73
73
 
74
- | Pattern | Built into (must use it) | Left to the app |
75
- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
76
- | **Enter and exit** | Dialogue, BottomSheet, SideSheet, Menu, MenuSub, ContextMenu, Popover, Tooltip, LinkPreview, Select, FABMenu, SplitButton, Snackbar, DateField/DateRangeField/TimeField popups | — |
77
- | **Lateral** | TabHolder (switching content panels); DateField/DateRangeField (changing the visible month, via `date/calendarMotion.ts`) | Navigation (`href`) tabs: the route change is the app's |
78
- | **Container transform** | FAB → its `surface` (`FAB.svelte`: a clip-path and colour morph on Motion, not `containerTransform`); `Search` / search `AppBar` bar → `SearchView` (`containerTransform`, both ways) | Card → detail, Carousel item → detail |
79
- | **Forward and backward** | — | Hierarchy levels, wizard steps |
80
- | **Top level** | — | Page changes from `Navbar`/`Rail`: they don't own the content region |
81
- | **Skeleton loaders** | — | Placeholders for data the app loads |
74
+ | Pattern | Built into (must use it) | Left to the app |
75
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
76
+ | **Enter and exit** | Dialogue, BottomSheet, SideSheet, Menu, MenuSub, ContextMenu, Popover, Tooltip, LinkPreview, Select, FABMenu, SplitButton, Snackbar, DateField/DateRangeField/TimeField popups | — |
77
+ | **Lateral** | TabHolder (switching content panels); DateField/DateRangeField (changing the visible month, via `date/calendarMotion.ts`) | Navigation (`href`) tabs: the route change is the app's |
78
+ | **Container transform** | FAB → its `surface` (`FAB.svelte`: a clip-path and colour morph on Motion, not `containerTransform`); `Search` / search `AppBar` bar → `SearchView` (`containerTransform`, both ways); `DraggablePane` ↔ its minimised pill (`containerTransform`, both ways) | Card → detail, Carousel item → detail |
79
+ | **Forward and backward** | — | Hierarchy levels, wizard steps |
80
+ | **Top level** | — | Page changes from `Navbar`/`Rail`: they don't own the content region |
81
+ | **Skeleton loaders** | — | Placeholders for data the app loads |
82
82
 
83
83
  A new component that mounts a surface must use enter/exit; one that pages between peer views (a stepper of equal steps) must use lateral. `Carousel` is not a pager: its motion is the scroll itself, items resizing through keylines. Opening a tapped item into its detail is a container transform the app applies.
84
84
 
@@ -196,10 +196,10 @@ MDC's own durations/easings (`short1`…`extraLong4` × `emphasized`/`standard`)
196
196
 
197
197
  ## Reduced motion
198
198
 
199
- Not implemented yet — tracked in https://github.com/noxlovette/material/issues/24. `Layer.svelte`'s ripple is the only thing that currently checks `prefers-reduced-motion`.
199
+ The OS `prefers-reduced-motion: reduce` setting governs both the Motion primitives and the CSS tokens. Call sites use the same API regardless of the setting. See [Applying transitions](https://m3.material.io/styles/motion/transitions/applying-transitions) for the M3 rationale.
200
200
 
201
- Target behavior, per [Applying transitions](https://m3.material.io/styles/motion/transitions/applying-transitions). When `prefers-reduced-motion: reduce` is set:
202
-
203
- - **Swap movement for subtle fades rather than removing the transition** (a jump cut would break the "no jarring jump cuts" rule). `enterExit.scale`/`dialog`/`slideUp`/`sideSheet`/`bottomSheet` fall back to `enterExit.fade`. `sharedAxis`, `lateral` and `fadeThrough` fall back to an opacity-only fade with no translate or scale, their region's box swapping instantly; `containerTransform` crossfades its two states in place without growing (`animation/reducedMotion.ts`).
204
- - **Disable decorative effects**: shape morphing (button `--btn-shape` morphs; `morphShape`/`shapeMorph` already swap instantly), parallax, the ripple (already done).
205
- - Keep effects-spring color/opacity feedback (state layers, hover/press color), since that motion carries meaning and isn't intense.
201
+ - `presence` keeps a short opacity fade for every `enterExit` preset, including sheets. Scale and translation stay at their settled values. `sharedAxis`, `lateral` and `fadeThrough` use an opacity-only fade; the region's box swaps immediately. `containerTransform` fades its snapshots without growing the group.
202
+ - `motion.css` makes the three spatial duration tokens `0ms` and maps their timing functions to non-overshooting effects curves. Color, opacity and state-layer transitions retain their effects springs.
203
+ - The `skeleton` attachment shows a static placeholder. It stops or resumes the pulse if the OS preference changes while mounted.
204
+ - Shape morphs in the shape library swap instantly, and ripples and decorative rotation stop. Button corner and button-group pressed feedback remain animated with a critically damped spring, so they do not overshoot.
205
+ - Storybook's **Motion/Transition patterns** page uses these same primitives, so it shows the reduced variants when the OS preference is on.
@@ -183,7 +183,8 @@
183
183
  skeleton: {
184
184
  id: 'skeleton',
185
185
  title: 'Skeleton loaders',
186
- summary: 'A placeholder pulses until real content fades in.',
186
+ summary:
187
+ 'A placeholder pulses until real content fades in; it stays still with reduced motion.',
187
188
  anchor: 'skeleton-loaders',
188
189
  run: runSkeleton
189
190
  }
@@ -76,11 +76,12 @@ export const containerTransform = (update, { from, to, spring = springTokens.spa
76
76
  root.style.setProperty(SHADOW, shadowOf(target) ?? fromShadow ?? 'none');
77
77
  };
78
78
  // The class lets motion.css keep both snapshots at their width, clipped by the container.
79
- const builder = animateView(updateAndFill, springTransition(spring))
79
+ const reduced = prefersReducedMotion();
80
+ const builder = animateView(updateAndFill, springTransition(reduced ? springTokens.effects : spring))
80
81
  .add(from, to)
81
82
  .class('md-container-transform');
82
83
  // Reduced motion: the container doesn't grow; its two states just crossfade in place.
83
- if (prefersReducedMotion())
84
+ if (reduced)
84
85
  builder.layout({ duration: 0 });
85
86
  return builder
86
87
  .old({ opacity: [1, 0] }, springTransition(springTokens.effects))
@@ -1,5 +1,7 @@
1
1
  import { animate } from 'motion';
2
2
  import { untrack } from 'svelte';
3
+ import { enterExit } from './enterExit.js';
4
+ import { prefersReducedMotion } from './reducedMotion.js';
3
5
  const fromTo = (from, to) => Object.fromEntries(Object.entries(to).map(([key, value]) => [key, [from[key], value]]));
4
6
  /**
5
7
  * Enter/exit attachment driven by Motion's `animate()`. Plays `transition.enter` from `hidden` to
@@ -33,7 +35,16 @@ export const presence = (isOpen, transition, onExitComplete) => (node) => {
33
35
  $effect(() => {
34
36
  const open = isOpen();
35
37
  untrack(() => {
36
- const { hidden, shown, exited = hidden, enter, exit } = transition;
38
+ // Reduced motion keeps the mount/unmount cue, but never animates the preset's
39
+ // scale or translation (including the sheet presets that normally have no fade).
40
+ const active = prefersReducedMotion() ? enterExit.fade : transition;
41
+ const { hidden, shown, exited = hidden, enter, exit } = active;
42
+ if (active !== transition) {
43
+ const shownTransform = transition.shown.transform;
44
+ const settled = Array.isArray(shownTransform) ? shownTransform.at(-1) : shownTransform;
45
+ if (typeof settled === 'string')
46
+ node.style.transform = settled;
47
+ }
37
48
  clearTimeout(settleTimer);
38
49
  if (open) {
39
50
  controls = animate(node, started ? shown : fromTo(hidden, shown), enter);
@@ -61,13 +61,15 @@ const fadeInPlace = (update, target, spring, incoming) => {
61
61
  outgoing.transform = Array(2).fill(`translate(${dx}px, ${dy}px)`);
62
62
  };
63
63
  const builder = view(run, target, spring);
64
+ // The whole-page group also has a layout animation when no target was supplied.
65
+ builder.layout({ duration: 0 });
64
66
  if (target)
65
- builder.layout({ duration: 0 }).crop(false);
67
+ builder.crop(false);
66
68
  return builder.old(outgoing, fadeOut).new(...incoming);
67
69
  };
68
70
  /* Reduced motion: every navigation pattern becomes this fade, with no movement or scale. M3
69
71
  swaps movement for a subtle fade rather than cutting. */
70
- const reducedFade = (update, target, spring) => fadeInPlace(update, target, spring, [{ opacity: [0, 1] }, fadeInAfterOut]);
72
+ const reducedFade = (update, target) => fadeInPlace(update, target, springTokens.effects, [{ opacity: [0, 1] }, fadeInAfterOut]);
71
73
  /**
72
74
  * M3 forward and backward (shared axis): outgoing and incoming content travel together along one
73
75
  * axis while fading through each other.
@@ -85,7 +87,7 @@ const reducedFade = (update, target, spring) => fadeInPlace(update, target, spri
85
87
  */
86
88
  export const sharedAxis = (update, { target, axis = 'x', direction = 'forward', spring = springTokens.spatial } = {}) => {
87
89
  if (prefersReducedMotion())
88
- return reducedFade(update, target, spring);
90
+ return reducedFade(update, target);
89
91
  const forward = direction === 'forward';
90
92
  const [outgoing, incoming] = axis === 'z'
91
93
  ? [`scale(${forward ? 1.1 : 0.8})`, `scale(${forward ? 0.8 : 1.1})`]
@@ -116,7 +118,7 @@ export const sharedAxis = (update, { target, axis = 'x', direction = 'forward',
116
118
  */
117
119
  export const lateral = (update, { target, axis = 'x', direction = 'forward', spring = springTokens.spatial } = {}) => {
118
120
  if (prefersReducedMotion())
119
- return reducedFade(update, target, spring);
121
+ return reducedFade(update, target);
120
122
  const sign = direction === 'forward' ? 1 : -1;
121
123
  const move = axis === 'y' ? 'translateY' : 'translateX';
122
124
  const builder = view(update, target, spring)
@@ -144,7 +146,7 @@ export const lateral = (update, { target, axis = 'x', direction = 'forward', spr
144
146
  */
145
147
  export const fadeThrough = (update, { target, spring = springTokens.spatial } = {}) => {
146
148
  if (prefersReducedMotion())
147
- return reducedFade(update, target, spring);
149
+ return reducedFade(update, target);
148
150
  return fadeInPlace(update, target, spring, [
149
151
  { opacity: [0, 1], transform: ['scale(0.92)', 'scale(1)'] },
150
152
  { opacity: fadeInAfterOut }
@@ -1,7 +1,8 @@
1
1
  import type { Attachment } from 'svelte/attachments';
2
2
  /**
3
- * M3 skeleton loader: a placeholder pulses until real content replaces it. Swap the placeholder
4
- * for the content with `presence(…, enterExit.fade)` so the reveal is a fade, not a cut.
3
+ * M3 skeleton loader: a placeholder pulses until real content replaces it, and stays static under
4
+ * reduced motion. Swap the placeholder for the content with `presence(…, enterExit.fade)` so the
5
+ * reveal is a fade, not a cut.
5
6
  * https://m3.material.io/styles/motion/transitions/transition-patterns#skeleton-loaders
6
7
  *
7
8
  * M3 "stable layouts" (https://m3.material.io/styles/motion/transitions/applying-transitions): the placeholder takes the loaded
@@ -1,11 +1,13 @@
1
1
  import { animate } from 'motion';
2
+ import { prefersReducedMotion } from './reducedMotion.js';
2
3
  import { springTokens, springTransition } from './spring.js';
3
4
  /* Hold at each end of the pulse — a critically damped spring alone settles in ~370ms, which reads
4
5
  as flicker. Not an M3 value: tune by eye. */
5
6
  const PULSE_HOLD_S = 0.5;
6
7
  /**
7
- * M3 skeleton loader: a placeholder pulses until real content replaces it. Swap the placeholder
8
- * for the content with `presence(…, enterExit.fade)` so the reveal is a fade, not a cut.
8
+ * M3 skeleton loader: a placeholder pulses until real content replaces it, and stays static under
9
+ * reduced motion. Swap the placeholder for the content with `presence(…, enterExit.fade)` so the
10
+ * reveal is a fade, not a cut.
9
11
  * https://m3.material.io/styles/motion/transitions/transition-patterns#skeleton-loaders
10
12
  *
11
13
  * M3 "stable layouts" (https://m3.material.io/styles/motion/transitions/applying-transitions): the placeholder takes the loaded
@@ -18,11 +20,27 @@ const PULSE_HOLD_S = 0.5;
18
20
  * ```
19
21
  */
20
22
  export const skeleton = (node) => {
21
- const controls = animate(node, { opacity: [1, 0.4] }, {
22
- ...springTransition(springTokens.slowEffects),
23
- repeat: Infinity,
24
- repeatType: 'reverse',
25
- repeatDelay: PULSE_HOLD_S
26
- });
27
- return () => controls.stop();
23
+ const media = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
24
+ const initialOpacity = node.style.opacity;
25
+ let controls;
26
+ const sync = () => {
27
+ controls?.stop();
28
+ controls = undefined;
29
+ node.style.opacity = initialOpacity;
30
+ if (prefersReducedMotion())
31
+ return;
32
+ controls = animate(node, { opacity: [1, 0.4] }, {
33
+ ...springTransition(springTokens.slowEffects),
34
+ repeat: Infinity,
35
+ repeatType: 'reverse',
36
+ repeatDelay: PULSE_HOLD_S
37
+ });
38
+ };
39
+ sync();
40
+ media?.addEventListener('change', sync);
41
+ return () => {
42
+ media?.removeEventListener('change', sync);
43
+ controls?.stop();
44
+ node.style.opacity = initialOpacity;
45
+ };
28
46
  };
@@ -50,3 +50,15 @@ export declare function drag(getOptions: () => DragOptions): Attachment<HTMLElem
50
50
  * extent of the bounds on that axis.
51
51
  */
52
52
  export declare function resist(value: number, min: number, max: number, size: number): number;
53
+ /**
54
+ * How fast a `resist`ed value moves per unit of `value` at that point: `1` inside the range,
55
+ * less and less past an edge. Scales a pointer's release velocity down to the speed the
56
+ * rubber-banded thing was actually moving at, so the spring it hands over to doesn't fling it.
57
+ */
58
+ export declare function resistSlope(value: number, min: number, max: number, size: number): number;
59
+ /**
60
+ * Where something released at `velocity` (px/s) would coast to — add it to the release position
61
+ * to aim a spring at, so a throw carries on in its direction instead of springing back to where
62
+ * it was let go.
63
+ */
64
+ export declare function project(velocity: number): number;
@@ -114,8 +114,11 @@ export function drag(getOptions) {
114
114
  const onUp = (event) => {
115
115
  if (event.pointerId !== pointerId)
116
116
  return;
117
- if (phase === 'dragging')
117
+ if (phase === 'dragging') {
118
+ // Include the release so a pause after the last move does not reuse stale momentum.
119
+ record(event);
118
120
  finish(velocity());
121
+ }
119
122
  else
120
123
  reset();
121
124
  };
@@ -215,3 +218,27 @@ export function resist(value, min, max, size) {
215
218
  return max + rubberBand(value - max, size);
216
219
  return value;
217
220
  }
221
+ /**
222
+ * How fast a `resist`ed value moves per unit of `value` at that point: `1` inside the range,
223
+ * less and less past an edge. Scales a pointer's release velocity down to the speed the
224
+ * rubber-banded thing was actually moving at, so the spring it hands over to doesn't fling it.
225
+ */
226
+ export function resistSlope(value, min, max, size) {
227
+ const overshoot = value < min ? min - value : value > max ? value - max : 0;
228
+ if (!overshoot)
229
+ return 1;
230
+ return RUBBER_BAND / ((overshoot * RUBBER_BAND) / size + 1) ** 2;
231
+ }
232
+ /*
233
+ Per-millisecond speed retained by a thrown object coasting to a stop. UIScrollView's `fast`
234
+ deceleration rate; M3 has no token for this, tune by eye.
235
+ */
236
+ const THROW_DECELERATION = 0.99;
237
+ /**
238
+ * Where something released at `velocity` (px/s) would coast to — add it to the release position
239
+ * to aim a spring at, so a throw carries on in its direction instead of springing back to where
240
+ * it was let go.
241
+ */
242
+ export function project(velocity) {
243
+ return ((velocity / 1000) * THROW_DECELERATION) / (1 - THROW_DECELERATION);
244
+ }
@@ -29,6 +29,7 @@ For a set of related options where one or more is selected, use `ConnectedButton
29
29
 
30
30
  const GROW = 0.15;
31
31
  const spring = springTransition(springTokens.fastSpatial);
32
+ const reducedSpring = springTransition(springTokens.fastEffects);
32
33
  const reducedMotion = () =>
33
34
  typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
34
35
 
@@ -57,7 +58,7 @@ For a set of related options where one or more is selected, use `ConnectedButton
57
58
  };
58
59
 
59
60
  const start = (el: HTMLElement) => {
60
- if (orientation !== 'horizontal' || press || reducedMotion()) return;
61
+ if (orientation !== 'horizontal' || press) return;
61
62
  const width = widthOf(el);
62
63
  const neighbours = [el.previousElementSibling, el.nextElementSibling].filter(isEnabled);
63
64
  const share = (width * GROW) / Math.max(neighbours.length, 1);
@@ -70,7 +71,8 @@ For a set of related options where one or more is selected, use `ConnectedButton
70
71
  })
71
72
  ]
72
73
  };
73
- for (const { el: item, to } of press.items) animate(item, { width: `${to}px` }, spring);
74
+ for (const { el: item, to } of press.items)
75
+ animate(item, { width: `${to}px` }, reducedMotion() ? reducedSpring : spring);
74
76
  };
75
77
 
76
78
  const end = () => {
@@ -78,7 +80,7 @@ For a set of related options where one or more is selected, use `ConnectedButton
78
80
  const { items } = press;
79
81
  press = null;
80
82
  for (const { el, width } of items) {
81
- animate(el, { width: `${width}px` }, spring).then(() => {
83
+ animate(el, { width: `${width}px` }, reducedMotion() ? reducedSpring : spring).then(() => {
82
84
  // Hand the width back to the layout, unless a new press picked this button up.
83
85
  if (press?.items.some((i) => i.el === el)) return;
84
86
  el.style.width = '';
@@ -25,6 +25,7 @@ const isDisabled = (el) => el.matches(':disabled, [aria-disabled="true"], [data-
25
25
  export function shapeMorph(el, corners) {
26
26
  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
27
27
  const spring = springTransition(springTokens.fastSpatial);
28
+ const reducedSpring = springTransition(springTokens.fastEffects);
28
29
  const state = { pressed: false, hovered: false, focusVisible: false };
29
30
  const resolve = () => {
30
31
  const style = getComputedStyle(el);
@@ -43,10 +44,7 @@ export function shapeMorph(el, corners) {
43
44
  return;
44
45
  targets[i] = to;
45
46
  controls[i]?.stop();
46
- if (reduced.matches)
47
- values[i].jump(to);
48
- else
49
- controls[i] = animate(values[i], to, spring);
47
+ controls[i] = animate(values[i], to, reduced.matches ? reducedSpring : spring);
50
48
  });
51
49
  };
52
50
  const set = (key, on) => {
@@ -81,6 +79,15 @@ export function shapeMorph(el, corners) {
81
79
  attributes: true,
82
80
  attributeFilter: ['class', 'data-state', 'aria-pressed', 'aria-expanded']
83
81
  });
82
+ const onReducedChange = () => {
83
+ if (!reduced.matches)
84
+ return;
85
+ controls.forEach((control, i) => {
86
+ control?.stop();
87
+ values[i].jump(targets[i]);
88
+ });
89
+ };
90
+ reduced.addEventListener?.('change', onReducedChange);
84
91
  el.addEventListener('pointerdown', onPointerDown);
85
92
  el.addEventListener('pointerenter', onEnter);
86
93
  el.addEventListener('pointerleave', onLeave);
@@ -92,6 +99,7 @@ export function shapeMorph(el, corners) {
92
99
  window.addEventListener('pointercancel', release);
93
100
  return () => {
94
101
  observer.disconnect();
102
+ reduced.removeEventListener?.('change', onReducedChange);
95
103
  controls.forEach((c) => c?.stop());
96
104
  unsubscribe.forEach((u) => u());
97
105
  el.removeEventListener('pointerdown', onPointerDown);
@@ -1,6 +1,13 @@
1
1
  <script module lang="ts">
2
2
  import { defineMeta } from '@storybook/addon-svelte-csf';
3
+ import { tick } from 'svelte';
3
4
  import DraggablePane from './DraggablePane.svelte';
5
+ import { containerTransform } from '../../../animation/containerTransform.js';
6
+ import { Button } from '../../buttons/index.js';
7
+ import { Card } from '../../cards/index.js';
8
+ import { DateField } from '../../date/index.js';
9
+ import { TimeField } from '../../time/index.js';
10
+ import { Body, Title } from '../../typography/index.js';
4
11
 
5
12
  const { Story } = defineMeta({
6
13
  title: 'Containers/DraggablePane',
@@ -18,6 +25,48 @@
18
25
  </script>
19
26
 
20
27
  <script lang="ts">
28
+ // "Opened From A Card": which event's pane is open, if any.
29
+ const days = [
30
+ { day: 21, events: [] },
31
+ { day: 22, events: [{ id: 'math', time: '16:00', title: 'Math' }] },
32
+ { day: 23, events: [] },
33
+ { day: 24, events: [{ id: 'physics', time: '17:00', title: 'Physics' }] },
34
+ { day: 25, events: [] }
35
+ ];
36
+ const eventsById = Object.fromEntries(days.flatMap((d) => d.events.map((e) => [e.id, e])));
37
+ let openEvent: string | undefined = $state();
38
+
39
+ const eventCard = (id: string) => `[data-story-event="${id}"]`;
40
+ const EVENT_PANE = '[data-story-event-pane]';
41
+
42
+ // As the calendar does it: the clicked card grows into the pane, and closing
43
+ // shrinks the pane back into the card it came from.
44
+ function openFrom(id: string) {
45
+ if (openEvent) {
46
+ openEvent = id;
47
+ return;
48
+ }
49
+ containerTransform(
50
+ async () => {
51
+ openEvent = id;
52
+ await tick();
53
+ },
54
+ { from: eventCard(id), to: EVENT_PANE }
55
+ );
56
+ }
57
+
58
+ function closePane() {
59
+ const id = openEvent;
60
+ if (!id) return;
61
+ containerTransform(
62
+ async () => {
63
+ openEvent = undefined;
64
+ await tick();
65
+ },
66
+ { from: EVENT_PANE, to: eventCard(id) }
67
+ );
68
+ }
69
+
21
70
  let boundedEl: HTMLDivElement | undefined = $state();
22
71
  let boundedX: number | undefined = $state();
23
72
  let boundedY: number | undefined = $state();
@@ -198,3 +247,53 @@
198
247
  </p>
199
248
  </DraggablePane>
200
249
  </Story>
250
+
251
+ <!--
252
+ The calendar's case: an event card grows into the edit pane (container
253
+ transform) and the pane's close button shrinks it back into that card. The
254
+ date field sits near the pane's right edge, so its picker spills out over
255
+ the page — it must stack above the pane (`z-layer-popup` over
256
+ `z-layer-pane`), never under it.
257
+ -->
258
+ <Story
259
+ name="Opened From A Card"
260
+ asChild
261
+ parameters={{ docs: { story: { inline: false, height: '720px' } } }}
262
+ >
263
+ <div class="gap-spacing-100 p-spacing-300 grid grid-cols-5">
264
+ {#each days as { day, events } (day)}
265
+ <Card type="outlined" padding="sm" class="gap-spacing-100 flex min-h-40 flex-col">
266
+ <Title size="small">{day}</Title>
267
+ {#each events as event (event.id)}
268
+ <Card
269
+ type="filled"
270
+ padding="sm"
271
+ data-story-event={event.id}
272
+ selected={openEvent === event.id}
273
+ onselect={() => openFrom(event.id)}
274
+ >
275
+ <Body size="small">{event.time}</Body>
276
+ <Title size="small">{event.title}</Title>
277
+ </Card>
278
+ {/each}
279
+ </Card>
280
+ {/each}
281
+ </div>
282
+
283
+ {#if openEvent}
284
+ <DraggablePane
285
+ data-story-event-pane
286
+ title={`Edit ${eventsById[openEvent].title}`}
287
+ initialX={32}
288
+ initialY={96}
289
+ resizable
290
+ onClose={closePane}
291
+ >
292
+ <div class="gap-spacing-200 flex w-80 flex-col">
293
+ <DateField label="Date" variant="outlined" />
294
+ <TimeField label="Start" value={eventsById[openEvent].time} />
295
+ <Button onclick={closePane}>Save</Button>
296
+ </div>
297
+ </DraggablePane>
298
+ {/if}
299
+ </Story>
@@ -26,12 +26,14 @@ the pane itself stays `position: fixed` and doesn't move with either.
26
26
  -->
27
27
  <script lang="ts">
28
28
  import clsx from 'clsx';
29
- import { onMount } from 'svelte';
30
- import { animate, type AnimationPlaybackControls } from 'motion';
29
+ import { onMount, tick } from 'svelte';
30
+ import { animate, motionValue } from 'motion';
31
31
  import { springTokens, springTransition } from '../../../animation/spring.js';
32
+ import { containerTransform } from '../../../animation/containerTransform.js';
33
+ import { prefersReducedMotion } from '../../../animation/reducedMotion.js';
32
34
  import { Icon, Layer } from '../../../utils/index.js';
33
35
  import { ButtonIcon } from '../../buttons/index.js';
34
- import { clickOutside, drag, resist } from '../../../attachments/index.js';
36
+ import { clickOutside, drag, project, resist, resistSlope } from '../../../attachments/index.js';
35
37
  import { draggablePane, type ResizeEdge } from './theme.js';
36
38
  import { dragPositions } from './dragStore.svelte.js';
37
39
  import type { DraggablePaneProps } from './types.js';
@@ -96,22 +98,26 @@ the pane itself stays `position: fixed` and doesn't move with either.
96
98
  // regardless of what those props say, silently overriding the safety-net
97
99
  // floor a consumer configured. A non-resizable pane keeps the plain
98
100
  // `min-w-72` class fallback (unaffected by minWidth/minHeight).
99
- const style = $derived(
100
- clsx(
101
- x !== undefined && `left: ${x}px;`,
102
- y !== undefined && `top: ${y}px;`,
103
- width !== undefined && `width: ${width}px;`,
104
- height !== undefined && `height: ${height}px;`,
105
- resizable && `min-width: ${minWidth}px;`,
106
- resizable && `min-height: ${minHeight}px;`,
107
- capWidth !== undefined && `max-width: ${capWidth}px;`,
108
- capHeight !== undefined && `max-height: ${capHeight}px;`
109
- )
110
- );
101
+ //
102
+ // These are `style:` directives, not a `style` string: Svelte sets directives one property at a
103
+ // time, whereas a changed `style` string replaces the whole inline style and would wipe the
104
+ // `translate` that `renderOffset` writes outside Svelte.
105
+ const px = (value: number | undefined, when = true) =>
106
+ when && value !== undefined ? `${value}px` : undefined;
111
107
 
112
108
  function persist() {
113
109
  if (!persistKey) return;
114
- dragPositions.set(persistKey, { x: x ?? initialX, y: y ?? initialY, width, height });
110
+ const next = { x: x ?? initialX, y: y ?? initialY, width, height };
111
+ const stored = dragPositions.get(persistKey);
112
+ // Bounds re-clamps run on every scroll/resize event; only real changes reach localStorage.
113
+ if (
114
+ stored?.x === next.x &&
115
+ stored.y === next.y &&
116
+ stored.width === next.width &&
117
+ stored.height === next.height
118
+ )
119
+ return;
120
+ dragPositions.set(persistKey, next);
115
121
  }
116
122
 
117
123
  function boundsRect() {
@@ -250,7 +256,11 @@ the pane itself stays `position: fixed` and doesn't move with either.
250
256
  /** Re-clamps the current position/size, and the standing size cap, against `bounds`'s current rect — call whenever that rect may have changed. */
251
257
  function recomputeBounds() {
252
258
  updateCaps();
253
- moveTo(x ?? initialX, y ?? initialY);
259
+ // A drag settles against the fresh bounds on release; a running spring is retargeted
260
+ // rather than having its resting position yanked out from under it.
261
+ if (dragging) return;
262
+ if (settling()) settleTo(x ?? initialX, y ?? initialY);
263
+ else moveTo(x ?? initialX, y ?? initialY);
254
264
  if (width !== undefined || height !== undefined) nudgeResize('se', 0, 0);
255
265
  }
256
266
 
@@ -273,7 +283,10 @@ the pane itself stays `position: fixed` and doesn't move with either.
273
283
  window.addEventListener('resize', recomputeBounds);
274
284
  return () => {
275
285
  window.removeEventListener('resize', recomputeBounds);
276
- stopSettle();
286
+ offRenderX();
287
+ offRenderY();
288
+ offsetX.destroy();
289
+ offsetY.destroy();
277
290
  };
278
291
  });
279
292
 
@@ -308,47 +321,85 @@ the pane itself stays `position: fixed` and doesn't move with either.
308
321
 
309
322
  /*
310
323
  Expressive drag: the pane tracks the pointer 1:1 (direct manipulation never lags behind a
311
- spring), rubber-bands past `bounds` instead of hitting a wall, and on release springs back
312
- inside on the fast spatial spring, carrying the pointer's velocity. Keyboard nudges glide on
313
- the same spring. Grabbing the pane mid-spring stops it where it is.
324
+ spring), rubber-bands past `bounds` instead of hitting a wall, and on release is thrown: it
325
+ springs, on the fast spatial spring and at the speed it was really moving, to where the throw
326
+ would coast to, clamped inside `bounds`. Keyboard nudges glide on the same spring. Grabbing the
327
+ pane mid-spring stops it where it is.
328
+
329
+ `x`/`y` (left/top) are where the pane rests, and change only when it comes to rest somewhere
330
+ new. The motion in between is a translate offset from there, written straight to the element
331
+ (`renderOffset`), so a frame of drag or spring never lays the page out, re-renders the
332
+ component, or hands a parent bound to `x`/`y` a new value.
314
333
  */
315
334
  const SETTLE_SPRING = springTransition(springTokens.fastSpatial);
316
335
 
317
- let settleX: AnimationPlaybackControls | undefined;
318
- let settleY: AnimationPlaybackControls | undefined;
319
- /** Where the running spring is headed; undefined once it's done or stopped. */
320
- let settleTarget: { x: number; y: number } | undefined;
336
+ const offsetX = motionValue(0);
337
+ const offsetY = motionValue(0);
338
+
339
+ function renderOffset() {
340
+ if (!panelEl) return;
341
+ const ox = offsetX.get();
342
+ const oy = offsetY.get();
343
+ panelEl.style.translate = ox || oy ? `${ox}px ${oy}px` : '';
344
+ }
345
+ const offRenderX = offsetX.on('change', renderOffset);
346
+ const offRenderY = offsetY.on('change', renderOffset);
347
+
348
+ const settling = () => offsetX.isAnimating() || offsetY.isAnimating();
321
349
 
322
350
  function stopSettle() {
323
- settleX?.stop();
324
- settleY?.stop();
325
- settleTarget = undefined;
351
+ offsetX.stop();
352
+ offsetY.stop();
326
353
  }
327
354
 
328
- /** Springs the pane to `target` (clamped), starting with `velocity` px/s. */
329
- function settleTo(targetX: number, targetY: number, velocity = { x: 0, y: 0 }) {
355
+ /** Makes where the pane is on screen right now its resting position, with no offset. */
356
+ function settleInPlace() {
330
357
  stopSettle();
358
+ const ox = offsetX.get();
359
+ const oy = offsetY.get();
360
+ if (!ox && !oy) return;
361
+ offsetX.jump(0);
362
+ offsetY.jump(0);
363
+ moveTo((x ?? initialX) + ox, (y ?? initialY) + oy);
364
+ }
365
+
366
+ /**
367
+ * Springs the pane to `target` (clamped) from wherever it is on screen, starting at `velocity`
368
+ * px/s — by default the velocity it's already moving at, so a retarget mid-flight is seamless.
369
+ */
370
+ function settleTo(
371
+ targetX: number,
372
+ targetY: number,
373
+ velocity = { x: offsetX.getVelocity(), y: offsetY.getVelocity() }
374
+ ) {
375
+ const fromX = (x ?? initialX) + offsetX.get();
376
+ const fromY = (y ?? initialY) + offsetY.get();
331
377
  const next = clamp(targetX, targetY);
332
- const fromX = x ?? initialX;
333
- const fromY = y ?? initialY;
334
- settleTarget = next;
335
- settleX = animate(fromX, next.x, {
336
- ...SETTLE_SPRING,
337
- velocity: velocity.x,
338
- onUpdate: (value) => (x = value),
339
- onComplete: () => (settleTarget = undefined)
340
- });
341
- settleY = animate(fromY, next.y, {
342
- ...SETTLE_SPRING,
343
- velocity: velocity.y,
344
- onUpdate: (value) => (y = value)
345
- });
346
- // Persist the resting position up front — the spring only animates towards it.
347
- if (persistKey) dragPositions.set(persistKey, { x: next.x, y: next.y, width, height });
378
+ // The target becomes the resting position straight away and the offset takes up the
379
+ // difference, so the pane doesn't move this frame; the spring then runs the offset to 0.
380
+ x = next.x;
381
+ y = next.y;
382
+ persist();
383
+ offsetX.jump(fromX - next.x);
384
+ offsetY.jump(fromY - next.y);
385
+ // Under reduced motion there is no spatial travel: the pane is simply where it lands.
386
+ if (prefersReducedMotion()) {
387
+ offsetX.jump(0);
388
+ offsetY.jump(0);
389
+ return;
390
+ }
391
+ animate(offsetX, 0, { ...SETTLE_SPRING, velocity: velocity.x });
392
+ animate(offsetY, 0, { ...SETTLE_SPRING, velocity: velocity.y });
348
393
  }
349
394
 
395
+ // Captured once per drag, so a pointer move reads no layout.
396
+ let restX = 0;
397
+ let restY = 0;
350
398
  let originX = 0;
351
399
  let originY = 0;
400
+ let pointerX = 0;
401
+ let pointerY = 0;
402
+ let range = { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0 };
352
403
 
353
404
  // Pointer plumbing (capture, release velocity) and the rubber-band come from the shared drag
354
405
  // attachment; this only positions the pane.
@@ -357,22 +408,78 @@ the pane itself stays `position: fixed` and doesn't move with either.
357
408
  onStart: () => {
358
409
  stopSettle();
359
410
  dragging = true;
360
- originX = x ?? initialX;
361
- originY = y ?? initialY;
411
+ restX = x ?? initialX;
412
+ restY = y ?? initialY;
413
+ originX = pointerX = restX + offsetX.get();
414
+ originY = pointerY = restY + offsetY.get();
415
+ const rect = boundsRect();
416
+ const min = clamp(-Infinity, -Infinity);
417
+ const max = clamp(Infinity, Infinity);
418
+ range = {
419
+ minX: min.x,
420
+ maxX: max.x,
421
+ minY: min.y,
422
+ maxY: max.y,
423
+ width: rect.right - rect.left,
424
+ height: rect.bottom - rect.top
425
+ };
362
426
  },
363
427
  onMove: (offset) => {
364
- const rect = boundsRect();
365
- const { x: minX, y: minY } = clamp(-Infinity, -Infinity);
366
- const { x: maxX, y: maxY } = clamp(Infinity, Infinity);
367
- x = resist(originX + offset.x, minX, maxX, rect.right - rect.left);
368
- y = resist(originY + offset.y, minY, maxY, rect.bottom - rect.top);
428
+ pointerX = originX + offset.x;
429
+ pointerY = originY + offset.y;
430
+ offsetX.set(resist(pointerX, range.minX, range.maxX, range.width) - restX);
431
+ offsetY.set(resist(pointerY, range.minY, range.maxY, range.height) - restY);
369
432
  },
370
433
  onEnd: (velocity) => {
371
434
  dragging = false;
372
- settleTo(x ?? initialX, y ?? initialY, velocity);
435
+ // Past an edge the pane only moved at the rubber-banded share of the pointer's speed.
436
+ const vx = velocity.x * resistSlope(pointerX, range.minX, range.maxX, range.width);
437
+ const vy = velocity.y * resistSlope(pointerY, range.minY, range.maxY, range.height);
438
+ const atX = restX + offsetX.get();
439
+ const atY = restY + offsetY.get();
440
+ settleTo(atX + project(vx), atY + project(vy), { x: vx, y: vy });
373
441
  }
374
442
  }));
375
443
 
444
+ /*
445
+ Minimising and restoring are an M3 container transform: the pane's surface morphs into the
446
+ pill and back. Only the pane's own buttons animate it; a consumer driving the bound
447
+ `collapsed` directly swaps the two instantly, since the transition has to snapshot the old
448
+ state before the change.
449
+ */
450
+ const uid = $props.id();
451
+ let pillEl: HTMLButtonElement | undefined = $state();
452
+
453
+ function setCollapsed(next: boolean) {
454
+ settleInPlace();
455
+ const from = next ? panelEl : pillEl;
456
+ if (!from || typeof document === 'undefined') {
457
+ collapsed = next;
458
+ return;
459
+ }
460
+ containerTransform(
461
+ async () => {
462
+ collapsed = next;
463
+ await tick();
464
+ },
465
+ {
466
+ from,
467
+ to: next ? `[data-draggable-pane-pill="${uid}"]` : `[data-draggable-pane="${uid}"]`
468
+ }
469
+ ).then(
470
+ () => {},
471
+ () => (collapsed = next)
472
+ );
473
+ }
474
+
475
+ // The pane unmounts while collapsed; it comes back at its resting position, with no offset.
476
+ $effect(() => {
477
+ if (!collapsed) return;
478
+ stopSettle();
479
+ offsetX.jump(0);
480
+ offsetY.jump(0);
481
+ });
482
+
376
483
  const NUDGE = 8;
377
484
 
378
485
  function onKeydown(event: KeyboardEvent) {
@@ -387,9 +494,9 @@ the pane itself stays `position: fixed` and doesn't move with either.
387
494
  const delta = deltas[event.key];
388
495
  if (!delta) return;
389
496
  event.preventDefault();
390
- // Retargets a running nudge, so holding an arrow key glides instead of stepping.
391
- const from = settleTarget ?? clamp(x ?? initialX, y ?? initialY);
392
- settleTo(from.x + delta[0], from.y + delta[1]);
497
+ // Steps from the resting position, which a running spring is already headed for, so a held
498
+ // arrow key retargets it and glides instead of stepping.
499
+ settleTo((x ?? initialX) + delta[0], (y ?? initialY) + delta[1]);
393
500
  }
394
501
 
395
502
  let resizeStartClientX = 0;
@@ -403,6 +510,7 @@ the pane itself stays `position: fixed` and doesn't move with either.
403
510
  return (event: PointerEvent) => {
404
511
  if (!resizable) return;
405
512
  event.stopPropagation();
513
+ settleInPlace();
406
514
  resizeDir = dir;
407
515
  resizeStartClientX = event.clientX;
408
516
  resizeStartClientY = event.clientY;
@@ -465,6 +573,7 @@ the pane itself stays `position: fixed` and doesn't move with either.
465
573
  const delta = deltas[event.key];
466
574
  if (!delta) return;
467
575
  event.preventDefault();
576
+ settleInPlace();
468
577
  nudgeResize(dir, delta[0], delta[1]);
469
578
  };
470
579
  }
@@ -475,9 +584,11 @@ the pane itself stays `position: fixed` and doesn't move with either.
475
584
 
476
585
  {#if collapsed}
477
586
  <button
587
+ bind:this={pillEl}
478
588
  type="button"
479
589
  class={miniBase({ class: minimizedClass })}
480
- onclick={() => (collapsed = false)}
590
+ data-draggable-pane-pill={uid}
591
+ onclick={() => setCollapsed(false)}
481
592
  aria-label={`Expand ${minimizedTitle ?? title ?? 'panel'}`}
482
593
  >
483
594
  <Icon name="open_in_full" size="sm" class={miniIcon()} />
@@ -487,7 +598,15 @@ the pane itself stays `position: fixed` and doesn't move with either.
487
598
  <div
488
599
  bind:this={panelEl}
489
600
  class={base({ class: className })}
490
- {style}
601
+ data-draggable-pane={uid}
602
+ style:left={px(x)}
603
+ style:top={px(y)}
604
+ style:width={px(width)}
605
+ style:height={px(height)}
606
+ style:min-width={px(minWidth, resizable)}
607
+ style:min-height={px(minHeight, resizable)}
608
+ style:max-width={px(capWidth)}
609
+ style:max-height={px(capHeight)}
491
610
  role="group"
492
611
  aria-label={title}
493
612
  {@attach onClickOutside && clickOutside(onClickOutside)}
@@ -526,7 +645,7 @@ the pane itself stays `position: fixed` and doesn't move with either.
526
645
  size="sm"
527
646
  iconProps={{ name: 'collapse_all' }}
528
647
  aria-label="Minimize"
529
- onclick={() => (collapsed = true)}
648
+ onclick={() => setCollapsed(true)}
530
649
  />
531
650
  {/if}
532
651
  {#if onClose}
@@ -114,7 +114,7 @@ Changing the visible month slides the grid with the M3 lateral transition (see c
114
114
  <DatePicker.Content sideOffset={4} align="start">
115
115
  {#snippet child({ wrapperProps, props, open })}
116
116
  <div {...wrapperProps}>
117
- <div {...props} {@attach presence(() => open, enterExit.scale)}>
117
+ <div {...props} class="z-layer-popup" {@attach presence(() => open, enterExit.scale)}>
118
118
  <DatePicker.Calendar class={calendar.surface()}>
119
119
  {#snippet children({ months, weekdays })}
120
120
  <DatePicker.Header class={calendar.header()}>
@@ -123,7 +123,7 @@ Changing the visible month slides the grid with the M3 lateral transition (see c
123
123
  <DateRangePicker.Content sideOffset={4} align="start">
124
124
  {#snippet child({ wrapperProps, props, open })}
125
125
  <div {...wrapperProps}>
126
- <div {...props} {@attach presence(() => open, enterExit.scale)}>
126
+ <div {...props} class="z-layer-popup" {@attach presence(() => open, enterExit.scale)}>
127
127
  <DateRangePicker.Calendar class={calendar.surface()}>
128
128
  {#snippet children({ months, weekdays })}
129
129
  <DateRangePicker.Header class={calendar.header()}>
@@ -284,6 +284,23 @@
284
284
  /* @generated:springs:end */
285
285
  }
286
286
 
287
+ /* Spatial CSS transitions settle immediately under the OS motion preference. Effects tokens
288
+ remain available for short opacity and color fades. Keep this outside the generated block. */
289
+ @media (prefers-reduced-motion: reduce) {
290
+ :root {
291
+ --md-sys-motion-duration-fast-spatial: 0ms;
292
+ --md-sys-motion-duration-spatial: 0ms;
293
+ --md-sys-motion-duration-slow-spatial: 0ms;
294
+ --md-sys-motion-timing-function-fast-spatial: var(
295
+ --md-sys-motion-timing-function-fast-effects-spring
296
+ );
297
+ --md-sys-motion-timing-function-spatial: var(--md-sys-motion-timing-function-effects-spring);
298
+ --md-sys-motion-timing-function-slow-spatial: var(
299
+ --md-sys-motion-timing-function-slow-effects-spring
300
+ );
301
+ }
302
+ }
303
+
287
304
  /*
288
305
  One utility per Expressive spring, setting duration + timing function together. Pair with a
289
306
  Tailwind transition-property utility: `transition-colors md-sys-motion-fast-effects`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noxlovette/material",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "description": "A Material Design 3 component library for Svelte, built on Bits UI.",
6
6
  "keywords": [