@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,329 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { createScope } from '../../core/scope';
3
+ import { reduced } from '../../core/reduced';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { blurIn, press, rollDigits } from '../../motion/micro';
6
+
7
+ export interface NotificationStackItem {
8
+ id: string;
9
+ title: string;
10
+ description?: string;
11
+ tone?: 'neutral' | 'success' | 'error' | 'info';
12
+ /** Milliseconds; zero keeps the notification until dismissed. */
13
+ duration?: number;
14
+ dismissible?: boolean;
15
+ action?: { label: string; onClick?: () => void };
16
+ }
17
+ export type NotificationStackDirection = 'down' | 'up' | 'side';
18
+ export interface NotificationStackOptions {
19
+ items?: NotificationStackItem[];
20
+ expanded?: boolean;
21
+ /** Where the deck opens: below its front card (default), above it, or into a sideways strip. */
22
+ direction?: NotificationStackDirection;
23
+ duration?: number;
24
+ disabled?: boolean;
25
+ label?: string;
26
+ labels?: { expand?: string; collapse?: string; dismiss?: string; empty?: string };
27
+ onDismiss?: (id: string) => void;
28
+ onAction?: (id: string) => void;
29
+ onExpandedChange?: (expanded: boolean) => void;
30
+ }
31
+ export interface NotificationStackInstance extends Instance {
32
+ push(item: NotificationStackItem): void;
33
+ dismiss(id: string): void;
34
+ clear(): void;
35
+ expand(): void;
36
+ collapse(): void;
37
+ toggle(): void;
38
+ setOptions(options: Partial<NotificationStackOptions>): void;
39
+ readonly items: readonly NotificationStackItem[];
40
+ readonly expanded: boolean;
41
+ }
42
+ interface Entry { item: NotificationStackItem; card: HTMLElement; remaining: number; started: number; timer?: ReturnType<typeof setTimeout>; }
43
+ let sequence = 0;
44
+
45
+ /** An isolated notification deck. It deliberately does not subscribe to the global toast store. */
46
+ export function createNotificationStack(root: HTMLElement | null, options: NotificationStackOptions = {}): NotificationStackInstance {
47
+ if (!root) return inert({ push() {}, dismiss() {}, clear() {}, expand() {}, collapse() {}, toggle() {}, setOptions() {}, items: [], expanded: false });
48
+ registerMotion();
49
+ const scope = createScope();
50
+ let opts = { ...options };
51
+ let expanded = options.expanded ?? false;
52
+ let hovered = false;
53
+ let focused = false;
54
+ let inviting = false;
55
+ let resize: ResizeObserver | null = null;
56
+ const heights = new WeakMap<HTMLElement, number>();
57
+ const original = [...root.childNodes];
58
+ const entries = new Map<string, Entry>();
59
+ const animations = new Set<gsap.core.Animation>();
60
+ const track = (animation: gsap.core.Animation | null) => {
61
+ // Keep an active set rather than retaining every dismissed card's timeline.
62
+ for (const prior of animations) if (prior.totalProgress() === 1 || !prior.parent) animations.delete(prior);
63
+ if (animation) animations.add(animation);
64
+ };
65
+ const deck = document.createElement('div');
66
+ deck.className = 'mk-notification-stack__deck';
67
+ deck.id = `mk-notifications-${++sequence}`;
68
+ const empty = document.createElement('p');
69
+ empty.className = 'mk-notification-stack__empty';
70
+ const toggle = document.createElement('button');
71
+ toggle.type = 'button';
72
+ toggle.className = 'mk-notification-stack__toggle';
73
+ toggle.setAttribute('aria-controls', deck.id);
74
+ const count = document.createElement('span');
75
+ const toggleText = document.createElement('span');
76
+ const chevron = document.createElement('span');
77
+ chevron.className = 'mk-notification-stack__chevron';
78
+ chevron.setAttribute('aria-hidden', 'true');
79
+ chevron.innerHTML = '<svg viewBox="0 0 16 16"><path d="m4 6 4 4 4-4"/></svg>';
80
+ toggle.append(count, toggleText, chevron);
81
+ const live = document.createElement('span');
82
+ live.className = 'mk-sr-only';
83
+ live.setAttribute('role', 'status');
84
+ live.setAttribute('aria-live', 'polite');
85
+ root.replaceChildren(deck, empty, toggle, live);
86
+ scope.attr(root, 'class', `${root.className} mk-notification-stack`.trim());
87
+ scope.attr(root, 'data-mk-ready', '');
88
+ scope.attr(root, 'role', 'region');
89
+ const pause = (entry: Entry) => {
90
+ if (entry.timer !== undefined) {
91
+ clearTimeout(entry.timer);
92
+ entry.timer = undefined;
93
+ entry.remaining = Math.max(0, entry.remaining - (Date.now() - entry.started));
94
+ }
95
+ };
96
+ const paused = () => hovered || focused || opts.disabled || document.hidden;
97
+ const start = (entry: Entry) => {
98
+ if (paused() || entry.remaining === Infinity || entry.timer !== undefined) return;
99
+ entry.started = Date.now();
100
+ entry.timer = setTimeout(() => dismiss(entry.item.id), entry.remaining);
101
+ };
102
+ const syncTimers = () => entries.forEach((entry) => paused() ? pause(entry) : start(entry));
103
+ const duration = (item: NotificationStackItem) => {
104
+ const ms = item.duration ?? opts.duration ?? 6000;
105
+ return Number.isFinite(ms) && ms > 0 ? ms : Infinity;
106
+ };
107
+ // `motion` marks an explicit open/close: the deck fans out card by card, newly
108
+ // revealed cards clear their blur and their content follows the same cadence.
109
+ const layout = (motion?: 'expand' | 'collapse') => {
110
+ if (scope.disposed) return;
111
+ const cards = [...entries.values()].reverse();
112
+ const direction: NotificationStackDirection = opts.direction === 'up' || opts.direction === 'side' ? opts.direction : 'down';
113
+ // Set first: the direction changes card anchoring and width before they are measured.
114
+ scope.attr(root, 'data-mk-direction', direction);
115
+ scope.attr(root, 'data-mk-expandable', !expanded && cards.length > 1 && !opts.disabled ? '' : null);
116
+ // Hovering a closed deck spreads its peeks a little, inviting the click that opens it.
117
+ const peek = inviting && !expanded && !reduced() ? 18 : 12;
118
+ cards.forEach((entry, index) => {
119
+ const visible = expanded || index === 0;
120
+ // Covered cards are decorative peeks; their controls are never focusable.
121
+ entry.card.inert = !visible || !!opts.disabled;
122
+ entry.card.setAttribute('aria-hidden', String(!visible));
123
+ entry.card.toggleAttribute('data-mk-peek', !visible);
124
+ });
125
+ // Peeks take the front card's outline, so only their surface shows beneath it.
126
+ if (cards[0]) deck.style.setProperty('--mk-stack-front', `${cards[0].card.offsetHeight}px`);
127
+ let offset = 0;
128
+ let tallest = 0;
129
+ cards.forEach((entry, index) => {
130
+ entry.card.style.zIndex = String(cards.length - index);
131
+ const layer = Math.min(index, 3);
132
+ let x = 0, y = 0;
133
+ if (expanded) {
134
+ if (direction === 'side') x = offset; else y = direction === 'up' ? -offset : offset;
135
+ offset += direction === 'side' ? entry.card.offsetWidth + 12 : entry.card.offsetHeight + 8;
136
+ } else if (direction === 'side') x = layer * peek;
137
+ else y = (direction === 'up' ? -1 : 1) * layer * peek;
138
+ tallest = Math.max(tallest, entry.card.offsetHeight);
139
+ const delay = reduced() ? 0 : motion === 'expand' ? index * .05 : motion === 'collapse' ? Math.max(0, cards.length - 1 - index) * .03 : 0;
140
+ entry.card.style.setProperty('--mk-stack-delay', `${delay}s`);
141
+ track(gsap.to(entry.card, { x, y, scale: expanded ? 1 : Math.max(.84, 1 - index * .045), opacity: expanded || index < 3 ? 1 : 0, duration: reduced() ? 0 : .45, delay, ease: EASE.swatch, overwrite: 'auto' }));
142
+ if (motion === 'expand' && index > 0 && !reduced()) track(gsap.fromTo(entry.card, { filter: 'blur(6px)' }, { filter: 'blur(0px)', duration: .4, delay, ease: 'power2.out', clearProps: 'filter' }));
143
+ });
144
+ const front = cards[0]?.card.offsetHeight || 100;
145
+ const height = !cards.length ? 0 : direction === 'side' ? expanded ? tallest : front : expanded ? offset - 8 : front + Math.min(cards.length - 1, 2) * peek;
146
+ if (!expanded && direction === 'side' && deck.scrollLeft) deck.scrollTo({ left: 0, behavior: reduced() ? 'auto' : 'smooth' });
147
+ track(gsap.to(deck, { height, duration: reduced() ? 0 : .4, ease: EASE.signature, overwrite: 'auto' }));
148
+ empty.hidden = cards.length > 0;
149
+ empty.textContent = opts.labels?.empty ?? 'Todo al día. No hay notificaciones.';
150
+ toggle.hidden = cards.length < 2;
151
+ toggle.disabled = !!opts.disabled;
152
+ toggle.setAttribute('aria-expanded', String(expanded));
153
+ const toggleLabel = expanded ? opts.labels?.collapse ?? 'Recoger' : opts.labels?.expand ?? 'Ver todas';
154
+ if (toggleText.textContent !== toggleLabel) {
155
+ toggleText.textContent = toggleLabel;
156
+ if (motion) track(blurIn(toggleText, { from: 'soft', duration: .25 }));
157
+ }
158
+ track(rollDigits(count, String(cards.length)));
159
+ scope.attr(root, 'aria-label', opts.label ?? 'Notificaciones');
160
+ scope.attr(root, 'data-mk-expanded', String(expanded));
161
+ scope.attr(root, 'data-mk-disabled', opts.disabled ? '' : null);
162
+ };
163
+ const dismiss = (id: string) => {
164
+ if (scope.disposed || opts.disabled) return;
165
+ const entry = entries.get(id);
166
+ if (!entry) return;
167
+ pause(entry);
168
+ resize?.unobserve(entry.card);
169
+ const hadFocus = entry.card.contains(document.activeElement);
170
+ entries.delete(id);
171
+ if (hadFocus) {
172
+ const next = [...entries.values()].reverse()[0];
173
+ const target = next?.card.querySelector<HTMLButtonElement>('button');
174
+ if (target) { next!.card.inert = false; target.focus(); }
175
+ else { root.setAttribute('tabindex', '-1'); root.focus(); }
176
+ }
177
+ gsap.killTweensOf(entry.card);
178
+ entry.card.inert = true;
179
+ entry.card.setAttribute('aria-hidden', 'true');
180
+ if (reduced()) entry.card.remove();
181
+ else track(gsap.to(entry.card, { y: '-=18', scale: .94, opacity: 0, filter: 'blur(8px)', duration: .23, ease: EASE.close, onComplete: () => entry.card.remove() }));
182
+ layout();
183
+ opts.onDismiss?.(id);
184
+ root.dispatchEvent(new CustomEvent('mk:dismiss', { bubbles: true, detail: { id } }));
185
+ };
186
+ const build = (item: NotificationStackItem): Entry => {
187
+ const card = document.createElement('article');
188
+ card.className = 'mk-notification-stack__card';
189
+ card.dataset.mkNotification = item.id;
190
+ card.dataset.mkTone = item.tone ?? 'neutral';
191
+ const mark = document.createElement('span');
192
+ mark.className = 'mk-notification-stack__mark';
193
+ mark.setAttribute('aria-hidden', 'true');
194
+ mark.textContent = item.tone === 'success' ? '✓' : item.tone === 'error' ? '!' : '↗';
195
+ const body = document.createElement('div');
196
+ body.className = 'mk-notification-stack__body';
197
+ const title = document.createElement('strong');
198
+ title.textContent = item.title;
199
+ body.append(title);
200
+ if (item.description) { const description = document.createElement('p'); description.textContent = item.description; body.append(description); }
201
+ if (item.action) {
202
+ const action = document.createElement('button');
203
+ action.type = 'button';
204
+ action.className = 'mk-notification-stack__action';
205
+ action.dataset.mkNotificationAction = item.id;
206
+ action.textContent = item.action.label;
207
+ action.disabled = !!opts.disabled;
208
+ body.append(action);
209
+ }
210
+ card.append(mark, body);
211
+ if (item.dismissible !== false) {
212
+ const close = document.createElement('button');
213
+ close.type = 'button';
214
+ close.className = 'mk-notification-stack__dismiss';
215
+ close.dataset.mkNotificationDismiss = item.id;
216
+ close.setAttribute('aria-label', `${opts.labels?.dismiss ?? 'Cerrar'}: ${item.title}`);
217
+ close.textContent = '×';
218
+ close.disabled = !!opts.disabled;
219
+ card.append(close);
220
+ }
221
+ return { item: { ...item }, card, remaining: duration(item), started: 0 };
222
+ };
223
+ const push = (item: NotificationStackItem) => {
224
+ if (scope.disposed || !item.id || entries.has(item.id)) return;
225
+ const entry = build(item);
226
+ entries.set(item.id, entry);
227
+ deck.append(entry.card);
228
+ resize?.observe(entry.card);
229
+ layout();
230
+ track(blurIn(entry.card, { from: 'pop', duration: .35 }));
231
+ live.textContent = `${item.title}${item.description ? `. ${item.description}` : ''}`;
232
+ start(entry);
233
+ };
234
+ const setExpanded = (next: boolean, notify = true, allowDisabled = false) => {
235
+ if (scope.disposed || expanded === next || (opts.disabled && !allowDisabled)) return;
236
+ // Collapsing while a covered card owns focus returns to the disclosure.
237
+ if (!next && [...entries.values()].slice(0, -1).some((entry) => entry.card.contains(document.activeElement))) {
238
+ if (opts.disabled) { root.tabIndex = -1; root.focus(); }
239
+ else toggle.focus();
240
+ }
241
+ expanded = next;
242
+ layout(next ? 'expand' : 'collapse');
243
+ if (notify) {
244
+ opts.onExpandedChange?.(expanded);
245
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { expanded } }));
246
+ }
247
+ };
248
+ scope.attr(root, 'tabindex', root.getAttribute('tabindex'));
249
+ scope.on(root, 'pointerenter', () => { hovered = true; syncTimers(); });
250
+ scope.on(root, 'pointerleave', () => { hovered = false; syncTimers(); });
251
+ scope.on(root, 'focusin', () => { focused = true; syncTimers(); });
252
+ scope.on(root, 'focusout', (event) => { focused = root.contains(event.relatedTarget as Node | null); syncTimers(); });
253
+ scope.on(document, 'visibilitychange', syncTimers);
254
+ scope.on(deck, 'pointerenter', () => { inviting = true; if (!expanded && entries.size > 1) layout(); });
255
+ scope.on(deck, 'pointerleave', () => { inviting = false; if (!expanded && entries.size > 1) layout(); });
256
+ scope.on(root, 'click', (event) => {
257
+ if (opts.disabled) return;
258
+ // The closed deck is itself a way in; the toggle stays the keyboard path.
259
+ if (!expanded && entries.size > 1 && deck.contains(event.target as Node) && !(event.target as Element).closest('button')) { setExpanded(true); return; }
260
+ const button = (event.target as Element).closest<HTMLButtonElement>('button');
261
+ if (!button || !root.contains(button) || button.disabled) return;
262
+ track(press(button));
263
+ if (button === toggle) setExpanded(!expanded);
264
+ else if (button.dataset.mkNotificationDismiss) dismiss(button.dataset.mkNotificationDismiss);
265
+ else if (button.dataset.mkNotificationAction) {
266
+ const id = button.dataset.mkNotificationAction;
267
+ entries.get(id)?.item.action?.onClick?.();
268
+ opts.onAction?.(id);
269
+ dismiss(id);
270
+ }
271
+ });
272
+ if (typeof ResizeObserver === 'function') {
273
+ resize = scope.observe(new ResizeObserver((records) => {
274
+ let changed = false;
275
+ for (const record of records) {
276
+ const card = record.target as HTMLElement;
277
+ if (heights.get(card) !== card.offsetHeight) { heights.set(card, card.offsetHeight); changed = true; }
278
+ }
279
+ if (changed) layout();
280
+ }));
281
+ }
282
+ // Card heights, rather than deck height, trigger reflow on responsive changes.
283
+ scope.on(window, 'resize', () => layout(), { passive: true });
284
+ const setOptions = (next: Partial<NotificationStackOptions>) => {
285
+ if (scope.disposed) return;
286
+ opts = { ...opts, ...next };
287
+ if (next.items) {
288
+ const ids = new Set(next.items.map((item) => item.id));
289
+ for (const [id, entry] of entries) if (!ids.has(id)) {
290
+ pause(entry);
291
+ resize?.unobserve(entry.card);
292
+ if (entry.card.contains(document.activeElement)) { root.setAttribute('tabindex', '-1'); root.focus(); }
293
+ gsap.killTweensOf(entry.card); entry.card.remove(); entries.delete(id);
294
+ }
295
+ for (const item of next.items) {
296
+ const existing = entries.get(item.id);
297
+ if (!existing) { push(item); continue; }
298
+ // Updating copy preserves the remaining lifetime of a notification.
299
+ const replacement = build(item);
300
+ if (existing.card.contains(document.activeElement)) { root.setAttribute('tabindex', '-1'); root.focus(); }
301
+ pause(existing);
302
+ replacement.remaining = existing.remaining;
303
+ existing.card.replaceWith(replacement.card);
304
+ gsap.killTweensOf(existing.card);
305
+ entries.set(item.id, replacement);
306
+ resize?.unobserve(existing.card);
307
+ resize?.observe(replacement.card);
308
+ }
309
+ const ordered = next.items.map((item) => entries.get(item.id)).filter((entry): entry is Entry => !!entry);
310
+ entries.clear();
311
+ for (const entry of ordered) { entries.set(entry.item.id, entry); deck.append(entry.card); }
312
+ }
313
+ if (next.expanded !== undefined) setExpanded(next.expanded, false, true);
314
+ for (const entry of entries.values()) entry.card.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
315
+ button.disabled = !!opts.disabled;
316
+ if (button.hasAttribute('data-mk-notification-dismiss')) button.setAttribute('aria-label', `${opts.labels?.dismiss ?? 'Cerrar'}: ${entry.item.title}`);
317
+ });
318
+ layout(); syncTimers();
319
+ };
320
+ for (const item of options.items ?? []) push(item);
321
+ layout();
322
+ return {
323
+ push, dismiss, clear() { for (const id of [...entries.keys()]) dismiss(id); },
324
+ expand: () => setExpanded(true), collapse: () => setExpanded(false), toggle: () => setExpanded(!expanded), setOptions,
325
+ get items() { return [...entries.values()].map((entry) => ({ ...entry.item })); },
326
+ get expanded() { return expanded; },
327
+ destroy() { if (scope.disposed) return; entries.forEach(pause); animations.forEach((animation) => animation.kill()); gsap.killTweensOf([...root.querySelectorAll('*')]); scope.dispose(); root.replaceChildren(...original); },
328
+ };
329
+ }
@@ -0,0 +1,41 @@
1
+ .mk-notification-stack, mk-notification-stack { display: block; width: min(100%, 400px); color: var(--mk-text); font-family: var(--mk-font); }
2
+ .mk-notification-stack__deck { position: relative; width: 100%; perspective: 900px; }
3
+ .mk-notification-stack__card { box-sizing: border-box; position: absolute; inset: 0 0 auto; display: flex; align-items: flex-start; gap: 12px; padding: 20px; min-height: 100px; border-radius: 24px; background: var(--mk-surface-2); box-shadow: 0 1px 0 var(--mk-outline-soft) inset, 0 12px 24px -20px var(--mk-shadow-color, rgb(0 0 0 / .4)); transform-origin: center top; }
4
+ .mk-notification-stack__body { min-width: 0; flex: 1; }
5
+ .mk-notification-stack__card > * { transition: opacity .25s ease var(--mk-stack-delay, 0s); }
6
+ .mk-notification-stack__card[data-mk-peek] { max-height: var(--mk-stack-front, 100px); overflow: hidden; }
7
+ .mk-notification-stack__card[data-mk-peek] > * { opacity: 0; }
8
+ .mk-notification-stack__body strong { display: block; font-size: 14px; font-weight: 600; line-height: 1.4; }
9
+ .mk-notification-stack__body p { margin: 4px 0 0; color: var(--mk-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
10
+ .mk-notification-stack__mark { flex-shrink: 0; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 12px; background: var(--mk-surface-3, var(--mk-surface)); font-size: 16px; }
11
+ .mk-notification-stack__card[data-mk-tone=success] .mk-notification-stack__mark { background: var(--mk-accent-soft); color: var(--mk-on-accent-soft); }
12
+ .mk-notification-stack__card[data-mk-tone=error] .mk-notification-stack__mark { background: color-mix(in srgb, var(--mk-error, #b3261e) 12%, var(--mk-surface-2)); color: var(--mk-error, #b3261e); }
13
+ .mk-notification-stack button { border: 0; color: inherit; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
14
+ .mk-notification-stack__dismiss { flex-shrink: 0; display: grid; place-items: center; width: 32px; height: 32px; margin: -6px -6px 0 0; border-radius: 64px; background: transparent; font-size: 20px !important; }
15
+ .mk-notification-stack__action { margin-top: 10px; min-height: 32px; padding: 6px 12px; border-radius: 64px; background: var(--mk-surface-3, var(--mk-surface)); font-size: 12px !important; font-weight: 600 !important; }
16
+ .mk-notification-stack__toggle { display: flex; align-items: center; gap: 8px; min-height: 36px; margin: 16px auto 0; padding: 6px 14px; border-radius: 64px; background: var(--mk-surface); font-size: 12px !important; transition: background .2s, padding .4s var(--mk-ease-swatch); }
17
+ .mk-notification-stack__toggle > span:first-child { min-width: 20px; font-variant-numeric: tabular-nums; }
18
+ .mk-notification-stack__chevron { display: grid; place-items: center; width: 16px; height: 16px; transition: rotate .45s var(--mk-ease-swatch); }
19
+ .mk-notification-stack__chevron svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
20
+ .mk-notification-stack[data-mk-expanded='true'] .mk-notification-stack__chevron { rotate: 180deg; }
21
+ .mk-notification-stack__empty { padding: 24px; margin: 0; border-radius: 24px; background: var(--mk-surface); text-align: center; font-size: 13px; color: var(--mk-muted); }
22
+ .mk-notification-stack [hidden] { display: none !important; }
23
+ /* A closed deck with more than one card opens on click. */
24
+ .mk-notification-stack[data-mk-expandable] .mk-notification-stack__deck { cursor: pointer; }
25
+ /* Up: cards rest on the deck's floor and rise above the front one; the disclosure sits on top. */
26
+ .mk-notification-stack[data-mk-direction='up'] { display: flex; flex-direction: column-reverse; }
27
+ .mk-notification-stack[data-mk-direction='up'] .mk-notification-stack__card { inset: auto 0 0; transform-origin: center bottom; }
28
+ .mk-notification-stack[data-mk-direction='up'] .mk-notification-stack__toggle { margin: 0 auto 16px; }
29
+ .mk-notification-stack[data-mk-direction='up'] .mk-notification-stack__chevron { rotate: 180deg; }
30
+ .mk-notification-stack[data-mk-direction='up'][data-mk-expanded='true'] .mk-notification-stack__chevron { rotate: 0deg; }
31
+ /* Side: peeks show at the right edge; open, the cards become a strip that scrolls sideways. */
32
+ .mk-notification-stack[data-mk-direction='side'] .mk-notification-stack__card { right: auto; width: min(86%, 340px); transform-origin: right center; }
33
+ .mk-notification-stack[data-mk-direction='side'][data-mk-expanded='true'] .mk-notification-stack__deck { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none; }
34
+ .mk-notification-stack[data-mk-direction='side'][data-mk-expanded='true'] .mk-notification-stack__deck::-webkit-scrollbar { display: none; }
35
+ .mk-notification-stack[data-mk-direction='side'] .mk-notification-stack__chevron { rotate: -90deg; }
36
+ .mk-notification-stack[data-mk-direction='side'][data-mk-expanded='true'] .mk-notification-stack__chevron { rotate: 90deg; }
37
+ .mk-notification-stack button:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 3px; }
38
+ .mk-notification-stack button:disabled { opacity: .4; cursor: not-allowed; }
39
+ @media (hover: hover) { .mk-notification-stack button:not(:disabled):hover { background: var(--mk-surface-3, var(--mk-surface)); } .mk-notification-stack__toggle:not(:disabled):hover { padding-inline: 18px; } }
40
+ @media (max-width: 480px) { .mk-notification-stack__card { padding: 16px; gap: 10px; } }
41
+ @media (prefers-reduced-motion: reduce) { .mk-notification-stack * { transition: none !important; } }
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createNotificationStack, type NotificationStackOptions, type NotificationStackInstance } from './engine';
5
+
6
+ export interface NotificationStackProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'children' | 'dangerouslySetInnerHTML'>, NotificationStackOptions {
7
+ instanceRef?: MutableRefObject<NotificationStackInstance | null>;
8
+ children?: never;
9
+ dangerouslySetInnerHTML?: never;
10
+ }
11
+ export function NotificationStack({ items, expanded, direction, duration, disabled, label, labels, onDismiss, onAction, onExpandedChange, instanceRef, className, children, dangerouslySetInnerHTML: _html, ...props }: NotificationStackProps) {
12
+ const root = useRef<HTMLDivElement>(null);
13
+ const instance = useRef<NotificationStackInstance | null>(null);
14
+ const onDismissRef = useLatest(onDismiss);
15
+ const onActionRef = useLatest(onAction);
16
+ const onExpandedChangeRef = useLatest(onExpandedChange);
17
+ useIsomorphicLayoutEffect(() => {
18
+ const created = createNotificationStack(root.current, { items, expanded, direction, duration, disabled, label, labels, onDismiss: (...args) => onDismissRef.current?.(...args), onAction: (...args) => onActionRef.current?.(...args), onExpandedChange: (...args) => onExpandedChangeRef.current?.(...args) });
19
+ instance.current = created;
20
+ return () => { created.destroy(); instance.current = null; };
21
+ // Options update the existing engine so timers, selection and scroll state survive.
22
+ }, []);
23
+ useIsomorphicLayoutEffect(() => {
24
+ instance.current?.setOptions({ items, expanded, direction, duration, disabled, label, labels });
25
+ }, [items, expanded, direction, duration, disabled, label, labels]);
26
+ useIsomorphicLayoutEffect(() => { if (instanceRef) instanceRef.current = instance.current; return () => { if (instanceRef) instanceRef.current = null; }; }, [instanceRef]);
27
+ return <div {...props} ref={root} className={['mk-notification-stack', className].filter(Boolean).join(' ')}></div>;
28
+ }
@@ -0,0 +1,26 @@
1
+ # Particle button
2
+
3
+ Botón tonal con una explosión breve de puntos al activarlo. Reutiliza `press` y `burst` del kit; las partículas salen desde el botón y desaparecen. Mantiene una sola explosión durante activaciones rápidas, con un máximo de 32 puntos. No hay efectos en reposo.
4
+
5
+ ```ts
6
+ import { createParticleButton } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/particle-button';
8
+ const effect = createParticleButton(document.querySelector('#done'), {
9
+ onAction: () => completeLocalStep(),
10
+ });
11
+ // effect.burst(); effect.destroy();
12
+ ```
13
+
14
+ `ParticleButtonOptions`: `label` (Celebrar, solo si el botón no tiene contenido propio), `count` (9 pares, limitado a 3–16), `distance` (52px, limitado a 12–100), `onAction`. `ParticleButtonInstance`: `burst()`, `destroy()`. Emite `mk:action` en una activación. El factory admite un botón o un host con botón; si falta, crea un botón nativo. Conserva el `type` proporcionado; por defecto es `button`. Respeta `disabled`/`aria-disabled`; el motor no ejecuta operaciones de red ni altera formularios.
15
+
16
+ ```tsx
17
+ <ParticleButton label="Completar un paso" onAction={completeLocalStep} />
18
+ ```
19
+
20
+ `ParticleButtonProps` añade atributos de botón e `instanceRef`. El contenido se proporciona con `label`: los hijos y `dangerouslySetInnerHTML` se filtran porque el motor administra el DOM. Un cambio de `instanceRef` limpia el anterior.
21
+
22
+ ```html
23
+ <mk-particle-button label="Celebrar"></mk-particle-button>
24
+ ```
25
+
26
+ `defineParticleButton()` registra `MkParticleButtonElement`; atributos `label`, `count`, `distance`. También acepta un botón escrito a mano como hijo y restaura su contenido al destruirse. Movimiento reducido mantiene la acción y suprime la explosión y la pulsación. `destroy()` elimina partículas, animaciones, eventos y los nodos creados, y restaura estilos originales.
@@ -0,0 +1,13 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createParticleButton, type ParticleButtonInstance } from './engine';
3
+
4
+ export class MkParticleButtonElement extends MkElement<ParticleButtonInstance> {
5
+ static options: Record<string, OptionType> = { label: 'string', count: 'number', distance: 'number' };
6
+ protected create(options: Record<string, unknown>) { return createParticleButton(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 defineParticleButton = () => defineElement('mk-particle-button', MkParticleButtonElement);
@@ -0,0 +1,52 @@
1
+ import { gsap, 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 { burst, press } from '../../motion/micro';
6
+
7
+ export interface ParticleButtonOptions {
8
+ /** Used only when the control has no authored content. */
9
+ label?: string;
10
+ /** Number of particle pairs, clamped to 3–16. */
11
+ count?: number;
12
+ distance?: number;
13
+ onAction?: () => void;
14
+ }
15
+ export interface ParticleButtonInstance extends Instance { burst(): void; }
16
+
17
+ export function createParticleButton(root: HTMLElement | null, options: ParticleButtonOptions = {}): ParticleButtonInstance {
18
+ if (!root) return inert({ burst() {} });
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 name = document.createElement('span'); name.textContent = options.label ?? 'Celebrar'; control.append(name); scope.add(() => name.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-particle-button', '');
32
+ scope.attr(root, 'data-mk-ready', '');
33
+ const count = Number.isFinite(options.count) ? Math.min(16, Math.max(3, Math.round(options.count!))) : 9;
34
+ const distance = Number.isFinite(options.distance) ? Math.min(100, Math.max(12, options.distance!)) : 52;
35
+ let particles: gsap.core.Animation | null = null;
36
+ let pressing: gsap.core.Animation | null = null;
37
+ const fire = () => {
38
+ if (scope.disposed || control.disabled || control.getAttribute('aria-disabled') === 'true') return;
39
+ particles?.kill();
40
+ particles = reduced() ? null : burst(control, { count, distance, ring: false, size: 5, colors: ['var(--mk-text)', 'var(--mk-accent)'] });
41
+ };
42
+ scope.on(control, 'click', (event) => {
43
+ if (control.disabled || control.getAttribute('aria-disabled') === 'true') { event.preventDefault(); return; }
44
+ pressing?.kill(); pressing = press(control);
45
+ pressing?.eventCallback('onComplete', () => { gsap.set(control, { clearProps: 'transform,scale' }); control.style.transform = originalTransform; if (!control.getAttribute('style')) control.removeAttribute('style'); });
46
+ fire();
47
+ options.onAction?.();
48
+ root.dispatchEvent(new CustomEvent('mk:action', { bubbles: true }));
49
+ });
50
+ scope.add(() => { particles?.kill(); pressing?.kill(); });
51
+ return { burst: fire, destroy: () => scope.dispose() };
52
+ }
@@ -0,0 +1,13 @@
1
+ .mk-particle-button,
2
+ mk-particle-button { display: inline-flex; overflow: visible; }
3
+ [data-mk-particle-button] {
4
+ position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: .6rem;
5
+ min-height: 48px; padding: .8rem 1.45rem; border: 1px solid var(--mk-outline-soft); border-radius: 16px;
6
+ background: var(--mk-surface-3); color: var(--mk-text); font: 500 .9375rem var(--mk-font); cursor: pointer;
7
+ transition: background 160ms, border-radius 320ms var(--mk-ease-swatch);
8
+ }
9
+ [data-mk-particle-button]:hover:not(:disabled) { background: var(--mk-surface-4); border-radius: 24px; }
10
+ [data-mk-particle-button]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
11
+ [data-mk-particle-button]:disabled,
12
+ [data-mk-particle-button][aria-disabled='true'] { opacity: .4; cursor: not-allowed; }
13
+ @media (prefers-reduced-motion: reduce) { [data-mk-particle-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 { createParticleButton, type ParticleButtonInstance, type ParticleButtonOptions } from './engine';
5
+
6
+ export interface ParticleButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'dangerouslySetInnerHTML'>, ParticleButtonOptions { instanceRef?: Ref<ParticleButtonInstance>; }
7
+ export function ParticleButton({ label = 'Celebrar', count, distance, onAction, instanceRef, type = 'button', className, ...props }: ParticleButtonProps) {
8
+ const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as ButtonHTMLAttributes<HTMLButtonElement>;
9
+ const root = useRef<HTMLButtonElement>(null);
10
+ const instance = useRef<ParticleButtonInstance | null>(null);
11
+ const action = useLatest(onAction);
12
+ useIsomorphicLayoutEffect(() => {
13
+ const created = createParticleButton(root.current, { label, count, distance, onAction: () => action.current?.() });
14
+ instance.current = created;
15
+ return () => { created.destroy(); instance.current = null; };
16
+ }, [label, count, distance]);
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, distance]);
21
+ return <button {...safe} type={type} ref={root} className={['mk-particle-button', className].filter(Boolean).join(' ')} />;
22
+ }
@@ -0,0 +1,43 @@
1
+ # RadioGroup
2
+
3
+ Opciones nativas con selección tonal: píldoras, tarjetas descriptivas y filas de ajustes. El radio del elemento elegido cambia a una cápsula; la marca entra con swatch y cada pulsación responde con press.
4
+
5
+ ```ts
6
+ import { createRadioGroup } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/radio-group';
8
+
9
+ const control = createRadioGroup(document.querySelector('.mk-radio-group'), {
10
+ items: [{value: 'weekly', label: 'Semanal'}, {value: 'monthly', label: 'Mensual', description: 'Un resumen al mes'}],
11
+ value: 'weekly', name: 'frequency', variant: 'card', onChange: console.log,
12
+ });
13
+ control.setValue('monthly');
14
+ ```
15
+
16
+ ## Opciones y contrato
17
+
18
+ `items: { value, label, description?, disabled? }[]`, `variant: pill | card | list` (pill), `value` / `defaultValue` (null), `name`, `label`, `required`, `disabled`, `error`, `onChange(value)`.
19
+
20
+ Un valor desconocido o null limpia la selección. Los valores duplicados de items se omiten. Las opciones disabled conservan su semántica nativa y no se pueden elegir con el usuario. El grupo vacío explica que no hay opciones.
21
+
22
+ La instancia expone `value: string | null`, `setValue(value, 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:change` con `detail: { value }`.
23
+
24
+ ## React y HTML
25
+
26
+ ```tsx
27
+ import { RadioGroup } from '@yewdiel21/motion-kit/react';
28
+
29
+ <RadioGroup items={items} value={frequency} onChange={setFrequency} name="frequency" variant="pill" />
30
+ ```
31
+
32
+ `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.
33
+
34
+ ```html
35
+ <mk-radio-group class="mk-radio-group" items='[{"value":"weekly","label":"Semanal"},{"value":"monthly","label":"Mensual"}]' value="weekly" name="frequency"></mk-radio-group>
36
+ ```
37
+
38
+ Registra con `defineRadioGroup()` 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`.
39
+
40
+ ## Accesibilidad y lifecycle
41
+
42
+ 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.
43
+
@@ -0,0 +1,41 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createRadioGroup, type RadioGroupInstance } from './engine';
3
+
4
+ /** Light DOM; attribute changes keep the current imperative value and reset baseline. */
5
+ export class MkRadioGroupElement extends MkElement<RadioGroupInstance> {
6
+ static options: Record<string, OptionType> = {"items":"json","value":"string","name":"string","label":"string","disabled":"boolean","required":"boolean","error":"string","variant":"string"};
7
+ private baseline: string | null | undefined;
8
+ private saved: string | null | undefined;
9
+ protected create(options: Record<string, unknown>) {
10
+ const created = createRadioGroup(this, { ...options, value: this.saved !== undefined ? this.saved : options.value as string | null | undefined, defaultValue: this.baseline });
11
+ if (this.baseline === undefined) this.baseline = created.value;
12
+ this.saved = undefined;
13
+ return created;
14
+ }
15
+ disconnectedCallback() {
16
+ this.saved = this.instance?.value;
17
+ super.disconnectedCallback();
18
+ }
19
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
20
+ if (previous === next) return;
21
+ if (name === 'value' && (!this.instance || !this.isConnected)) {
22
+ this.saved = next === null ? this.baseline ?? null : next;
23
+ return;
24
+ }
25
+ if (!this.instance || !this.isConnected) return;
26
+ if (name === 'value') {
27
+ const parsed = next === null ? this.baseline ?? null : next;
28
+ this.instance.setValue(parsed, false);
29
+ return;
30
+ }
31
+ this.saved = this.instance.value;
32
+ super.attributeChangedCallback(name, previous, next);
33
+ }
34
+ get value(): string | null { return this.instance?.value ?? this.saved ?? null; }
35
+ set value(next: string | null) { this.setValue(next); }
36
+ setValue(value: string | null, notify = false) { if (this.instance) this.instance.setValue(value, notify); else this.saved = value; }
37
+ reset(notify = false) { this.instance?.reset(notify); }
38
+
39
+ }
40
+ export const defineRadioGroup = () => defineElement('mk-radio-group', MkRadioGroupElement);
41
+
@@ -0,0 +1,11 @@
1
+ import { createChoices, type ChoiceInstance, type ChoiceItem, type ChoiceOptions } from './shared';
2
+
3
+ export interface RadioGroupItem extends ChoiceItem {}
4
+ export interface RadioGroupOptions extends Omit<ChoiceOptions, 'variant' | 'caption' | 'eventValue'> {
5
+ /** Pill selectors, descriptive cards, or compact settings rows. */
6
+ variant?: 'pill' | 'card' | 'list';
7
+ }
8
+ export interface RadioGroupInstance extends ChoiceInstance {}
9
+ export function createRadioGroup(root: HTMLElement | null, options: RadioGroupOptions = {}): RadioGroupInstance {
10
+ return createChoices(root, 'radio', options);
11
+ }