@noxlovette/material 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-skill/material-design/SKILL.md +5 -2
- package/claude-skill/material-design/references/accessibility-checklist.md +5 -0
- package/claude-skill/material-design/references/component-patterns.md +24 -3
- package/claude-skill/material-design/references/motion-guide.md +53 -11
- package/claude-skill/material-design/references/tokens-and-styles.md +4 -0
- package/dist/animation/TransitionPatterns.stories.svelte +50 -0
- package/dist/animation/containerTransform.js +10 -4
- package/dist/animation/reducedMotion.d.ts +2 -0
- package/dist/animation/reducedMotion.js +2 -0
- package/dist/animation/sharedAxisTransition.d.ts +20 -3
- package/dist/animation/sharedAxisTransition.js +67 -8
- package/dist/attachments/drag.d.ts +52 -0
- package/dist/attachments/drag.js +217 -0
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/components/chips/Chip.mdx +24 -11
- package/dist/components/chips/Chip.stories.svelte +10 -0
- package/dist/components/chips/Chip.svelte +58 -18
- package/dist/components/chips/Chip.svelte.d.ts +3 -1
- package/dist/components/chips/ChipGroup.stories.svelte +73 -0
- package/dist/components/chips/ChipGroup.stories.svelte.d.ts +4 -0
- package/dist/components/chips/ChipGroup.svelte +242 -0
- package/dist/components/chips/ChipGroup.svelte.d.ts +33 -0
- package/dist/components/chips/index.d.ts +1 -0
- package/dist/components/chips/index.js +1 -0
- package/dist/components/chips/theme.d.ts +182 -21
- package/dist/components/chips/theme.js +116 -25
- package/dist/components/chips/types.d.ts +36 -2
- package/dist/components/containers/app/theme.d.ts +1 -1
- package/dist/components/containers/app/theme.js +1 -1
- package/dist/components/containers/bottom-sheet/BottomSheet.stories.svelte +1 -1
- package/dist/components/containers/list/List.svelte +8 -2
- package/dist/components/containers/list/ListItem.mdx +6 -0
- package/dist/components/containers/list/ListItem.svelte +77 -2
- package/dist/components/containers/list/context.d.ts +14 -0
- package/dist/components/containers/list/theme.d.ts +6 -0
- package/dist/components/containers/list/theme.js +10 -2
- package/dist/components/containers/pane/DraggablePane.svelte +25 -63
- package/dist/components/containers/pane/Pane.svelte +1 -1
- package/dist/components/containers/pane/theme.js +32 -6
- package/dist/components/containers/popover/theme.d.ts +3 -3
- package/dist/components/date/theme.d.ts +6 -6
- package/dist/components/forms/checkbox/theme.js +1 -1
- package/dist/components/forms/command/theme.d.ts +3 -3
- package/dist/components/misc/Avatar.stories.svelte +56 -7
- package/dist/components/misc/Avatar.stories.svelte.d.ts +2 -17
- package/dist/components/misc/Avatar.svelte +82 -8
- package/dist/components/misc/Avatar.svelte.d.ts +5 -0
- package/dist/components/misc/theme.d.ts +6 -0
- package/dist/components/misc/theme.js +5 -3
- package/dist/components/misc/types.d.ts +17 -1
- package/dist/components/nav/appbar/theme.js +2 -1
- package/dist/components/nav/currentRoute.d.ts +11 -0
- package/dist/components/nav/currentRoute.js +28 -0
- package/dist/components/nav/navbar/NavbarItem.svelte +3 -6
- package/dist/components/nav/navbar/theme.d.ts +3 -3
- package/dist/components/nav/rail/Rail.mdx +65 -23
- package/dist/components/nav/rail/Rail.stories.svelte +24 -4
- package/dist/components/nav/rail/Rail.stories.svelte.d.ts +2 -17
- package/dist/components/nav/rail/Rail.svelte +163 -48
- package/dist/components/nav/rail/Rail.svelte.d.ts +6 -3
- package/dist/components/nav/rail/RailItem.svelte +122 -65
- package/dist/components/nav/rail/RailItem.svelte.d.ts +3 -2
- package/dist/components/nav/rail/theme.d.ts +69 -84
- package/dist/components/nav/rail/theme.js +79 -66
- package/dist/components/nav/tabs/Tabs.stories.svelte +2 -1
- package/dist/index.css +2 -0
- package/dist/styles/components.css +50 -0
- package/dist/styles/icon.css +13 -0
- package/dist/utils/icon/Icon.stories.svelte +97 -0
- package/dist/utils/icon/Icon.stories.svelte.d.ts +4 -0
- package/dist/utils/icon/Icon.svelte +101 -12
- package/dist/utils/icon/Icon.svelte.d.ts +9 -0
- package/dist/utils/icon/MaterialSymbolsProvider.svelte +11 -4
- package/dist/utils/icon/base-icons.d.ts +1 -1
- package/dist/utils/icon/base-icons.js +1 -1
- package/dist/utils/icon/icon-names.d.ts +2 -0
- package/dist/utils/icon/icon-names.js +2 -0
- package/dist/utils/icon/swap.d.ts +6 -0
- package/dist/utils/icon/swap.js +45 -0
- package/dist/utils/icon/types.d.ts +59 -7
- package/dist/utils/tv.d.ts +3 -4
- package/dist/utils/tv.js +14 -6
- package/package.json +2 -1
|
@@ -52,11 +52,14 @@ This repo is a single, already-opinionated M3 component library, not a blank can
|
|
|
52
52
|
|
|
53
53
|
- Color tokens: `md-sys-color-{primary,secondary,tertiary,error}`, each with a `-container` tonal pair and `on-*` text/icon pair (guarantees AA contrast by construction).
|
|
54
54
|
- Type: `md-sys-typescale-{display,headline,title,body,label}-{large,medium,small}`, plus `md-sys-typescale-emphasized-*` for selected/active/unread states.
|
|
55
|
-
- Spacing: `*-spacing-{0,25,50,75,100,…,900}` (M3 `md.sys.measurement.space*`, 8dp base: `p-spacing-200` = 16dp).
|
|
55
|
+
- Spacing: `*-spacing-{0,25,50,75,100,…,900}` (M3 `md.sys.measurement.space*`, 8dp base: `p-spacing-200` = 16dp). Off-grid component dimensions: `--md-comp-*` in `styles/components.css`, used as `w-(--md-comp-…)`.
|
|
56
|
+
- Surfaces: the window (`App`, rail, navbar, nav lists) is `surface-container`; content sits in `surface` panes (`Pane`'s default). A viewport `Rail` publishes `--md-rail-inset`, which `App` and `AppBar` already use; never offset a page by hand (`md:ml-24`).
|
|
57
|
+
- `tv` and `twMerge` come from `$lib/utils/tv.js`, never `tailwind-variants`/`tailwind-merge` directly.
|
|
56
58
|
- Elevation: `shadow-elevation-{0..5}`.
|
|
57
59
|
- Shape: `radius-{none,xs,sm,md,lg,xl,full}` (buttons instead drive shape via the `--btn-shape`/`--btn-pressed-shape` CSS vars in `.md-btn-morph`).
|
|
60
|
+
- Icons: `Icon.svelte` with a typed `name`. Selected state is `fill={1}`. A control whose icon swaps with its state passes `transition: 'rotate'` (toggles) or `'fade'`. See motion-guide.md → "Icon swaps".
|
|
58
61
|
- Expressive shapes (circle, cookie, sunny, heart…): `animatableShapes`/`animatableShapesSmall` + `shapeMorph` for morphs. See motion-guide.md → "Shape morphing".
|
|
59
|
-
- Motion: M3 Expressive springs only (`springTokens` in `animation/spring.ts`). JS: `presence`/`Presence` + `enterExit` presets, `containerTransform`, `sharedAxis`, `lateral`, `fadeThrough`, `skeleton` — all on Motion, never Svelte transitions. CSS: `transition-* md-sys-motion-{fast-spatial,spatial,slow-spatial,fast-effects,effects,slow-effects}` utilities, never `duration-*`/`ease-*`.
|
|
62
|
+
- Motion: M3 Expressive springs only (`springTokens` in `animation/spring.ts`). Dragging: the `drag()` attachment + `resist()`. Several parts changing together: one progress spring in a CSS variable (the rail's `--rail-p`). JS: `presence`/`Presence` + `enterExit` presets, `containerTransform`, `sharedAxis`, `lateral`, `fadeThrough`, `skeleton` — all on Motion, never Svelte transitions. CSS: `transition-* md-sys-motion-{fast-spatial,spatial,slow-spatial,fast-effects,effects,slow-effects}` utilities, never `duration-*`/`ease-*`.
|
|
60
63
|
- Transition pattern choice: appearing on this screen → enter/exit. Hero expands into its detail → container transform (only in shallow hierarchies). Parent/child or sequential steps → forward/backward (`sharedAxis`). Peers in one set → lateral, which never fades. Navbar/rail/drawer destinations → top level (`fadeThrough`), never lateral. Never use enter/exit or lateral for hierarchical navigation. Sheets slide without fading. Don't use bouncy (`fastSpatial`) springs for navigation transitions.
|
|
61
64
|
- Shared primitives: wrap interactive surfaces with `Layer.svelte` (state layer + ripple, already skips itself under `prefers-reduced-motion`), render icons with `Icon.svelte`, use `Divider.svelte` instead of Bits UI's `Separator`.
|
|
62
65
|
|
|
@@ -11,9 +11,14 @@ Run through this before calling a component/UI change done.
|
|
|
11
11
|
|
|
12
12
|
- Interactive elements should hit the M3 minimum 48dp target. The existing `size` scales already encode this (e.g. button `sm`/`md` = `h-spacing-500`/`h-spacing-700`); when adding a new interactive size below that, either pad the hit area (see `.text-link`'s `min-height: 44px` + padding trick) or confirm it's genuinely dense-UI/desktop-only and document why.
|
|
13
13
|
|
|
14
|
+
- A target can be larger than what's drawn: a rail destination's link spans the rail's full width while its indicator hugs the icon; a chip's 18dp trailing icon gets a 48dp `::after` target. Draw state layers and focus rings on the visible element, triggered by the whole target (`group-hover:`, `group-focus-visible:`).
|
|
15
|
+
|
|
14
16
|
## Focus
|
|
15
17
|
|
|
16
18
|
- Anything focusable needs a visible focus state — use `md-sys-state-focus-indicator`, don't rely on the browser default or remove `outline` without replacing it.
|
|
19
|
+
- Every drag has a keyboard equivalent and an announcement: `ChipGroup` reorders on Alt+Arrow and reports "X, moved to N of M" in an `aria-live` region, with the instructions in an `aria-describedby` hint.
|
|
20
|
+
- Toggles report their state (`aria-expanded` on the rail's menu button); a modal surface closes on Escape and a scrim click (the modal rail also closes when a destination is picked); removable items remove on Backspace/Delete (input chips).
|
|
21
|
+
- Icons are decorative (`aria-hidden`) by default. Give `Icon` an `aria-label` only when it carries meaning with no visible text beside it; it then takes `role="img"`.
|
|
17
22
|
- Verify keyboard operability, not just click handlers — Bits UI primitives (dialogs, menus, selects) handle this by default; if you're building something interactive from scratch, check it against Bits UI's docs (https://bits-ui.com/llms.txt) for the expected keyboard model first.
|
|
18
23
|
|
|
19
24
|
## Motion
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## The `tv()` shape
|
|
4
4
|
|
|
5
|
-
Every component's styles live in the category's `theme.ts`, built with `tv()
|
|
5
|
+
Every component's styles live in the category's `theme.ts`, built with `tv()`. Import it from `$lib/utils/tv.js`, not `tailwind-variants`: that instance teaches tailwind-merge the library's own class groups, so a caller's `z-40` replaces a `z-layer-*` and `size-[18px]`/`p-4` replaces `size-spacing-250`/`p-spacing-200` instead of both shipping. For a class list built outside `tv()`, use the `twMerge` exported from the same file.
|
|
6
6
|
|
|
7
7
|
- `slots` — one key per rendered element (`base`, `icon`, `label`, ...) so multi-element components can vary each part independently
|
|
8
8
|
- `variants` — one axis per meaningful design decision (`variant`, `color`, `size`, `shape`, `selected`, ...); each value maps to either a class string (single-slot components) or an object keyed by slot (multi-slot components)
|
|
@@ -26,8 +26,15 @@ Only fall back to `bare` (no color/background at all) when the component supplie
|
|
|
26
26
|
|
|
27
27
|
## Reuse before you build
|
|
28
28
|
|
|
29
|
-
- **State layer / ripple** — wrap the interactive element with `Layer.svelte` (`src/lib/utils/Layer.svelte`) rather than writing hover/press opacity by hand. It listens for `.m3-layer` on its parent, already respects `prefers-reduced-motion` for the ripple, and its tint
|
|
30
|
-
- **Icons** — always `Icon.svelte
|
|
29
|
+
- **State layer / ripple** — wrap the interactive element with `Layer.svelte` (`src/lib/utils/Layer.svelte`) rather than writing hover/press opacity by hand. It listens for `.m3-layer` on its parent, already respects `prefers-reduced-motion` for the ripple, and its tint (hover 0.08, focus and pressed 0.10) matches the M3 state-layer tokens — don't retune those numbers per component.
|
|
30
|
+
- **Icons** — always `Icon.svelte`, never a raw `<span class="material-symbols-...">` or an inline SVG for a Material Symbol.
|
|
31
|
+
- `name` is typed (`MaterialSymbolName`), so a typo is a type error. A `string[]` of names needs `as const` or `satisfies MaterialSymbolName[]`.
|
|
32
|
+
- Show state with `fill` (0 → 1 on the selected item; it animates), not a weight change.
|
|
33
|
+
- `size="inline"` for a symbol set in running text. `grad` defaults to `'auto'` (-25 on dark schemes).
|
|
34
|
+
- When a control's icon swaps between two states (menu ↔ menu_open, add ↔ close, play ↔ pause), pass `transition: 'rotate'` (a toggle) or `'fade'`. Otherwise the glyph jumps. See motion-guide.md → "Icon swaps".
|
|
35
|
+
- **Dragging** — the `drag()` attachment (`$lib/attachments`), never hand-rolled pointer events. It handles pointer capture (and recapture when the node moves in the DOM), a start `threshold`, a touch long-press `touchDelay` so swipes still scroll, the release velocity, and swallowing the click after a drag. Pair it with `resist()` for rubber-banding at the bounds and a Motion spring on release that takes the velocity. `DraggablePane` and `ChipGroup` are built on it.
|
|
36
|
+
- **Sets of chips** — `ChipGroup` (8dp gaps, wrapping; `reorderable` adds drag and Alt+Arrow reordering with the M3 dragged state), not a flex row of `Chip`s. An input chip is two buttons (its action, and a remove button with a `removeLabel`) and removes on Backspace/Delete; a filter chip takes `trailingIconProps` (e.g. a dropdown arrow).
|
|
37
|
+
- **Lists** — `List`'s `variant` depends on what's behind it. `standard` items are `surface` and assume a `surface` backdrop (a `surface` pane). On the `surface-container` window (a sidebar nav on a backgroundless pane), use `segmented`: its segments take the window's tone, so only the selected pill and hover tint show. A standard list there renders as notched white slabs. The selected fill is its own element (`selection` slot), not the item's background, so it can travel between items; keep it that way when restyling selection.
|
|
31
38
|
- **Dividers** — `Divider.svelte`, not Bits UI's `Separator` (per project CLAUDE.md — it already implements the same primitive).
|
|
32
39
|
- **Focus ring** — the `md-sys-state-focus-indicator` utility, not a custom `:focus` style.
|
|
33
40
|
- **Disabled state** — `.md-component-button-base`'s disabled handling (via `disabled:`/`aria-disabled`/`data-disabled` selectors in `component.css`), not a manually-toggled opacity class.
|
|
@@ -50,6 +57,20 @@ in the library, not in a one-off route file:
|
|
|
50
57
|
|
|
51
58
|
See `/docs/pane` in the showcase site for the full prop reference and worked examples.
|
|
52
59
|
|
|
60
|
+
### Surfaces: window vs panes
|
|
61
|
+
|
|
62
|
+
`App` paints the window `surface-container`; content lives in `surface` panes (`Pane`'s default `background`, rounded top corners). Navigation belongs to the window, not a pane: the `Rail` (on `surface-container`, the spec's optional role), a `Navbar`, a guides-style nav list on a `background={false}` pane. So keep the content `Pane` at its default background, and don't separate a nav column from the content with a border: the colour change does it.
|
|
63
|
+
|
|
64
|
+
### Page ends and overscroll
|
|
65
|
+
|
|
66
|
+
- **End space:** a `full` Pane (the default, a page that scrolls with the window) ends its content 72dp above its bottom edge, whatever its `padding` preset (not with `padding="none"`). That's a 56dp FAB plus its 16dp margin, so the last line scrolls clear of a FAB and doesn't finish flush against the window. Don't add bottom padding by hand on top of it.
|
|
67
|
+
- **Overscroll:** a page with an `App` doesn't bounce or pull-to-refresh (`overscroll-behavior: none` on `:root:has(.md-app)`, base layer), and its canvas is `surface-container`. An app that wants pull-to-refresh sets `html { overscroll-behavior: auto }`.
|
|
68
|
+
- **Pane basis follows the grid:** inside a `PaneGrid`, a Pane's `flex-basis` is its width in a row and `auto` in a column (each direction tier restates it), so stacked panes take their content height. Don't set `basis-*` on a Pane yourself.
|
|
69
|
+
|
|
70
|
+
### Clearing the rail
|
|
71
|
+
|
|
72
|
+
A viewport-anchored `Rail` publishes `--md-rail-inset` on `<html>`: 0 below `md`, the collapsed 96dp on medium windows (the expanded rail is modal there and overlays), and its live width from `lg` (it pushes content, in step with its spring). `App`'s shell pads by it and `AppBar` starts at it. Never add `md:ml-24` or similar to a page, a `PaneGrid` or an app bar. A custom shell uses `ps-(--md-rail-inset)`; another fixed surface spanning the window uses `left-(--md-rail-inset)`. A rail with `anchor="parent"` renders a spacer beside itself instead, so it and its content go in a flex row.
|
|
73
|
+
|
|
53
74
|
## Before exporting a new component
|
|
54
75
|
|
|
55
76
|
Follow CLAUDE.md's "Adding a New Component" steps as the mechanical checklist (create `.svelte` + `types.ts`, define `theme.ts` with `tv()`, export from the category `index.ts`, run `bun scripts/generate-components-index.ts`, add a showcase route and a docs page) — this skill governs the _design_ decisions (which variant/color/motion) that should be made before or while writing that `theme.ts`, not the export mechanics themselves.
|
|
@@ -54,16 +54,18 @@ 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, FAB → sheet | `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 })`
|
|
64
|
-
| **Top level** — unrelated destinations (navigation bar) | `fadeThrough(update, { target })` | Old fades out, new fades in scaling from 92%.
|
|
65
|
-
| **Skeleton loaders** | `{@attach skeleton}` on the placeholder | Pulses until replaced; reveal the content with `presence(…, enterExit.fade)`.
|
|
66
|
-
|
|
|
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, FAB → sheet | `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. |
|
|
67
69
|
|
|
68
70
|
### Which components must use which pattern
|
|
69
71
|
|
|
@@ -115,8 +117,48 @@ Within a set, every path has the same point count, start point and winding, so M
|
|
|
115
117
|
- **Morph with `shapeMorph(() => path)`** (attachment on a `<path>`) or `morphShape(pathEl, to)`. Both use `fastSpatial` by default, stop the previous morph and start from the shape on screen, and swap instantly under `prefers-reduced-motion`.
|
|
116
118
|
- **Pick by name** with `animatableShapes[name]` / `animatableShapesSmall[name]` (`ShapeName`, `shapeNames`). A map pulls in its whole set, so import individual `pathAnimatable*` constants when bundle size matters.
|
|
117
119
|
- `LoadingIndicator` morphs through seven of the small shapes (`LOADING_SHAPES`) on its own component spring.
|
|
120
|
+
- `Avatar` takes any shape (`shape`, a clip path from the small set, morphing on change) and can turn it at a constant speed (`spin="clockwise" | "counterclockwise"`, one turn per 14s, off under reduced motion). Like the loading indicator's global rotation, a constant spin is linear, not a spring.
|
|
118
121
|
- Live demo: Storybook → Motion/Shapes.
|
|
119
122
|
|
|
123
|
+
## Icon swaps
|
|
124
|
+
|
|
125
|
+
Material Symbols are font glyphs, so one icon can't morph its outline into another. When a
|
|
126
|
+
control's icon changes with its state, `Icon`'s `transition` prop crossfades the two glyphs on
|
|
127
|
+
springs instead of swapping them in one frame (presets in `utils/icon/swap.ts`, run through
|
|
128
|
+
`presence`):
|
|
129
|
+
|
|
130
|
+
- **`'rotate'`**: for a toggle between two states of one control: menu ↔ menu_open (the rail's
|
|
131
|
+
menu button), add ↔ close, expand ↔ collapse. The old glyph turns out the same way the new
|
|
132
|
+
one turns in, so the pair reads as one motion.
|
|
133
|
+
- **`'fade'`**: scale and fade without turning, for swaps that aren't a toggle (play ↔ pause,
|
|
134
|
+
a status icon changing).
|
|
135
|
+
- **`'none'`** (default): leave icons that change with data (list rows, table cells) on this,
|
|
136
|
+
or a whole list animates when its data reloads.
|
|
137
|
+
|
|
138
|
+
```svelte
|
|
139
|
+
<ButtonIcon iconProps={{ name: open ? 'menu_open' : 'menu', transition: 'rotate' }} … />
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Movement is on `fastSpatial` (an icon is a small element), opacity on `fastEffects`, and a
|
|
143
|
+
change back mid-flight retargets the leaving glyph instead of stacking a third. Under reduced
|
|
144
|
+
motion both become a plain crossfade. Outlined ↔ filled for a selected state is `fill`, not a
|
|
145
|
+
swap; it already animates through `font-variation-settings`. True path morphs between icons are
|
|
146
|
+
issue #36.
|
|
147
|
+
|
|
148
|
+
## Selection indicators
|
|
149
|
+
|
|
150
|
+
A selection that moves between peers moves its indicator rather than blinking it out and in.
|
|
151
|
+
|
|
152
|
+
- **Between items of one set** (a `List`'s selected fill, Tabs' bar): the indicator travels from
|
|
153
|
+
the old item to the new one. `ListItem` hands the outgoing fill's rect (relative to the `List`)
|
|
154
|
+
through the List context in `$effect.pre`, and the incoming item springs its own fill from
|
|
155
|
+
there in `$effect` on `spatial`, lifted above the items it crosses. Reuse that hand-off for
|
|
156
|
+
new selectable sets, not a list-level overlay: segment backgrounds would hide one.
|
|
157
|
+
- **Appearing in place** (the rail's active indicator): it grows from its centre to its edges
|
|
158
|
+
and fades in on `fastSpatial`, and the deselected one shrinks back; the state layer stays on
|
|
159
|
+
top. RailItem drives a 0–1 spring into `--rail-sel`.
|
|
160
|
+
- Under reduced motion both just appear at the end state.
|
|
161
|
+
|
|
120
162
|
## CSS: state-driven transitions
|
|
121
163
|
|
|
122
164
|
For hover/press/selected/focus motion driven by pseudo-classes or `data-*` state, use a Tailwind transition-property utility plus one spring utility from `motion.css`:
|
|
@@ -158,6 +200,6 @@ Not implemented yet — tracked in https://github.com/noxlovette/material/issues
|
|
|
158
200
|
|
|
159
201
|
Target behavior, per [Applying transitions](https://m3.material.io/styles/motion/transitions/applying-transitions). When `prefers-reduced-motion: reduce` is set:
|
|
160
202
|
|
|
161
|
-
- **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 `
|
|
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`).
|
|
162
204
|
- **Disable decorative effects**: shape morphing (button `--btn-shape` morphs; `morphShape`/`shapeMorph` already swap instantly), parallax, the ripple (already done).
|
|
163
205
|
- Keep effects-spring color/opacity feedback (state layers, hover/press color), since that motion carries meaning and isn't intense.
|
|
@@ -48,6 +48,10 @@ The full M3 type scale (https://m3.material.io/styles/typography/type-scale-toke
|
|
|
48
48
|
|
|
49
49
|
`md.sys.measurement.space<N>` (N/100 × the 8dp base) is registered in Tailwind's spacing namespace. Every spacing utility therefore takes it: `p-spacing-200` (16dp), `gap-spacing-50` (4dp), `mr-spacing-300` (24dp), `size-spacing-600` (48dp). The available numbers are 0, 25, 50, 75, 100, 125, 150, 175, 200, 250, 300, 400, 450, 500, 600, 700, 800 and 900. The number is M3's token number, not Tailwind's 4px multiplier.
|
|
50
50
|
|
|
51
|
+
## Component dimensions (`src/lib/styles/components.css`)
|
|
52
|
+
|
|
53
|
+
M3 component dimensions that aren't on the spacing grid are custom properties named after their token in the M3 token database, with dots as dashes: `--md-comp-nav-rail-collapsed-container-width` (96dp), `--md-comp-nav-rail-expanded-container-width-minimum` (220dp), and so on. Use them as `w-(--md-comp-…)`. A new off-grid spec value goes in this file under its token name, never as a Tailwind number (`w-24`) or an arbitrary value (`w-[96px]`). A dimension that is on the grid uses the spacing token instead. Migrating the remaining numeric/arbitrary sizes is issue #30.
|
|
54
|
+
|
|
51
55
|
## Elevation (`src/lib/styles/elevation.css`)
|
|
52
56
|
|
|
53
57
|
`shadow-elevation-{0..5}`, each a two-layer (spot + ambient) shadow scaled to the M3 elevation spec, using `color-mix` against the `shadow` color role so it adapts per theme automatically. Elevation communicates depth/priority — reach for a higher level when a surface should read as "above" its neighbors (menus, FABs, dialogs), not as a decorative effect.
|
|
@@ -98,6 +98,24 @@
|
|
|
98
98
|
);
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
/* Lateral on the y axis — vertical tabs: the next peer pushes up from below. */
|
|
102
|
+
const steps = ['Shipping', 'Payment', 'Review'];
|
|
103
|
+
let step = $state(0);
|
|
104
|
+
const runLateralY = () => {
|
|
105
|
+
const next = (step + 1) % steps.length;
|
|
106
|
+
return lateral(
|
|
107
|
+
async () => {
|
|
108
|
+
step = next;
|
|
109
|
+
await tick();
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
target: '[data-demo="lateral-y"]',
|
|
113
|
+
axis: 'y',
|
|
114
|
+
direction: next > step ? 'forward' : 'backward'
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
101
119
|
/* Top level — unrelated destinations, as from a navigation bar. */
|
|
102
120
|
const destinations = [
|
|
103
121
|
{ label: 'Home', color: 'bg-md-sys-color-primary-container text-md-sys-color-primary' },
|
|
@@ -172,6 +190,14 @@
|
|
|
172
190
|
} satisfies Record<string, Pattern>;
|
|
173
191
|
|
|
174
192
|
const all = Object.values(patterns);
|
|
193
|
+
// A variant of lateral, not a seventh pattern: its own story, not in the overview grid.
|
|
194
|
+
const lateralY: Pattern = {
|
|
195
|
+
id: 'lateral-y',
|
|
196
|
+
title: 'Lateral, vertical',
|
|
197
|
+
summary: "Peers laid out top to bottom slide on the y axis: lateral with axis: 'y'.",
|
|
198
|
+
anchor: 'lateral',
|
|
199
|
+
run: runLateralY
|
|
200
|
+
};
|
|
175
201
|
|
|
176
202
|
/** Loops `run` while `looping` is on; toggling the loop restarts the interval. */
|
|
177
203
|
const loop =
|
|
@@ -258,6 +284,29 @@
|
|
|
258
284
|
aria-hidden="true">{tab + 1}</span
|
|
259
285
|
>
|
|
260
286
|
</div>
|
|
287
|
+
{:else if pattern.id === 'lateral-y'}
|
|
288
|
+
<div class="inset-spacing-150 gap-spacing-200 absolute flex">
|
|
289
|
+
<div class="gap-spacing-200 flex flex-col justify-center">
|
|
290
|
+
{#each steps as name, i (name)}
|
|
291
|
+
<span
|
|
292
|
+
class={[
|
|
293
|
+
'md-sys-typescale-label-large',
|
|
294
|
+
i === step ? 'text-md-sys-color-primary' : 'text-md-sys-color-on-surface-variant'
|
|
295
|
+
]}>{name}</span
|
|
296
|
+
>
|
|
297
|
+
{/each}
|
|
298
|
+
</div>
|
|
299
|
+
<div
|
|
300
|
+
data-demo="lateral-y"
|
|
301
|
+
class="bg-md-sys-color-surface-container relative flex flex-1 items-center justify-center rounded-lg"
|
|
302
|
+
>
|
|
303
|
+
{@render shape(pathFourLeafClover, 'size-24 text-md-sys-color-secondary')}
|
|
304
|
+
<span
|
|
305
|
+
class="md-sys-typescale-display-small text-md-sys-color-on-secondary absolute"
|
|
306
|
+
aria-hidden="true">{step + 1}</span
|
|
307
|
+
>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
261
310
|
{:else if pattern.id === 'top'}
|
|
262
311
|
<div
|
|
263
312
|
data-demo="top"
|
|
@@ -364,6 +413,7 @@
|
|
|
364
413
|
<Story name="Container transform" asChild>{@render single(patterns.container)}</Story>
|
|
365
414
|
<Story name="Forward and backward" asChild>{@render single(patterns.axis)}</Story>
|
|
366
415
|
<Story name="Lateral" asChild>{@render single(patterns.lateral)}</Story>
|
|
416
|
+
<Story name="Lateral vertical" asChild>{@render single(lateralY)}</Story>
|
|
367
417
|
<Story name="Top level" asChild>{@render single(patterns.top)}</Story>
|
|
368
418
|
<Story name="Enter and exit" asChild>{@render single(patterns.enterExit)}</Story>
|
|
369
419
|
<Story name="Skeleton loaders" asChild>{@render single(patterns.skeleton)}</Story>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { animateView } from 'motion';
|
|
2
|
+
import { prefersReducedMotion } from './reducedMotion.js';
|
|
2
3
|
import { springTokens, springTransition } from './spring.js';
|
|
3
4
|
/**
|
|
4
5
|
* M3 container transform: one container morphs its bounds, shape and color into another while
|
|
@@ -27,7 +28,12 @@ import { springTokens, springTransition } from './spring.js';
|
|
|
27
28
|
* );
|
|
28
29
|
* ```
|
|
29
30
|
*/
|
|
30
|
-
export const containerTransform = (update, { from, to, spring = springTokens.spatial }) =>
|
|
31
|
-
.add(from, to)
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
export const containerTransform = (update, { from, to, spring = springTokens.spatial }) => {
|
|
32
|
+
const builder = animateView(update, springTransition(spring)).add(from, to);
|
|
33
|
+
// Reduced motion: the container doesn't grow; its two states just crossfade in place.
|
|
34
|
+
if (prefersReducedMotion())
|
|
35
|
+
builder.layout({ duration: 0 });
|
|
36
|
+
return builder
|
|
37
|
+
.old({ opacity: [1, 0] }, springTransition(springTokens.fastEffects))
|
|
38
|
+
.new({ opacity: [0, 1] }, { ...springTransition(springTokens.effects), delay: 0.05 });
|
|
39
|
+
};
|
|
@@ -8,6 +8,15 @@ interface NavigationOptions {
|
|
|
8
8
|
direction?: NavigationDirection;
|
|
9
9
|
spring?: SpringToken;
|
|
10
10
|
}
|
|
11
|
+
export interface LateralOptions extends NavigationOptions {
|
|
12
|
+
/**
|
|
13
|
+
* The axis the peers are laid out on: `x` for a row (tabs, a carousel), `y` for a column
|
|
14
|
+
* (vertical tabs, a vertical carousel, a stepper laid out top to bottom). `forward` moves to
|
|
15
|
+
* the next peer: right or down.
|
|
16
|
+
* @default 'x'
|
|
17
|
+
*/
|
|
18
|
+
axis?: 'x' | 'y';
|
|
19
|
+
}
|
|
11
20
|
export interface SharedAxisOptions extends NavigationOptions {
|
|
12
21
|
/** `x` for horizontal steps, `y` for vertical, `z` for parent → child depth. */
|
|
13
22
|
axis?: 'x' | 'y' | 'z';
|
|
@@ -30,18 +39,21 @@ export interface SharedAxisOptions extends NavigationOptions {
|
|
|
30
39
|
export declare const sharedAxis: (update: () => void | Promise<void>, { target, axis, direction, spring }?: SharedAxisOptions) => import("motion-dom").ViewTransitionBuilder;
|
|
31
40
|
/**
|
|
32
41
|
* M3 lateral: peer screens at the same level (tabs, carousels) slide past each other edge to edge,
|
|
33
|
-
* without fading — the new screen pushes the old one out.
|
|
42
|
+
* without fading — the new screen pushes the old one out. Along the axis the peers are laid out
|
|
43
|
+
* on: `axis: 'y'` for a vertical set (vertical tabs, a vertical carousel, a top-to-bottom
|
|
44
|
+
* stepper), where the next peer pushes up from below.
|
|
34
45
|
* https://m3.material.io/styles/motion/transitions/transition-patterns#lateral
|
|
35
46
|
*
|
|
36
47
|
* Only for peers in one set (https://m3.material.io/styles/motion/transitions/applying-transitions). Never use it for hierarchical screens
|
|
37
48
|
* (use `sharedAxis`) or navbar/rail/drawer destinations (use `fadeThrough`, since the implied swipe
|
|
38
|
-
* conflicts with carousels and swipeable list items).
|
|
49
|
+
* conflicts with carousels and swipeable list items). A vertical nav list is still a drawer: a
|
|
50
|
+
* vertical slide there reads as the page jumping its scroll. Don't add a fade: it hides the peer
|
|
39
51
|
* relationship and makes the slide look like forward/backward.
|
|
40
52
|
*
|
|
41
53
|
* Built in: `TabHolder` (switching content panels) and `DateField`/`DateRangeField` (changing the
|
|
42
54
|
* visible month). Any new component that pages between peer views must use this too.
|
|
43
55
|
*/
|
|
44
|
-
export declare const lateral: (update: () => void | Promise<void>, { target, direction, spring }?:
|
|
56
|
+
export declare const lateral: (update: () => void | Promise<void>, { target, axis, direction, spring }?: LateralOptions) => import("motion-dom").ViewTransitionBuilder;
|
|
45
57
|
/**
|
|
46
58
|
* M3 top level (fade through): destinations with no spatial relationship, e.g. navigation bar
|
|
47
59
|
* items. The outgoing screen fades out, then the incoming one fades in while scaling up from 92%.
|
|
@@ -53,6 +65,11 @@ export declare const lateral: (update: () => void | Promise<void>, { target, dir
|
|
|
53
65
|
*
|
|
54
66
|
* Not built into `Navbar`/`Rail`: they don't own the content region that changes, so the app wraps
|
|
55
67
|
* its own route change in this.
|
|
68
|
+
*
|
|
69
|
+
* The `target` region swaps its box instantly instead of sliding or resizing to the new page's:
|
|
70
|
+
* that movement would be exactly the spatial connection this pattern avoids, and after a
|
|
71
|
+
* navigation that resets the scroll, the region would glide down by the distance scrolled.
|
|
72
|
+
* Its snapshots aren't cropped, and the old one stays where it was on screen (see `fadeInPlace`).
|
|
56
73
|
*/
|
|
57
74
|
export declare const fadeThrough: (update: () => void | Promise<void>, { target, spring }?: Omit<NavigationOptions, "direction">) => import("motion-dom").ViewTransitionBuilder;
|
|
58
75
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { animateView } from 'motion';
|
|
2
|
+
import { prefersReducedMotion } from './reducedMotion.js';
|
|
2
3
|
import { springTokens, springTransition } from './spring.js';
|
|
3
4
|
const SHARED_AXIS_OFFSET_PX = 30;
|
|
4
5
|
const view = (update, target, spring) => {
|
|
@@ -10,6 +11,46 @@ const view = (update, target, spring) => {
|
|
|
10
11
|
and hidden in the fastest part of the motion. Don't lengthen these. */
|
|
11
12
|
const fadeOut = springTransition(springTokens.fastEffects);
|
|
12
13
|
const fadeIn = { ...springTransition(springTokens.effects), delay: 0.05 };
|
|
14
|
+
/* For fades with nothing moving to hide the handover (fade through, reduced motion): the new
|
|
15
|
+
page waits until the old one is all but gone (fastEffects is under 0.5% at 120ms). Starting
|
|
16
|
+
earlier leaves a ghost of the old layout behind the new one, made brighter by the additive
|
|
17
|
+
blend the browser puts on a crossfade. M3's fade through starts the incoming screen about a
|
|
18
|
+
third of the way in, so this is on spec. */
|
|
19
|
+
const fadeInAfterOut = { ...springTransition(springTokens.effects), delay: 0.12 };
|
|
20
|
+
/*
|
|
21
|
+
A region that fades in place, for fade through and every reduced-motion fade:
|
|
22
|
+
- Its box swaps to the new page's at once: sliding or resizing it would be the spatial
|
|
23
|
+
connection these fades avoid.
|
|
24
|
+
- Neither snapshot is cropped. Motion crops a region whose aspect ratio changed, scaling both
|
|
25
|
+
snapshots to cover the new box, so a short page fading out over a tall one looked zoomed.
|
|
26
|
+
- The outgoing snapshot stays exactly where it was on screen. A navigation usually resets the
|
|
27
|
+
scroll, so the region's new box sits elsewhere than the old one (400px lower after scrolling
|
|
28
|
+
400px); drawn in the new box, the old page would jump to its top as it fades, and the two
|
|
29
|
+
layouts look like they collide. The offset is measured around `update` and written into the
|
|
30
|
+
old keyframes, which Motion reads only once the update has run.
|
|
31
|
+
*/
|
|
32
|
+
const fadeInPlace = (update, target, spring, incoming) => {
|
|
33
|
+
const outgoing = { opacity: [1, 0] };
|
|
34
|
+
const region = () => typeof target === 'string' ? document.querySelector(target) : (target ?? null);
|
|
35
|
+
const before = target ? region()?.getBoundingClientRect() : undefined;
|
|
36
|
+
const run = async () => {
|
|
37
|
+
await update();
|
|
38
|
+
const after = region()?.getBoundingClientRect();
|
|
39
|
+
if (!before || !after)
|
|
40
|
+
return;
|
|
41
|
+
const dx = before.left - after.left;
|
|
42
|
+
const dy = before.top - after.top;
|
|
43
|
+
if (dx || dy)
|
|
44
|
+
outgoing.transform = Array(2).fill(`translate(${dx}px, ${dy}px)`);
|
|
45
|
+
};
|
|
46
|
+
const builder = view(run, target, spring);
|
|
47
|
+
if (target)
|
|
48
|
+
builder.layout({ duration: 0 }).crop(false);
|
|
49
|
+
return builder.old(outgoing, fadeOut).new(...incoming);
|
|
50
|
+
};
|
|
51
|
+
/* Reduced motion: every navigation pattern becomes this fade, with no movement or scale. M3
|
|
52
|
+
swaps movement for a subtle fade rather than cutting. */
|
|
53
|
+
const reducedFade = (update, target, spring) => fadeInPlace(update, target, spring, [{ opacity: [0, 1] }, fadeInAfterOut]);
|
|
13
54
|
/**
|
|
14
55
|
* M3 forward and backward (shared axis): outgoing and incoming content travel together along one
|
|
15
56
|
* axis while fading through each other.
|
|
@@ -26,6 +67,8 @@ const fadeIn = { ...springTransition(springTokens.effects), delay: 0.05 };
|
|
|
26
67
|
* Not built into any component: hierarchy levels and wizard steps live in the consuming app.
|
|
27
68
|
*/
|
|
28
69
|
export const sharedAxis = (update, { target, axis = 'x', direction = 'forward', spring = springTokens.spatial } = {}) => {
|
|
70
|
+
if (prefersReducedMotion())
|
|
71
|
+
return reducedFade(update, target, spring);
|
|
29
72
|
const forward = direction === 'forward';
|
|
30
73
|
const [outgoing, incoming] = axis === 'z'
|
|
31
74
|
? [`scale(${forward ? 1.1 : 0.8})`, `scale(${forward ? 0.8 : 1.1})`]
|
|
@@ -40,22 +83,28 @@ export const sharedAxis = (update, { target, axis = 'x', direction = 'forward',
|
|
|
40
83
|
};
|
|
41
84
|
/**
|
|
42
85
|
* M3 lateral: peer screens at the same level (tabs, carousels) slide past each other edge to edge,
|
|
43
|
-
* without fading — the new screen pushes the old one out.
|
|
86
|
+
* without fading — the new screen pushes the old one out. Along the axis the peers are laid out
|
|
87
|
+
* on: `axis: 'y'` for a vertical set (vertical tabs, a vertical carousel, a top-to-bottom
|
|
88
|
+
* stepper), where the next peer pushes up from below.
|
|
44
89
|
* https://m3.material.io/styles/motion/transitions/transition-patterns#lateral
|
|
45
90
|
*
|
|
46
91
|
* Only for peers in one set (https://m3.material.io/styles/motion/transitions/applying-transitions). Never use it for hierarchical screens
|
|
47
92
|
* (use `sharedAxis`) or navbar/rail/drawer destinations (use `fadeThrough`, since the implied swipe
|
|
48
|
-
* conflicts with carousels and swipeable list items).
|
|
93
|
+
* conflicts with carousels and swipeable list items). A vertical nav list is still a drawer: a
|
|
94
|
+
* vertical slide there reads as the page jumping its scroll. Don't add a fade: it hides the peer
|
|
49
95
|
* relationship and makes the slide look like forward/backward.
|
|
50
96
|
*
|
|
51
97
|
* Built in: `TabHolder` (switching content panels) and `DateField`/`DateRangeField` (changing the
|
|
52
98
|
* visible month). Any new component that pages between peer views must use this too.
|
|
53
99
|
*/
|
|
54
|
-
export const lateral = (update, { target, direction = 'forward', spring = springTokens.spatial } = {}) => {
|
|
100
|
+
export const lateral = (update, { target, axis = 'x', direction = 'forward', spring = springTokens.spatial } = {}) => {
|
|
101
|
+
if (prefersReducedMotion())
|
|
102
|
+
return reducedFade(update, target, spring);
|
|
55
103
|
const sign = direction === 'forward' ? 1 : -1;
|
|
104
|
+
const move = axis === 'y' ? 'translateY' : 'translateX';
|
|
56
105
|
const builder = view(update, target, spring)
|
|
57
|
-
.old({ transform: [
|
|
58
|
-
.new({ transform: [
|
|
106
|
+
.old({ transform: [`${move}(0%)`, `${move}(${-sign * 100}%)`] })
|
|
107
|
+
.new({ transform: [`${move}(${sign * 100}%)`, `${move}(0%)`] });
|
|
59
108
|
return target ? builder.crop(true) : builder;
|
|
60
109
|
};
|
|
61
110
|
/**
|
|
@@ -69,7 +118,17 @@ export const lateral = (update, { target, direction = 'forward', spring = spring
|
|
|
69
118
|
*
|
|
70
119
|
* Not built into `Navbar`/`Rail`: they don't own the content region that changes, so the app wraps
|
|
71
120
|
* its own route change in this.
|
|
121
|
+
*
|
|
122
|
+
* The `target` region swaps its box instantly instead of sliding or resizing to the new page's:
|
|
123
|
+
* that movement would be exactly the spatial connection this pattern avoids, and after a
|
|
124
|
+
* navigation that resets the scroll, the region would glide down by the distance scrolled.
|
|
125
|
+
* Its snapshots aren't cropped, and the old one stays where it was on screen (see `fadeInPlace`).
|
|
72
126
|
*/
|
|
73
|
-
export const fadeThrough = (update, { target, spring = springTokens.spatial } = {}) =>
|
|
74
|
-
|
|
75
|
-
|
|
127
|
+
export const fadeThrough = (update, { target, spring = springTokens.spatial } = {}) => {
|
|
128
|
+
if (prefersReducedMotion())
|
|
129
|
+
return reducedFade(update, target, spring);
|
|
130
|
+
return fadeInPlace(update, target, spring, [
|
|
131
|
+
{ opacity: [0, 1], transform: ['scale(0.92)', 'scale(1)'] },
|
|
132
|
+
{ opacity: fadeInAfterOut }
|
|
133
|
+
]);
|
|
134
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
export type DragPoint = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
export type DragOptions = {
|
|
7
|
+
/** Stops new drags from starting. A drag already running finishes. */
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* How far (px) the pointer must move before a drag starts. Below it the press stays a plain
|
|
11
|
+
* press, so a click on a draggable button still clicks. `0` starts on pointerdown.
|
|
12
|
+
* @default 0
|
|
13
|
+
*/
|
|
14
|
+
threshold?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Touch only: how long (ms) a finger must rest before a drag starts. Moving sooner scrolls the
|
|
17
|
+
* page instead, so a row of draggable things doesn't trap swipes. `0` treats touch like a mouse.
|
|
18
|
+
* @default 0
|
|
19
|
+
*/
|
|
20
|
+
touchDelay?: number;
|
|
21
|
+
/** The drag started. */
|
|
22
|
+
onStart?: (event: PointerEvent) => void;
|
|
23
|
+
/** The pointer moved; `offset` is its total travel since the press. */
|
|
24
|
+
onMove?: (offset: DragPoint, event: PointerEvent) => void;
|
|
25
|
+
/**
|
|
26
|
+
* The drag ended. `velocity` (px/s) is the pointer's speed over its last 100ms, for a spring to
|
|
27
|
+
* carry on release; it's zero when the browser cancelled the pointer.
|
|
28
|
+
*/
|
|
29
|
+
onEnd?: (velocity: DragPoint) => void;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Pointer dragging for an element: capture, a start threshold, a long-press delay on touch, and
|
|
33
|
+
* the release velocity. It moves nothing itself: `onMove` gets the pointer's offset and the
|
|
34
|
+
* caller positions things, typically with `resist` at the edges and a Motion spring on release.
|
|
35
|
+
*
|
|
36
|
+
* Options are read through a getter at event time, so changing them never tears down a drag in
|
|
37
|
+
* progress:
|
|
38
|
+
*
|
|
39
|
+
* ```svelte
|
|
40
|
+
* <div {@attach drag(() => ({ threshold: 4, onMove: (o) => (offset = o) }))}>…</div>
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* After a drag the click that would follow the release is swallowed, so dragging a button
|
|
44
|
+
* doesn't also press it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function drag(getOptions: () => DragOptions): Attachment<HTMLElement>;
|
|
47
|
+
/**
|
|
48
|
+
* `value` clamped to `[min, max]` with rubber-banding instead of a wall: past an edge it keeps
|
|
49
|
+
* following the pointer, less and less. `size` is the scale of the resistance, usually the
|
|
50
|
+
* extent of the bounds on that axis.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resist(value: number, min: number, max: number, size: number): number;
|