@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.
Files changed (158) hide show
  1. package/README.md +44 -0
  2. package/dist/auto/index.d.ts +23 -3
  3. package/dist/auto/index.js +43 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-RBEKR5UZ.js → chunk-AWDEKDEJ.js} +2 -2
  6. package/dist/{chunk-NFZIX3IA.js → chunk-EDMXWUW3.js} +2 -2
  7. package/dist/{chunk-SDQ776LG.js → chunk-Q7IW4FV3.js} +3941 -43
  8. package/dist/chunk-Q7IW4FV3.js.map +1 -0
  9. package/dist/css/components/badge-button.css +16 -0
  10. package/dist/css/components/bento-grid.css +13 -0
  11. package/dist/css/components/drilldown-menu.css +134 -0
  12. package/dist/css/components/equalizer-slider.css +26 -0
  13. package/dist/css/components/handwriting-text.css +15 -0
  14. package/dist/css/components/magnetize-button.css +18 -0
  15. package/dist/css/components/morphing-page-dots.css +13 -0
  16. package/dist/css/components/not-found.css +22 -0
  17. package/dist/css/components/notification-stack.css +42 -0
  18. package/dist/css/components/particle-button.css +14 -0
  19. package/dist/css/components/radio-group.css +29 -0
  20. package/dist/css/components/rating-scale.css +23 -0
  21. package/dist/css/components/scroll-preview-dock.css +18 -0
  22. package/dist/css/components/select.css +16 -0
  23. package/dist/css/components/signature-pad.css +131 -0
  24. package/dist/css/components/stepper.css +36 -0
  25. package/dist/css/components/sticky-header.css +10 -0
  26. package/dist/css/components/temperature-slider.css +29 -0
  27. package/dist/css/components/voice-message-bubble.css +67 -0
  28. package/dist/css/components/waveform-player.css +51 -0
  29. package/dist/css/components/wheel-picker.css +18 -0
  30. package/dist/css/tokens.css +4 -0
  31. package/dist/elements/index.d.ts +292 -4
  32. package/dist/elements/index.js +856 -4
  33. package/dist/elements/index.js.map +1 -1
  34. package/dist/{reveal-DRpNO4KP.d.ts → engine-t8d7brjn.d.ts} +534 -1
  35. package/dist/index.css +713 -0
  36. package/dist/index.d.ts +23 -23
  37. package/dist/index.js +43 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/react/index.d.ts +151 -7
  40. package/dist/react/index.js +893 -3
  41. package/dist/react/index.js.map +1 -1
  42. package/dist/{reduced-DQwVXQYb.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
  43. package/dist/{store-Da3zSh8Z.d.ts → store-6hZNKiee.d.ts} +1 -1
  44. package/package.json +110 -110
  45. package/registry.json +244 -0
  46. package/src/auto/auto-init.ts +339 -293
  47. package/src/components/badge-button/README.md +28 -0
  48. package/src/components/badge-button/badge-button.css +15 -0
  49. package/src/components/badge-button/element.ts +16 -0
  50. package/src/components/badge-button/engine.ts +85 -0
  51. package/src/components/badge-button/react.tsx +31 -0
  52. package/src/components/bento-grid/README.md +26 -0
  53. package/src/components/bento-grid/bento-grid.css +12 -0
  54. package/src/components/bento-grid/element.ts +22 -0
  55. package/src/components/bento-grid/engine.ts +153 -0
  56. package/src/components/bento-grid/react.tsx +25 -0
  57. package/src/components/drilldown-menu/README.md +163 -0
  58. package/src/components/drilldown-menu/drilldown-menu.css +133 -0
  59. package/src/components/drilldown-menu/element.ts +59 -0
  60. package/src/components/drilldown-menu/engine.ts +378 -0
  61. package/src/components/drilldown-menu/react.tsx +76 -0
  62. package/src/components/equalizer-slider/README.md +43 -0
  63. package/src/components/equalizer-slider/element.ts +41 -0
  64. package/src/components/equalizer-slider/engine.ts +32 -0
  65. package/src/components/equalizer-slider/equalizer-slider.css +26 -0
  66. package/src/components/equalizer-slider/react.tsx +60 -0
  67. package/src/components/equalizer-slider/shared.ts +161 -0
  68. package/src/components/handwriting-text/README.md +29 -0
  69. package/src/components/handwriting-text/element.ts +14 -0
  70. package/src/components/handwriting-text/engine.ts +108 -0
  71. package/src/components/handwriting-text/glyphs.ts +71 -0
  72. package/src/components/handwriting-text/handwriting-text.css +14 -0
  73. package/src/components/handwriting-text/react.tsx +23 -0
  74. package/src/components/magnetize-button/README.md +26 -0
  75. package/src/components/magnetize-button/element.ts +13 -0
  76. package/src/components/magnetize-button/engine.ts +84 -0
  77. package/src/components/magnetize-button/magnetize-button.css +17 -0
  78. package/src/components/magnetize-button/react.tsx +22 -0
  79. package/src/components/morphing-page-dots/README.md +28 -0
  80. package/src/components/morphing-page-dots/element.ts +16 -0
  81. package/src/components/morphing-page-dots/engine.ts +89 -0
  82. package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
  83. package/src/components/morphing-page-dots/react.tsx +32 -0
  84. package/src/components/not-found/README.md +26 -0
  85. package/src/components/not-found/element.ts +15 -0
  86. package/src/components/not-found/engine.ts +57 -0
  87. package/src/components/not-found/not-found.css +21 -0
  88. package/src/components/not-found/react.tsx +23 -0
  89. package/src/components/notification-stack/README.md +24 -0
  90. package/src/components/notification-stack/element.ts +26 -0
  91. package/src/components/notification-stack/engine.ts +329 -0
  92. package/src/components/notification-stack/notification-stack.css +41 -0
  93. package/src/components/notification-stack/react.tsx +28 -0
  94. package/src/components/particle-button/README.md +26 -0
  95. package/src/components/particle-button/element.ts +13 -0
  96. package/src/components/particle-button/engine.ts +52 -0
  97. package/src/components/particle-button/particle-button.css +13 -0
  98. package/src/components/particle-button/react.tsx +22 -0
  99. package/src/components/radio-group/README.md +43 -0
  100. package/src/components/radio-group/element.ts +41 -0
  101. package/src/components/radio-group/engine.ts +11 -0
  102. package/src/components/radio-group/radio-group.css +28 -0
  103. package/src/components/radio-group/react.tsx +60 -0
  104. package/src/components/radio-group/shared.ts +118 -0
  105. package/src/components/rating-scale/README.md +43 -0
  106. package/src/components/rating-scale/element.ts +41 -0
  107. package/src/components/rating-scale/engine.ts +63 -0
  108. package/src/components/rating-scale/rating-scale.css +22 -0
  109. package/src/components/rating-scale/react.tsx +60 -0
  110. package/src/components/scroll-preview-dock/README.md +24 -0
  111. package/src/components/scroll-preview-dock/element.ts +20 -0
  112. package/src/components/scroll-preview-dock/engine.ts +228 -0
  113. package/src/components/scroll-preview-dock/react.tsx +26 -0
  114. package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
  115. package/src/components/select/README.md +23 -9
  116. package/src/components/select/select.css +16 -0
  117. package/src/components/signature-pad/README.md +97 -0
  118. package/src/components/signature-pad/element.ts +57 -0
  119. package/src/components/signature-pad/engine.ts +550 -0
  120. package/src/components/signature-pad/react.tsx +56 -0
  121. package/src/components/signature-pad/signature-pad.css +130 -0
  122. package/src/components/stepper/README.md +46 -0
  123. package/src/components/stepper/element.ts +42 -0
  124. package/src/components/stepper/engine.ts +103 -0
  125. package/src/components/stepper/react.tsx +60 -0
  126. package/src/components/stepper/stepper.css +35 -0
  127. package/src/components/sticky-header/README.md +30 -0
  128. package/src/components/sticky-header/element.ts +19 -0
  129. package/src/components/sticky-header/engine.ts +95 -0
  130. package/src/components/sticky-header/react.tsx +25 -0
  131. package/src/components/sticky-header/sticky-header.css +9 -0
  132. package/src/components/temperature-slider/README.md +42 -0
  133. package/src/components/temperature-slider/element.ts +53 -0
  134. package/src/components/temperature-slider/engine.ts +44 -0
  135. package/src/components/temperature-slider/react.tsx +66 -0
  136. package/src/components/temperature-slider/temperature-slider.css +29 -0
  137. package/src/components/voice-message-bubble/README.md +31 -0
  138. package/src/components/voice-message-bubble/element.ts +20 -0
  139. package/src/components/voice-message-bubble/engine.ts +79 -0
  140. package/src/components/voice-message-bubble/react.tsx +27 -0
  141. package/src/components/voice-message-bubble/voice-message-bubble.css +14 -0
  142. package/src/components/waveform-player/README.md +30 -0
  143. package/src/components/waveform-player/element.ts +21 -0
  144. package/src/components/waveform-player/engine.ts +344 -0
  145. package/src/components/waveform-player/react.tsx +27 -0
  146. package/src/components/waveform-player/waveform-player.css +50 -0
  147. package/src/components/wheel-picker/README.md +35 -0
  148. package/src/components/wheel-picker/element.ts +25 -0
  149. package/src/components/wheel-picker/engine.ts +239 -0
  150. package/src/components/wheel-picker/react.tsx +32 -0
  151. package/src/components/wheel-picker/wheel-picker.css +17 -0
  152. package/src/css/tokens.css +4 -0
  153. package/src/elements/index.ts +318 -258
  154. package/src/index.ts +21 -0
  155. package/src/react/index.ts +192 -171
  156. package/dist/chunk-SDQ776LG.js.map +0 -1
  157. /package/dist/{chunk-RBEKR5UZ.js.map → chunk-AWDEKDEJ.js.map} +0 -0
  158. /package/dist/{chunk-NFZIX3IA.js.map → chunk-EDMXWUW3.js.map} +0 -0
@@ -0,0 +1,42 @@
1
+ # TemperatureSlider
2
+
3
+ Rango de temperatura con número rodante, pequeñas marcas y un tinte frío/cálido construido con los tokens del tema. La cifra, el relleno y el pulgar responden inmediatamente al arrastrar.
4
+
5
+ ```ts
6
+ import { createTemperatureSlider } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/temperature-slider';
8
+
9
+ const control = createTemperatureSlider(document.querySelector('.mk-temperature-slider'), {
10
+ value: 22, unit: 'C', label: 'Temperatura deseada', name: 'temperature', onInput: console.log,
11
+ });
12
+ control.setValue(24);
13
+ ```
14
+
15
+ ## Opciones y contrato
16
+
17
+ `unit: C | F` (C), `min` (16 / 60), `max` (30 / 86), `step` (1), `value` / `defaultValue` (22 / 72), `label`, `name`, `disabled`, `error`, `formatValue`, `onInput(value)`, `onChange(value)`.
18
+
19
+ unit determina el sufijo y los límites predeterminados. Cambiar el atributo o la opción `unit` al crear no convierte el número; `setUnit('F' | 'C', range?, notify?)` sí lo convierte en el mismo control: la cifra y los extremos ruedan y el pulgar se desliza sin desmontar nada. En React, cambiar la prop `unit` usa `setUnit`. Los cambios por código, presets y teclado deslizan el pulgar con la curva swatch del kit; al arrastrar sigue al puntero sin retraso. --mk-temperature-cool y --mk-temperature-hot permiten adaptar los dos extremos con colores semánticos del tema.
20
+
21
+ La instancia expone `value: number`, `unit`, `setValue(value, notify = false)`, `setUnit(unit, range?, notify = false)`, `reset(notify = false)` y `destroy()`. Las escrituras imperativas actualizan el control incluso estando disabled. `setValue` no cambia el valor inicial al que vuelve reset. Las notificaciones usan callbacks y eventos `mk:input / mk:change` con `detail: { value }`.
22
+
23
+ ## React y HTML
24
+
25
+ ```tsx
26
+ import { TemperatureSlider } from '@yewdiel21/motion-kit/react';
27
+
28
+ <TemperatureSlider value={temperature} onInput={setTemperature} unit="C" name="temperature" />
29
+ ```
30
+
31
+ `value` controla la instancia; `defaultValue` inicia una instancia que conserva sus propios cambios. Las opciones pueden cambiar sin perder la selección no controlada. `instanceRef` recibe la instancia y admite cambiar de objeto ref. Los children y dangerouslySetInnerHTML no se pasan al host porque el motor posee ese DOM.
32
+
33
+ ```html
34
+ <mk-temperature-slider class="mk-temperature-slider" value="22" unit="C" label="Temperatura deseada" name="temperature"></mk-temperature-slider>
35
+ ```
36
+
37
+ Registra con `defineTemperatureSlider()` desde `@yewdiel21/motion-kit/elements` o utiliza el auto registro. Los atributos de opciones se pueden cambiar en vivo; el valor imperativo se conserva al cambiar otra opción. `disabled="false"` se lee como false. El elemento expone `value`, `setValue`, `reset`.
38
+
39
+ ## Accesibilidad y lifecycle
40
+
41
+ Se conserva el input nativo, su etiqueta, teclado, tacto, validación y participación en formularios. El reset de formulario es cancelable; el control espera al reset nativo y verifica defaultPrevented. Movimiento reducido conserva toda la funcionalidad. destroy es idempotente y libera animaciones, eventos y timers, elimina solo las partes creadas y restaura los atributos del host. Los temas utilizan DM Sans y tokens --mk-*; el CSS de este componente es independiente de los otros estilos.
42
+
@@ -0,0 +1,53 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createTemperatureSlider, type TemperatureSliderInstance, type TemperatureUnit } from './engine';
3
+
4
+ /** Light DOM; attribute changes keep the current imperative value and reset baseline. */
5
+ export class MkTemperatureSliderElement extends MkElement<TemperatureSliderInstance> {
6
+ static options: Record<string, OptionType> = {"min":"number","max":"number","step":"number","value":"number","label":"string","name":"string","disabled":"boolean","error":"string","unit":"string"};
7
+ private baseline: number | undefined;
8
+ private saved: number | undefined;
9
+ private syncing = false;
10
+ protected create(options: Record<string, unknown>) {
11
+ const created = createTemperatureSlider(this, { ...options, value: this.saved !== undefined ? this.saved : options.value as number | undefined, defaultValue: this.baseline });
12
+ if (this.baseline === undefined) this.baseline = created.value;
13
+ this.saved = undefined;
14
+ return created;
15
+ }
16
+ disconnectedCallback() {
17
+ this.saved = this.instance?.value;
18
+ super.disconnectedCallback();
19
+ }
20
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
21
+ if (previous === next || this.syncing) return;
22
+ if (name === 'value' && (!this.instance || !this.isConnected)) {
23
+ this.saved = next === null ? this.baseline ?? 22 : Number.isFinite(Number(next)) ? Number(next) : this.baseline ?? 22;
24
+ return;
25
+ }
26
+ if (!this.instance || !this.isConnected) return;
27
+ if (name === 'value') {
28
+ const parsed = next === null ? this.baseline ?? 22 : Number(next);
29
+ this.instance.setValue(parsed, false);
30
+ return;
31
+ }
32
+ this.saved = this.instance.value;
33
+ super.attributeChangedCallback(name, previous, next);
34
+ }
35
+ get value(): number { return this.instance?.value ?? this.saved ?? 22; }
36
+ set value(next: number) { this.setValue(next); }
37
+ setValue(value: number, notify = false) { if (this.instance) this.instance.setValue(value, notify); else this.saved = value; }
38
+ reset(notify = false) { this.instance?.reset(notify); }
39
+ get unit(): TemperatureUnit { return this.instance?.unit ?? (this.getAttribute('unit') === 'F' ? 'F' : 'C'); }
40
+ /** Converts in place and glides; the unit, min and max attributes follow without remounting. */
41
+ setUnit(unit: TemperatureUnit, notify = false) {
42
+ if (!this.instance) { this.setAttribute('unit', unit); return; }
43
+ this.instance.setUnit(unit, {}, notify);
44
+ this.syncing = true;
45
+ try {
46
+ this.setAttribute('unit', this.instance.unit);
47
+ for (const name of ['min', 'max']) this.removeAttribute(name);
48
+ } finally { this.syncing = false; }
49
+ }
50
+
51
+ }
52
+ export const defineTemperatureSlider = () => defineElement('mk-temperature-slider', MkTemperatureSliderElement);
53
+
@@ -0,0 +1,44 @@
1
+ import { createNumericRange, type NumericOptions, type NumericInstance } from '../equalizer-slider/shared';
2
+
3
+ export type TemperatureUnit = 'C' | 'F';
4
+ export interface TemperatureSliderOptions extends NumericOptions { unit?: TemperatureUnit }
5
+ export interface TemperatureSliderInstance extends NumericInstance {
6
+ readonly unit: TemperatureUnit;
7
+ /** Converts the value into `unit` and glides to it. `min`/`max` default to the unit's comfort range. */
8
+ setUnit(unit: TemperatureUnit, range?: { min?: number; max?: number; step?: number }, notify?: boolean): void;
9
+ }
10
+ const RANGE = { C: { min: 16, max: 30, value: 22 }, F: { min: 60, max: 86, value: 72 } } as const;
11
+ const convert = (value: number, from: TemperatureUnit, to: TemperatureUnit) => from === to ? value : to === 'F' ? value * 9 / 5 + 32 : (value - 32) * 5 / 9;
12
+
13
+ export function createTemperatureSlider(root: HTMLElement | null, options: TemperatureSliderOptions = {}): TemperatureSliderInstance {
14
+ let unit: TemperatureUnit = options.unit === 'F' ? 'F' : 'C';
15
+ const formatFor = (next: TemperatureUnit) => (n: number) => `${n}°${next}`;
16
+ const range = createNumericRange(root, options, {
17
+ prefix: 'temperature', label: 'Temperatura', ...RANGE[unit],
18
+ format: formatFor(unit),
19
+ decorate({ track, thumb }) {
20
+ const rail = document.createElement('span'); rail.dataset.mkTemperatureRail = ''; rail.setAttribute('aria-hidden', 'true'); track.prepend(rail);
21
+ const fill = document.createElement('span'); fill.dataset.mkTemperatureFill = ''; rail.append(fill);
22
+ const ticks = document.createElement('span'); ticks.dataset.mkTemperatureTicks = ''; ticks.setAttribute('aria-hidden', 'true');
23
+ for (let i = 0; i <= 14; i++) ticks.append(document.createElement('i'));
24
+ track.prepend(ticks); thumb.textContent = '··';
25
+ return (ratio) => {
26
+ root?.style.setProperty('--mk-temperature-warm', `${Math.round(ratio * 100)}%`);
27
+ fill.style.width = `${ratio * 100}%`;
28
+ };
29
+ },
30
+ });
31
+ return {
32
+ get value() { return range.value; },
33
+ get unit() { return unit; },
34
+ setValue: range.setValue,
35
+ reset: range.reset,
36
+ setUnit(next, limits = {}, notify = false) {
37
+ const target: TemperatureUnit = next === 'F' ? 'F' : 'C';
38
+ const value = Math.round(convert(range.value, unit, target));
39
+ unit = target;
40
+ range.configure({ min: limits.min ?? RANGE[target].min, max: limits.max ?? RANGE[target].max, step: limits.step, format: options.formatValue ?? formatFor(target), value }, notify);
41
+ },
42
+ destroy: range.destroy,
43
+ };
44
+ }
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createTemperatureSlider, type TemperatureSliderOptions, type TemperatureSliderInstance } from './engine';
5
+
6
+ export interface TemperatureSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, Omit<TemperatureSliderOptions, 'value' | 'defaultValue'> {
7
+ value?: number;
8
+ defaultValue?: number;
9
+ instanceRef?: MutableRefObject<TemperatureSliderInstance | null>;
10
+ /** The engine owns this component's DOM. */
11
+ children?: never;
12
+ dangerouslySetInnerHTML?: never;
13
+ }
14
+ export function TemperatureSlider({ value, defaultValue, min, max, step, label, name, disabled, error, formatValue, unit, onChange, onInput, instanceRef, className, children: _children, dangerouslySetInnerHTML: _html, ...props }: TemperatureSliderProps) {
15
+ const root = useRef<HTMLDivElement>(null);
16
+ const engine = useRef<TemperatureSliderInstance | null>(null);
17
+ const latestValue = useLatest(value);
18
+ const change = useLatest(onChange);
19
+ const input = useLatest(onInput);
20
+ const baseline = useRef(value !== undefined ? value : defaultValue);
21
+ const current = useRef(baseline.current);
22
+ const reconcile = () => queueMicrotask(() => {
23
+ if (latestValue.current !== undefined) engine.current?.setValue(latestValue.current, false);
24
+ });
25
+ useIsomorphicLayoutEffect(() => {
26
+ const created = createTemperatureSlider(root.current, {
27
+ min, max, step, label, name, disabled, error, formatValue, unit, value: latestValue.current !== undefined ? latestValue.current : current.current, defaultValue: baseline.current,
28
+ onChange: (next) => { change.current?.(next); reconcile(); },
29
+ onInput: (next) => { input.current?.(next); if (input.current || !change.current) reconcile(); },
30
+ });
31
+ engine.current = created;
32
+ const form = root.current?.closest('form');
33
+ const pending = new Set<ReturnType<typeof setTimeout>>();
34
+ const reset = (event: Event) => {
35
+ const timer = setTimeout(() => {
36
+ pending.delete(timer);
37
+ if (!event.defaultPrevented && latestValue.current !== undefined) created.setValue(latestValue.current, false);
38
+ }, 0);
39
+ pending.add(timer);
40
+ };
41
+ form?.addEventListener('reset', reset);
42
+ return () => {
43
+ form?.removeEventListener('reset', reset);
44
+ pending.forEach((timer) => clearTimeout(timer));
45
+ current.current = created.value; created.destroy(); engine.current = null;
46
+ };
47
+ // Callbacks and controlled value are read through refs.
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
+ }, [min, max, step, label, name, disabled, error, formatValue]);
50
+ // A unit change converts and glides in place instead of remounting the slider.
51
+ useIsomorphicLayoutEffect(() => {
52
+ const next = unit === 'F' ? 'F' : 'C';
53
+ if (engine.current && engine.current.unit !== next) engine.current.setUnit(next, { min, max, step });
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ }, [unit]);
56
+ useIsomorphicLayoutEffect(() => {
57
+ if (value !== undefined) engine.current?.setValue(value, false);
58
+ });
59
+ useIsomorphicLayoutEffect(() => {
60
+ if (!instanceRef) return;
61
+ instanceRef.current = engine.current;
62
+ return () => { instanceRef.current = null; };
63
+ }, [instanceRef, min, max, step, label, name, disabled, error, formatValue]);
64
+ return <div {...props} ref={root} className={['mk-temperature-slider', className].filter(Boolean).join(' ')} />;
65
+ }
66
+
@@ -0,0 +1,29 @@
1
+ :is(.mk-temperature-slider, mk-temperature-slider) { display: block; width: 100%; min-width: 0; color: var(--mk-text); font: 500 .875rem/1.4 var(--mk-font); }
2
+ [data-mk-temperature-body] { display: grid; gap: .875rem; }
3
+ [data-mk-temperature-header] { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
4
+ [data-mk-temperature-output] { font-variant-numeric: tabular-nums; white-space: nowrap; }
5
+ [data-mk-temperature-track] { position: relative; height: 76px; min-width: 0; border-radius: 24px; background: var(--mk-surface-2); }
6
+ [data-mk-temperature-input] { position: absolute; z-index: 3; inset: 0; box-sizing: border-box; width: 100%; height: 100%; margin: 0; appearance: none; -webkit-appearance: none; background: transparent; cursor: ew-resize; opacity: 0; }
7
+ [data-mk-temperature-input]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 32px; height: 76px; background: transparent; }
8
+ [data-mk-temperature-input]::-moz-range-thumb { width: 32px; height: 76px; border: 0; background: transparent; }
9
+ [data-mk-temperature-thumb] { position: absolute; z-index: 2; top: 50%; left: calc(16px + (100% - 32px) * var(--mk-temperature-ratio, .5)); translate: -50% -50%; pointer-events: none; }
10
+ [data-mk-temperature-track]:has(:focus-visible) { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
11
+ [data-mk-temperature-ends] { display: flex; justify-content: space-between; margin-top: -.375rem; color: var(--mk-muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
12
+ [data-mk-temperature-message] { color: var(--mk-error); font-size: .8125rem; font-weight: 400; }
13
+ [data-mk-temperature-message]:empty { display: none; }
14
+ :is(.mk-temperature-slider, mk-temperature-slider)[data-mk-disabled] { opacity: .4; }
15
+ :is(.mk-temperature-slider, mk-temperature-slider)[data-mk-disabled] [data-mk-temperature-input] { cursor: not-allowed; }
16
+ :is(.mk-temperature-slider, mk-temperature-slider)[data-mk-error] [data-mk-temperature-track] { outline: 1px solid var(--mk-error); }
17
+ :is(.mk-temperature-slider, mk-temperature-slider) { --mk-temperature-cool: var(--mk-accent); --mk-temperature-hot: var(--md-sys-color-tertiary, var(--mk-text)); --mk-temperature-tint: color-mix(in srgb, var(--mk-temperature-cool), var(--mk-temperature-hot) var(--mk-temperature-warm, 50%)); }
18
+ [data-mk-temperature-output] { font-size: clamp(2rem, 6vw, 2.75rem); font-weight: 500; letter-spacing: -.06em; line-height: 1.2; }
19
+ [data-mk-temperature-track] { height: 64px; background: color-mix(in srgb, var(--mk-temperature-tint) 5%, var(--mk-surface-2)); }
20
+ [data-mk-temperature-rail] { position: absolute; inset: 28px 16px; border-radius: 64px; background: var(--mk-outline-soft); overflow: hidden; }
21
+ [data-mk-temperature-fill] { display: block; height: 100%; border-radius: inherit; background: color-mix(in srgb, var(--mk-temperature-tint) 42%, var(--mk-text)); }
22
+ [data-mk-temperature-ticks] { position: absolute; inset: 0 16px; display: flex; justify-content: space-between; align-items: center; pointer-events: none; }
23
+ [data-mk-temperature-ticks] > i { width: 1px; height: 18px; background: var(--mk-outline-soft); }
24
+ [data-mk-temperature-thumb] { display: grid; place-items: center; width: 30px; height: 40px; border-radius: 12px; background: var(--mk-text); color: var(--mk-bg); font: 700 18px/1 var(--mk-font); box-shadow: 0 0 0 4px var(--mk-surface-2); transition: width .35s var(--mk-ease-swatch), border-radius .35s var(--mk-ease-swatch); }
25
+ :is(.mk-temperature-slider, mk-temperature-slider)[data-mk-held] [data-mk-temperature-thumb] { width: 38px; border-radius: 16px; }
26
+ @media (hover: hover) { [data-mk-temperature-track]:has(:not(:disabled):hover) [data-mk-temperature-thumb] { border-radius: 16px; width: 34px; } }
27
+ @media (prefers-reduced-motion: reduce) { [data-mk-temperature-thumb] { transition: none; } }
28
+ @media (forced-colors: active) { [data-mk-temperature-rail] { background: GrayText; } [data-mk-temperature-fill], [data-mk-temperature-thumb] { background: Highlight; } }
29
+
@@ -0,0 +1,31 @@
1
+ # Voice Message Bubble
2
+
3
+ Burbuja entrante/saliente con autor, hora y estado de entrega. Compone el reproductor real [Waveform Player](../waveform-player/README.md); no graba, no solicita micrófono y no inicia audio automáticamente.
4
+
5
+ ```tsx
6
+ import { VoiceMessageBubble } from '@yewdiel21/motion-kit/react';
7
+ import '@yewdiel21/motion-kit/css/components/waveform-player';
8
+ import '@yewdiel21/motion-kit/css/components/voice-message-bubble';
9
+
10
+ <VoiceMessageBubble src="/mensaje.wav" peaks={[.2, .6, .9, .4]} author="Ana"
11
+ timestamp="10:42" direction="incoming" />
12
+ <VoiceMessageBubble src="/respuesta.wav" peaks={peaks} author="Tú"
13
+ timestamp="10:43" direction="outgoing" status="read" />
14
+ ```
15
+
16
+ ```html
17
+ <mk-voice-message-bubble src="/mensaje.wav" peaks="[0.2,0.6,0.9,0.4]"
18
+ author="Ana" timestamp="10:42" direction="incoming"></mk-voice-message-bubble>
19
+ <script type="module">
20
+ import { defineVoiceMessageBubble } from '@yewdiel21/motion-kit/elements';
21
+ defineVoiceMessageBubble();
22
+ </script>
23
+ ```
24
+
25
+ `createVoiceMessageBubble(root, options)` acepta todas las opciones del reproductor y añade `direction` (`incoming` por defecto u `outgoing`), `author`, `timestamp` (texto visible), `dateTime` (fecha legible por máquinas, opcional) y `status` (`sending`, `sent`, `delivered`, `read`, `failed`). El estado de entrega lo controla tu aplicación; no implica envío de mensajes. Autor/hora/estado vacíos se ocultan. El nombre accesible del reproductor incluye el autor, o usa `label` si lo especificas.
26
+
27
+ La instancia ofrece `play(): Promise<boolean>`, `pause()`, `seek(seconds)`, `update(partialOptions)`, `state`, `audio`, `destroy()`. `VoiceMessageBubble` admite `instanceRef`. `MkVoiceMessageBubbleElement` expone los mismos métodos y actualiza atributos sin recrear el audio. `peaks` y `playback-rates` son JSON; el atributo de fecha es `date-time`. Los cambios de autor, dirección y hora conservan la reproducción. Eventos `mk:audio-*` del reproductor burbujean desde la burbuja.
28
+
29
+ Necesita **waveform-player.css y voice-message-bubble.css**. El motor también depende de `waveform-player/engine.ts`; copiar la receta debe incluir esa dependencia. Usa los tokens del tema y respeta movimiento reducido a través del reproductor. Los picos se suministran de antemano; no realiza análisis automático de audio. Agrega una transcripción cerca del mensaje para contenido hablado. Los demos usan un sonido sintetizado original local de seis segundos, sin voz humana.
30
+
31
+ Tipos: `VoiceMessageBubbleOptions`, `VoiceMessageBubbleInstance`, `VoiceMessageStatus`, `VoiceMessageBubbleProps`.
@@ -0,0 +1,20 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createVoiceMessageBubble, type VoiceMessageBubbleInstance, type VoiceMessageBubbleOptions } from './engine';
3
+
4
+ export class MkVoiceMessageBubbleElement extends MkElement<VoiceMessageBubbleInstance> {
5
+ static options: Record<string, OptionType> = { src: 'string', peaks: 'json', label: 'string', disabled: 'boolean', playbackRates: 'json', direction: 'string', author: 'string', timestamp: 'string', dateTime: 'string', status: 'string' };
6
+ protected create(options: Record<string, unknown>) { return createVoiceMessageBubble(this, options as VoiceMessageBubbleOptions); }
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<VoiceMessageBubbleOptions>) { 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(MkVoiceMessageBubbleElement.options, option)) return;
17
+ this.instance.update({ [option]: this.options[option] });
18
+ }
19
+ }
20
+ export const defineVoiceMessageBubble = () => defineElement('mk-voice-message-bubble', MkVoiceMessageBubbleElement);
@@ -0,0 +1,79 @@
1
+ import { createScope } from '../../core/scope';
2
+ import type { WaveformPlayerInstance, WaveformPlayerOptions } from '../waveform-player/engine';
3
+ import { createWaveformPlayer } from '../waveform-player/engine';
4
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
5
+ import { reduced } from '../../core/reduced';
6
+
7
+ export type VoiceMessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
8
+ export interface VoiceMessageBubbleOptions extends WaveformPlayerOptions {
9
+ direction?: 'incoming' | 'outgoing';
10
+ author?: string;
11
+ timestamp?: string;
12
+ /** Optional machine-readable timestamp for the time element. */
13
+ dateTime?: string;
14
+ status?: VoiceMessageStatus;
15
+ }
16
+ export interface VoiceMessageBubbleInstance extends WaveformPlayerInstance {
17
+ update(options: Partial<VoiceMessageBubbleOptions>): void;
18
+ }
19
+
20
+ const statusLabels: Record<VoiceMessageStatus, string> = { sending: 'Enviando', sent: 'Enviado', delivered: 'Entregado', read: 'Leído', failed: 'No enviado' };
21
+
22
+ /** A message presentation around the real waveform audio player. Never records audio. */
23
+ export function createVoiceMessageBubble(root: HTMLElement | null, options: VoiceMessageBubbleOptions = {}): VoiceMessageBubbleInstance {
24
+ if (!root) return createWaveformPlayer(null, options);
25
+ registerMotion();
26
+ const scope = createScope();
27
+ let opts = { ...options };
28
+ const content = document.createElement('div');
29
+ content.setAttribute('data-mk-voice-message-content', '');
30
+ const header = document.createElement('div');
31
+ header.setAttribute('data-mk-voice-message-header', '');
32
+ const author = document.createElement('span');
33
+ author.setAttribute('data-mk-voice-message-author', '');
34
+ const caption = document.createElement('span');
35
+ caption.setAttribute('data-mk-voice-message-caption', '');
36
+ caption.textContent = 'Mensaje de voz';
37
+ header.append(author, caption);
38
+ const playerRoot = document.createElement('div');
39
+ playerRoot.className = 'mk-waveform-player';
40
+ const footer = document.createElement('div');
41
+ footer.setAttribute('data-mk-voice-message-footer', '');
42
+ const timestamp = document.createElement('time');
43
+ const status = document.createElement('span');
44
+ status.setAttribute('data-mk-voice-message-status', '');
45
+ footer.append(timestamp, status);
46
+ content.append(header, playerRoot, footer);
47
+ root.append(content);
48
+ scope.add(() => content.remove());
49
+ scope.attr(root, 'data-mk-ready', '');
50
+ scope.attr(root, 'data-mk-direction', 'incoming');
51
+ const player = createWaveformPlayer(playerRoot);
52
+ scope.add(() => player.destroy());
53
+ const update = (next: Partial<VoiceMessageBubbleOptions>) => {
54
+ if (scope.disposed) return;
55
+ opts = { ...opts, ...next };
56
+ author.textContent = opts.author ?? '';
57
+ author.hidden = !opts.author;
58
+ timestamp.textContent = opts.timestamp ?? '';
59
+ timestamp.hidden = !opts.timestamp;
60
+ if (opts.dateTime) timestamp.dateTime = opts.dateTime;
61
+ else timestamp.removeAttribute('datetime');
62
+ status.textContent = opts.status && statusLabels[opts.status] || '';
63
+ status.hidden = !status.textContent;
64
+ root.setAttribute('data-mk-direction', opts.direction === 'outgoing' ? 'outgoing' : 'incoming');
65
+ const { direction: _direction, author: _author, timestamp: _timestamp, dateTime: _dateTime, status: _status, ...audio } = opts;
66
+ player.update({ ...audio, label: opts.label ?? (opts.author ? `mensaje de voz de ${opts.author}` : 'mensaje de voz') });
67
+ };
68
+ update(options);
69
+ // The message grows from its conversational edge, matching the shared chat
70
+ // vocabulary. Its real controls remain available throughout the entrance.
71
+ let entrance: gsap.core.Tween | null = null;
72
+ if (!reduced()) {
73
+ content.style.transformOrigin = opts.direction === 'outgoing' ? 'right bottom' : 'left bottom';
74
+ entrance = gsap.fromTo(content, { scale: .88, opacity: 0, filter: 'blur(12px)', y: 10 }, { scale: 1, opacity: 1, filter: 'blur(0px)', y: 0, duration: .65, ease: EASE.signature, clearProps: 'transform,opacity,filter' });
75
+ }
76
+ scope.add(() => entrance?.kill());
77
+ return { play: () => player.play(), pause: () => player.pause(), seek: n => player.seek(n), update,
78
+ get state() { return player.state; }, get audio() { return player.audio; }, destroy: () => scope.dispose() };
79
+ }
@@ -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,14 @@
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: .5rem; padding: 1rem 1.25rem .75rem; background: var(--mk-surface-2); border: 0; border-radius: 32px 32px 32px 12px; }
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: var(--mk-surface-3); border-radius: 32px 32px 12px 32px; }
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
+ /* The track steps one tonal level above its bubble, like the equalizer above the page. */
12
+ [data-mk-voice-message-content] .mk-waveform-player { --mk-wave-surface: var(--mk-surface-3); }
13
+ :is(.mk-voice-message-bubble, mk-voice-message-bubble)[data-mk-direction='outgoing'] .mk-waveform-player { --mk-wave-surface: var(--mk-surface-4); }
14
+ @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. Comparte el lenguaje del equalizer: pista tonal, barras en píldora remuestreadas al ancho (media de los picos suministrados, sin inventar datos), cabezal recortado que crece al pasar el puntero y al arrastrar, y barras que rebotan al cruzar el cabezal. Tokens locales: `--mk-wave-color` (reproducido y cabezal, texto por defecto), `--mk-wave-track` (pendiente), `--mk-wave-surface` (pista); 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);