@noxlovette/material 0.9.0 → 0.9.2
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/claude-skill/material-design/references/motion-guide.md +20 -20
- package/dist/animation/presence.svelte.js +37 -1
- package/dist/animation/sharedAxisTransition.d.ts +2 -1
- package/dist/animation/sharedAxisTransition.js +19 -1
- package/dist/attachments/drag.d.ts +12 -0
- package/dist/attachments/drag.js +28 -1
- package/dist/components/buttons/FAB.svelte +4 -1
- package/dist/components/buttons/theme.d.ts +6 -3
- package/dist/components/buttons/theme.js +3 -2
- package/dist/components/containers/context-menu/theme.js +4 -4
- package/dist/components/containers/menu/Menu.svelte +2 -5
- package/dist/components/containers/menu/theme.js +4 -2
- package/dist/components/containers/pane/DraggablePane.stories.svelte +99 -0
- package/dist/components/containers/pane/DraggablePane.svelte +179 -60
- package/dist/components/date/DateField.svelte +1 -1
- package/dist/components/date/DateRangeField.svelte +1 -1
- package/dist/components/forms/search/SearchView.svelte +50 -4
- package/dist/components/forms/search/SearchView.svelte.d.ts +2 -1
- package/dist/components/nav/appbar/theme.js +1 -1
- package/dist/components/nav/navbar/theme.js +1 -1
- package/dist/components/nav/rail/theme.js +1 -1
- package/dist/components/toolbar/theme.js +4 -1
- package/dist/styles/motion.css +26 -0
- package/dist/utils/Layer.svelte +3 -1
- package/package.json +1 -1
|
@@ -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)
|
|
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`)
|
|
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** | —
|
|
80
|
-
| **Top level** | —
|
|
81
|
-
| **Skeleton loaders** | —
|
|
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
|
|
|
@@ -27,12 +27,14 @@ const fromTo = (from, to) => Object.fromEntries(Object.entries(to).map(([key, va
|
|
|
27
27
|
export const presence = (isOpen, transition, onExitComplete) => (node) => {
|
|
28
28
|
let controls;
|
|
29
29
|
let started = false;
|
|
30
|
+
let settleTimer;
|
|
30
31
|
if (transition.origin)
|
|
31
32
|
node.style.transformOrigin = transition.origin;
|
|
32
33
|
$effect(() => {
|
|
33
34
|
const open = isOpen();
|
|
34
35
|
untrack(() => {
|
|
35
36
|
const { hidden, shown, exited = hidden, enter, exit } = transition;
|
|
37
|
+
clearTimeout(settleTimer);
|
|
36
38
|
if (open) {
|
|
37
39
|
controls = animate(node, started ? shown : fromTo(hidden, shown), enter);
|
|
38
40
|
}
|
|
@@ -42,11 +44,45 @@ export const presence = (isOpen, transition, onExitComplete) => (node) => {
|
|
|
42
44
|
if (controls === current)
|
|
43
45
|
onExitComplete?.();
|
|
44
46
|
});
|
|
47
|
+
settleTimer = setTimeout(() => {
|
|
48
|
+
if (controls === current)
|
|
49
|
+
settle(node);
|
|
50
|
+
}, exitDeadline(current));
|
|
45
51
|
}
|
|
46
52
|
started = true;
|
|
47
53
|
});
|
|
48
54
|
});
|
|
49
|
-
return () =>
|
|
55
|
+
return () => {
|
|
56
|
+
clearTimeout(settleTimer);
|
|
57
|
+
controls?.stop();
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
/*
|
|
61
|
+
bits-ui unmounts its content only once every animation on it has settled, and `Presence` once
|
|
62
|
+
Motion's exit resolves. On touch devices a closed menu was reported staying mounted, one per
|
|
63
|
+
open, stacking up (#53): some animation on the node never settled. So once the exit has had its
|
|
64
|
+
time, whatever is still running or paused there is finished (or cancelled, if it can't be),
|
|
65
|
+
which settles bits-ui's wait and the exit's promise.
|
|
66
|
+
*/
|
|
67
|
+
const EXIT_DEADLINE_MARGIN_MS = 150;
|
|
68
|
+
const EXIT_DEADLINE_FALLBACK_MS = 1000;
|
|
69
|
+
const exitDeadline = (controls) => {
|
|
70
|
+
const seconds = controls.duration;
|
|
71
|
+
return Number.isFinite(seconds) && seconds > 0
|
|
72
|
+
? seconds * 1000 + EXIT_DEADLINE_MARGIN_MS
|
|
73
|
+
: EXIT_DEADLINE_FALLBACK_MS;
|
|
74
|
+
};
|
|
75
|
+
const settle = (node) => {
|
|
76
|
+
for (const animation of node.getAnimations?.() ?? []) {
|
|
77
|
+
if (animation.playState === 'finished')
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
animation.finish();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
animation.cancel();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
50
86
|
};
|
|
51
87
|
/**
|
|
52
88
|
* Keeps an element mounted until its exit animation finishes — the Motion replacement for a
|
|
@@ -64,7 +64,8 @@ export declare const lateral: (update: () => void | Promise<void>, { target, axi
|
|
|
64
64
|
* instead of `lateral` there.
|
|
65
65
|
*
|
|
66
66
|
* Not built into `Navbar`/`Rail`: they don't own the content region that changes, so the app wraps
|
|
67
|
-
* its own route change in this.
|
|
67
|
+
* its own route change in this. They, the AppBar and a docked Toolbar stay still and on top while
|
|
68
|
+
* it runs; give a fixed floating Toolbar or FAB the `md-vt-persist` class for the same (motion.css).
|
|
68
69
|
*
|
|
69
70
|
* The `target` region swaps its box instantly instead of sliding or resizing to the new page's:
|
|
70
71
|
* that movement would be exactly the spatial connection this pattern avoids, and after a
|
|
@@ -2,8 +2,25 @@ import { animateView } from 'motion';
|
|
|
2
2
|
import { prefersReducedMotion } from './reducedMotion.js';
|
|
3
3
|
import { springTokens, springTransition } from './spring.js';
|
|
4
4
|
const SHARED_AXIS_OFFSET_PX = 30;
|
|
5
|
+
/* While a navigation transition runs, `.md-vt-persist` chrome (Navbar, Rail, AppBar, …) gets
|
|
6
|
+
its own static layer above the page's (motion.css). Counted, since transitions queue. */
|
|
7
|
+
const NAVIGATING = 'md-navigation-transition';
|
|
8
|
+
let navigating = 0;
|
|
9
|
+
const markNavigating = (builder) => {
|
|
10
|
+
const root = document.documentElement;
|
|
11
|
+
navigating++;
|
|
12
|
+
root.classList.add(NAVIGATING);
|
|
13
|
+
new Promise((resolve, reject) => builder.then ? builder.then(resolve, reject) : resolve(undefined))
|
|
14
|
+
.then((animation) => animation?.finished)
|
|
15
|
+
.catch(() => { })
|
|
16
|
+
.finally(() => {
|
|
17
|
+
if (--navigating === 0)
|
|
18
|
+
root.classList.remove(NAVIGATING);
|
|
19
|
+
});
|
|
20
|
+
};
|
|
5
21
|
const view = (update, target, spring) => {
|
|
6
22
|
const builder = animateView(update, springTransition(spring));
|
|
23
|
+
markNavigating(builder);
|
|
7
24
|
return target ? builder.add(target) : builder;
|
|
8
25
|
};
|
|
9
26
|
/* Outgoing content clears quickly; incoming content waits a beat so the two never overlap fully.
|
|
@@ -117,7 +134,8 @@ export const lateral = (update, { target, axis = 'x', direction = 'forward', spr
|
|
|
117
134
|
* instead of `lateral` there.
|
|
118
135
|
*
|
|
119
136
|
* Not built into `Navbar`/`Rail`: they don't own the content region that changes, so the app wraps
|
|
120
|
-
* its own route change in this.
|
|
137
|
+
* its own route change in this. They, the AppBar and a docked Toolbar stay still and on top while
|
|
138
|
+
* it runs; give a fixed floating Toolbar or FAB the `md-vt-persist` class for the same (motion.css).
|
|
121
139
|
*
|
|
122
140
|
* The `target` region swaps its box instantly instead of sliding or resizing to the new page's:
|
|
123
141
|
* that movement would be exactly the spatial connection this pattern avoids, and after a
|
|
@@ -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;
|
package/dist/attachments/drag.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -343,7 +343,10 @@ Floating action buttons (FABs) help people take primary actions.
|
|
|
343
343
|
surfaceOpen && 'invisible',
|
|
344
344
|
// In its footprint, pinned to the top trailing corner the close button shares.
|
|
345
345
|
withMenu &&
|
|
346
|
-
neverExpanded && [
|
|
346
|
+
neverExpanded && [
|
|
347
|
+
'col-start-1 row-start-1 self-start justify-self-end',
|
|
348
|
+
!open && footprint[size]
|
|
349
|
+
],
|
|
347
350
|
className
|
|
348
351
|
)
|
|
349
352
|
})}
|
|
@@ -443,7 +443,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
|
|
|
443
443
|
};
|
|
444
444
|
/**
|
|
445
445
|
* The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
|
|
446
|
-
* corners spring to 56dp round in FAB.svelte
|
|
446
|
+
* corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
|
|
447
|
+
* settles, so the button can't fall back to its content width (#49: a 24×56 pill).
|
|
447
448
|
*/
|
|
448
449
|
menuOpen: {
|
|
449
450
|
true: {
|
|
@@ -509,7 +510,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
|
|
|
509
510
|
};
|
|
510
511
|
/**
|
|
511
512
|
* The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
|
|
512
|
-
* corners spring to 56dp round in FAB.svelte
|
|
513
|
+
* corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
|
|
514
|
+
* settles, so the button can't fall back to its content width (#49: a 24×56 pill).
|
|
513
515
|
*/
|
|
514
516
|
menuOpen: {
|
|
515
517
|
true: {
|
|
@@ -575,7 +577,8 @@ export declare const fab: import("tailwind-variants").TVReturnType<{
|
|
|
575
577
|
};
|
|
576
578
|
/**
|
|
577
579
|
* The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
|
|
578
|
-
* corners spring to 56dp round in FAB.svelte
|
|
580
|
+
* corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
|
|
581
|
+
* settles, so the button can't fall back to its content width (#49: a 24×56 pill).
|
|
579
582
|
*/
|
|
580
583
|
menuOpen: {
|
|
581
584
|
true: {
|
|
@@ -276,11 +276,12 @@ export const fab = tv({
|
|
|
276
276
|
},
|
|
277
277
|
/**
|
|
278
278
|
* The FAB menu's open state: the FAB becomes the round close button, label shut. Its size and
|
|
279
|
-
* corners spring to 56dp round in FAB.svelte
|
|
279
|
+
* corners spring to 56dp round in FAB.svelte; the classes hold that box once the spring
|
|
280
|
+
* settles, so the button can't fall back to its content width (#49: a 24×56 pill).
|
|
280
281
|
*/
|
|
281
282
|
menuOpen: {
|
|
282
283
|
true: {
|
|
283
|
-
base: 'justify-center px-spacing-0',
|
|
284
|
+
base: 'justify-center px-spacing-0 size-spacing-700 rounded-[1.75rem]',
|
|
284
285
|
icon: 'size-spacing-250 text-[20px]',
|
|
285
286
|
labelTrack: 'grid-cols-[0fr]!'
|
|
286
287
|
},
|
|
@@ -2,7 +2,7 @@ import { tv } from '../../../utils/tv.js';
|
|
|
2
2
|
export const contextMenu = tv({
|
|
3
3
|
slots: {
|
|
4
4
|
content: `
|
|
5
|
-
z-layer-popup min-w-48 max-w-sm gap-spacing-50 overflow-y-auto rounded-lg
|
|
5
|
+
z-layer-popup outline-none min-w-48 max-w-sm gap-spacing-50 overflow-y-auto rounded-lg
|
|
6
6
|
bg-md-sys-color-surface-container-high px-spacing-100 py-spacing-50
|
|
7
7
|
shadow-elevation-3 ring-md-sys-color-outline/10
|
|
8
8
|
`,
|
|
@@ -10,14 +10,14 @@ export const contextMenu = tv({
|
|
|
10
10
|
rounded-sm relative flex w-full cursor-pointer select-none items-center gap-spacing-100
|
|
11
11
|
px-spacing-150 py-spacing-100 text-left md-sys-typescale-body-large text-md-sys-color-on-surface
|
|
12
12
|
outline-none transition-colors md-sys-motion-fast-effects
|
|
13
|
-
|
|
13
|
+
data-[highlighted]:bg-md-sys-color-on-surface/8
|
|
14
14
|
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
|
|
15
15
|
`,
|
|
16
16
|
subTrigger: `
|
|
17
17
|
rounded-sm relative flex w-full cursor-pointer select-none items-center gap-spacing-100
|
|
18
18
|
px-spacing-150 py-spacing-100 text-left md-sys-typescale-body-large text-md-sys-color-on-surface
|
|
19
19
|
outline-none transition-colors md-sys-motion-fast-effects
|
|
20
|
-
|
|
20
|
+
data-[state=open]:bg-md-sys-color-on-surface/8
|
|
21
21
|
data-[highlighted]:bg-md-sys-color-on-surface/8
|
|
22
22
|
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
|
|
23
23
|
`,
|
|
@@ -36,7 +36,7 @@ export const contextMenu = tv({
|
|
|
36
36
|
},
|
|
37
37
|
color: {
|
|
38
38
|
error: {
|
|
39
|
-
item: 'text-md-sys-color-error
|
|
39
|
+
item: 'text-md-sys-color-error data-[highlighted]:bg-md-sys-color-error/8',
|
|
40
40
|
icon: 'text-md-sys-color-error'
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -20,6 +20,7 @@ Contrast with `MenuItem` (a single interactive row inside the panel) and
|
|
|
20
20
|
import { DropdownMenu } from 'bits-ui';
|
|
21
21
|
import clsx from 'clsx';
|
|
22
22
|
import Button from '../../buttons/Button.svelte';
|
|
23
|
+
import { menu } from './theme.js';
|
|
23
24
|
import type { MenuProps } from './types.js';
|
|
24
25
|
|
|
25
26
|
let {
|
|
@@ -49,11 +50,7 @@ Contrast with `MenuItem` (a single interactive row inside the panel) and
|
|
|
49
50
|
<div {...wrapperProps} class={wrapperProps.class as any}>
|
|
50
51
|
<div
|
|
51
52
|
{...props}
|
|
52
|
-
class={clsx(
|
|
53
|
-
'bg-md-sys-color-surface-container-high shadow-elevation-3 ring-md-sys-color-outline/10 gap-spacing-50 px-spacing-100 py-spacing-50 max-w-sm min-w-48 overflow-y-auto rounded-lg',
|
|
54
|
-
props.class as any,
|
|
55
|
-
contentClass
|
|
56
|
-
)}
|
|
53
|
+
class={clsx(menu().content(), props.class as any, contentClass)}
|
|
57
54
|
{@attach presence(() => isOpen, enterExit.scale)}
|
|
58
55
|
>
|
|
59
56
|
{@render children()}
|
|
@@ -4,12 +4,14 @@ export const menu = tv({
|
|
|
4
4
|
content: `
|
|
5
5
|
bg-md-sys-color-surface-container-high shadow-elevation-3
|
|
6
6
|
ring-md-sys-color-outline/10 max-w-sm min-w-48 gap-spacing-50
|
|
7
|
-
overflow-y-auto rounded-lg px-spacing-100 py-spacing-50
|
|
7
|
+
overflow-y-auto rounded-lg px-spacing-100 py-spacing-50 outline-none
|
|
8
8
|
`,
|
|
9
|
+
// No `hover:` fill: bits-ui sets `data-highlighted` on mouse hover already,
|
|
10
|
+
// and a `:hover` tint sticks to the last-tapped row on touch screens (#48).
|
|
9
11
|
item: `
|
|
10
12
|
rounded-sm h-11 relative flex w-full cursor-pointer items-center gap-spacing-100 px-spacing-150 py-spacing-100
|
|
11
13
|
md-sys-typescale-body-medium whitespace-nowrap text-md-sys-color-on-surface
|
|
12
|
-
|
|
14
|
+
data-[highlighted]:bg-md-sys-color-on-surface/8
|
|
13
15
|
focus-visible:outline-2 focus-visible:outline-offset-2
|
|
14
16
|
focus-visible:outline-md-sys-color-primary
|
|
15
17
|
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-38
|
|
@@ -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,
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
settleTarget = undefined;
|
|
351
|
+
offsetX.stop();
|
|
352
|
+
offsetY.stop();
|
|
326
353
|
}
|
|
327
354
|
|
|
328
|
-
/**
|
|
329
|
-
function
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
});
|
|
346
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
391
|
-
|
|
392
|
-
settleTo(
|
|
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
|
-
|
|
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
|
-
{
|
|
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={() => (
|
|
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()}>
|
|
@@ -9,7 +9,8 @@ open a view from a bar of your own, passing that bar as `anchor`.
|
|
|
9
9
|
from 24dp to 12dp) with results in a container 2dp below. `layout` takes one per window tier and
|
|
10
10
|
defaults to full-screen on compact windows, docked from medium up. Switched in CSS.
|
|
11
11
|
- **Motion.** Opening and closing are an M3 container transform between the bar and the view, on
|
|
12
|
-
the `spatial` spring; a crossfade under reduced motion.
|
|
12
|
+
the `spatial` spring; a crossfade under reduced motion. Following a result link closes it at
|
|
13
|
+
once, leaving the route change to the app's own transition.
|
|
13
14
|
- **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
|
|
14
15
|
back button or a click outside closes it. The field is a combobox over the `results` listbox:
|
|
15
16
|
arrow keys move through the `role="option"` items and Enter picks one. Enter with no item
|
|
@@ -58,11 +59,19 @@ open a view from a bar of your own, passing that bar as `anchor`.
|
|
|
58
59
|
let shown = $state(untrack(() => open));
|
|
59
60
|
let view = $state<HTMLElement>();
|
|
60
61
|
let busy = false;
|
|
62
|
+
// Closing because a result link was followed: no transform, and focus doesn't go back to the bar.
|
|
63
|
+
let leaving = false;
|
|
64
|
+
|
|
65
|
+
const detached = (target: HTMLElement | string | undefined) =>
|
|
66
|
+
!target || (typeof target !== 'string' && !target.isConnected);
|
|
61
67
|
|
|
62
68
|
const sync = () => {
|
|
63
69
|
if (busy || open === shown) return;
|
|
64
70
|
const opening = open;
|
|
65
|
-
if (opening)
|
|
71
|
+
if (opening) {
|
|
72
|
+
leaving = false;
|
|
73
|
+
measure();
|
|
74
|
+
}
|
|
66
75
|
const update = async () => {
|
|
67
76
|
// The view takes the bar's place: hidden, the bar isn't left behind in the page's snapshot
|
|
68
77
|
// (a second, static bar under the morph). Shown again first on close, so focus can return.
|
|
@@ -77,8 +86,18 @@ open a view from a bar of your own, passing that bar as `anchor`.
|
|
|
77
86
|
busy = false;
|
|
78
87
|
sync();
|
|
79
88
|
};
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
// Without both ends on the page (the bar unmounted by a navigation) there is nothing to morph
|
|
90
|
+
// between; a transform that can't start must still close the view, or the Dialog keeps the
|
|
91
|
+
// page locked (#51).
|
|
92
|
+
if ((leaving && !opening) || detached(from) || detached(to)) {
|
|
93
|
+
update().then(settle, settle);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
containerTransform(update, { from: from!, to: to! }).then(settle, settle);
|
|
98
|
+
} catch {
|
|
99
|
+
update().then(settle, settle);
|
|
100
|
+
}
|
|
82
101
|
};
|
|
83
102
|
|
|
84
103
|
$effect(() => {
|
|
@@ -185,10 +204,37 @@ open a view from a bar of your own, passing that bar as `anchor`.
|
|
|
185
204
|
// Back to the bar's field. The bar opens on click or typing, never on focus, so this is safe.
|
|
186
205
|
function focusBar(e: Event) {
|
|
187
206
|
e.preventDefault();
|
|
207
|
+
// Following a result: the page is changing, and focusing a field would raise the keyboard.
|
|
208
|
+
if (leaving) {
|
|
209
|
+
leaving = false;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
188
212
|
const field = anchor instanceof HTMLInputElement ? anchor : anchor?.querySelector('input');
|
|
189
213
|
field?.focus();
|
|
190
214
|
}
|
|
191
215
|
|
|
216
|
+
/*
|
|
217
|
+
Picking a result that is a link navigates away, so the view closes at once instead of morphing
|
|
218
|
+
back into a bar that's about to leave. A morph there would run alongside the route's own view
|
|
219
|
+
transition and, if the bar's page unmounts, never finish (#51). The click still reaches the
|
|
220
|
+
link, since the view unmounts only after this event. New-tab and download clicks keep it open.
|
|
221
|
+
*/
|
|
222
|
+
$effect(() => {
|
|
223
|
+
const el = resultsEl;
|
|
224
|
+
if (!el) return;
|
|
225
|
+
const onclick = (e: MouseEvent) => {
|
|
226
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
227
|
+
return;
|
|
228
|
+
const link = (e.target as Element | null)?.closest?.('a[href]');
|
|
229
|
+
if (!(link instanceof HTMLAnchorElement) || !el.contains(link)) return;
|
|
230
|
+
if ((link.target && link.target !== '_self') || link.hasAttribute('download')) return;
|
|
231
|
+
leaving = true;
|
|
232
|
+
open = false;
|
|
233
|
+
};
|
|
234
|
+
el.addEventListener('click', onclick);
|
|
235
|
+
return () => el.removeEventListener('click', onclick);
|
|
236
|
+
});
|
|
237
|
+
|
|
192
238
|
function clear() {
|
|
193
239
|
value = '';
|
|
194
240
|
inputRef?.focus();
|
|
@@ -9,7 +9,8 @@ import type { SearchViewProps } from './types.js';
|
|
|
9
9
|
* from 24dp to 12dp) with results in a container 2dp below. `layout` takes one per window tier and
|
|
10
10
|
* defaults to full-screen on compact windows, docked from medium up. Switched in CSS.
|
|
11
11
|
* - **Motion.** Opening and closing are an M3 container transform between the bar and the view, on
|
|
12
|
-
* the `spatial` spring; a crossfade under reduced motion.
|
|
12
|
+
* the `spatial` spring; a crossfade under reduced motion. Following a result link closes it at
|
|
13
|
+
* once, leaving the route change to the app's own transition.
|
|
13
14
|
* - **Behavior.** A bits-ui `Dialog`: focus stays in the view, the page doesn't scroll, and Esc, the
|
|
14
15
|
* back button or a click outside closes it. The field is a combobox over the `results` listbox:
|
|
15
16
|
* arrow keys move through the `role="option"` items and Enter picks one. Enter with no item
|
|
@@ -122,7 +122,7 @@ export function appbarSize(size, hasSubtitle) {
|
|
|
122
122
|
export const appbar = tv({
|
|
123
123
|
slots: {
|
|
124
124
|
// Starts beside a viewport-anchored Rail (styles/components.css), which runs full height.
|
|
125
|
-
base: 'fixed top-spacing-0 left-(--md-rail-inset) right-spacing-0 flex flex-col z-layer-bar transition-colors md-sys-motion-effects',
|
|
125
|
+
base: 'md-vt-persist fixed top-spacing-0 left-(--md-rail-inset) right-spacing-0 flex flex-col z-layer-bar transition-colors md-sys-motion-effects',
|
|
126
126
|
row: 'grid w-full items-center px-spacing-50',
|
|
127
127
|
childrenRow: 'w-full px-spacing-100 pb-spacing-100',
|
|
128
128
|
ghost: 'w-full shrink-0 pointer-events-none',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { tv } from '../../../utils/tv.js';
|
|
2
2
|
export const navbar = tv({
|
|
3
3
|
slots: {
|
|
4
|
-
base: 'shadow-elevation-2 bg-md-sys-color-surface-container z-layer-bar flex h-20 fixed bottom-spacing-0 w-full left-spacing-0 justify-around py-spacing-150 md:hidden',
|
|
4
|
+
base: 'md-vt-persist shadow-elevation-2 bg-md-sys-color-surface-container z-layer-bar flex h-20 fixed bottom-spacing-0 w-full left-spacing-0 justify-around py-spacing-150 md:hidden',
|
|
5
5
|
items: 'flex justify-around w-full',
|
|
6
6
|
fab: 'bottom-24 right-spacing-200 absolute',
|
|
7
7
|
ghost: 'h-20 w-full shrink-0 md:hidden pointer-events-none'
|
|
@@ -34,7 +34,7 @@ export const rail = tv({
|
|
|
34
34
|
variants: {
|
|
35
35
|
anchor: {
|
|
36
36
|
viewport: {
|
|
37
|
-
base: 'fixed top-[var(--rail-top,0px)] bottom-spacing-0 left-spacing-0',
|
|
37
|
+
base: 'md-vt-persist fixed top-[var(--rail-top,0px)] bottom-spacing-0 left-spacing-0',
|
|
38
38
|
scrim: 'fixed'
|
|
39
39
|
},
|
|
40
40
|
parent: {
|
|
@@ -33,7 +33,10 @@ export const toolbar = tv({
|
|
|
33
33
|
},
|
|
34
34
|
variant: {
|
|
35
35
|
floating: { root: 'inline-flex', segment: 'rounded-full shadow-elevation-1' },
|
|
36
|
-
docked: {
|
|
36
|
+
docked: {
|
|
37
|
+
root: 'md-vt-persist fixed left-spacing-0 z-layer-bar w-full',
|
|
38
|
+
segment: 'w-full rounded-none'
|
|
39
|
+
}
|
|
37
40
|
},
|
|
38
41
|
color: {
|
|
39
42
|
standard: { segment: 'bg-md-sys-color-surface-container' },
|
package/dist/styles/motion.css
CHANGED
|
@@ -371,3 +371,29 @@
|
|
|
371
371
|
background-color: var(--md-container-transform-color, transparent);
|
|
372
372
|
box-shadow: var(--md-container-transform-shadow, none);
|
|
373
373
|
}
|
|
374
|
+
|
|
375
|
+
/*
|
|
376
|
+
App chrome stays put through navigation transitions (#50). Motion's `animateView` puts the
|
|
377
|
+
`target` region (or the root) in its own layer, and the whole `::view-transition` tree paints
|
|
378
|
+
over the live page, so a fixed bar outside `main` was covered by `main`'s snapshots and a fixed
|
|
379
|
+
toolbar or FAB inside it faded and scaled with the page. `md-vt-persist` lifts an element
|
|
380
|
+
into its own layer that is drawn above the page's and never animates: the old snapshot is
|
|
381
|
+
hidden and the new one is the live element.
|
|
382
|
+
|
|
383
|
+
Scoped to the navigation helpers (sharedAxis, lateral, fadeThrough), which set
|
|
384
|
+
`md-navigation-transition` on the root while they run, so a container transform (the search
|
|
385
|
+
view) still grows over the Navbar instead of under it. Built into Navbar, viewport-anchored
|
|
386
|
+
Rail, AppBar and docked Toolbar; add the class to an app-positioned floating Toolbar or FAB.
|
|
387
|
+
Browsers without `match-element` fall back to the old behaviour.
|
|
388
|
+
*/
|
|
389
|
+
:root.md-navigation-transition .md-vt-persist {
|
|
390
|
+
view-transition-name: match-element;
|
|
391
|
+
view-transition-class: md-persist;
|
|
392
|
+
}
|
|
393
|
+
::view-transition-group(*.md-persist),
|
|
394
|
+
::view-transition-new(*.md-persist) {
|
|
395
|
+
animation: none !important;
|
|
396
|
+
}
|
|
397
|
+
::view-transition-old(*.md-persist) {
|
|
398
|
+
display: none;
|
|
399
|
+
}
|
package/dist/utils/Layer.svelte
CHANGED
|
@@ -194,7 +194,9 @@
|
|
|
194
194
|
:global(input:disabled + .layer-container) > .tint,
|
|
195
195
|
:global(:disabled) > .tint
|
|
196
196
|
) {
|
|
197
|
-
|
|
197
|
+
/* pointer: fine too — some touch phones report hover: hover, and a
|
|
198
|
+
tapped element keeps :hover until the next tap elsewhere (#48). */
|
|
199
|
+
@media (hover: hover) and (pointer: fine) {
|
|
198
200
|
&:is(:global(:hover) > .tint, :global(:active) > .tint) {
|
|
199
201
|
opacity: 0.08;
|
|
200
202
|
}
|