spark-html-motion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # spark-html-motion
2
+
3
+ Declarative **enter / leave transitions** for
4
+ [spark-html](https://github.com/wilkinnovo/spark) — the Spark way: no compiler,
5
+ no virtual DOM, 0 dependencies (~0.5 KB). When an `<template if>` / `<template
6
+ each>` block adds or removes an element, it animates in/out. A leaving element
7
+ is held in the DOM until its exit animation finishes, then removed.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install spark-html-motion
13
+ ```
14
+
15
+ ## Use
16
+
17
+ Register once, **before `mount()`**, then opt elements in with a `transition`
18
+ attribute:
19
+
20
+ ```js
21
+ import { mount } from 'spark-html';
22
+ import { motion } from 'spark-html-motion';
23
+
24
+ motion();
25
+ mount(document.body);
26
+ ```
27
+
28
+ ```html
29
+ <template each="t in todos">
30
+ <li transition="slide">{t.text}</li>
31
+ </template>
32
+
33
+ <template if="open">
34
+ <div class="panel" transition="fade">…</div>
35
+ </template>
36
+ ```
37
+
38
+ - `transition="fade | slide | scale"` — or the directive form `transition:fade`.
39
+ - `transition-duration="300"` — milliseconds (per element).
40
+ - `transition-easing="ease-out"` — any CSS easing (per element).
41
+
42
+ The **initial render is not animated** by default (only later enters/leaves) —
43
+ pass `motion({ appear: true })` if you want the first paint to animate too.
44
+ `prefers-reduced-motion: reduce` is honored automatically (no animation).
45
+
46
+ ## Options & defaults
47
+
48
+ ```js
49
+ motion({
50
+ preset: 'fade', // default preset for a bare `transition` attribute
51
+ duration: 200, // ms
52
+ easing: 'ease',
53
+ appear: false, // animate the initial mount?
54
+ });
55
+ ```
56
+
57
+ ## Custom presets
58
+
59
+ `presets` is a plain object of `{ in: Keyframe[], out: Keyframe[] }` (standard
60
+ [Web Animations](https://developer.mozilla.org/docs/Web/API/Element/animate)
61
+ keyframes) — add your own:
62
+
63
+ ```js
64
+ import { presets, motion } from 'spark-html-motion';
65
+ presets.zoom = {
66
+ in: [{ transform: 'scale(0)' }, { transform: 'scale(1)' }],
67
+ out: [{ transform: 'scale(1)' }, { transform: 'scale(0)' }],
68
+ };
69
+ motion();
70
+ // <li transition="zoom">…</li>
71
+ ```
72
+
73
+ ## How it works
74
+
75
+ Spark core exposes a tiny `lifecycle({ enter, leave })` seam; this package
76
+ registers into it and drives the Web Animations API. Nothing animates unless you
77
+ call `motion()`, and elements without a `transition` attribute are added/removed
78
+ instantly — so the cost is strictly opt-in.
79
+
80
+ ## License
81
+
82
+ MIT © Wilkin Novo
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "spark-html-motion",
3
+ "version": "0.1.0",
4
+ "description": "Declarative enter/leave transitions for spark-html — transition=\"fade|slide|scale\" on if/each blocks, Web Animations API, 0 deps, no compiler.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "homepage": "https://wilkinnovo.github.io/spark",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "test": "node test/motion.js"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/wilkinnovo/spark.git",
24
+ "directory": "packages/spark-html-motion"
25
+ },
26
+ "dependencies": {
27
+ "spark-html": "^0.21.6"
28
+ },
29
+ "keywords": [
30
+ "spark-html",
31
+ "motion",
32
+ "transition",
33
+ "animation",
34
+ "enter-leave"
35
+ ],
36
+ "license": "MIT"
37
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export interface MotionOptions {
2
+ /** Default preset when an element uses a bare `transition` attribute. Default: "fade". */
3
+ preset?: "fade" | "slide" | "scale" | (string & {});
4
+ /** Default duration in milliseconds. Default: 200. */
5
+ duration?: number;
6
+ /** Default easing. Default: "ease". */
7
+ easing?: string;
8
+ /** Animate the initial mount too (off by default — only later enters animate). */
9
+ appear?: boolean;
10
+ }
11
+
12
+ export interface Preset {
13
+ in: Keyframe[];
14
+ out: Keyframe[];
15
+ }
16
+
17
+ /** Built-in keyframe presets, keyed by name. Mutate to add your own. */
18
+ export const presets: Record<string, Preset>;
19
+
20
+ /**
21
+ * Register enter/leave transitions for Spark if/each blocks. Call once before
22
+ * `mount()`. Opt elements in with `transition="fade|slide|scale"` (or the
23
+ * directive form `transition:fade`).
24
+ */
25
+ export function motion(options?: MotionOptions): void;
26
+ export default motion;
package/src/index.js ADDED
@@ -0,0 +1,135 @@
1
+ // spark-html-motion — declarative enter/leave transitions for Spark, the Spark
2
+ // way: no compiler, no virtual DOM. It plugs into the core's lifecycle seam
3
+ // (`lifecycle()` from spark-html) and animates nodes that if/each blocks add or
4
+ // remove. Animation is the Web Animations API (0 deps); a leaving node is held
5
+ // in the DOM until its exit animation finishes, then removed.
6
+ //
7
+ // import { mount } from 'spark-html';
8
+ // import { motion } from 'spark-html-motion';
9
+ // motion(); // register once, before mount()
10
+ // mount(document.body);
11
+ //
12
+ // <template each="t in todos">
13
+ // <li transition="slide">{t.text}</li> <!-- or transition:fade -->
14
+ // </template>
15
+ //
16
+ // Opt in per element with `transition="fade|slide|scale"` (or the directive
17
+ // form `transition:fade`). Tune with `transition-duration="300"` (ms) and
18
+ // `transition-easing="ease-out"`. Honors prefers-reduced-motion.
19
+
20
+ import { lifecycle } from 'spark-html';
21
+
22
+ export const presets = {
23
+ fade: {
24
+ in: [{ opacity: 0 }, { opacity: 1 }],
25
+ out: [{ opacity: 1 }, { opacity: 0 }],
26
+ },
27
+ slide: {
28
+ in: [
29
+ { opacity: 0, transform: 'translateY(8px)' },
30
+ { opacity: 1, transform: 'translateY(0)' },
31
+ ],
32
+ out: [
33
+ { opacity: 1, transform: 'translateY(0)' },
34
+ { opacity: 0, transform: 'translateY(8px)' },
35
+ ],
36
+ },
37
+ scale: {
38
+ in: [
39
+ { opacity: 0, transform: 'scale(.96)' },
40
+ { opacity: 1, transform: 'scale(1)' },
41
+ ],
42
+ out: [
43
+ { opacity: 1, transform: 'scale(1)' },
44
+ { opacity: 0, transform: 'scale(.96)' },
45
+ ],
46
+ },
47
+ };
48
+
49
+ const prefersReduced = () =>
50
+ typeof matchMedia === 'function' &&
51
+ matchMedia('(prefers-reduced-motion: reduce)').matches;
52
+
53
+ // Resolve an element's transition config from its attributes, or null to skip.
54
+ function configFor(node, defaults) {
55
+ if (!node || node.nodeType !== 1 || !node.getAttribute) return null;
56
+ let name = node.getAttribute('transition');
57
+ if (name == null && node.attributes) {
58
+ for (const a of node.attributes) {
59
+ if (a.name && a.name.indexOf('transition:') === 0) {
60
+ name = a.name.slice('transition:'.length);
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ if (name == null) return null; // not opted in
66
+ name = (name && name.trim()) || defaults.preset;
67
+ const keyframes = presets[name] || presets[defaults.preset];
68
+ const dAttr = node.getAttribute('transition-duration');
69
+ const d = dAttr != null && dAttr !== '' ? Number(dAttr) : NaN;
70
+ const duration = Number.isFinite(d) && d >= 0 ? d : defaults.duration;
71
+ const easing = node.getAttribute('transition-easing') || defaults.easing;
72
+ return { keyframes, duration, easing };
73
+ }
74
+
75
+ export function motion(options = {}) {
76
+ const defaults = {
77
+ preset: options.preset || 'fade',
78
+ duration: options.duration != null ? options.duration : 200,
79
+ easing: options.easing || 'ease',
80
+ };
81
+
82
+ // Don't animate the initial mount unless asked — only later enters. The first
83
+ // render runs synchronously inside mount(); flip `ready` on a later frame.
84
+ let ready = !!options.appear;
85
+ if (!ready && typeof requestAnimationFrame === 'function') {
86
+ requestAnimationFrame(() =>
87
+ requestAnimationFrame(() => {
88
+ ready = true;
89
+ }),
90
+ );
91
+ }
92
+
93
+ const canAnimate = (node) =>
94
+ typeof node.animate === 'function' && !prefersReduced();
95
+
96
+ lifecycle({
97
+ enter(node) {
98
+ if (!ready) return;
99
+ const cfg = configFor(node, defaults);
100
+ if (!cfg || !canAnimate(node)) return;
101
+ node.animate(cfg.keyframes.in, {
102
+ duration: cfg.duration,
103
+ easing: cfg.easing,
104
+ });
105
+ },
106
+ leave(node, remove) {
107
+ const cfg = configFor(node, defaults);
108
+ if (!cfg || !canAnimate(node)) {
109
+ remove();
110
+ return;
111
+ }
112
+ const anim = node.animate(cfg.keyframes.out, {
113
+ duration: cfg.duration,
114
+ easing: cfg.easing,
115
+ fill: 'forwards', // hold the faded-out frame until we detach
116
+ });
117
+ let done = false;
118
+ const finish = () => {
119
+ if (done) return;
120
+ done = true;
121
+ remove();
122
+ };
123
+ if (anim && anim.finished && typeof anim.finished.then === 'function') {
124
+ anim.finished.then(finish, finish);
125
+ } else if (anim && typeof anim.addEventListener === 'function') {
126
+ anim.addEventListener('finish', finish);
127
+ anim.addEventListener('cancel', finish);
128
+ } else {
129
+ finish();
130
+ }
131
+ },
132
+ });
133
+ }
134
+
135
+ export default motion;