The ergonomic companion to `motionAttrs`: wrap any content to animate it IN and
OUT. `<Motion motion="scale-fade">…</Motion>` plays the preset's enter on mount
and its exit on removal (Svelte `out:` flips to `data-state='closed'` + retains
the node for the eidos-declared duration, like `<Cascade.Item>`). Rides the
existing state-presets — no new mechanism, no JS engine.
- morfo `motion.ts` (scope ['eidos']): one Provider part declaring `data-state`.
- eidos `motion/`: component (spreads `motionAttrs` + `out:` retention) + types +
index + README (incl. the `<Motion>` vs `motionAttrs` table + the content-domain
boundary).
- badge demo: a `<Motion>` show/hide example next to the prop showcase.
Caught + fixed a real collision: `data-motion` is already the reduced-motion prefs
attr (`[data-motion='reduce']`), so the wrapper emits NO part-presence marker (a
prefs `closest('[data-motion]')` would have matched it) — only the functional
`data-animation-style` + `data-state` from `motionAttrs`.
Verified at runtime: enter (data-state=open → `scale-in, fade-in`, no data-motion)
and exit (Hide → retained with data-state=closed → `scale-out, fade-out`). `check`
clean for the morfo + component + demo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
parent
0a6d112cde
commit
54268717b4
@ -0,0 +1,71 @@
|
||||
# Motion
|
||||
|
||||
A single-element **content** animator (RFC §D.12 — the content domain). Wrap any content you
|
||||
want to animate **in and out**; it rides the existing state-presets + reduced-motion — **no new
|
||||
mechanism, no JS engine**. Same `motion` selector as the overlays and `<Cascade>`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Motion } from '$uix/eidos/components/motion'
|
||||
let show = $state(true)
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<Motion motion="scale-fade">
|
||||
<div class="card">…</div>
|
||||
</Motion>
|
||||
{/if}
|
||||
```
|
||||
|
||||
`motion` is any registered preset (`fade` / `scale-fade` / `slide-fade` / …); `'none'` disables.
|
||||
The keyframe / duration / easing / reduced-motion all come from the preset.
|
||||
|
||||
## How it works
|
||||
|
||||
| Phase | Mechanism |
|
||||
| --- | --- |
|
||||
| **enter** (on mount) | `motionAttrs(motion)` puts `data-animation-style` + a presentation `data-state="open"` on the element → the EXISTING `[data-animation-style][data-state='open']` enter rule plays once. Flash-free (attrs at insertion, `backwards` fill). |
|
||||
| **exit** (on removal) | Svelte `out:` flips the node to `data-state='closed'` (the existing exit rule plays) and **retains** it for the eidos-declared duration (read via the ActiveDom `getComputedStyle`), then unmounts. |
|
||||
|
||||
The wrapper writes **no visual var** and runs **no JS animation** — it only sets the state attr
|
||||
and holds the node during exit. Eidos owns the keyframes.
|
||||
|
||||
## `<Motion>` vs `motionAttrs`
|
||||
|
||||
| | Use | Extra node | Exit |
|
||||
| --- | --- | --- | --- |
|
||||
| **`<Motion>`** | wrap content to animate in/out | yes (a real box — needed for transform/opacity) | ✓ (`out:` retention) |
|
||||
| **`motionAttrs(preset)`** | spread onto your OWN element | no | — (enter only; you manage exit) |
|
||||
|
||||
Reach for `motionAttrs` when an extra wrapper would disrupt layout (inline content) and you only
|
||||
need the entrance; reach for `<Motion>` when you want the full in/out lifecycle.
|
||||
|
||||
## Domain boundary
|
||||
|
||||
Motion is the **content** domain only — content that simply appears/disappears, with no semantic
|
||||
event and no state machine. Do **not** wrap:
|
||||
|
||||
- a **stateful** component (its own `data-state`: a Card's `selected`/`idle`, a checkbox's
|
||||
`checked`) — Motion's presentation `data-state` would clash; that component selects its preset
|
||||
via its own `motion` prop tied to its own state.
|
||||
- an **event** surface (an overlay's `emerge`, a `commit`) — the firma owns the motion; the prop
|
||||
there is an override, not a wrapper.
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `motion` | `MotionPresetName` | `'fade'` | The preset played on mount (enter) and removal (exit). `'none'` disables. |
|
||||
|
||||
Plus any `HTMLAttributes<HTMLDivElement>`.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **No recipe, no morfo events.** Motion is structural lifecycle + a preset selector; the morfo
|
||||
(`scope: ['eidos']`) declares only the `data-state` of its single part. It reuses the
|
||||
`motion` registry + the state-presets — it adds no keyframes of its own.
|
||||
- **Reuses `data-state`, not a bespoke trigger.** The content domain fires via a constant
|
||||
presentation `data-state="open"` (the `<Cascade.Item>` pattern), so the existing enter/exit +
|
||||
reduced-motion rules apply verbatim and the generator stays untouched (RFC §D.12).
|
||||
- **A real box, not `display: contents`.** The transform/opacity need a layout box. For
|
||||
zero-extra-node, use `motionAttrs` on your own element.
|
||||
@ -0,0 +1,16 @@
|
||||
// Motion — a single-element content animator (RFC §D.12, content domain).
|
||||
//
|
||||
// import { Motion } from '$uix/eidos/components/motion';
|
||||
//
|
||||
// {#if show}
|
||||
// <Motion motion="scale-fade">…</Motion>
|
||||
// {/if}
|
||||
//
|
||||
// Plays the preset's enter on mount + exit on removal (out: retention), riding the
|
||||
// existing state-presets. For NO extra wrapper node, spread `motionAttrs(preset)`
|
||||
// onto your own element (enter-only).
|
||||
import Motion from './motion.svelte';
|
||||
|
||||
export { Motion };
|
||||
export default Motion;
|
||||
export type { MotionProps } from './types';
|
||||
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Motion>` — a single-element CONTENT animator (RFC §D.12, content domain).
|
||||
*
|
||||
* Names a `motion` preset; plays its ENTER on mount (presentation `data-state="open"`
|
||||
* via `motionAttrs`, reusing the EXISTING state-preset) and its EXIT on removal (flips
|
||||
* to `data-state='closed'` so the existing exit preset plays, then retains the node via
|
||||
* Svelte `out:` for the eidos-declared duration before unmount). No new mechanism, no JS
|
||||
* engine — just the existing enter/exit rules + reduced-motion.
|
||||
*
|
||||
* {#if show}<Motion motion="scale-fade">…</Motion>{/if}
|
||||
*
|
||||
* Renders a real box (needed for the transform/opacity to apply). For NO extra node,
|
||||
* spread `motionAttrs(preset)` onto your own element instead (enter-only). For a
|
||||
* STATEFUL component or an EVENT firma, don't wrap — those own the trigger.
|
||||
*/
|
||||
import { ActiveEidos, motionAttrs } from '$uix/eidos';
|
||||
import type { MotionProps } from './types';
|
||||
|
||||
let { motion = 'fade', children, ...rest }: MotionProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
|
||||
// Lifecycle only (no visual var written): on removal flip to `data-state='closed'`
|
||||
// so the EXISTING exit preset plays, then hold the node for its eidos-declared
|
||||
// duration (read ActiveDom-compliant) before Svelte unmounts it.
|
||||
function leave(node: HTMLElement) {
|
||||
node.setAttribute('data-state', 'closed');
|
||||
const cs = eidos.dom.getWindow(node).getComputedStyle(node);
|
||||
const ms = (parseFloat(cs.animationDelay) + parseFloat(cs.animationDuration)) * 1000 || 200;
|
||||
return { duration: ms };
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
No part-presence marker: `data-motion` is taken by the reduced-motion prefs
|
||||
(`[data-motion='reduce']`) — emitting it here would collide (a prefs
|
||||
`closest('[data-motion]')` could match this wrapper). The functional attrs
|
||||
(`data-animation-style` + `data-state`) come from `motionAttrs`; that's all the
|
||||
enter/exit rules need.
|
||||
-->
|
||||
<div {...motionAttrs(motion)} out:leave {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@ -0,0 +1,22 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { MotionPresetName } from '$uix/eidos/lib/motion/registry';
|
||||
|
||||
/**
|
||||
* Props for `<Motion>` — a single-element CONTENT animator (RFC §D.12). Wrap any
|
||||
* content you want to animate in and out; it rides the existing state-presets
|
||||
* (no new mechanism, no JS engine), reusing the same `motion` selector as the
|
||||
* overlays and `<Cascade>`.
|
||||
*
|
||||
* {#if show}<Motion motion="scale-fade">…</Motion>{/if}
|
||||
*/
|
||||
export type MotionProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
||||
/**
|
||||
* The motion preset — any registered eidos preset (`fade` / `scale-fade` /
|
||||
* `slide-fade` / …). Plays its ENTER on mount and its EXIT on removal; the
|
||||
* keyframe / duration / reduced-motion all come from the preset. `'none'`
|
||||
* disables. @default 'fade'
|
||||
*/
|
||||
motion?: MotionPresetName;
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* Motion — a single-element CONTENT animator (RFC §D.12, content domain).
|
||||
*
|
||||
* Pure `--state` moment, NOT a sema event: the consumer names a `motion` preset,
|
||||
* the Provider plays its ENTER on mount (presentation `data-state="open"`) and its
|
||||
* EXIT on removal (the eidos wrapper flips to `closed` + retains via Svelte `out:`
|
||||
* for the preset's declared duration). Eidos paints it by reacting to the EXISTING
|
||||
* preset's `data-state` rule — no new mechanism, no JS engine. The `motion` preset
|
||||
* (`data-animation-style`) is an eidos-only visual selector; the morfo declares
|
||||
* only the structural `data-state`.
|
||||
*
|
||||
* For a STATEFUL component (its own `data-state` machine) or an EVENT firma, do
|
||||
* NOT wrap with Motion — those own the trigger. Motion is the content domain only.
|
||||
*/
|
||||
export const motionMorfo = {
|
||||
name: 'Motion',
|
||||
kebab: 'motion',
|
||||
scope: ['eidos'],
|
||||
events: [],
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
data: [
|
||||
{
|
||||
attr: 'data-state',
|
||||
values: ['open', 'closed'],
|
||||
value: v.stateRef('open')
|
||||
}
|
||||
],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
Loading…
Reference in new issue