@yewdiel21/motion-kit 0.16.0 → 0.18.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 +44 -0
- package/dist/auto/index.d.ts +23 -3
- package/dist/auto/index.js +43 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-RBEKR5UZ.js → chunk-AWDEKDEJ.js} +2 -2
- package/dist/{chunk-NFZIX3IA.js → chunk-EDMXWUW3.js} +2 -2
- package/dist/{chunk-SDQ776LG.js → chunk-Q7IW4FV3.js} +3941 -43
- package/dist/chunk-Q7IW4FV3.js.map +1 -0
- package/dist/css/components/badge-button.css +16 -0
- package/dist/css/components/bento-grid.css +13 -0
- package/dist/css/components/drilldown-menu.css +134 -0
- package/dist/css/components/equalizer-slider.css +26 -0
- package/dist/css/components/handwriting-text.css +15 -0
- package/dist/css/components/magnetize-button.css +18 -0
- package/dist/css/components/morphing-page-dots.css +13 -0
- package/dist/css/components/not-found.css +22 -0
- package/dist/css/components/notification-stack.css +42 -0
- package/dist/css/components/particle-button.css +14 -0
- package/dist/css/components/radio-group.css +29 -0
- package/dist/css/components/rating-scale.css +23 -0
- package/dist/css/components/scroll-preview-dock.css +18 -0
- package/dist/css/components/select.css +16 -0
- package/dist/css/components/signature-pad.css +131 -0
- package/dist/css/components/stepper.css +36 -0
- package/dist/css/components/sticky-header.css +10 -0
- package/dist/css/components/temperature-slider.css +29 -0
- package/dist/css/components/voice-message-bubble.css +67 -0
- package/dist/css/components/waveform-player.css +51 -0
- package/dist/css/components/wheel-picker.css +18 -0
- package/dist/css/tokens.css +4 -0
- package/dist/elements/index.d.ts +292 -4
- package/dist/elements/index.js +856 -4
- package/dist/elements/index.js.map +1 -1
- package/dist/{reveal-DRpNO4KP.d.ts → engine-t8d7brjn.d.ts} +534 -1
- package/dist/index.css +713 -0
- package/dist/index.d.ts +23 -23
- package/dist/index.js +43 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +151 -7
- package/dist/react/index.js +893 -3
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-DQwVXQYb.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
- package/dist/{store-Da3zSh8Z.d.ts → store-6hZNKiee.d.ts} +1 -1
- package/package.json +110 -110
- package/registry.json +244 -0
- package/src/auto/auto-init.ts +339 -293
- package/src/components/badge-button/README.md +28 -0
- package/src/components/badge-button/badge-button.css +15 -0
- package/src/components/badge-button/element.ts +16 -0
- package/src/components/badge-button/engine.ts +85 -0
- package/src/components/badge-button/react.tsx +31 -0
- package/src/components/bento-grid/README.md +26 -0
- package/src/components/bento-grid/bento-grid.css +12 -0
- package/src/components/bento-grid/element.ts +22 -0
- package/src/components/bento-grid/engine.ts +153 -0
- package/src/components/bento-grid/react.tsx +25 -0
- package/src/components/drilldown-menu/README.md +163 -0
- package/src/components/drilldown-menu/drilldown-menu.css +133 -0
- package/src/components/drilldown-menu/element.ts +59 -0
- package/src/components/drilldown-menu/engine.ts +378 -0
- package/src/components/drilldown-menu/react.tsx +76 -0
- package/src/components/equalizer-slider/README.md +43 -0
- package/src/components/equalizer-slider/element.ts +41 -0
- package/src/components/equalizer-slider/engine.ts +32 -0
- package/src/components/equalizer-slider/equalizer-slider.css +26 -0
- package/src/components/equalizer-slider/react.tsx +60 -0
- package/src/components/equalizer-slider/shared.ts +161 -0
- package/src/components/handwriting-text/README.md +29 -0
- package/src/components/handwriting-text/element.ts +14 -0
- package/src/components/handwriting-text/engine.ts +108 -0
- package/src/components/handwriting-text/glyphs.ts +71 -0
- package/src/components/handwriting-text/handwriting-text.css +14 -0
- package/src/components/handwriting-text/react.tsx +23 -0
- package/src/components/magnetize-button/README.md +26 -0
- package/src/components/magnetize-button/element.ts +13 -0
- package/src/components/magnetize-button/engine.ts +84 -0
- package/src/components/magnetize-button/magnetize-button.css +17 -0
- package/src/components/magnetize-button/react.tsx +22 -0
- package/src/components/morphing-page-dots/README.md +28 -0
- package/src/components/morphing-page-dots/element.ts +16 -0
- package/src/components/morphing-page-dots/engine.ts +89 -0
- package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
- package/src/components/morphing-page-dots/react.tsx +32 -0
- package/src/components/not-found/README.md +26 -0
- package/src/components/not-found/element.ts +15 -0
- package/src/components/not-found/engine.ts +57 -0
- package/src/components/not-found/not-found.css +21 -0
- package/src/components/not-found/react.tsx +23 -0
- package/src/components/notification-stack/README.md +24 -0
- package/src/components/notification-stack/element.ts +26 -0
- package/src/components/notification-stack/engine.ts +329 -0
- package/src/components/notification-stack/notification-stack.css +41 -0
- package/src/components/notification-stack/react.tsx +28 -0
- package/src/components/particle-button/README.md +26 -0
- package/src/components/particle-button/element.ts +13 -0
- package/src/components/particle-button/engine.ts +52 -0
- package/src/components/particle-button/particle-button.css +13 -0
- package/src/components/particle-button/react.tsx +22 -0
- package/src/components/radio-group/README.md +43 -0
- package/src/components/radio-group/element.ts +41 -0
- package/src/components/radio-group/engine.ts +11 -0
- package/src/components/radio-group/radio-group.css +28 -0
- package/src/components/radio-group/react.tsx +60 -0
- package/src/components/radio-group/shared.ts +118 -0
- package/src/components/rating-scale/README.md +43 -0
- package/src/components/rating-scale/element.ts +41 -0
- package/src/components/rating-scale/engine.ts +63 -0
- package/src/components/rating-scale/rating-scale.css +22 -0
- package/src/components/rating-scale/react.tsx +60 -0
- package/src/components/scroll-preview-dock/README.md +24 -0
- package/src/components/scroll-preview-dock/element.ts +20 -0
- package/src/components/scroll-preview-dock/engine.ts +228 -0
- package/src/components/scroll-preview-dock/react.tsx +26 -0
- package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
- package/src/components/select/README.md +23 -9
- package/src/components/select/select.css +16 -0
- package/src/components/signature-pad/README.md +97 -0
- package/src/components/signature-pad/element.ts +57 -0
- package/src/components/signature-pad/engine.ts +550 -0
- package/src/components/signature-pad/react.tsx +56 -0
- package/src/components/signature-pad/signature-pad.css +130 -0
- package/src/components/stepper/README.md +46 -0
- package/src/components/stepper/element.ts +42 -0
- package/src/components/stepper/engine.ts +103 -0
- package/src/components/stepper/react.tsx +60 -0
- package/src/components/stepper/stepper.css +35 -0
- package/src/components/sticky-header/README.md +30 -0
- package/src/components/sticky-header/element.ts +19 -0
- package/src/components/sticky-header/engine.ts +95 -0
- package/src/components/sticky-header/react.tsx +25 -0
- package/src/components/sticky-header/sticky-header.css +9 -0
- package/src/components/temperature-slider/README.md +42 -0
- package/src/components/temperature-slider/element.ts +53 -0
- package/src/components/temperature-slider/engine.ts +44 -0
- package/src/components/temperature-slider/react.tsx +66 -0
- package/src/components/temperature-slider/temperature-slider.css +29 -0
- package/src/components/voice-message-bubble/README.md +31 -0
- package/src/components/voice-message-bubble/element.ts +20 -0
- package/src/components/voice-message-bubble/engine.ts +79 -0
- package/src/components/voice-message-bubble/react.tsx +27 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +14 -0
- package/src/components/waveform-player/README.md +30 -0
- package/src/components/waveform-player/element.ts +21 -0
- package/src/components/waveform-player/engine.ts +344 -0
- package/src/components/waveform-player/react.tsx +27 -0
- package/src/components/waveform-player/waveform-player.css +50 -0
- package/src/components/wheel-picker/README.md +35 -0
- package/src/components/wheel-picker/element.ts +25 -0
- package/src/components/wheel-picker/engine.ts +239 -0
- package/src/components/wheel-picker/react.tsx +32 -0
- package/src/components/wheel-picker/wheel-picker.css +17 -0
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +318 -258
- package/src/index.ts +21 -0
- package/src/react/index.ts +192 -171
- package/dist/chunk-SDQ776LG.js.map +0 -1
- /package/dist/{chunk-RBEKR5UZ.js.map → chunk-AWDEKDEJ.js.map} +0 -0
- /package/dist/{chunk-NFZIX3IA.js.map → chunk-EDMXWUW3.js.map} +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Magnetize button
|
|
2
|
+
|
|
3
|
+
Partículas discretas reposan alrededor de un botón tonal. Hover o foco las atrae hacia el perímetro; al salir se separan y vuelven a reposar. Es un efecto de atracción de partículas, con el botón estable. Las transiciones usan `EASE.swatch` y se pueden interrumpir. No usa RAF ni un ticker propio en reposo.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createMagnetizeButton } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/magnetize-button';
|
|
8
|
+
const magnet = createMagnetizeButton(document.querySelector('#gather'), {
|
|
9
|
+
onAction: () => groupSelectedIdeas(),
|
|
10
|
+
});
|
|
11
|
+
// magnet.attract(); magnet.release(); magnet.destroy();
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`MagnetizeButtonOptions`: `label` (Reunir ideas, solo si falta contenido), `count` (14, limitado a 4–24), `reach` (42px, limitado a 16–90), `onAction`. `MagnetizeButtonInstance`: `attract()`, `release()`, `attracted`, `destroy()`. Emite `mk:action` en clic nativo, incluido teclado y tacto. El factory acepta un botón, un host con botón o crea uno si falta. Su `type` predeterminado es `button`.
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
<MagnetizeButton label="Reunir ideas" onAction={groupSelectedIdeas} instanceRef={magnetRef} />
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`MagnetizeButtonProps` añade atributos nativos e `instanceRef`. El motor posee los hijos; se usa `label` y se filtran `children`/`dangerouslySetInnerHTML` en ejecución.
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<mk-magnetize-button count="14" reach="42"><button type="button">Reunir ideas</button></mk-magnetize-button>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`defineMagnetizeButton()` registra `MkMagnetizeButtonElement`; atributos `label`, `count`, `reach`. Teclado muestra la misma atracción y un anillo de foco. Movimiento reducido o disabled oculta partículas; la acción sigue siendo nativa. Reserva hasta `reach` de aire alrededor del botón para ver el efecto. `destroy()` desconecta observers, media listeners, eventos y animaciones, retira partículas y restaura nodos y estilos originales.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createMagnetizeButton, type MagnetizeButtonInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkMagnetizeButtonElement extends MkElement<MagnetizeButtonInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = { label: 'string', count: 'number', reach: 'number' };
|
|
6
|
+
protected create(options: Record<string, unknown>) { return createMagnetizeButton(this, options); }
|
|
7
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
8
|
+
const focused = this.contains(document.activeElement);
|
|
9
|
+
super.attributeChangedCallback(name, previous, next);
|
|
10
|
+
if (focused) this.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export const defineMagnetizeButton = () => defineElement('mk-magnetize-button', MkMagnetizeButtonElement);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope } from '../../core/scope';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { press } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface MagnetizeButtonOptions {
|
|
8
|
+
label?: string;
|
|
9
|
+
/** Discrete particles, clamped to 4–24. No idle ticker. */
|
|
10
|
+
count?: number;
|
|
11
|
+
/** Distance outside the button where the particles rest, default 42px. */
|
|
12
|
+
reach?: number;
|
|
13
|
+
onAction?: () => void;
|
|
14
|
+
}
|
|
15
|
+
export interface MagnetizeButtonInstance extends Instance { attract(): void; release(): void; readonly attracted: boolean; }
|
|
16
|
+
|
|
17
|
+
export function createMagnetizeButton(root: HTMLElement | null, options: MagnetizeButtonOptions = {}): MagnetizeButtonInstance {
|
|
18
|
+
if (!root) return inert({ attract() {}, release() {}, attracted: false });
|
|
19
|
+
registerMotion();
|
|
20
|
+
const scope = createScope();
|
|
21
|
+
let button = root.matches('button') ? root as HTMLButtonElement : root.querySelector<HTMLButtonElement>('button');
|
|
22
|
+
if (!button) { button = document.createElement('button'); root.append(button); const made = button; scope.add(() => made.remove()); }
|
|
23
|
+
const control = button;
|
|
24
|
+
if (!control.textContent?.trim() && !control.querySelector('[aria-label], svg, img')) {
|
|
25
|
+
const label = document.createElement('span'); label.textContent = options.label ?? 'Reunir ideas'; control.append(label); scope.add(() => label.remove());
|
|
26
|
+
}
|
|
27
|
+
const originalStyle = control.getAttribute('style');
|
|
28
|
+
const originalTransform = control.style.transform;
|
|
29
|
+
scope.add(() => { if (originalStyle === null) control.removeAttribute('style'); else control.setAttribute('style', originalStyle); });
|
|
30
|
+
scope.attr(control, 'type', control.getAttribute('type') ?? 'button');
|
|
31
|
+
scope.attr(control, 'data-mk-magnetize-button', '');
|
|
32
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
33
|
+
const layer = document.createElement('span'); layer.dataset.mkMagnetParticles = ''; layer.setAttribute('aria-hidden', 'true'); control.append(layer); scope.add(() => layer.remove());
|
|
34
|
+
const count = Number.isFinite(options.count) ? Math.min(24, Math.max(4, Math.round(options.count!))) : 14;
|
|
35
|
+
const reach = Number.isFinite(options.reach) ? Math.min(90, Math.max(16, options.reach!)) : 42;
|
|
36
|
+
const dots: HTMLElement[] = [];
|
|
37
|
+
for (let i = 0; i < count; i++) { const dot = document.createElement('i'); layer.append(dot); dots.push(dot); }
|
|
38
|
+
let hovered = false;
|
|
39
|
+
let focused = false;
|
|
40
|
+
let attracted = false;
|
|
41
|
+
let tween: gsap.core.Timeline | null = null;
|
|
42
|
+
let pressing: gsap.core.Animation | null = null;
|
|
43
|
+
const disabled = () => control.disabled || control.getAttribute('aria-disabled') === 'true';
|
|
44
|
+
const positions = (near: boolean) => {
|
|
45
|
+
const w = control.offsetWidth || 160;
|
|
46
|
+
const h = control.offsetHeight || 48;
|
|
47
|
+
return dots.map((_, i) => {
|
|
48
|
+
const angle = i / count * Math.PI * 2 - Math.PI / 2;
|
|
49
|
+
// Intersect a ray with the rounded rectangle envelope; attraction lands at the perimeter.
|
|
50
|
+
const c = Math.cos(angle); const s = Math.sin(angle);
|
|
51
|
+
const boundary = Math.min((w / 2 + 4) / Math.max(.001, Math.abs(c)), (h / 2 + 4) / Math.max(.001, Math.abs(s)));
|
|
52
|
+
const radius = boundary + (near ? 0 : reach * (.72 + (i % 3) * .14));
|
|
53
|
+
return { x: c * radius, y: s * radius, opacity: near ? .88 : .25, scale: near ? 1 : .6 };
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const paint = (near: boolean, animate: boolean) => {
|
|
57
|
+
tween?.kill(); tween = null;
|
|
58
|
+
layer.hidden = reduced() || disabled();
|
|
59
|
+
if (layer.hidden) { attracted = false; scope.attr(control, 'data-mk-attracted', null); return; }
|
|
60
|
+
attracted = near;
|
|
61
|
+
scope.attr(control, 'data-mk-attracted', near ? '' : null);
|
|
62
|
+
const targets = positions(near);
|
|
63
|
+
if (!animate) { dots.forEach((dot, i) => gsap.set(dot, targets[i]!)); return; }
|
|
64
|
+
tween = gsap.timeline();
|
|
65
|
+
dots.forEach((dot, i) => tween!.to(dot, { ...targets[i]!, duration: near ? .62 : .48, ease: near ? EASE.swatch : EASE.signature }, i * .008));
|
|
66
|
+
};
|
|
67
|
+
const sync = () => paint(hovered || focused, true);
|
|
68
|
+
scope.on(control, 'pointerenter', () => { hovered = true; sync(); });
|
|
69
|
+
scope.on(control, 'pointerleave', () => { hovered = false; sync(); });
|
|
70
|
+
scope.on(control, 'focus', () => { focused = true; sync(); });
|
|
71
|
+
scope.on(control, 'blur', () => { focused = false; sync(); });
|
|
72
|
+
scope.on(control, 'click', (event) => {
|
|
73
|
+
if (disabled()) { event.preventDefault(); return; }
|
|
74
|
+
pressing?.kill(); pressing = press(control);
|
|
75
|
+
pressing?.eventCallback('onComplete', () => { gsap.set(control, { clearProps: 'transform,scale' }); control.style.transform = originalTransform; if (!control.getAttribute('style')) control.removeAttribute('style'); });
|
|
76
|
+
options.onAction?.(); root.dispatchEvent(new CustomEvent('mk:action', { bubbles: true }));
|
|
77
|
+
});
|
|
78
|
+
if (typeof ResizeObserver !== 'undefined') scope.observe(new ResizeObserver(() => paint(attracted, false))).observe(control);
|
|
79
|
+
if (typeof MutationObserver !== 'undefined') scope.observe(new MutationObserver(() => paint(hovered || focused, false))).observe(control, { attributes: true, attributeFilter: ['disabled', 'aria-disabled'] });
|
|
80
|
+
if (typeof matchMedia === 'function') { const media = matchMedia('(prefers-reduced-motion: reduce)'); scope.on(media, 'change', () => paint(hovered || focused, false)); }
|
|
81
|
+
paint(false, false);
|
|
82
|
+
scope.add(() => { tween?.kill(); pressing?.kill(); gsap.killTweensOf(dots); });
|
|
83
|
+
return { attract: () => { if (!scope.disposed) paint(true, true); }, release: () => { if (!scope.disposed) paint(false, true); }, get attracted() { return attracted; }, destroy: () => scope.dispose() };
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.mk-magnetize-button,
|
|
2
|
+
mk-magnetize-button { display: inline-flex; overflow: visible; }
|
|
3
|
+
[data-mk-magnetize-button] {
|
|
4
|
+
position: relative; display: inline-flex; align-items: center; justify-content: center; gap: .6rem; min-height: 48px;
|
|
5
|
+
padding: .8rem 1.5rem; border: 1px solid var(--mk-outline-soft); border-radius: 16px; background: var(--mk-surface-3);
|
|
6
|
+
color: var(--mk-text); font: 500 .9375rem var(--mk-font); cursor: pointer; overflow: visible;
|
|
7
|
+
transition: background 160ms, border-radius 350ms var(--mk-ease-swatch);
|
|
8
|
+
}
|
|
9
|
+
[data-mk-magnetize-button]:is(:hover, [data-mk-attracted]):not(:disabled) { border-radius: 24px; background: var(--mk-surface-4); }
|
|
10
|
+
[data-mk-magnetize-button]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 8px; }
|
|
11
|
+
[data-mk-magnetize-button]:disabled,
|
|
12
|
+
[data-mk-magnetize-button][aria-disabled='true'] { opacity: .4; cursor: not-allowed; }
|
|
13
|
+
[data-mk-magnet-particles] { position: absolute; inset: 0; pointer-events: none; }
|
|
14
|
+
[data-mk-magnet-particles][hidden] { display: none; }
|
|
15
|
+
[data-mk-magnet-particles] i { position: absolute; top: 50%; left: 50%; width: 4px; height: 4px; margin: -2px; border-radius: 999px; background: var(--mk-muted); }
|
|
16
|
+
[data-mk-magnet-particles] i:nth-child(4n) { width: 5px; height: 5px; background: var(--mk-accent); }
|
|
17
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-magnet-particles] { display: none; } [data-mk-magnetize-button] { transition: none; } }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type ButtonHTMLAttributes, type Ref } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createMagnetizeButton, type MagnetizeButtonInstance, type MagnetizeButtonOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface MagnetizeButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'dangerouslySetInnerHTML'>, MagnetizeButtonOptions { instanceRef?: Ref<MagnetizeButtonInstance>; }
|
|
7
|
+
export function MagnetizeButton({ label = 'Reunir ideas', count, reach, onAction, instanceRef, type = 'button', className, ...props }: MagnetizeButtonProps) {
|
|
8
|
+
const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as ButtonHTMLAttributes<HTMLButtonElement>;
|
|
9
|
+
const root = useRef<HTMLButtonElement>(null);
|
|
10
|
+
const instance = useRef<MagnetizeButtonInstance | null>(null);
|
|
11
|
+
const action = useLatest(onAction);
|
|
12
|
+
useIsomorphicLayoutEffect(() => {
|
|
13
|
+
const created = createMagnetizeButton(root.current, { label, count, reach, onAction: () => action.current?.() });
|
|
14
|
+
instance.current = created;
|
|
15
|
+
return () => { created.destroy(); instance.current = null; };
|
|
16
|
+
}, [label, count, reach]);
|
|
17
|
+
useIsomorphicLayoutEffect(() => {
|
|
18
|
+
if (typeof instanceRef === 'function') instanceRef(instance.current); else if (instanceRef) instanceRef.current = instance.current;
|
|
19
|
+
return () => { if (typeof instanceRef === 'function') instanceRef(null); else if (instanceRef) instanceRef.current = null; };
|
|
20
|
+
}, [instanceRef, label, count, reach]);
|
|
21
|
+
return <button {...safe} type={type} ref={root} className={['mk-magnetize-button', className].filter(Boolean).join(' ')} />;
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Morphing page dots
|
|
2
|
+
|
|
3
|
+
Indicador de páginas: una píldora se estira hacia el nuevo índice y su cola alcanza el destino con `EASE.swatch`. Los puntos siguen siendo botones nativos de 44px con foco visible y `aria-current="page"`. No inicia reproducción automática ni crea diapositivas.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createMorphingPageDots } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/morphing-page-dots';
|
|
8
|
+
const dots = createMorphingPageDots(document.querySelector('#pages'), {
|
|
9
|
+
count: 4, value: 0, pageIds: ['slide-a', 'slide-b', 'slide-c', 'slide-d'],
|
|
10
|
+
onChange: (index) => showSlide(index),
|
|
11
|
+
});
|
|
12
|
+
dots.setValue(2, true);
|
|
13
|
+
// dots.destroy();
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`MorphingPageDotsOptions`: `count` (5, entre 1–20), `value` (índice desde 0), `label` (Páginas), `labels` (nombres de cada página), `pageIds` (aria-controls opcional), `disabled`, `onChange(index)`. `MorphingPageDotsInstance`: `setValue(index, notify = false)`, `value`, `destroy()`. Emite `mk:change` con `{value}` al notificar. Flechas izquierda/derecha recorren y seleccionan con wrapping; Home/End van a los extremos. Respeta RTL. La app debe mantener las diapositivas y su contenido accesible sincronizados.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<MorphingPageDots count={slides.length} value={index} onChange={setIndex} />
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
React admite `value` controlado o `defaultValue`, `instanceRef` y atributos de div. El estado controlado sigue siendo la autoridad si se rechaza el cambio. El motor posee todos los hijos; `children` y `dangerouslySetInnerHTML` se filtran también en ejecución.
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<mk-morphing-page-dots count="4" value="0" labels='["Sierra","Lago","Pinar","Dunas"]'></mk-morphing-page-dots>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`defineMorphingPageDots()` registra `MkMorphingPageDotsElement`; atributos `count`, `value`, `label`, `labels` (JSON), `page-ids` (JSON), `disabled`. Conserva la selección al cambiar presentación o reconectar; un cambio del atributo `value` la reemplaza. Movimiento reducido coloca la píldora directamente. `destroy()` elimina observers, animaciones, eventos y devuelve los nodos/atributos originales.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createMorphingPageDots, type MorphingPageDotsInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkMorphingPageDotsElement extends MkElement<MorphingPageDotsInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = { count: 'number', value: 'number', label: 'string', labels: 'json', pageIds: 'json', disabled: 'boolean' };
|
|
6
|
+
private retained: number | undefined;
|
|
7
|
+
protected create(options: Record<string, unknown>) { return createMorphingPageDots(this, { ...options, ...(this.retained === undefined ? {} : { value: this.retained }) }); }
|
|
8
|
+
disconnectedCallback() { this.retained = this.instance?.value ?? this.retained; super.disconnectedCallback(); }
|
|
9
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
10
|
+
const focusIndex = [...this.querySelectorAll('button')].findIndex((button) => button === document.activeElement);
|
|
11
|
+
if (previous !== next) this.retained = name === 'value' ? undefined : this.instance?.value ?? this.retained;
|
|
12
|
+
super.attributeChangedCallback(name, previous, next);
|
|
13
|
+
if (focusIndex >= 0) { const buttons = this.querySelectorAll('button'); buttons[Math.min(focusIndex, buttons.length - 1)]?.focus({ preventScroll: true }); }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export const defineMorphingPageDots = () => defineElement('mk-morphing-page-dots', MkMorphingPageDotsElement);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope } from '../../core/scope';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { press } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface MorphingPageDotsOptions {
|
|
8
|
+
count?: number;
|
|
9
|
+
value?: number;
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Accessible page names; by default "Página 1 de 5". */
|
|
12
|
+
labels?: string[];
|
|
13
|
+
/** Optional ids of the real carousel slides controlled by each dot. */
|
|
14
|
+
pageIds?: string[];
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
onChange?: (index: number) => void;
|
|
17
|
+
}
|
|
18
|
+
export interface MorphingPageDotsInstance extends Instance {
|
|
19
|
+
setValue(index: number, notify?: boolean): void;
|
|
20
|
+
readonly value: number;
|
|
21
|
+
}
|
|
22
|
+
export function createMorphingPageDots(root: HTMLElement | null, options: MorphingPageDotsOptions = {}): MorphingPageDotsInstance {
|
|
23
|
+
if (!root) return inert({ setValue() {}, value: 0 });
|
|
24
|
+
registerMotion();
|
|
25
|
+
const scope = createScope();
|
|
26
|
+
const count = Number.isFinite(options.count) ? Math.min(20, Math.max(1, Math.round(options.count!))) : 5;
|
|
27
|
+
const normalize = (index: number) => Math.min(count - 1, Math.max(0, Math.round(index)));
|
|
28
|
+
let value = normalize(Number.isFinite(options.value) ? options.value! : 0);
|
|
29
|
+
const authored = [...root.childNodes];
|
|
30
|
+
const rail = document.createElement('span'); rail.dataset.mkPageRail = '';
|
|
31
|
+
const blob = document.createElement('i'); blob.dataset.mkPageBlob = ''; blob.setAttribute('aria-hidden', 'true'); rail.append(blob);
|
|
32
|
+
const buttons: HTMLButtonElement[] = [];
|
|
33
|
+
for (let i = 0; i < count; i++) {
|
|
34
|
+
const button = document.createElement('button'); button.type = 'button'; button.dataset.mkPageDot = '';
|
|
35
|
+
button.setAttribute('aria-label', options.labels?.[i] ?? `Página ${i + 1} de ${count}`);
|
|
36
|
+
if (options.pageIds?.[i]) button.setAttribute('aria-controls', options.pageIds[i]!);
|
|
37
|
+
button.disabled = Boolean(options.disabled);
|
|
38
|
+
const dot = document.createElement('i'); dot.setAttribute('aria-hidden', 'true'); button.append(dot); rail.append(button); buttons.push(button);
|
|
39
|
+
}
|
|
40
|
+
root.replaceChildren(rail);
|
|
41
|
+
scope.add(() => { rail.remove(); root.append(...authored); });
|
|
42
|
+
scope.attr(root, 'role', 'group');
|
|
43
|
+
scope.attr(root, 'aria-label', options.label ?? 'Páginas');
|
|
44
|
+
scope.attr(root, 'data-mk-morphing-page-dots', '');
|
|
45
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
46
|
+
let moving: gsap.core.Timeline | null = null;
|
|
47
|
+
let pressing: gsap.core.Animation | null = null;
|
|
48
|
+
const at = (index: number) => {
|
|
49
|
+
const button = buttons[index]!;
|
|
50
|
+
const size = button.offsetWidth || 44;
|
|
51
|
+
return (button.offsetLeft || index * size) + size / 2 - 12;
|
|
52
|
+
};
|
|
53
|
+
const place = () => { moving?.kill(); moving = null; gsap.set(blob, { left: at(value), width: 24, scaleY: 1 }); };
|
|
54
|
+
const paint = () => buttons.forEach((button, i) => { button.setAttribute('aria-current', i === value ? 'page' : 'false'); button.tabIndex = i === value ? 0 : -1; });
|
|
55
|
+
const setValue = (index: number, notify = false) => {
|
|
56
|
+
if (scope.disposed || !Number.isFinite(index)) return;
|
|
57
|
+
const next = normalize(index);
|
|
58
|
+
if (value === next) return;
|
|
59
|
+
const start = at(value); const end = at(next);
|
|
60
|
+
value = next;
|
|
61
|
+
paint();
|
|
62
|
+
moving?.kill();
|
|
63
|
+
if (reduced()) place();
|
|
64
|
+
else {
|
|
65
|
+
// Stretch the leading edge, then let the tail catch up on the shared swatch curve.
|
|
66
|
+
const current = Number(gsap.getProperty(blob, 'left')) || start;
|
|
67
|
+
moving = gsap.timeline()
|
|
68
|
+
.to(blob, { left: Math.min(current, end), width: Math.abs(end - current) + 24, scaleY: .68, duration: .18, ease: EASE.wipe })
|
|
69
|
+
.to(blob, { left: end, width: 24, scaleY: 1, duration: .48, ease: EASE.swatch }, .13);
|
|
70
|
+
}
|
|
71
|
+
if (notify) { options.onChange?.(value); root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value } })); }
|
|
72
|
+
};
|
|
73
|
+
buttons.forEach((button, i) => {
|
|
74
|
+
scope.on(button, 'click', () => { if (button.disabled) return; pressing?.kill(); pressing = press(button); setValue(i, true); });
|
|
75
|
+
scope.on(button, 'keydown', (event) => {
|
|
76
|
+
if (button.disabled) return;
|
|
77
|
+
const key = event.key;
|
|
78
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key)) return;
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
const rtl = getComputedStyle(root).direction === 'rtl';
|
|
81
|
+
let next = key === 'Home' ? 0 : key === 'End' ? count - 1 : (value + ((key === 'ArrowRight') !== rtl ? 1 : -1) + count) % count;
|
|
82
|
+
setValue(next, true); buttons[next]!.focus();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
if (typeof ResizeObserver !== 'undefined') scope.observe(new ResizeObserver(place)).observe(rail);
|
|
86
|
+
paint(); place();
|
|
87
|
+
scope.add(() => { moving?.kill(); pressing?.kill(); });
|
|
88
|
+
return { setValue, get value() { return value; }, destroy: () => scope.dispose() };
|
|
89
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.mk-morphing-page-dots,
|
|
2
|
+
mk-morphing-page-dots { display: inline-flex; max-width: 100%; color: var(--mk-text); font-family: var(--mk-font); }
|
|
3
|
+
[data-mk-page-rail] { position: relative; display: inline-flex; max-width: 100%; border-radius: 999px; background: var(--mk-surface-2); padding: 0; overflow-x: auto; }
|
|
4
|
+
[data-mk-page-dot] { position: relative; flex: 0 0 44px; width: 44px; height: 44px; display: grid; place-items: center; padding: 0; background: transparent; border: 0; border-radius: 999px; cursor: pointer; }
|
|
5
|
+
[data-mk-page-dot] > i { width: 8px; height: 8px; border-radius: 999px; background: var(--mk-muted); opacity: .45; transition: opacity 150ms; }
|
|
6
|
+
[data-mk-page-dot]:hover > i { opacity: .85; }
|
|
7
|
+
[data-mk-page-dot][aria-current='page'] > i { opacity: 0; }
|
|
8
|
+
[data-mk-page-dot]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: -3px; }
|
|
9
|
+
[data-mk-page-dot]:disabled { cursor: not-allowed; opacity: .4; }
|
|
10
|
+
[data-mk-page-blob] { position: absolute; top: 18px; left: 10px; width: 24px; height: 8px; background: var(--mk-text); border-radius: 999px; pointer-events: none; }
|
|
11
|
+
[data-mk-morphing-page-dots]:has(button:disabled) [data-mk-page-blob] { opacity: .4; }
|
|
12
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-page-dot] > i { transition: none; } }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type Ref } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createMorphingPageDots, type MorphingPageDotsInstance, type MorphingPageDotsOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface MorphingPageDotsProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'dangerouslySetInnerHTML' | 'onChange'>, MorphingPageDotsOptions {
|
|
7
|
+
defaultValue?: number;
|
|
8
|
+
instanceRef?: Ref<MorphingPageDotsInstance>;
|
|
9
|
+
}
|
|
10
|
+
export function MorphingPageDots({ count, value, defaultValue = 0, label, labels, pageIds, disabled, onChange, instanceRef, className, ...props }: MorphingPageDotsProps) {
|
|
11
|
+
const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as HTMLAttributes<HTMLDivElement>;
|
|
12
|
+
const root = useRef<HTMLDivElement>(null);
|
|
13
|
+
const instance = useRef<MorphingPageDotsInstance | null>(null);
|
|
14
|
+
const current = useLatest({ value, onChange });
|
|
15
|
+
const retained = useRef(defaultValue);
|
|
16
|
+
const labelsKey = JSON.stringify(labels); const pagesKey = JSON.stringify(pageIds);
|
|
17
|
+
useIsomorphicLayoutEffect(() => {
|
|
18
|
+
let created: MorphingPageDotsInstance;
|
|
19
|
+
created = createMorphingPageDots(root.current, {
|
|
20
|
+
count, value: current.current.value ?? retained.current, label, labels, pageIds, disabled,
|
|
21
|
+
onChange: (next) => { current.current.onChange?.(next); if (current.current.value !== undefined) created.setValue(current.current.value); },
|
|
22
|
+
});
|
|
23
|
+
instance.current = created;
|
|
24
|
+
return () => { retained.current = created.value; created.destroy(); instance.current = null; };
|
|
25
|
+
}, [count, label, labelsKey, pagesKey, disabled]);
|
|
26
|
+
useIsomorphicLayoutEffect(() => { if (value !== undefined) instance.current?.setValue(value); }, [value]);
|
|
27
|
+
useIsomorphicLayoutEffect(() => {
|
|
28
|
+
if (typeof instanceRef === 'function') instanceRef(instance.current); else if (instanceRef) instanceRef.current = instance.current;
|
|
29
|
+
return () => { if (typeof instanceRef === 'function') instanceRef(null); else if (instanceRef) instanceRef.current = null; };
|
|
30
|
+
}, [instanceRef, count, label, labelsKey, pagesKey, disabled]);
|
|
31
|
+
return <div {...safe} ref={root} className={['mk-morphing-page-dots', className].filter(Boolean).join(' ')} />;
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Not found
|
|
2
|
+
|
|
3
|
+
Estado 404 con ilustración vectorial original: dos cuatros y un pequeño portal en lugar del cero. Su entrada combina blur escalonado y un asentamiento `swatch`; permanece quieto después. Usa superficies del tema y reserva el acento para la brújula.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createNotFound } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/not-found';
|
|
8
|
+
const missing = createNotFound(document.querySelector('#missing'), {
|
|
9
|
+
brand: 'Mi biblioteca', homeHref: '/library',
|
|
10
|
+
});
|
|
11
|
+
// missing.replay(); missing.destroy();
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`NotFoundOptions`: `brand` (Motion Kit), `title`, `description`, `homeHref` (/), `homeLabel`, `backLabel`, `showBack` (true), `onBack`. El enlace Home navega a su href real. Back llama `history.back()`; se desactiva cuando no hay entrada previa. `onBack` permite que un router posea la acción. `NotFoundInstance`: `replay()`, `destroy()`. Todo el contenido configurable se inserta como texto, no como HTML.
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
<NotFound brand="Mi biblioteca" homeHref="/library" instanceRef={missingRef} />
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`NotFoundProps` añade atributos de div e `instanceRef`; el motor posee todos los hijos, por lo que filtra `children` y `dangerouslySetInnerHTML` en ejecución. Cambiar copy o destinos actualiza la instancia. Movimiento reducido conserva toda la navegación y muestra el estado completo inmediatamente.
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<mk-not-found brand="Mi biblioteca" home-href="/library" title="No encontramos esa lectura."></mk-not-found>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`defineNotFound()` registra `MkNotFoundElement`; atributos `brand`, `title`, `description`, `home-href`, `home-label`, `back-label`, `show-back`. El contenido usa heading h2 para poder incrustarlo en un layout; la página anfitriona determina su h1 y su código HTTP. `destroy()` detiene la entrada y devuelve los nodos y atributos originales.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createNotFound, type NotFoundInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkNotFoundElement extends MkElement<NotFoundInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = { brand: 'string', title: 'string', description: 'string', homeHref: 'string', homeLabel: 'string', backLabel: 'string', showBack: 'boolean' };
|
|
6
|
+
protected create(options: Record<string, unknown>) { return createNotFound(this, options); }
|
|
7
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
8
|
+
const active = document.activeElement;
|
|
9
|
+
const focused = this.contains(active);
|
|
10
|
+
const back = active?.tagName === 'BUTTON';
|
|
11
|
+
super.attributeChangedCallback(name, previous, next);
|
|
12
|
+
if (focused) (this.querySelector<HTMLElement>(back ? 'button:not(:disabled)' : 'a') ?? this.querySelector<HTMLElement>('a'))?.focus({ preventScroll: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const defineNotFound = () => defineElement('mk-not-found', MkNotFoundElement);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope } from '../../core/scope';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { blurIn, press } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface NotFoundOptions {
|
|
8
|
+
brand?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
homeHref?: string;
|
|
12
|
+
homeLabel?: string;
|
|
13
|
+
backLabel?: string;
|
|
14
|
+
showBack?: boolean;
|
|
15
|
+
/** A router may own Back. Without this callback it calls history.back(). */
|
|
16
|
+
onBack?: () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface NotFoundInstance extends Instance { replay(): void; }
|
|
19
|
+
const ILLUSTRATION = '<svg viewBox="0 0 320 140" fill="none" aria-hidden="true"><path data-mk-lost-track d="M20 115H100Q118 115 118 95V72Q118 53 138 53H185Q205 53 205 73V95Q205 115 224 115H300"/><g data-mk-lost-four><path d="M72 23L35 79H84M77 23V107"/><path d="M263 23L226 79H275M268 23V107"/></g><g data-mk-lost-portal><rect x="124" y="18" width="72" height="96" rx="32"/><ellipse cx="160" cy="66" rx="17" ry="29"/><path d="M145 66h30M160 51v30"/></g><path data-mk-lost-star d="M211 13v12m-6-6h12M105 118v8m-4-4h8"/></svg>';
|
|
20
|
+
export function createNotFound(root: HTMLElement | null, options: NotFoundOptions = {}): NotFoundInstance {
|
|
21
|
+
if (!root) return inert({ replay() {} });
|
|
22
|
+
registerMotion();
|
|
23
|
+
const scope = createScope();
|
|
24
|
+
const authored = [...root.childNodes];
|
|
25
|
+
const content = document.createElement('div'); content.dataset.mkNotFoundContent = '';
|
|
26
|
+
const art = document.createElement('div'); art.dataset.mkNotFoundArt = ''; art.innerHTML = ILLUSTRATION;
|
|
27
|
+
const eyebrow = document.createElement('p'); eyebrow.dataset.mkNotFoundCode = ''; eyebrow.textContent = `404 · ${options.brand ?? 'Motion Kit'}`;
|
|
28
|
+
const heading = document.createElement('h2'); heading.textContent = options.title ?? 'Este camino se perdió.';
|
|
29
|
+
const description = document.createElement('p'); description.dataset.mkNotFoundDescription = ''; description.textContent = options.description ?? 'La página que buscas no está aquí. Volvamos a un lugar conocido.';
|
|
30
|
+
const actions = document.createElement('div'); actions.dataset.mkNotFoundActions = '';
|
|
31
|
+
const home = document.createElement('a'); home.href = options.homeHref ?? '/'; home.textContent = options.homeLabel ?? 'Volver al inicio';
|
|
32
|
+
actions.append(home);
|
|
33
|
+
const back = document.createElement('button'); back.type = 'button'; back.textContent = options.backLabel ?? 'Ir atrás';
|
|
34
|
+
back.disabled = !options.onBack && history.length <= 1;
|
|
35
|
+
if (options.showBack !== false) actions.append(back);
|
|
36
|
+
content.append(art, eyebrow, heading, description, actions);
|
|
37
|
+
root.replaceChildren(content);
|
|
38
|
+
scope.add(() => { content.remove(); root.append(...authored); });
|
|
39
|
+
scope.attr(root, 'data-mk-not-found', '');
|
|
40
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
41
|
+
let entering: gsap.core.Animation | null = null;
|
|
42
|
+
let drawing: gsap.core.Animation | null = null;
|
|
43
|
+
let pressing: gsap.core.Animation | null = null;
|
|
44
|
+
const portal = art.querySelector('[data-mk-lost-portal]');
|
|
45
|
+
const replay = () => {
|
|
46
|
+
if (scope.disposed) return;
|
|
47
|
+
entering?.kill(); drawing?.kill();
|
|
48
|
+
if (reduced()) { gsap.set([art, eyebrow, heading, description, actions], { clearProps: 'all' }); return; }
|
|
49
|
+
entering = blurIn([art, eyebrow, heading, description, actions], { from: 'soft', duration: .5, stagger: .055 });
|
|
50
|
+
drawing = gsap.fromTo(portal, { rotation: -12, scale: .82, transformOrigin: '50% 50%' }, { rotation: 0, scale: 1, duration: .8, ease: EASE.swatch });
|
|
51
|
+
};
|
|
52
|
+
scope.on(back, 'click', () => { if (!back.disabled) { pressing?.kill(); pressing = press(back); if (options.onBack) options.onBack(); else history.back(); } });
|
|
53
|
+
scope.on(home, 'click', () => { pressing?.kill(); pressing = press(home); });
|
|
54
|
+
replay();
|
|
55
|
+
scope.add(() => { entering?.kill(); drawing?.kill(); pressing?.kill(); });
|
|
56
|
+
return { replay, destroy: () => scope.dispose() };
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
.mk-not-found,
|
|
2
|
+
mk-not-found { display: block; max-width: 34rem; color: var(--mk-text); font-family: var(--mk-font); }
|
|
3
|
+
[data-mk-not-found-content] { display: grid; justify-items: center; text-align: center; gap: .75rem; padding: .75rem 1rem; }
|
|
4
|
+
[data-mk-not-found-art] { width: min(100%, 320px); }
|
|
5
|
+
[data-mk-not-found-art] svg { display: block; width: 100%; overflow: visible; }
|
|
6
|
+
[data-mk-lost-track] { stroke: var(--mk-outline); stroke-width: 1.5; stroke-dasharray: 3 5; }
|
|
7
|
+
[data-mk-lost-four] { stroke: var(--mk-text); stroke-width: 9; stroke-linecap: round; stroke-linejoin: round; }
|
|
8
|
+
[data-mk-lost-portal] rect { fill: var(--mk-surface-3); stroke: var(--mk-outline-soft); }
|
|
9
|
+
[data-mk-lost-portal] ellipse { fill: var(--mk-surface-1); stroke: var(--mk-outline); stroke-width: 1.5; }
|
|
10
|
+
[data-mk-lost-portal] path,
|
|
11
|
+
[data-mk-lost-star] { stroke: var(--mk-accent); stroke-width: 2; stroke-linecap: round; }
|
|
12
|
+
[data-mk-not-found-code] { margin: 0; color: var(--mk-muted); font-size: .6875rem; letter-spacing: .1em; text-transform: uppercase; }
|
|
13
|
+
[data-mk-not-found-content] h2 { margin: 0; font-size: clamp(1.5rem, 4vw, 2rem); font-weight: 550; line-height: 1.15; letter-spacing: -.045em; }
|
|
14
|
+
[data-mk-not-found-description] { max-width: 34ch; margin: 0; color: var(--mk-muted); font-size: .875rem; line-height: 1.55; }
|
|
15
|
+
[data-mk-not-found-actions] { display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem; margin-top: .5rem; }
|
|
16
|
+
[data-mk-not-found-actions] :is(a, button) { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: .7rem 1rem; border: 0; border-radius: 16px; background: var(--mk-surface-3); color: var(--mk-text); font: 500 .875rem var(--mk-font); text-decoration: none; cursor: pointer; transition: background 160ms, border-radius 320ms var(--mk-ease-swatch); }
|
|
17
|
+
[data-mk-not-found-actions] a { background: var(--mk-text); color: var(--mk-bg); }
|
|
18
|
+
[data-mk-not-found-actions] :is(a, button):hover { border-radius: 24px; }
|
|
19
|
+
[data-mk-not-found-actions] :is(a, button):focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
|
|
20
|
+
[data-mk-not-found-actions] button:disabled { opacity: .4; cursor: not-allowed; }
|
|
21
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-not-found-actions] :is(a, button) { transition: none; } }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type Ref } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createNotFound, type NotFoundInstance, type NotFoundOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface NotFoundProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'dangerouslySetInnerHTML' | 'title'>, NotFoundOptions { instanceRef?: Ref<NotFoundInstance>; }
|
|
7
|
+
export function NotFound({ brand, title, description, homeHref, homeLabel, backLabel, showBack, onBack, instanceRef, className, ...props }: NotFoundProps) {
|
|
8
|
+
const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as HTMLAttributes<HTMLDivElement>;
|
|
9
|
+
const root = useRef<HTMLDivElement>(null);
|
|
10
|
+
const instance = useRef<NotFoundInstance | null>(null);
|
|
11
|
+
const current = useLatest(onBack);
|
|
12
|
+
const hasBack = Boolean(onBack);
|
|
13
|
+
useIsomorphicLayoutEffect(() => {
|
|
14
|
+
const created = createNotFound(root.current, { brand, title, description, homeHref, homeLabel, backLabel, showBack, onBack: hasBack ? () => current.current?.() : undefined });
|
|
15
|
+
instance.current = created;
|
|
16
|
+
return () => { created.destroy(); instance.current = null; };
|
|
17
|
+
}, [brand, title, description, homeHref, homeLabel, backLabel, showBack, hasBack]);
|
|
18
|
+
useIsomorphicLayoutEffect(() => {
|
|
19
|
+
if (typeof instanceRef === 'function') instanceRef(instance.current); else if (instanceRef) instanceRef.current = instance.current;
|
|
20
|
+
return () => { if (typeof instanceRef === 'function') instanceRef(null); else if (instanceRef) instanceRef.current = null; };
|
|
21
|
+
}, [instanceRef, brand, title, description, homeHref, homeLabel, backLabel, showBack, hasBack]);
|
|
22
|
+
return <div {...safe} ref={root} className={['mk-not-found', className].filter(Boolean).join(' ')} />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Notification stack
|
|
2
|
+
|
|
3
|
+
A local notification deck with overlapping tonal cards, explicit expand/collapse, actions, dismissal and pausable durations. It is independent of the global `toast` store, so several decks can coexist.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createNotificationStack } from '@yewdiel21/motion-kit';
|
|
7
|
+
const stack = createNotificationStack(document.querySelector('#notifications'), {
|
|
8
|
+
items: [{ id: 'saved', title: 'Cambios guardados', tone: 'success', duration: 0 }],
|
|
9
|
+
});
|
|
10
|
+
stack.push({ id: 'review', title: 'Versión lista', duration: 6000, action: { label: 'Revisar', onClick: openReview } });
|
|
11
|
+
stack.expand();
|
|
12
|
+
// On unmount:
|
|
13
|
+
stack.destroy();
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Exports: `createNotificationStack`, `NotificationStackItem`, `NotificationStackOptions`, `NotificationStackInstance`; React `NotificationStack`, `NotificationStackProps`; elements `MkNotificationStackElement`, `defineNotificationStack` (`mk-notification-stack`). CSS: `notification-stack.css` plus the kit tokens/theme/fonts.
|
|
17
|
+
|
|
18
|
+
Options: `items`, `expanded`, `direction` (`down` default, `up` or `side`), `duration` (milliseconds, default 6000, zero persists), `disabled`, `label`, `labels` (`expand`, `collapse`, `dismiss`, `empty`), `onDismiss(id)`, `onAction(id)`, `onExpandedChange(expanded)`. Items have `id`, `title`, optional `description`, `tone`, `duration`, `dismissible`, `action: {label,onClick?}`. IDs are unique; pushing an existing ID is ignored. `items` lists oldest first. `setOptions({items})` replaces data while preserving the remaining lifetime of matching IDs.
|
|
19
|
+
|
|
20
|
+
`direction: 'up'` rests the cards on the deck's floor and grows above the front card (anchor its container to the bottom, for example with `flex-direction: column-reverse`); `'side'` peeks at the right edge and opens into a strip that scrolls sideways. A click on a closed deck opens it, and hovering spreads its peeks to invite that click; the toggle remains the keyboard path. Opening fans the cards out one by one as their blur clears.
|
|
21
|
+
|
|
22
|
+
Instance: `push(item)`, `dismiss(id)`, `clear()`, `expand()`, `collapse()`, `toggle()`, `setOptions(partial)`, readonly `items`/`expanded`, `destroy()`. Events: `mk:dismiss` `{id}` and `mk:change` `{expanded}`. Hover, focus, document visibility and disabled state pause timers. Covered cards are inert until expanded. Dismissal moves focus to a surviving control or the region. Reduced motion applies the same state immediately.
|
|
23
|
+
|
|
24
|
+
React owns an empty host; pass `items`, never children or HTML. `instanceRef` exposes the engine for `push`. An element accepts JSON `items`/`labels` and the other scalar options as attributes; `push`, `items` and `setOptions` also work as properties/methods. Attribute updates retain the live engine.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createNotificationStack, type NotificationStackInstance, type NotificationStackOptions, type NotificationStackItem } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkNotificationStackElement extends MkElement<NotificationStackInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = {"items":"json","expanded":"boolean","direction":"string","duration":"number","disabled":"boolean","label":"string","labels":"json"};
|
|
6
|
+
protected create(options: Record<string, unknown>) { return createNotificationStack(this, options); }
|
|
7
|
+
setOptions(options: Partial<NotificationStackOptions>) { this.instance?.setOptions(options); }
|
|
8
|
+
|
|
9
|
+
push(item: NotificationStackItem) { this.instance?.push(item); }
|
|
10
|
+
dismiss(id: string) { this.instance?.dismiss(id); }
|
|
11
|
+
clear() { this.instance?.clear(); }
|
|
12
|
+
expand() { this.instance?.expand(); }
|
|
13
|
+
collapse() { this.instance?.collapse(); }
|
|
14
|
+
toggle() { this.instance?.toggle(); }
|
|
15
|
+
get expanded() { return this.instance?.expanded ?? false; }
|
|
16
|
+
set expanded(value: boolean) { this.instance?.setOptions({ expanded: value }); }
|
|
17
|
+
get items() { return [...(this.instance?.items ?? [])]; }
|
|
18
|
+
set items(value: NotificationStackItem[]) { this.instance?.setOptions({ items: value }); }
|
|
19
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
20
|
+
if (previous === next || !this.isConnected || !this.instance) return;
|
|
21
|
+
const option = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
22
|
+
const defaults: Record<string, unknown> = {"items":[],"expanded":false,"direction":"down","duration":6000,"disabled":false,"label":"Notificaciones","labels":{}};
|
|
23
|
+
this.instance.setOptions({ [option]: this.options[option] ?? defaults[option] });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const defineNotificationStack = () => defineElement('mk-notification-stack', MkNotificationStackElement);
|