@ozsarman/clarityjs 0.6.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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Clarity.js — Transition & Animation System
3
+ *
4
+ * Vue 3-compatible transition primitives built on Web Animations API + CSS classes.
5
+ *
6
+ * ─── CSS Class Transitions ───────────────────────────────────────────────────
7
+ *
8
+ * <Transition name="fade">
9
+ * <when cond={show}><div>Hello</div></when>
10
+ * </Transition>
11
+ *
12
+ * Applied classes (matching Vue's convention):
13
+ * Enter: fade-enter-from → fade-enter-active → (remove -from, keep -active) → fade-enter-to
14
+ * → done (remove all)
15
+ * Leave: fade-leave-from → fade-leave-active → fade-leave-to → done (remove element)
16
+ *
17
+ * ─── JS Hook Transitions ─────────────────────────────────────────────────────
18
+ *
19
+ * <Transition
20
+ * onBeforeEnter={el => el.style.opacity = 0}
21
+ * onEnter={(el, done) => el.animate([{opacity:0},{opacity:1}], 300).onfinish = done}
22
+ * onLeave={(el, done) => el.animate([{opacity:1},{opacity:0}], 300).onfinish = done}
23
+ * >
24
+ * ...
25
+ * </Transition>
26
+ *
27
+ * ─── TransitionGroup ─────────────────────────────────────────────────────────
28
+ *
29
+ * Wraps a list and animates items entering/leaving.
30
+ * Supports FLIP animations for reordering.
31
+ *
32
+ * <TransitionGroup name="list" tag="ul">
33
+ * <list items={todos}>
34
+ * <li key={item.id}>{ item.text }</li>
35
+ * </list>
36
+ * </TransitionGroup>
37
+ *
38
+ * Author: Claude (Anthropic) + Özdemir Sarman
39
+ */
40
+
41
+ // ─── Transition ───────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Wraps a single conditional child with enter/leave transitions.
45
+ *
46
+ * @param {object} options
47
+ * @param {string} [options.name='v'] — CSS class prefix
48
+ * @param {boolean} [options.appear=false] — animate on initial render
49
+ * @param {string} [options.enterFromClass] — override generated class
50
+ * @param {string} [options.enterActiveClass]
51
+ * @param {string} [options.enterToClass]
52
+ * @param {string} [options.leaveFromClass]
53
+ * @param {string} [options.leaveActiveClass]
54
+ * @param {string} [options.leaveToClass]
55
+ * @param {Function} [options.onBeforeEnter] — (el) => void
56
+ * @param {Function} [options.onEnter] — (el, done) => void
57
+ * @param {Function} [options.onAfterEnter] — (el) => void
58
+ * @param {Function} [options.onBeforeLeave] — (el) => void
59
+ * @param {Function} [options.onLeave] — (el, done) => void
60
+ * @param {Function} [options.onAfterLeave] — (el) => void
61
+ * @param {Function | Node} options.children — Child factory or node
62
+ * @returns {Element}
63
+ */
64
+ export function Transition({
65
+ name = 'v',
66
+ appear = false,
67
+ enterFromClass,
68
+ enterActiveClass,
69
+ enterToClass,
70
+ leaveFromClass,
71
+ leaveActiveClass,
72
+ leaveToClass,
73
+ onBeforeEnter,
74
+ onEnter,
75
+ onAfterEnter,
76
+ onEnterCancelled,
77
+ onBeforeLeave,
78
+ onLeave,
79
+ onAfterLeave,
80
+ onLeaveCancelled,
81
+ children,
82
+ } = {}) {
83
+ const classes = {
84
+ enterFrom: enterFromClass ?? `${name}-enter-from`,
85
+ enterActive: enterActiveClass ?? `${name}-enter-active`,
86
+ enterTo: enterToClass ?? `${name}-enter-to`,
87
+ leaveFrom: leaveFromClass ?? `${name}-leave-from`,
88
+ leaveActive: leaveActiveClass ?? `${name}-leave-active`,
89
+ leaveTo: leaveToClass ?? `${name}-leave-to`,
90
+ };
91
+
92
+ // Wrapper is invisible — we only need it as a mount point
93
+ const wrapper = document.createElement('div');
94
+ wrapper.setAttribute('data-clarity-transition', name);
95
+ wrapper.style.cssText = 'display:contents'; // transparent layout
96
+
97
+ // Render child
98
+ let currentEl = null;
99
+ const child = typeof children === 'function' ? children() : children;
100
+
101
+ if (child instanceof Node) {
102
+ currentEl = child;
103
+ wrapper.appendChild(child);
104
+ if (appear) {
105
+ _nextTick(() => _enter(currentEl, classes, { onBeforeEnter, onEnter, onAfterEnter }));
106
+ }
107
+ }
108
+
109
+ // Expose API so parent can trigger transitions
110
+ wrapper._transitionEnter = (el) => {
111
+ currentEl = el;
112
+ wrapper.innerHTML = '';
113
+ wrapper.appendChild(el);
114
+ _enter(el, classes, { onBeforeEnter, onEnter, onAfterEnter });
115
+ };
116
+
117
+ wrapper._transitionLeave = (el, callback) => {
118
+ _leave(el, classes, {
119
+ onBeforeLeave,
120
+ onLeave,
121
+ onAfterLeave,
122
+ onLeaveCancelled,
123
+ callback,
124
+ });
125
+ };
126
+
127
+ return wrapper;
128
+ }
129
+
130
+ // ─── TransitionGroup ──────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Animate a list of children entering, leaving, and moving (FLIP).
134
+ *
135
+ * @param {object} options
136
+ * @param {string} [options.name='v'] — CSS class prefix
137
+ * @param {string} [options.tag='div'] — Wrapper element tag
138
+ * @param {Function} options.children — Returns array of keyed nodes
139
+ * @param {boolean} [options.moveClass] — Class applied during FLIP move
140
+ * @returns {Element}
141
+ */
142
+ export function TransitionGroup({
143
+ name = 'v',
144
+ tag = 'div',
145
+ children,
146
+ moveClass,
147
+ } = {}) {
148
+ const classes = {
149
+ enterFrom: `${name}-enter-from`,
150
+ enterActive: `${name}-enter-active`,
151
+ enterTo: `${name}-enter-to`,
152
+ leaveFrom: `${name}-leave-from`,
153
+ leaveActive: `${name}-leave-active`,
154
+ leaveTo: `${name}-leave-to`,
155
+ move: moveClass ?? `${name}-move`,
156
+ };
157
+
158
+ const wrapper = document.createElement(tag);
159
+ wrapper.setAttribute('data-clarity-transition-group', name);
160
+
161
+ // Initial render (no animation)
162
+ const initial = typeof children === 'function' ? children() : (children ?? []);
163
+ const _nodeMap = new Map(); // key → node
164
+
165
+ const initNodes = Array.isArray(initial) ? initial.flat() : [initial];
166
+ for (const node of initNodes) {
167
+ if (node instanceof Node) {
168
+ const key = node.dataset?.key ?? node.__key ?? wrapper.children.length;
169
+ _nodeMap.set(key, node);
170
+ wrapper.appendChild(node);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Update the list — call with new nodes to animate insertions/removals.
176
+ * @param {Node[]} newNodes
177
+ */
178
+ wrapper.update = function(newNodes) {
179
+ const incoming = new Map();
180
+ for (const node of newNodes.flat()) {
181
+ if (!(node instanceof Node)) continue;
182
+ const key = node.dataset?.key ?? node.__key ?? String(Math.random());
183
+ incoming.set(key, node);
184
+ }
185
+
186
+ // ── FLIP: record old positions ───────────────────────────────────────────
187
+ const oldPositions = new Map();
188
+ for (const [key, node] of _nodeMap) {
189
+ if (node.isConnected) {
190
+ oldPositions.set(key, node.getBoundingClientRect());
191
+ }
192
+ }
193
+
194
+ // ── Remove nodes no longer in list ───────────────────────────────────────
195
+ for (const [key, node] of _nodeMap) {
196
+ if (!incoming.has(key)) {
197
+ _nodeMap.delete(key);
198
+ _leave(node, classes, {
199
+ callback: () => node.parentNode?.removeChild(node),
200
+ });
201
+ }
202
+ }
203
+
204
+ // ── Insert new nodes ─────────────────────────────────────────────────────
205
+ let ref = null;
206
+ for (const [key, node] of incoming) {
207
+ if (!_nodeMap.has(key)) {
208
+ _nodeMap.set(key, node);
209
+ wrapper.insertBefore(node, ref);
210
+ _enter(node, classes, {});
211
+ }
212
+ ref = node.nextSibling;
213
+ }
214
+
215
+ // ── Reorder existing nodes ────────────────────────────────────────────────
216
+ let insertRef = null;
217
+ for (const [key, node] of [...incoming].reverse()) {
218
+ if (_nodeMap.has(key)) {
219
+ if (node.nextSibling !== insertRef) {
220
+ wrapper.insertBefore(node, insertRef);
221
+ }
222
+ insertRef = node;
223
+ }
224
+ }
225
+
226
+ // ── FLIP: apply move transitions ─────────────────────────────────────────
227
+ _nextTick(() => {
228
+ for (const [key, node] of _nodeMap) {
229
+ const old = oldPositions.get(key);
230
+ if (!old || !node.isConnected) continue;
231
+ const now = node.getBoundingClientRect();
232
+ const dx = old.left - now.left;
233
+ const dy = old.top - now.top;
234
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
235
+
236
+ // Apply inverse transform, then animate to zero
237
+ node.style.transform = `translate(${dx}px, ${dy}px)`;
238
+ node.style.transition = 'none';
239
+ _nextTick(() => {
240
+ node.classList.add(classes.move);
241
+ node.style.transform = '';
242
+ node.style.transition = '';
243
+ _onTransitionEnd(node, () => {
244
+ node.classList.remove(classes.move);
245
+ });
246
+ });
247
+ }
248
+ });
249
+ };
250
+
251
+ return wrapper;
252
+ }
253
+
254
+ // ─── Internal animation helpers ───────────────────────────────────────────────
255
+
256
+ /**
257
+ * Run the enter transition sequence on an element.
258
+ */
259
+ function _enter(el, classes, hooks = {}) {
260
+ if (!el || !el.classList) return;
261
+
262
+ const { onBeforeEnter, onEnter, onAfterEnter } = hooks;
263
+
264
+ // 1. Add enter-from + enter-active before paint
265
+ el.classList.add(classes.enterFrom, classes.enterActive);
266
+ onBeforeEnter?.(el);
267
+
268
+ _nextTick(() => {
269
+ // 2. Remove enter-from, add enter-to (triggers CSS transition)
270
+ el.classList.remove(classes.enterFrom);
271
+ el.classList.add(classes.enterTo);
272
+
273
+ if (typeof onEnter === 'function') {
274
+ // JS hook mode — caller calls done() when animation is finished
275
+ const done = () => _finishEnter(el, classes, onAfterEnter);
276
+ onEnter(el, done);
277
+ } else {
278
+ // CSS mode — wait for transition/animation to finish
279
+ _onTransitionEnd(el, () => _finishEnter(el, classes, onAfterEnter));
280
+ }
281
+ });
282
+ }
283
+
284
+ function _finishEnter(el, classes, onAfterEnter) {
285
+ el.classList.remove(classes.enterActive, classes.enterTo);
286
+ onAfterEnter?.(el);
287
+ }
288
+
289
+ /**
290
+ * Run the leave transition sequence on an element.
291
+ */
292
+ function _leave(el, classes, hooks = {}) {
293
+ if (!el || !el.classList) {
294
+ hooks.callback?.();
295
+ return;
296
+ }
297
+
298
+ const { onBeforeLeave, onLeave, onAfterLeave, onLeaveCancelled, callback } = hooks;
299
+
300
+ el.classList.add(classes.leaveFrom, classes.leaveActive);
301
+ onBeforeLeave?.(el);
302
+
303
+ _nextTick(() => {
304
+ el.classList.remove(classes.leaveFrom);
305
+ el.classList.add(classes.leaveTo);
306
+
307
+ if (typeof onLeave === 'function') {
308
+ const done = () => _finishLeave(el, classes, onAfterLeave, callback);
309
+ onLeave(el, done);
310
+ } else {
311
+ _onTransitionEnd(el, () => _finishLeave(el, classes, onAfterLeave, callback));
312
+ }
313
+ });
314
+ }
315
+
316
+ function _finishLeave(el, classes, onAfterLeave, callback) {
317
+ el.classList.remove(classes.leaveActive, classes.leaveTo);
318
+ onAfterLeave?.(el);
319
+ callback?.();
320
+ }
321
+
322
+ /**
323
+ * Wait for the CSS transition or animation to complete on an element.
324
+ * Falls back to a 1-frame timeout if no transition is detected.
325
+ */
326
+ function _onTransitionEnd(el, callback) {
327
+ // Check if there is actually a transition/animation to wait for
328
+ const style = window.getComputedStyle(el);
329
+ const duration = parseFloat(style.transitionDuration ?? '0') ||
330
+ parseFloat(style.animationDuration ?? '0');
331
+
332
+ if (duration > 0) {
333
+ const handler = () => {
334
+ el.removeEventListener('transitionend', handler);
335
+ el.removeEventListener('animationend', handler);
336
+ callback();
337
+ };
338
+ el.addEventListener('transitionend', handler, { once: true });
339
+ el.addEventListener('animationend', handler, { once: true });
340
+ } else {
341
+ // No CSS transition — call immediately after paint
342
+ requestAnimationFrame(callback);
343
+ }
344
+ }
345
+
346
+ function _nextTick(fn) {
347
+ // Two rAFs ensure browser has painted before we apply the "to" class
348
+ requestAnimationFrame(() => requestAnimationFrame(fn));
349
+ }
350
+
351
+ // ─── useTransition ────────────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * Programmatic transition hook — use when you need to trigger enter/leave
355
+ * transitions imperatively (e.g. from a store action).
356
+ *
357
+ * @param {object} options — same as Transition props
358
+ * @returns {{ enter: (el) => void, leave: (el, callback?) => void, classes: object }}
359
+ */
360
+ export function useTransition(options = {}) {
361
+ const name = options.name ?? 'v';
362
+ const classes = {
363
+ enterFrom: options.enterFromClass ?? `${name}-enter-from`,
364
+ enterActive: options.enterActiveClass ?? `${name}-enter-active`,
365
+ enterTo: options.enterToClass ?? `${name}-enter-to`,
366
+ leaveFrom: options.leaveFromClass ?? `${name}-leave-from`,
367
+ leaveActive: options.leaveActiveClass ?? `${name}-leave-active`,
368
+ leaveTo: options.leaveToClass ?? `${name}-leave-to`,
369
+ };
370
+
371
+ return {
372
+ classes,
373
+ enter: (el) => _enter(el, classes, options),
374
+ leave: (el, callback) => _leave(el, classes, { ...options, callback }),
375
+ };
376
+ }
377
+
378
+ // ─── Built-in transition presets (CSS-in-JS) ─────────────────────────────────
379
+
380
+ /**
381
+ * Inject built-in transition preset styles into the document head.
382
+ * Called once during app init if you want the presets available globally.
383
+ *
384
+ * Available presets: 'fade', 'slide-up', 'slide-down', 'scale', 'slide-right', 'slide-left'
385
+ *
386
+ * @param {string[]} [presets] — which presets to inject (default: all)
387
+ */
388
+ export function injectTransitionStyles(presets) {
389
+ if (typeof document === 'undefined') return;
390
+
391
+ const CSS = _buildPresetCSS(presets);
392
+ const existing = document.querySelector('[data-clarity-transitions]');
393
+ if (existing) return; // already injected
394
+
395
+ const style = document.createElement('style');
396
+ style.setAttribute('data-clarity-transitions', '');
397
+ style.textContent = CSS;
398
+ document.head.appendChild(style);
399
+ }
400
+
401
+ function _buildPresetCSS(names) {
402
+ const all = {
403
+ fade: `
404
+ .v-enter-active, .v-leave-active { transition: opacity 0.25s ease; }
405
+ .v-enter-from, .v-leave-to { opacity: 0; }
406
+ .fade-enter-active, .fade-leave-active { transition: opacity 0.25s ease; }
407
+ .fade-enter-from, .fade-leave-to { opacity: 0; }
408
+ `,
409
+ 'slide-up': `
410
+ .slide-up-enter-active, .slide-up-leave-active { transition: opacity 0.3s ease, transform 0.3s ease; }
411
+ .slide-up-enter-from { opacity: 0; transform: translateY(12px); }
412
+ .slide-up-leave-to { opacity: 0; transform: translateY(-12px); }
413
+ `,
414
+ 'slide-down': `
415
+ .slide-down-enter-active, .slide-down-leave-active { transition: opacity 0.3s ease, transform 0.3s ease; }
416
+ .slide-down-enter-from { opacity: 0; transform: translateY(-12px); }
417
+ .slide-down-leave-to { opacity: 0; transform: translateY(12px); }
418
+ `,
419
+ 'slide-right': `
420
+ .slide-right-enter-active, .slide-right-leave-active { transition: opacity 0.3s ease, transform 0.3s ease; }
421
+ .slide-right-enter-from { opacity: 0; transform: translateX(-16px); }
422
+ .slide-right-leave-to { opacity: 0; transform: translateX(16px); }
423
+ `,
424
+ 'slide-left': `
425
+ .slide-left-enter-active, .slide-left-leave-active { transition: opacity 0.3s ease, transform 0.3s ease; }
426
+ .slide-left-enter-from { opacity: 0; transform: translateX(16px); }
427
+ .slide-left-leave-to { opacity: 0; transform: translateX(-16px); }
428
+ `,
429
+ scale: `
430
+ .scale-enter-active, .scale-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
431
+ .scale-enter-from { opacity: 0; transform: scale(0.95); }
432
+ .scale-leave-to { opacity: 0; transform: scale(0.95); }
433
+ `,
434
+ };
435
+
436
+ const keys = names ?? Object.keys(all);
437
+ return keys.map(k => all[k] ?? '').join('\n');
438
+ }