@yewdiel21/motion-kit 0.16.0 → 0.17.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.
Files changed (60) hide show
  1. package/README.md +21 -0
  2. package/dist/auto/index.d.ts +8 -3
  3. package/dist/auto/index.js +13 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-SDQ776LG.js → chunk-7IQTMF7Y.js} +1414 -38
  6. package/dist/chunk-7IQTMF7Y.js.map +1 -0
  7. package/dist/{chunk-NFZIX3IA.js → chunk-BZLMCHUD.js} +2 -2
  8. package/dist/{chunk-RBEKR5UZ.js → chunk-VJFPTLAK.js} +2 -2
  9. package/dist/css/components/drilldown-menu.css +130 -0
  10. package/dist/css/components/signature-pad.css +104 -0
  11. package/dist/css/components/voice-message-bubble.css +50 -0
  12. package/dist/css/components/waveform-player.css +36 -0
  13. package/dist/css/components/wheel-picker.css +17 -0
  14. package/dist/elements/index.d.ts +90 -4
  15. package/dist/elements/index.js +281 -4
  16. package/dist/elements/index.js.map +1 -1
  17. package/dist/{reveal-DRpNO4KP.d.ts → engine-Bn6hC87X.d.ts} +194 -1
  18. package/dist/index.css +305 -0
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +13 -3
  21. package/dist/index.js.map +1 -1
  22. package/dist/react/index.d.ts +45 -7
  23. package/dist/react/index.js +227 -3
  24. package/dist/react/index.js.map +1 -1
  25. package/dist/{reduced-DQwVXQYb.d.ts → reduced-YS6TZjpa.d.ts} +1 -1
  26. package/dist/{store-Da3zSh8Z.d.ts → store-CZ6vdtIE.d.ts} +1 -1
  27. package/package.json +110 -110
  28. package/registry.json +64 -0
  29. package/src/auto/auto-init.ts +308 -293
  30. package/src/components/drilldown-menu/README.md +163 -0
  31. package/src/components/drilldown-menu/drilldown-menu.css +129 -0
  32. package/src/components/drilldown-menu/element.ts +59 -0
  33. package/src/components/drilldown-menu/engine.ts +338 -0
  34. package/src/components/drilldown-menu/react.tsx +76 -0
  35. package/src/components/signature-pad/README.md +97 -0
  36. package/src/components/signature-pad/element.ts +57 -0
  37. package/src/components/signature-pad/engine.ts +437 -0
  38. package/src/components/signature-pad/react.tsx +56 -0
  39. package/src/components/signature-pad/signature-pad.css +103 -0
  40. package/src/components/voice-message-bubble/README.md +31 -0
  41. package/src/components/voice-message-bubble/element.ts +20 -0
  42. package/src/components/voice-message-bubble/engine.ts +68 -0
  43. package/src/components/voice-message-bubble/react.tsx +27 -0
  44. package/src/components/voice-message-bubble/voice-message-bubble.css +12 -0
  45. package/src/components/waveform-player/README.md +30 -0
  46. package/src/components/waveform-player/element.ts +21 -0
  47. package/src/components/waveform-player/engine.ts +261 -0
  48. package/src/components/waveform-player/react.tsx +27 -0
  49. package/src/components/waveform-player/waveform-player.css +35 -0
  50. package/src/components/wheel-picker/README.md +33 -0
  51. package/src/components/wheel-picker/element.ts +18 -0
  52. package/src/components/wheel-picker/engine.ts +210 -0
  53. package/src/components/wheel-picker/react.tsx +32 -0
  54. package/src/components/wheel-picker/wheel-picker.css +16 -0
  55. package/src/elements/index.ts +273 -258
  56. package/src/index.ts +295 -290
  57. package/src/react/index.ts +176 -171
  58. package/dist/chunk-SDQ776LG.js.map +0 -1
  59. /package/dist/{chunk-NFZIX3IA.js.map → chunk-BZLMCHUD.js.map} +0 -0
  60. /package/dist/{chunk-RBEKR5UZ.js.map → chunk-VJFPTLAK.js.map} +0 -0
@@ -0,0 +1,68 @@
1
+ import { createScope } from '../../core/scope';
2
+ import type { WaveformPlayerInstance, WaveformPlayerOptions } from '../waveform-player/engine';
3
+ import { createWaveformPlayer } from '../waveform-player/engine';
4
+
5
+ export type VoiceMessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
6
+ export interface VoiceMessageBubbleOptions extends WaveformPlayerOptions {
7
+ direction?: 'incoming' | 'outgoing';
8
+ author?: string;
9
+ timestamp?: string;
10
+ /** Optional machine-readable timestamp for the time element. */
11
+ dateTime?: string;
12
+ status?: VoiceMessageStatus;
13
+ }
14
+ export interface VoiceMessageBubbleInstance extends WaveformPlayerInstance {
15
+ update(options: Partial<VoiceMessageBubbleOptions>): void;
16
+ }
17
+
18
+ const statusLabels: Record<VoiceMessageStatus, string> = { sending: 'Enviando', sent: 'Enviado', delivered: 'Entregado', read: 'Leído', failed: 'No enviado' };
19
+
20
+ /** A message presentation around the real waveform audio player. Never records audio. */
21
+ export function createVoiceMessageBubble(root: HTMLElement | null, options: VoiceMessageBubbleOptions = {}): VoiceMessageBubbleInstance {
22
+ if (!root) return createWaveformPlayer(null, options);
23
+ const scope = createScope();
24
+ let opts = { ...options };
25
+ const content = document.createElement('div');
26
+ content.setAttribute('data-mk-voice-message-content', '');
27
+ const header = document.createElement('div');
28
+ header.setAttribute('data-mk-voice-message-header', '');
29
+ const author = document.createElement('span');
30
+ author.setAttribute('data-mk-voice-message-author', '');
31
+ const caption = document.createElement('span');
32
+ caption.setAttribute('data-mk-voice-message-caption', '');
33
+ caption.textContent = 'Mensaje de voz';
34
+ header.append(author, caption);
35
+ const playerRoot = document.createElement('div');
36
+ playerRoot.className = 'mk-waveform-player';
37
+ const footer = document.createElement('div');
38
+ footer.setAttribute('data-mk-voice-message-footer', '');
39
+ const timestamp = document.createElement('time');
40
+ const status = document.createElement('span');
41
+ status.setAttribute('data-mk-voice-message-status', '');
42
+ footer.append(timestamp, status);
43
+ content.append(header, playerRoot, footer);
44
+ root.append(content);
45
+ scope.add(() => content.remove());
46
+ scope.attr(root, 'data-mk-ready', '');
47
+ scope.attr(root, 'data-mk-direction', 'incoming');
48
+ const player = createWaveformPlayer(playerRoot);
49
+ scope.add(() => player.destroy());
50
+ const update = (next: Partial<VoiceMessageBubbleOptions>) => {
51
+ if (scope.disposed) return;
52
+ opts = { ...opts, ...next };
53
+ author.textContent = opts.author ?? '';
54
+ author.hidden = !opts.author;
55
+ timestamp.textContent = opts.timestamp ?? '';
56
+ timestamp.hidden = !opts.timestamp;
57
+ if (opts.dateTime) timestamp.dateTime = opts.dateTime;
58
+ else timestamp.removeAttribute('datetime');
59
+ status.textContent = opts.status && statusLabels[opts.status] || '';
60
+ status.hidden = !status.textContent;
61
+ root.setAttribute('data-mk-direction', opts.direction === 'outgoing' ? 'outgoing' : 'incoming');
62
+ const { direction: _direction, author: _author, timestamp: _timestamp, dateTime: _dateTime, status: _status, ...audio } = opts;
63
+ player.update({ ...audio, label: opts.label ?? (opts.author ? `mensaje de voz de ${opts.author}` : 'mensaje de voz') });
64
+ };
65
+ update(options);
66
+ return { play: () => player.play(), pause: () => player.pause(), seek: n => player.seek(n), update,
67
+ get state() { return player.state; }, get audio() { return player.audio; }, destroy: () => scope.dispose() };
68
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect } from '../../react/use-recipe';
4
+ import { createVoiceMessageBubble, type VoiceMessageBubbleInstance, type VoiceMessageBubbleOptions } from './engine';
5
+
6
+ export interface VoiceMessageBubbleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onPlay' | 'onPause' | 'onEnded' | 'onError' | 'onTimeUpdate' | 'children' | 'dangerouslySetInnerHTML'>, VoiceMessageBubbleOptions {
7
+ instanceRef?: MutableRefObject<VoiceMessageBubbleInstance | null>;
8
+ }
9
+ export function VoiceMessageBubble({ src, peaks, label, disabled, playbackRates, onStateChange, onTimeUpdate, onPlay, onPause, onEnded, onError, direction, author, timestamp, dateTime, status, instanceRef, className, ...props }: VoiceMessageBubbleProps) {
10
+ // React must never reconcile descendants owned by the composed player engine.
11
+ const { children: _children, dangerouslySetInnerHTML: _html, ...rootProps } = props as HTMLAttributes<HTMLDivElement>;
12
+ const root = useRef<HTMLDivElement>(null);
13
+ const instance = useRef<VoiceMessageBubbleInstance | null>(null);
14
+ useIsomorphicLayoutEffect(() => {
15
+ const created = createVoiceMessageBubble(root.current);
16
+ instance.current = created;
17
+ return () => { created.destroy(); instance.current = null; };
18
+ }, []);
19
+ useIsomorphicLayoutEffect(() => {
20
+ instance.current?.update({ src, peaks, label, disabled, playbackRates, onStateChange, onTimeUpdate, onPlay, onPause, onEnded, onError, direction, author, timestamp, dateTime, status });
21
+ });
22
+ useIsomorphicLayoutEffect(() => {
23
+ if (instanceRef) instanceRef.current = instance.current;
24
+ return () => { if (instanceRef) instanceRef.current = null; };
25
+ }, [instanceRef]);
26
+ return <div {...rootProps} ref={root} className={['mk-voice-message-bubble', className].filter(Boolean).join(' ')} />;
27
+ }
@@ -0,0 +1,12 @@
1
+ /* Requires waveform-player.css; the bubble composes its player without duplicating it. */
2
+ .mk-voice-message-bubble, mk-voice-message-bubble { display: block; width: min(100%, 25rem); color: var(--mk-text); font-family: var(--mk-font); }
3
+ [data-mk-voice-message-content] { display: grid; gap: .75rem; padding: 1rem 1.125rem .75rem; background: var(--mk-surface-2); border: 1px solid var(--mk-outline-soft); border-radius: 1.5rem 1.5rem 1.5rem .375rem; }
4
+ :is(.mk-voice-message-bubble, mk-voice-message-bubble)[data-mk-direction='outgoing'] { margin-inline-start: auto; }
5
+ :is(.mk-voice-message-bubble, mk-voice-message-bubble)[data-mk-direction='outgoing'] [data-mk-voice-message-content] { background: color-mix(in srgb, var(--mk-accent) 10%, var(--mk-surface-1)); border-radius: 1.5rem 1.5rem .375rem 1.5rem; }
6
+ [data-mk-voice-message-header], [data-mk-voice-message-footer] { display: flex; flex-wrap: wrap; align-items: center; gap: .375rem .75rem; font-size: .6875rem; color: var(--mk-muted); }
7
+ [data-mk-voice-message-author] { font-weight: 600; color: var(--mk-text); }
8
+ [data-mk-voice-message-caption] { margin-inline-start: auto; }
9
+ [data-mk-voice-message-footer] { justify-content: flex-end; }
10
+ [data-mk-voice-message-status] { color: var(--mk-accent); }
11
+ [data-mk-voice-message-content] .mk-waveform-player { --mk-wave-track: color-mix(in srgb, var(--mk-text) 38%, transparent); }
12
+ @media (forced-colors: active) { [data-mk-voice-message-content] { border-color: CanvasText; } }
@@ -0,0 +1,30 @@
1
+ # Waveform Player
2
+
3
+ Reproductor real de `HTMLAudioElement` con onda de barras redondeadas, botón reproducir/pausar, posición nativa accesible, tiempo actual/duración y velocidad opcional. No inicia solo ni solicita el micrófono.
4
+
5
+ ```tsx
6
+ import { WaveformPlayer } from '@yewdiel21/motion-kit/react';
7
+ import '@yewdiel21/motion-kit/css/components/waveform-player';
8
+
9
+ <WaveformPlayer src="/audio/mensaje.wav" peaks={[.2, .5, .9, .4]} label="mensaje de Ana" />
10
+ ```
11
+
12
+ ```html
13
+ <mk-waveform-player src="/audio/mensaje.wav" peaks="[0.2,0.5,0.9,0.4]" label="mensaje de Ana"></mk-waveform-player>
14
+ <script type="module">
15
+ import { defineWaveformPlayer } from '@yewdiel21/motion-kit/elements';
16
+ defineWaveformPlayer();
17
+ </script>
18
+ ```
19
+
20
+ Los picos son amplitudes normalizadas **suministradas por la aplicación**, por ejemplo desde el servidor o un proceso previo. El componente no descarga ni analiza el archivo para calcularlos. Acota amplitudes a 0…1 y muestra las primeras 256 barras; sin picos muestra barras neutras iguales. El progreso y la duración sí proceden del audio real. Usa un archivo o una URL compatible con el navegador.
21
+
22
+ `createWaveformPlayer(root, options)` devuelve `play(): Promise<boolean>`, `pause()`, `seek(seconds)`, `update(partialOptions)`, `state`, `audio` y `destroy()`. `play` resuelve `false` cuando falla, se cancela o falta una fuente; los rechazos del navegador se capturan y se muestran en el estado. `seek` se acota a la duración; antes de metadatos o con duración infinita no hace nada. `audio` es el elemento real y pasa a `null` tras destruir. `state` es una copia: `{ status, currentTime, duration, playbackRate, error }`. El estado puede ser `idle`, `loading`, `ready`, `playing`, `paused`, `ended` o `error`; el siguiente play tras finalizar vuelve al inicio.
23
+
24
+ Opciones: `src`, `peaks`, `label` (nombre accesible), `disabled`, `playbackRates` (por defecto `[1, 1.5, 2]`, `false` oculta el control; tasas válidas 0.25…4), `onStateChange`, `onTimeUpdate`, `onPlay`, `onPause`, `onEnded`, `onError`. Los callbacks reciben una copia del estado. `onStateChange` informa transiciones de estado; `onTimeUpdate` informa reproducción/búsqueda. Eventos DOM burbujeantes: `mk:audio-statechange`, `mk:audio-play`, `mk:audio-pause`, `mk:audio-ended`, `mk:audio-error`, `mk:audio-timeupdate`, con el estado en `detail`.
25
+
26
+ React admite `instanceRef` y actualiza las opciones/callbacks sin recrear el audio. El custom element expone los mismos métodos, `state`, `audio` y `instance`; los atributos son reactivos (`playback-rates` y `peaks` son JSON). Cambiar solamente picos, etiquetas o callbacks conserva fuente, tiempo y reproducción. Cambiar `src` pausa, cancela solicitudes y reinicia tiempo/duración. `destroy()` detiene el audio, vacía la fuente, retira listeners/animaciones y conserva el DOM ajeno.
27
+
28
+ El slider nativo conserva teclado (flechas, Home/End), toque y semántica accesible; su nombre incluye `label` y su valor se anuncia como tiempo. Carga, errores y finalización usan un estado discreto; los tiempos no se anuncian continuamente. El pequeño resorte GSAP del botón se omite con movimiento reducido. Tokens locales: `--mk-wave-color`, `--mk-wave-track`; el resto usa los colores, tipografía y superficies del tema.
29
+
30
+ Tipos: `WaveformPlayerOptions`, `WaveformPlayerInstance`, `WaveformPlayerState`, `WaveformPlayerStatus`, `WaveformPlayerProps`. El componente reproduce contenido provisto por la aplicación: agrega una transcripción cerca del reproductor cuando el audio contenga información hablada.
@@ -0,0 +1,21 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createWaveformPlayer, type WaveformPlayerInstance, type WaveformPlayerOptions } from './engine';
3
+
4
+ export class MkWaveformPlayerElement extends MkElement<WaveformPlayerInstance> {
5
+ static options: Record<string, OptionType> = { src: 'string', peaks: 'json', label: 'string', disabled: 'boolean', playbackRates: 'json' };
6
+ protected create(options: Record<string, unknown>) { return createWaveformPlayer(this, options as WaveformPlayerOptions); }
7
+ play() { return this.instance?.play() ?? Promise.resolve(false); }
8
+ pause() { this.instance?.pause(); }
9
+ seek(seconds: number) { this.instance?.seek(seconds); }
10
+ update(options: Partial<WaveformPlayerOptions>) { this.instance?.update(options); }
11
+ get state() { return this.instance?.state; }
12
+ get audio() { return this.instance?.audio ?? null; }
13
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
14
+ if (previous === next || !this.instance) return;
15
+ const option = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
16
+ if (!Object.hasOwn(MkWaveformPlayerElement.options, option)) return;
17
+ // Undefined resets only the removed option; imperative values for other options survive.
18
+ this.instance.update({ [option]: this.options[option] });
19
+ }
20
+ }
21
+ export const defineWaveformPlayer = () => defineElement('mk-waveform-player', MkWaveformPlayerElement);
@@ -0,0 +1,261 @@
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
+
6
+ export type WaveformPlayerStatus = 'idle' | 'loading' | 'ready' | 'playing' | 'paused' | 'ended' | 'error';
7
+ export interface WaveformPlayerState {
8
+ status: WaveformPlayerStatus;
9
+ currentTime: number;
10
+ /** 0 until finite metadata is available. Live streams cannot be sought. */
11
+ duration: number;
12
+ playbackRate: number;
13
+ error: string | null;
14
+ }
15
+ export interface WaveformPlayerOptions {
16
+ src?: string;
17
+ /** Normalized amplitudes (0…1), supplied by your application, not audio analysis. */
18
+ peaks?: readonly number[];
19
+ label?: string;
20
+ disabled?: boolean;
21
+ /** Defaults to [1, 1.5, 2]. false hides the rate button. */
22
+ playbackRates?: readonly number[] | false;
23
+ /** Status transitions (time changes have their own callback). */
24
+ onStateChange?: (state: WaveformPlayerState) => void;
25
+ onTimeUpdate?: (state: WaveformPlayerState) => void;
26
+ onPlay?: (state: WaveformPlayerState) => void;
27
+ onPause?: (state: WaveformPlayerState) => void;
28
+ onEnded?: (state: WaveformPlayerState) => void;
29
+ onError?: (state: WaveformPlayerState) => void;
30
+ }
31
+ export interface WaveformPlayerInstance extends Instance {
32
+ /** Resolves false when unavailable, cancelled, or rejected. Never rejects for media errors. */
33
+ play(): Promise<boolean>;
34
+ pause(): void;
35
+ /** Seconds, clamped to the finite duration; ignored until metadata is available. */
36
+ seek(seconds: number): void;
37
+ /** Updates presentation/callbacks in place. A different src pauses and resets playback. */
38
+ update(options: Partial<WaveformPlayerOptions>): void;
39
+ readonly state: WaveformPlayerState;
40
+ readonly audio: HTMLAudioElement | null;
41
+ }
42
+
43
+ const initialState = (): WaveformPlayerState => ({ status: 'idle', currentTime: 0, duration: 0, playbackRate: 1, error: null });
44
+ const finite = (n: number) => Number.isFinite(n) && n >= 0;
45
+ const clock = (n: number) => `${Math.floor(n / 60)}:${String(Math.floor(n % 60)).padStart(2, '0')}`;
46
+ const playIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 9 6-9 6Z"/></svg>';
47
+ const pauseIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6v12M16 6v12"/></svg>';
48
+
49
+ export function createWaveformPlayer(root: HTMLElement | null, options: WaveformPlayerOptions = {}): WaveformPlayerInstance {
50
+ if (!root) return inert({ play: async () => false, pause() {}, seek() {}, update() {}, state: initialState(), audio: null });
51
+ registerMotion();
52
+ const scope = createScope();
53
+ let opts = { ...options };
54
+ let state = initialState();
55
+ let source = '';
56
+ let request = 0;
57
+ let requested = false;
58
+ let pending: Promise<boolean> | null = null;
59
+ const make = <K extends keyof HTMLElementTagNameMap>(tag: K, part: string, parent: HTMLElement = root) => {
60
+ const el = document.createElement(tag);
61
+ el.setAttribute(`data-mk-wave-${part}`, '');
62
+ parent.append(el);
63
+ return el;
64
+ };
65
+ // A single owned container preserves authored siblings and makes destroy reversible.
66
+ const container = make('div', 'content');
67
+ scope.add(() => container.remove());
68
+ const audio = make('audio', 'audio', container);
69
+ audio.preload = 'metadata';
70
+ audio.hidden = true;
71
+ const toggle = make('button', 'toggle', container);
72
+ toggle.type = 'button';
73
+ const body = make('div', 'body', container);
74
+ const track = make('div', 'track', body);
75
+ const bars = make('div', 'bars', track);
76
+ bars.setAttribute('aria-hidden', 'true');
77
+ const range = make('input', 'seek', track);
78
+ range.type = 'range';
79
+ range.min = '0';
80
+ range.max = '0';
81
+ range.step = '0.1';
82
+ const footer = make('div', 'footer', body);
83
+ const time = make('span', 'time', footer);
84
+ const rate = make('button', 'rate', footer);
85
+ rate.type = 'button';
86
+ const message = make('span', 'message', container);
87
+ message.setAttribute('role', 'status');
88
+ message.setAttribute('aria-live', 'polite');
89
+ scope.attr(root, 'data-mk-ready', '');
90
+ scope.attr(root, 'data-mk-wave-state', 'idle');
91
+ let barNodes: HTMLElement[] = [];
92
+ let effect: gsap.core.Tween | undefined;
93
+ const snapshot = () => ({ ...state });
94
+ const rates = () => {
95
+ const values = opts.playbackRates === false ? [] : (opts.playbackRates ?? [1, 1.5, 2]);
96
+ return Array.isArray(values) ? [...new Set(values.filter(n => typeof n === 'number' && Number.isFinite(n) && n >= 0.25 && n <= 4))] : [];
97
+ };
98
+ const paint = () => {
99
+ const playing = state.status === 'playing';
100
+ toggle.innerHTML = playing || requested ? pauseIcon : playIcon;
101
+ toggle.setAttribute('aria-label', `${playing || requested ? 'Pausar' : 'Reproducir'} ${opts.label ?? 'audio'}`);
102
+ toggle.disabled = !!opts.disabled || !source;
103
+ range.disabled = !!opts.disabled || !state.duration;
104
+ range.max = String(state.duration);
105
+ range.value = String(state.currentTime);
106
+ range.setAttribute('aria-label', `Posición de ${opts.label ?? 'audio'}`);
107
+ range.setAttribute('aria-valuetext', `${clock(state.currentTime)} de ${clock(state.duration)}`);
108
+ time.textContent = `${clock(state.currentTime)} / ${clock(state.duration)}`;
109
+ rate.hidden = rates().length < 2;
110
+ rate.disabled = !!opts.disabled;
111
+ rate.textContent = `${state.playbackRate}×`;
112
+ rate.setAttribute('aria-label', `Velocidad ${state.playbackRate}×. Cambiar velocidad`);
113
+ const progress = state.duration ? state.currentTime / state.duration : 0;
114
+ barNodes.forEach((bar, i) => bar.toggleAttribute('data-mk-played', (i + 0.5) / barNodes.length <= progress));
115
+ root.setAttribute('data-mk-wave-state', state.status);
116
+ message.textContent = state.error ?? (state.status === 'loading' ? 'Cargando audio…' : state.status === 'ended' ? 'Audio finalizado' : '');
117
+ };
118
+ const emit = (patch: Partial<WaveformPlayerState>, event?: 'play' | 'pause' | 'ended' | 'error' | 'timeupdate') => {
119
+ if (scope.disposed) return;
120
+ const previous = state.status;
121
+ state = { ...state, ...patch };
122
+ paint();
123
+ const next = snapshot();
124
+ if (event === 'timeupdate') opts.onTimeUpdate?.(next);
125
+ if (previous !== state.status) opts.onStateChange?.(snapshot());
126
+ if (event === 'play') opts.onPlay?.(snapshot());
127
+ if (event === 'pause') opts.onPause?.(snapshot());
128
+ if (event === 'ended') opts.onEnded?.(snapshot());
129
+ if (event === 'error') opts.onError?.(snapshot());
130
+ if (previous !== state.status) root.dispatchEvent(new CustomEvent('mk:audio-statechange', { bubbles: true, detail: snapshot() }));
131
+ if (event) root.dispatchEvent(new CustomEvent(`mk:audio-${event}`, { bubbles: true, detail: snapshot() }));
132
+ };
133
+ const bounce = (target: HTMLElement) => {
134
+ if (reduced()) return;
135
+ effect?.progress(1);
136
+ effect?.kill();
137
+ effect = gsap.fromTo(target, { scale: 0.88 }, { scale: 1, duration: 0.4, ease: EASE.swatch, clearProps: 'transform' });
138
+ };
139
+ const fail = (message: string) => { requested = false; emit({ status: 'error', error: message }, 'error'); };
140
+ const pause = () => {
141
+ if (scope.disposed) return;
142
+ request++;
143
+ requested = false;
144
+ pending = null;
145
+ audio.pause();
146
+ if (state.status !== 'idle' && state.status !== 'error' && state.status !== 'ended' && state.status !== 'paused') emit({ status: 'paused' }, 'pause');
147
+ else paint();
148
+ };
149
+ const seek = (seconds: number) => {
150
+ if (scope.disposed || !state.duration || !Number.isFinite(seconds)) return;
151
+ const next = Math.max(0, Math.min(state.duration, seconds));
152
+ try { audio.currentTime = next; }
153
+ catch { return; }
154
+ emit({ currentTime: next, ...(state.status === 'ended' && next < state.duration ? { status: 'paused' as const } : {}) }, 'timeupdate');
155
+ };
156
+ const play = (): Promise<boolean> => {
157
+ if (scope.disposed || opts.disabled || !source) return Promise.resolve(false);
158
+ if (pending) return pending;
159
+ if (state.status === 'playing') return Promise.resolve(true);
160
+ if (state.status === 'ended') seek(0);
161
+ const token = ++request;
162
+ requested = true;
163
+ emit({ status: 'loading', error: null });
164
+ // Promise.resolve also supports older media implementations that return void.
165
+ let started: Promise<void>;
166
+ try { started = Promise.resolve(audio.play()); }
167
+ catch (error) { started = Promise.reject(error); }
168
+ const result = started.then(() => {
169
+ if (scope.disposed || token !== request) return false;
170
+ requested = false;
171
+ if (audio.paused) { emit({ status: 'paused' }); return false; }
172
+ if (state.status !== 'playing') emit({ status: 'playing', error: null }, 'play');
173
+ return true;
174
+ }).catch((error: unknown) => {
175
+ if (!scope.disposed && token === request) {
176
+ requested = false;
177
+ if (error instanceof Error && error.name === 'AbortError') emit({ status: 'paused' });
178
+ else fail('No se pudo reproducir el audio. Vuelve a intentarlo.');
179
+ }
180
+ return false;
181
+ }).finally(() => { if (token === request) pending = null; });
182
+ pending = result;
183
+ return result;
184
+ };
185
+ const renderPeaks = () => {
186
+ const supplied = Array.isArray(opts.peaks) ? opts.peaks : [];
187
+ // Empty data is an explicit neutral placeholder, never fabricated audio analysis.
188
+ const values = supplied.length ? supplied : Array<number>(48).fill(0.24);
189
+ bars.replaceChildren();
190
+ barNodes = values.slice(0, 256).map(value => {
191
+ const bar = make('i', 'bar', bars);
192
+ const peak = typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
193
+ bar.style.height = `${8 + peak * 92}%`;
194
+ return bar;
195
+ });
196
+ };
197
+ const update = (next: Partial<WaveformPlayerOptions>) => {
198
+ if (scope.disposed) return;
199
+ const previousPeaks = opts.peaks;
200
+ opts = { ...opts, ...next };
201
+ const nextSource = typeof opts.src === 'string' ? opts.src : '';
202
+ if (source !== nextSource) {
203
+ request++;
204
+ pending = null;
205
+ requested = false;
206
+ audio.pause();
207
+ source = nextSource;
208
+ if (source) audio.setAttribute('src', source);
209
+ else audio.removeAttribute('src');
210
+ // load() aborts the previous resource and any in-flight play request.
211
+ audio.load();
212
+ audio.playbackRate = state.playbackRate;
213
+ emit({ ...initialState(), playbackRate: state.playbackRate, status: source ? 'loading' : 'idle' });
214
+ }
215
+ if (opts.disabled && (requested || state.status === 'playing')) pause();
216
+ if (previousPeaks !== opts.peaks || !barNodes.length) renderPeaks();
217
+ paint();
218
+ };
219
+ scope.on(toggle, 'click', () => { bounce(toggle); if (requested || state.status === 'playing') pause(); else void play(); });
220
+ scope.on(range, 'input', () => seek(Number(range.value)));
221
+ scope.on(rate, 'click', () => {
222
+ const available = rates();
223
+ const index = available.indexOf(state.playbackRate);
224
+ const next = available[(index + 1) % available.length];
225
+ if (next === undefined) return;
226
+ audio.playbackRate = next;
227
+ emit({ playbackRate: next });
228
+ bounce(rate);
229
+ });
230
+ const metadata = () => {
231
+ const duration = finite(audio.duration) ? audio.duration : 0;
232
+ emit({ duration, currentTime: duration ? Math.min(duration, Math.max(0, audio.currentTime || 0)) : 0,
233
+ ...(state.status === 'loading' && !requested ? { status: 'ready' as const } : {}) });
234
+ };
235
+ scope.on(audio, 'loadedmetadata', metadata);
236
+ scope.on(audio, 'durationchange', metadata);
237
+ scope.on(audio, 'timeupdate', () => emit({ currentTime: finite(audio.currentTime) ? audio.currentTime : 0 }, 'timeupdate'));
238
+ scope.on(audio, 'playing', () => {
239
+ if (!source || opts.disabled || !requested && state.status !== 'playing') { audio.pause(); return; }
240
+ requested = false;
241
+ if (state.status !== 'playing') emit({ status: 'playing', error: null }, 'play');
242
+ });
243
+ scope.on(audio, 'pause', () => {
244
+ if (state.status === 'playing' || requested) { requested = false; emit({ status: 'paused' }, 'pause'); }
245
+ });
246
+ scope.on(audio, 'waiting', () => { if (state.status === 'playing') { requested = true; emit({ status: 'loading' }); } });
247
+ scope.on(audio, 'ended', () => { request++; pending = null; requested = false; emit({ status: 'ended', currentTime: state.duration || state.currentTime }, 'ended'); });
248
+ scope.on(audio, 'error', () => { request++; pending = null; fail('No se pudo cargar el audio. Comprueba el archivo.'); });
249
+ scope.on(audio, 'ratechange', () => { if (audio.playbackRate !== state.playbackRate) emit({ playbackRate: audio.playbackRate }); });
250
+ scope.add(() => {
251
+ request++;
252
+ requested = false;
253
+ pending = null;
254
+ effect?.kill();
255
+ audio.pause();
256
+ audio.removeAttribute('src');
257
+ audio.load();
258
+ });
259
+ update(options);
260
+ return { play, pause, seek, update, get state() { return snapshot(); }, get audio() { return scope.disposed ? null : audio; }, destroy: () => scope.dispose() };
261
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect } from '../../react/use-recipe';
4
+ import { createWaveformPlayer, type WaveformPlayerInstance, type WaveformPlayerOptions } from './engine';
5
+
6
+ export interface WaveformPlayerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onPlay' | 'onPause' | 'onEnded' | 'onError' | 'onTimeUpdate' | 'children' | 'dangerouslySetInnerHTML'>, WaveformPlayerOptions {
7
+ instanceRef?: MutableRefObject<WaveformPlayerInstance | null>;
8
+ }
9
+ export function WaveformPlayer({ src, peaks, label, disabled, playbackRates, onStateChange, onTimeUpdate, onPlay, onPause, onEnded, onError, instanceRef, className, ...props }: WaveformPlayerProps) {
10
+ // The engine owns descendants. Also filter runtime JS props that bypass the public type.
11
+ const { children: _children, dangerouslySetInnerHTML: _html, ...rootProps } = props as HTMLAttributes<HTMLDivElement>;
12
+ const root = useRef<HTMLDivElement>(null);
13
+ const instance = useRef<WaveformPlayerInstance | null>(null);
14
+ useIsomorphicLayoutEffect(() => {
15
+ const created = createWaveformPlayer(root.current);
16
+ instance.current = created;
17
+ return () => { created.destroy(); instance.current = null; };
18
+ }, []);
19
+ useIsomorphicLayoutEffect(() => {
20
+ instance.current?.update({ src, peaks, label, disabled, playbackRates, onStateChange, onTimeUpdate, onPlay, onPause, onEnded, onError });
21
+ });
22
+ useIsomorphicLayoutEffect(() => {
23
+ if (instanceRef) instanceRef.current = instance.current;
24
+ return () => { if (instanceRef) instanceRef.current = null; };
25
+ }, [instanceRef]);
26
+ return <div {...rootProps} ref={root} className={['mk-waveform-player', className].filter(Boolean).join(' ')} />;
27
+ }
@@ -0,0 +1,35 @@
1
+ .mk-waveform-player, mk-waveform-player {
2
+ --mk-wave-color: var(--mk-accent);
3
+ --mk-wave-track: color-mix(in srgb, var(--mk-text) 38%, transparent);
4
+ display: block;
5
+ width: 100%;
6
+ min-width: 0;
7
+ color: var(--mk-text);
8
+ font-family: var(--mk-font);
9
+ }
10
+ [data-mk-wave-content] { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; column-gap: .875rem; }
11
+ [data-mk-wave-toggle] { display: grid; place-items: center; width: 3rem; height: 3rem; border: 0; border-radius: 50%; background: var(--mk-wave-color); color: var(--mk-on-accent, var(--mk-bg)); cursor: pointer; }
12
+ [data-mk-wave-toggle] svg { width: 1.4rem; height: 1.4rem; fill: currentColor; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
13
+ [data-mk-wave-body] { min-width: 0; }
14
+ [data-mk-wave-track] { position: relative; height: 3rem; border-radius: .5rem; }
15
+ [data-mk-wave-bars] { display: flex; align-items: center; gap: 3px; height: 100%; pointer-events: none; overflow: hidden; }
16
+ [data-mk-wave-bar] { display: block; flex: 1 1 0; min-width: 1px; border-radius: 999px; background: var(--mk-wave-track); transition: background-color .16s ease; }
17
+ [data-mk-wave-bar][data-mk-played] { background: var(--mk-wave-color); }
18
+ [data-mk-wave-seek] { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
19
+ [data-mk-wave-track]:has(input:focus-visible) { outline: 2px solid var(--mk-wave-color); outline-offset: 4px; }
20
+ [data-mk-wave-footer] { display: flex; justify-content: space-between; align-items: center; gap: .75rem; min-height: 2rem; }
21
+ [data-mk-wave-time] { color: var(--mk-muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
22
+ [data-mk-wave-rate] { min-width: 44px; min-height: 44px; padding: .25rem .5rem; border: 0; border-radius: 99px; color: var(--mk-text); background: var(--mk-surface-2); font: 600 .75rem var(--mk-font); cursor: pointer; }
23
+ [data-mk-wave-rate][hidden] { display: none; }
24
+ [data-mk-wave-message] { grid-column: 2; color: var(--mk-muted); font-size: .75rem; line-height: 1.5; }
25
+ [data-mk-wave-message]:empty { display: none; }
26
+ [data-mk-wave-toggle]:focus-visible, [data-mk-wave-rate]:focus-visible { outline: 2px solid var(--mk-wave-color); outline-offset: 3px; }
27
+ [data-mk-wave-toggle]:disabled, [data-mk-wave-rate]:disabled { opacity: .5; cursor: default; }
28
+ [data-mk-wave-track]:has(input:disabled) { opacity: .55; }
29
+ [data-mk-wave-state='error'] [data-mk-wave-message] { color: var(--mk-error, var(--mk-text)); }
30
+ @media (prefers-reduced-motion: reduce) { [data-mk-wave-bar] { transition: none; } }
31
+ @media (forced-colors: active) {
32
+ [data-mk-wave-toggle], [data-mk-wave-rate] { border: 1px solid ButtonText; }
33
+ [data-mk-wave-bar] { background: GrayText; }
34
+ [data-mk-wave-bar][data-mk-played] { background: Highlight; }
35
+ }
@@ -0,0 +1,33 @@
1
+ # wheel-picker
2
+
3
+ Selector de rueda con desplazamiento nativo e inercia en táctil, arrastre de ratón, ajuste al centro y navegación por teclado. El valor elegido queda en un input oculto para formularios.
4
+
5
+ ```tsx
6
+ import '@yewdiel21/motion-kit/css';
7
+ import { WheelPicker } from '@yewdiel21/motion-kit/react';
8
+
9
+ <WheelPicker label="Duración" name="duration" value={duration} onChange={setDuration}
10
+ items={[{ value: '15', label: '15 min' }, { value: '25', label: '25 min' }, { value: '45', label: '45 min' }]} />
11
+ ```
12
+
13
+ En React, `defaultValue` inicializa el estado sin controlarlo; `instanceRef` permite llamar al motor. Los cambios de `items` y `value` se aplican sin sustituir el listbox.
14
+
15
+ ```html
16
+ <mk-wheel-picker label="Duración" name="duration" value="25"
17
+ items='[{"value":"15","label":"15 min"},{"value":"25","label":"25 min"},{"value":"45","label":"45 min"}]'>
18
+ </mk-wheel-picker>
19
+ <script>
20
+ import { defineElements } from '@yewdiel21/motion-kit/elements';
21
+ defineElements(['mk-wheel-picker']);
22
+ </script>
23
+ ```
24
+
25
+ También puedes usar `createWheelPicker(root, options)` en vanilla. El CLI copia las fuentes con `npx @yewdiel21/motion-kit add wheel-picker --react --elements`.
26
+
27
+ Opciones: `items` (strings o `{value,label,disabled?}`), `value`, `name`, `label`, `disabled`, `visibleItems` (impar de 3 a 9; 5 por defecto), `itemHeight` (44…96 px; 44 por defecto), `onInput(value)` durante el desplazamiento y `onChange(value)` al confirmar. Los valores duplicados se omiten y las etiquetas siempre se tratan como texto.
28
+
29
+ La instancia expone `setValue(value, notify=false)`, `setItems(items, notify=false)`, `value` y `destroy()`. Eventos: `mk:input` y `mk:change` con `{value,item}`. Las actualizaciones por código son silenciosas por defecto.
30
+
31
+ Flechas arriba/abajo omiten opciones deshabilitadas; Home/End van a los extremos y PageUp/PageDown saltan varias filas. Escribir busca una etiqueta por prefijo. El listbox tiene un solo foco y `aria-activedescendant`, las opciones exponen selección/estado deshabilitado y los cambios confirmados se anuncian. El reset de formulario recupera el valor inicial o una opción válida si cambió la lista. Sin opciones, el valor enviado es vacío.
32
+
33
+ El movimiento reducido elimina la inclinación y los desplazamientos programados suaves. Se usan los tokens de superficie, acento, tipografía y forma del kit; `--mk-wheel-row` y `--mk-wheel-rows` reflejan el tamaño configurado. El componente cancela temporizadores y listeners y restaura el contenedor al destruirse.
@@ -0,0 +1,18 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createWheelPicker, type WheelPickerInstance, type WheelPickerEntry } from './engine';
3
+
4
+ export class MkWheelPickerElement extends MkElement<WheelPickerInstance> {
5
+ static options: Record<string, OptionType> = { items: 'json', value: 'string', name: 'string', label: 'string', disabled: 'boolean', visibleItems: 'number', itemHeight: 'number' };
6
+ protected create(options: Record<string, unknown>) { return createWheelPicker(this, options); }
7
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
8
+ if (previous === next || !this.instance) return;
9
+ if (name === 'value' && next !== null) { this.instance.setValue(next); return; }
10
+ if (name === 'items') { this.instance.setItems((this.options.items as WheelPickerEntry[]) ?? []); if (this.options.value !== undefined) this.instance.setValue(String(this.options.value)); return; }
11
+ super.attributeChangedCallback(name, previous, next);
12
+ }
13
+ get value() { return this.instance?.value ?? ''; }
14
+ set value(next: string) { this.setAttribute('value', next); }
15
+ setValue(value: string, notify = false) { this.instance?.setValue(value, notify); }
16
+ setItems(items: WheelPickerEntry[], notify = false) { this.instance?.setItems(items, notify); }
17
+ }
18
+ export const defineWheelPicker = () => defineElement('mk-wheel-picker', MkWheelPickerElement);