@yewdiel21/motion-kit 0.14.0 → 0.15.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 (154) hide show
  1. package/README.md +174 -155
  2. package/bin/motion-kit.mjs +3 -3
  3. package/dist/auto/index.d.ts +6 -3
  4. package/dist/auto/index.js +8 -2
  5. package/dist/auto/index.js.map +1 -1
  6. package/dist/{chunk-EY4BJKC4.js → chunk-6IRJYXJU.js} +2 -2
  7. package/dist/{chunk-CU6SXW3E.js → chunk-A6QQCF6V.js} +1309 -310
  8. package/dist/chunk-A6QQCF6V.js.map +1 -0
  9. package/dist/chunk-JXRS3OEA.js +3638 -0
  10. package/dist/chunk-JXRS3OEA.js.map +1 -0
  11. package/dist/css/components/add-to-cart.css +72 -0
  12. package/dist/css/components/bidirectional-slider.css +168 -0
  13. package/dist/css/components/data-table.css +121 -26
  14. package/dist/css/components/filter-builder.css +10 -15
  15. package/dist/css/components/heatmap-chart.css +391 -0
  16. package/dist/css/components/hover-card.css +8 -2
  17. package/dist/css/components/image-preview.css +90 -2
  18. package/dist/css/components/json-viewer.css +33 -1
  19. package/dist/css/components/nav.css +102 -102
  20. package/dist/css/components/orbit-menu.css +130 -0
  21. package/dist/css/components/pagination.css +10 -5
  22. package/dist/css/components/pie-chart.css +390 -0
  23. package/dist/css/components/pill-button.css +135 -0
  24. package/dist/css/components/radar-chart.css +370 -0
  25. package/dist/css/components/radial-chart.css +398 -0
  26. package/dist/css/components/sankey-chart.css +358 -0
  27. package/dist/css/components/scroll-island.css +111 -0
  28. package/dist/css/components/select.css +136 -0
  29. package/dist/css/components/toolbar.css +122 -0
  30. package/dist/css/components/tree-view.css +79 -0
  31. package/dist/css/components/voice-dots.css +70 -0
  32. package/dist/elements/index.d.ts +180 -7
  33. package/dist/elements/index.js +287 -6
  34. package/dist/elements/index.js.map +1 -1
  35. package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
  36. package/dist/index.css +1832 -168
  37. package/dist/index.d.ts +4 -4
  38. package/dist/index.js +33 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/react/index.d.ts +117 -11
  41. package/dist/react/index.js +314 -13
  42. package/dist/react/index.js.map +1 -1
  43. package/dist/{reduced-BBUusld6.d.ts → reduced-6X_bns60.d.ts} +1 -1
  44. package/dist/{reveal-DEvp6U6T.d.ts → reveal-BfyKKsw4.d.ts} +97 -4
  45. package/dist/store-CA2Qcun_.d.ts +453 -0
  46. package/package.json +9 -16
  47. package/registry.json +1096 -948
  48. package/src/auto/auto-init.ts +6 -0
  49. package/src/components/add-to-cart/README.md +34 -0
  50. package/src/components/add-to-cart/add-to-cart.css +71 -0
  51. package/src/components/add-to-cart/element.ts +20 -0
  52. package/src/components/add-to-cart/engine.ts +206 -0
  53. package/src/components/add-to-cart/react.tsx +29 -0
  54. package/src/components/bidirectional-slider/README.md +46 -0
  55. package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
  56. package/src/components/bidirectional-slider/element.ts +42 -0
  57. package/src/components/bidirectional-slider/engine.ts +289 -0
  58. package/src/components/bidirectional-slider/react.tsx +45 -0
  59. package/src/components/chart/README.md +10 -0
  60. package/src/components/chart/element.ts +2 -0
  61. package/src/components/chart/engine.ts +50 -23
  62. package/src/components/chart/parts.ts +237 -0
  63. package/src/components/chart/react.tsx +2 -2
  64. package/src/components/chart/use-chart.ts +55 -0
  65. package/src/components/data-table/README.md +3 -3
  66. package/src/components/data-table/data-table.css +121 -26
  67. package/src/components/data-table/engine.ts +81 -29
  68. package/src/components/editable-table/engine.ts +67 -11
  69. package/src/components/filter-builder/engine.ts +33 -2
  70. package/src/components/filter-builder/filter-builder.css +10 -15
  71. package/src/components/heatmap-chart/README.md +48 -0
  72. package/src/components/heatmap-chart/element.ts +21 -0
  73. package/src/components/heatmap-chart/engine.ts +281 -0
  74. package/src/components/heatmap-chart/heatmap-chart.css +93 -0
  75. package/src/components/heatmap-chart/react.tsx +14 -0
  76. package/src/components/hover-card/engine.ts +30 -1
  77. package/src/components/hover-card/hover-card.css +8 -2
  78. package/src/components/image-preview/element.ts +1 -1
  79. package/src/components/image-preview/engine.ts +95 -15
  80. package/src/components/image-preview/image-preview.css +90 -2
  81. package/src/components/image-preview/react.tsx +3 -3
  82. package/src/components/json-viewer/engine.ts +11 -2
  83. package/src/components/json-viewer/json-viewer.css +33 -1
  84. package/src/components/nav/README.md +12 -10
  85. package/src/components/nav/element.ts +5 -5
  86. package/src/components/nav/engine.ts +21 -14
  87. package/src/components/nav/nav.css +102 -102
  88. package/src/components/nav/react.tsx +4 -4
  89. package/src/components/orbit-menu/README.md +46 -0
  90. package/src/components/orbit-menu/element.ts +27 -0
  91. package/src/components/orbit-menu/engine.ts +179 -0
  92. package/src/components/orbit-menu/orbit-menu.css +129 -0
  93. package/src/components/orbit-menu/react.tsx +44 -0
  94. package/src/components/pagination/README.md +1 -1
  95. package/src/components/pagination/engine.ts +50 -4
  96. package/src/components/pagination/pagination.css +10 -5
  97. package/src/components/pie-chart/README.md +48 -0
  98. package/src/components/pie-chart/element.ts +30 -0
  99. package/src/components/pie-chart/engine.ts +283 -0
  100. package/src/components/pie-chart/pie-chart.css +92 -0
  101. package/src/components/pie-chart/react.tsx +14 -0
  102. package/src/components/pill-button/README.md +46 -0
  103. package/src/components/pill-button/element.ts +16 -0
  104. package/src/components/pill-button/engine.ts +77 -0
  105. package/src/components/pill-button/pill-button.css +134 -0
  106. package/src/components/pill-button/react.tsx +32 -0
  107. package/src/components/radar-chart/README.md +48 -0
  108. package/src/components/radar-chart/element.ts +30 -0
  109. package/src/components/radar-chart/engine.ts +327 -0
  110. package/src/components/radar-chart/radar-chart.css +72 -0
  111. package/src/components/radar-chart/react.tsx +14 -0
  112. package/src/components/radial-chart/README.md +48 -0
  113. package/src/components/radial-chart/element.ts +30 -0
  114. package/src/components/radial-chart/engine.ts +290 -0
  115. package/src/components/radial-chart/radial-chart.css +100 -0
  116. package/src/components/radial-chart/react.tsx +14 -0
  117. package/src/components/sankey-chart/README.md +48 -0
  118. package/src/components/sankey-chart/element.ts +31 -0
  119. package/src/components/sankey-chart/engine.ts +401 -0
  120. package/src/components/sankey-chart/react.tsx +14 -0
  121. package/src/components/sankey-chart/sankey-chart.css +60 -0
  122. package/src/components/scroll-island/README.md +34 -0
  123. package/src/components/scroll-island/element.ts +16 -0
  124. package/src/components/scroll-island/engine.ts +252 -0
  125. package/src/components/scroll-island/react.tsx +26 -0
  126. package/src/components/scroll-island/scroll-island.css +110 -0
  127. package/src/components/select/README.md +34 -0
  128. package/src/components/select/element.ts +26 -0
  129. package/src/components/select/engine.ts +267 -0
  130. package/src/components/select/react.tsx +33 -0
  131. package/src/components/select/select.css +135 -0
  132. package/src/components/toolbar/README.md +46 -0
  133. package/src/components/toolbar/element.ts +30 -0
  134. package/src/components/toolbar/engine.ts +230 -0
  135. package/src/components/toolbar/react.tsx +34 -0
  136. package/src/components/toolbar/toolbar.css +121 -0
  137. package/src/components/tree-view/element.ts +1 -1
  138. package/src/components/tree-view/engine.ts +174 -0
  139. package/src/components/tree-view/react.tsx +7 -3
  140. package/src/components/tree-view/tree-view.css +79 -0
  141. package/src/components/voice-dots/README.md +48 -0
  142. package/src/components/voice-dots/element.ts +45 -0
  143. package/src/components/voice-dots/engine.ts +243 -0
  144. package/src/components/voice-dots/react.tsx +52 -0
  145. package/src/components/voice-dots/voice-dots.css +69 -0
  146. package/src/elements/index.ts +39 -0
  147. package/src/index.ts +16 -0
  148. package/src/motion/liquid.ts +14 -4
  149. package/src/react/index.ts +14 -1
  150. package/dist/chunk-6P4P7NU6.js +0 -1401
  151. package/dist/chunk-6P4P7NU6.js.map +0 -1
  152. package/dist/chunk-CU6SXW3E.js.map +0 -1
  153. package/dist/store-DjkTBX7f.d.ts +0 -195
  154. /package/dist/{chunk-EY4BJKC4.js.map → chunk-6IRJYXJU.js.map} +0 -0
@@ -0,0 +1,289 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { press } from '../../motion/micro';
6
+
7
+ /*
8
+ * Bidirectional slider: a value either side of an origin (−100…100 around 0 by
9
+ * default), for volume, balance, exposure or a correction. The fill grows from
10
+ * the origin towards the thumb in the "less" or "more" colour; crossing the
11
+ * origin pops its notch; reaching an end bumps that end's icon. Dragging past
12
+ * an end stretches the track like rubber (up to 4 %) and it springs back when
13
+ * let go. The − and + buttons step it (held, they repeat), a double click
14
+ * glides back to the origin, and a bubble shows the signed value while it is
15
+ * held or focused. Keys, the buttons and setValue glide on the swatch curve
16
+ * (.38,.49,0,1.5); dragging follows the pointer. Built on a native
17
+ * <input type="range"> (keyboard, touch, forms and assistive tech).
18
+ */
19
+
20
+ export interface BidirectionalSliderOptions {
21
+ /** Default −100. */
22
+ min?: number;
23
+ /** Default 100. */
24
+ max?: number;
25
+ /** Where the fill starts (default 0, kept inside min…max). */
26
+ origin?: number;
27
+ step?: number;
28
+ value?: number;
29
+ disabled?: boolean;
30
+ name?: string;
31
+ /** Accessible name of the input. */
32
+ label?: string;
33
+ /** The end icons: − and + (default), the speaker, or none. */
34
+ icons?: 'signs' | 'volume' | false;
35
+ /** Text for the bubble and aria-valuetext (default: signed, "+24", "−12"). */
36
+ formatValue?: (value: number) => string;
37
+ onInput?: (value: number) => void;
38
+ onChange?: (value: number) => void;
39
+ }
40
+
41
+ export interface BidirectionalSliderInstance extends Instance {
42
+ setValue(value: number, notify?: boolean): void;
43
+ /** Back to the origin. */
44
+ reset(notify?: boolean): void;
45
+ readonly value: number;
46
+ }
47
+
48
+ const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
49
+ const ICONS = {
50
+ signs: ['<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h12"/></svg>', '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h12M12 6v12"/></svg>'],
51
+ volume: [
52
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5"/></svg>',
53
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5"/><path d="M18 7a7 7 0 0 1 0 10"/></svg>',
54
+ ],
55
+ } as const;
56
+
57
+ export function createBidirectionalSlider(root: HTMLElement | null, options: BidirectionalSliderOptions = {}): BidirectionalSliderInstance {
58
+ if (!root) return inert({ setValue() {}, reset() {}, value: 0 });
59
+ registerMotion();
60
+ const scope = createScope();
61
+ const min = finite(options.min) ? options.min : -100;
62
+ const max = finite(options.max) && options.max > min ? options.max : Math.max(min + 1, 100);
63
+ const origin = Math.min(max, Math.max(min, finite(options.origin) ? options.origin : 0));
64
+ const step = finite(options.step) && options.step > 0 ? options.step : 1;
65
+ const decimals = (String(step).split('.')[1] ?? '').length;
66
+ const snap = (n: number) => Number(Math.min(max, Math.max(min, Math.round((n - min) / step) * step + min)).toFixed(decimals));
67
+ const format =
68
+ options.formatValue ??
69
+ ((n: number) => {
70
+ const delta = Number((n - origin).toFixed(decimals));
71
+ const text = new Intl.NumberFormat(undefined, { maximumFractionDigits: decimals }).format(Math.abs(delta));
72
+ return delta > 0 ? `+${text}` : delta < 0 ? `−${text}` : text;
73
+ });
74
+
75
+ const made = <K extends keyof HTMLElementTagNameMap>(tag: K, parent: Element, data: string) => {
76
+ const element = document.createElement(tag);
77
+ element.setAttribute(`data-mk-bislider-${data}`, '');
78
+ parent.append(element);
79
+ scope.add(() => element.remove());
80
+ return element;
81
+ };
82
+ const icons = options.icons === false ? null : ICONS[options.icons ?? 'signs'];
83
+ const less = icons ? made('button', root, 'less') : null;
84
+ const track = made('span', root, 'track');
85
+ const more = icons ? made('button', root, 'more') : null;
86
+ if (less && more && icons) {
87
+ less.type = more.type = 'button';
88
+ less.tabIndex = more.tabIndex = -1;
89
+ less.innerHTML = icons[0];
90
+ more.innerHTML = icons[1];
91
+ less.setAttribute('aria-label', 'Menos');
92
+ more.setAttribute('aria-label', 'Más');
93
+ root.prepend(less);
94
+ }
95
+ // The fill and notch are clipped to the track's corners; the thumb and bubble are not.
96
+ const clip = made('span', track, 'clip');
97
+ clip.setAttribute('aria-hidden', 'true');
98
+ const fill = made('span', clip, 'fill');
99
+ const notch = made('span', clip, 'notch');
100
+ const thumb = made('span', track, 'thumb');
101
+ const bubble = made('span', track, 'bubble');
102
+ for (const part of [fill, notch, thumb, bubble]) part.setAttribute('aria-hidden', 'true');
103
+
104
+ let input = root.querySelector<HTMLInputElement>('input[type="range"]');
105
+ if (input) {
106
+ // An authored input goes into the track and back to its place (with its attributes) on destroy.
107
+ const kept = input;
108
+ const place = { parent: kept.parentNode, next: kept.nextSibling };
109
+ const saved = ['min', 'max', 'step', 'value', 'data-mk-bislider-input'].map((name) => [name, kept.getAttribute(name)] as const);
110
+ const value = kept.value;
111
+ scope.add(() => {
112
+ for (const [name, was] of saved) {
113
+ if (was === null) kept.removeAttribute(name);
114
+ else kept.setAttribute(name, was);
115
+ }
116
+ kept.value = value;
117
+ place.parent?.insertBefore(kept, place.next);
118
+ });
119
+ } else {
120
+ input = document.createElement('input');
121
+ input.type = 'range';
122
+ scope.add(() => input!.remove());
123
+ }
124
+ const range = input;
125
+ track.append(range);
126
+ range.dataset.mkBisliderInput = '';
127
+ range.min = String(min);
128
+ range.max = String(max);
129
+ range.step = String(step);
130
+ if (options.name) scope.attr(range, 'name', options.name);
131
+ if (options.label && !range.getAttribute('aria-label')) scope.attr(range, 'aria-label', options.label);
132
+ if (options.disabled) scope.attr(range, 'disabled', '');
133
+ const start = snap(finite(options.value) ? options.value : range.getAttribute('value') !== null ? Number(range.getAttribute('value')) : origin);
134
+ range.value = String(start);
135
+ range.defaultValue = String(start);
136
+ scope.attr(root, 'data-mk-ready', '');
137
+
138
+ let value = start;
139
+ let committed = start;
140
+ /** What is drawn (trails the value while gliding). */
141
+ const shown = { v: start };
142
+ const pct = (n: number) => ((n - min) / (max - min)) * 100;
143
+ notch.style.left = `${pct(origin)}%`;
144
+
145
+ const paint = () => {
146
+ const at = pct(shown.v);
147
+ const from = pct(origin);
148
+ fill.style.left = `${Math.min(at, from)}%`;
149
+ fill.style.width = `${Math.abs(at - from)}%`;
150
+ thumb.style.left = `${at}%`;
151
+ bubble.style.left = `${at}%`;
152
+ root.setAttribute('data-mk-side', shown.v > origin ? 'more' : shown.v < origin ? 'less' : 'origin');
153
+ };
154
+ scope.add(() => root.removeAttribute('data-mk-side'));
155
+ const describe = () => {
156
+ const text = format(value);
157
+ bubble.textContent = text;
158
+ range.setAttribute('aria-valuetext', text);
159
+ };
160
+ scope.add(() => range.removeAttribute('aria-valuetext'));
161
+
162
+ const effects = new Set<gsap.core.Animation>();
163
+ const keep = (animation: gsap.core.Animation | null) => {
164
+ if (!animation) return;
165
+ effects.add(animation);
166
+ for (const effect of effects) if (effect.totalProgress() === 1) effects.delete(effect);
167
+ };
168
+ scope.add(() => {
169
+ gsap.killTweensOf(shown);
170
+ for (const effect of effects) effect.kill();
171
+ effects.clear();
172
+ });
173
+ const bump = (element: Element | null, scale = 1.25) => {
174
+ if (!element || reduced()) return;
175
+ keep(gsap.fromTo(element, { scale }, { scale: 1, duration: 0.5, ease: 'elastic.out(1, 0.4)', overwrite: 'auto' }));
176
+ };
177
+
178
+ let held = false;
179
+ /** Set the value; `glide` tweens the drawing, else it follows at once. */
180
+ const set = (next: number, glide: boolean, notify: 'input' | 'change' | false) => {
181
+ const before = value;
182
+ value = snap(next);
183
+ range.value = String(value);
184
+ describe();
185
+ gsap.killTweensOf(shown);
186
+ if (glide && !reduced()) keep(gsap.to(shown, { v: value, duration: 0.45, ease: EASE.swatch, onUpdate: paint }));
187
+ else {
188
+ shown.v = value;
189
+ paint();
190
+ }
191
+ // Crossing the origin pops its notch; arriving at an end bumps that end's icon.
192
+ if ((before - origin) * (value - origin) < 0 || (before !== origin && value === origin)) bump(notch, 1.8);
193
+ if (value !== before && value === max) bump(more);
194
+ if (value !== before && value === min) bump(less);
195
+ if (notify && value !== before) {
196
+ options.onInput?.(value);
197
+ root.dispatchEvent(new CustomEvent('mk:input', { bubbles: true, detail: { value } }));
198
+ }
199
+ if (notify === 'change') commit();
200
+ };
201
+ const commit = () => {
202
+ if (value === committed) return;
203
+ committed = value;
204
+ options.onChange?.(value);
205
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value } }));
206
+ };
207
+
208
+ scope.on(range, 'input', () => {
209
+ const next = Number(range.value);
210
+ const before = value;
211
+ set(next, !held, false);
212
+ if (value !== before) {
213
+ options.onInput?.(value);
214
+ root.dispatchEvent(new CustomEvent('mk:input', { bubbles: true, detail: { value } }));
215
+ }
216
+ });
217
+ scope.on(range, 'change', commit);
218
+ scope.on(range, 'dblclick', () => set(origin, true, 'change'));
219
+
220
+ // Held: the thumb grows and the bubble shows; past an end, the track stretches like rubber.
221
+ let pointer: number | null = null;
222
+ scope.on(range, 'pointerdown', (event) => {
223
+ const down = event as PointerEvent;
224
+ if (range.disabled || down.button !== 0) return;
225
+ held = true;
226
+ pointer = down.pointerId;
227
+ root.setAttribute('data-mk-held', '');
228
+ });
229
+ scope.on(window, 'pointermove', (event) => {
230
+ const move = event as PointerEvent;
231
+ if (!held || move.pointerId !== pointer || reduced()) return;
232
+ const box = track.getBoundingClientRect();
233
+ const over = move.clientX > box.right ? move.clientX - box.right : move.clientX < box.left ? move.clientX - box.left : 0;
234
+ const stretch = Math.min(1, Math.abs(over) / 80) * 0.04;
235
+ gsap.set(track, { scaleX: 1 + stretch, transformOrigin: over > 0 ? '0% 50%' : '100% 50%' });
236
+ });
237
+ const release = (event: Event) => {
238
+ if (!held || (event as PointerEvent).pointerId !== pointer) return;
239
+ held = false;
240
+ pointer = null;
241
+ root.removeAttribute('data-mk-held');
242
+ if (!reduced()) keep(gsap.to(track, { scaleX: 1, duration: 0.6, ease: 'elastic.out(1, 0.35)', clearProps: 'transform,transformOrigin' }));
243
+ };
244
+ scope.on(window, 'pointerup', release);
245
+ scope.on(window, 'pointercancel', release);
246
+ scope.add(() => {
247
+ root.removeAttribute('data-mk-held');
248
+ gsap.set(track, { clearProps: 'transform,transformOrigin' });
249
+ });
250
+
251
+ // − and +: a step per press, repeating while held.
252
+ let repeat: ReturnType<typeof setTimeout> | undefined;
253
+ const stop = () => {
254
+ clearTimeout(repeat);
255
+ if (repeat !== undefined) commit();
256
+ repeat = undefined;
257
+ };
258
+ scope.add(() => clearTimeout(repeat));
259
+ for (const [button, direction] of [[less, -1], [more, 1]] as const) {
260
+ if (!button) continue;
261
+ scope.on(button, 'pointerdown', (event) => {
262
+ if (range.disabled || (event as PointerEvent).button !== 0) return;
263
+ event.preventDefault();
264
+ press(button);
265
+ const tick = (delay: number) => {
266
+ set(value + step * direction, true, 'input');
267
+ repeat = setTimeout(() => tick(Math.max(50, delay * 0.8)), delay);
268
+ };
269
+ tick(400);
270
+ });
271
+ scope.on(button, 'pointerup', stop);
272
+ scope.on(button, 'pointerleave', stop);
273
+ scope.on(button, 'pointercancel', stop);
274
+ }
275
+ const form = range.form;
276
+ if (form) scope.on(form, 'reset', () => setTimeout(() => set(Number(range.defaultValue), true, false)));
277
+
278
+ describe();
279
+ paint();
280
+
281
+ return {
282
+ setValue: (next, notify = false) => set(next, true, notify ? 'change' : false),
283
+ reset: (notify = false) => set(origin, true, notify ? 'change' : false),
284
+ get value() {
285
+ return value;
286
+ },
287
+ destroy: () => scope.dispose(),
288
+ };
289
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createBidirectionalSlider, type BidirectionalSliderInstance, type BidirectionalSliderOptions } from './engine';
5
+
6
+ export interface BidirectionalSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput'>, BidirectionalSliderOptions {
7
+ instanceRef?: MutableRefObject<BidirectionalSliderInstance | null>;
8
+ }
9
+
10
+ /** <BidirectionalSlider label="Volumen" icons="volume" value={volume} onChange={setVolume} /> */
11
+ export function BidirectionalSlider({ min, max, origin, step, value, disabled, name, label, icons, formatValue, onInput, onChange, instanceRef, className, ...props }: BidirectionalSliderProps) {
12
+ const ref = useRef<HTMLDivElement>(null);
13
+ const input = useLatest(onInput);
14
+ const change = useLatest(onChange);
15
+ const format = useLatest(formatValue);
16
+ const instance = useRef<BidirectionalSliderInstance | null>(null);
17
+ useIsomorphicLayoutEffect(() => {
18
+ const created = createBidirectionalSlider(ref.current, {
19
+ min,
20
+ max,
21
+ origin,
22
+ step,
23
+ value,
24
+ disabled,
25
+ name,
26
+ label,
27
+ icons,
28
+ formatValue: formatValue ? (n) => format.current!(n) : undefined,
29
+ onInput: (n) => input.current?.(n),
30
+ onChange: (n) => change.current?.(n),
31
+ });
32
+ instance.current = created;
33
+ if (instanceRef) instanceRef.current = created;
34
+ return () => {
35
+ created.destroy();
36
+ instance.current = null;
37
+ if (instanceRef) instanceRef.current = null;
38
+ };
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, [min, max, origin, step, disabled, name, label, icons]);
41
+ useIsomorphicLayoutEffect(() => {
42
+ if (value !== undefined && instance.current && instance.current.value !== value) instance.current.setValue(value);
43
+ }, [value]);
44
+ return <div {...props} ref={ref} className={['mk-bidirectional-slider', className].filter(Boolean).join(' ')} />;
45
+ }
@@ -1,5 +1,15 @@
1
1
  # chart
2
2
 
3
+ Desde 0.15 también puedes mezclar series con `type="composed"`. Cada serie declara `type: 'line' | 'bar' | 'area'`. Un área acepta `lowerValues` para rellenar el intervalo entre la frontera inferior y `values`; los huecos `null` interrumpen el relleno. La tabla y el tooltip muestran ambos límites.
4
+
5
+ ```tsx
6
+ <Chart type="composed" labels={['Lun', 'Mar', 'Mié']} series={[
7
+ { name: 'Ventas', type: 'bar', values: [20, 32, 28] },
8
+ { name: 'Meta', type: 'line', values: [25, 30, 35] },
9
+ { name: 'Rango', type: 'area', values: [24, 36, 32], lowerValues: [16, 26, 22] },
10
+ ]} axis endLabel={false} />
11
+ ```
12
+
3
13
  Gráfica SVG reutilizable de línea, área y barras (agrupadas o apiladas) sin motor canvas ni dependencias. Sigue las gráficas de métricas de Melon Mind: base en cero, línea de 2 px, área que se desvanece de .25 a 0, barras de hasta 24 px con extremo redondeado de 4 px y huecos de 2 px, rejilla fina y tooltip con superficie invertida.
4
14
 
5
15
  ```html
@@ -18,6 +18,8 @@ export class MkChartElement extends MkElement<ChartInstance> {
18
18
  emptyLabel: 'string',
19
19
  legend: 'boolean',
20
20
  tooltip: 'boolean',
21
+ axis: 'boolean',
22
+ endLabel: 'boolean',
21
23
  };
22
24
 
23
25
  protected create(options: Record<string, unknown>) {
@@ -18,7 +18,7 @@ import { inert, warnOnce, type Instance } from '../../core/types';
18
18
  * table carries every value for screen readers. Resizes with its container.
19
19
  */
20
20
 
21
- export type ChartType = 'line' | 'area' | 'bar';
21
+ export type ChartType = 'line' | 'area' | 'bar' | 'composed';
22
22
 
23
23
  export interface ChartPoint {
24
24
  label: string;
@@ -29,6 +29,10 @@ export interface ChartSeries {
29
29
  name: string;
30
30
  /** One value per label; null is a gap (no data). */
31
31
  values: Array<number | null>;
32
+ /** Per-series mark in a composed chart (default line). */
33
+ type?: 'line' | 'area' | 'bar';
34
+ /** An area's lower boundary; values is the upper boundary. Nulls leave gaps. */
35
+ lowerValues?: Array<number | null>;
32
36
  /** Any CSS colour; default: monochrome for one series, else the --mk-chart-N slot. */
33
37
  color?: string;
34
38
  /** Lines: an SVG dash pattern, e.g. '5 5' (a pace or goal) or '1 6' (a projection). */
@@ -116,6 +120,8 @@ function modelOf(options: ChartOptions): Model {
116
120
  values: Array.from({ length: count }, (_, j) => (finite(series.values[j]) ? series.values[j]! : null)),
117
121
  color: series.color ?? (mono ? 'var(--mk-chart-mono)' : i < SLOTS ? `var(--mk-chart-${i + 1})` : 'var(--mk-chart-other)'),
118
122
  dash: series.dash,
123
+ type: series.type,
124
+ lowerValues: series.lowerValues ? Array.from({ length: count }, (_, j) => finite(series.lowerValues![j]) ? series.lowerValues![j]! : null) : undefined,
119
125
  }));
120
126
  return { labels: fullLabels, series };
121
127
  }
@@ -171,7 +177,7 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
171
177
  let cursor: SVGGElement | null = null;
172
178
  let band: SVGRectElement | null = null;
173
179
  /** What is drawn now: values and domain (they trail the model while gliding). */
174
- let shown: { values: Values; lo: number; hi: number; type: ChartType; stacked: boolean } | null = null;
180
+ let shown: { values: Values; lower: Values; lo: number; hi: number; type: ChartType; stacked: boolean } | null = null;
175
181
  let glide: gsap.core.Tween | null = null;
176
182
  const effects = new Set<gsap.core.Animation>();
177
183
  const keep = (animation: gsap.core.Animation) => {
@@ -190,7 +196,9 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
190
196
  if (options.formatValue) return options.formatValue(value);
191
197
  return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value);
192
198
  };
193
- const type = (): ChartType => (options.type === 'bar' || options.type === 'area' ? options.type : 'line');
199
+ const type = (): ChartType => (options.type === 'bar' || options.type === 'area' || options.type === 'composed' ? options.type : 'line');
200
+ const seriesType = (index: number) => type() === 'composed' ? model.series[index]!.type ?? 'line' : type();
201
+ const hasBars = () => model.series.some((_, index) => seriesType(index) === 'bar');
194
202
  const stacked = () => type() === 'bar' && Boolean(options.stacked) && model.series.length > 1;
195
203
  const hasData = () => model.labels.length > 0 && model.series.some((series) => series.values.some(finite));
196
204
 
@@ -206,6 +214,13 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
206
214
  return { up, down };
207
215
  };
208
216
  const targetValues = (): Values => model.series.map((series) => series.values);
217
+ const lowerValues = (): Values => model.series.map((series) => series.lowerValues ?? []);
218
+ const rangeText = (series: Model['series'][number], index: number) => {
219
+ const value = series.values[index];
220
+ const lower = series.lowerValues?.[index];
221
+ if (!finite(value)) return '—';
222
+ return finite(lower) ? `${format(lower)} – ${format(value)}` : format(value);
223
+ };
209
224
  const totals = (index: number) => totalsOf(targetValues(), index);
210
225
 
211
226
  const domainOf = (values: Values) => {
@@ -224,6 +239,9 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
224
239
  lo = Math.min(lo, value);
225
240
  }
226
241
  }
242
+ for (const list of lowerValues()) {
243
+ if (finite(list[i])) { lo = Math.min(lo, list[i]!); hi = Math.max(hi, list[i]!); }
244
+ }
227
245
  });
228
246
  if (hi === lo) hi = lo + 1;
229
247
  const step = niceStep((hi - lo) / 4);
@@ -272,7 +290,7 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
272
290
  for (const series of model.series) {
273
291
  const td = document.createElement('td');
274
292
  const value = series.values[i];
275
- td.textContent = finite(value) ? format(value) : '—';
293
+ td.textContent = rangeText(series, i);
276
294
  row.append(td);
277
295
  }
278
296
  if (stacked()) {
@@ -291,11 +309,11 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
291
309
  const show = options.legend ?? model.series.length > 1;
292
310
  legend.hidden = !show || !hasData();
293
311
  if (legend.hidden) return;
294
- for (const series of model.series) {
312
+ for (const [index, series] of model.series.entries()) {
295
313
  const item = document.createElement('li');
296
314
  const swatch = document.createElement('span');
297
315
  swatch.dataset.mkChartSwatch = '';
298
- swatch.dataset.mkType = type();
316
+ swatch.dataset.mkType = seriesType(index);
299
317
  swatch.style.background = series.color;
300
318
  swatch.style.color = series.color;
301
319
  if (series.dash) swatch.dataset.mkDash = '';
@@ -314,19 +332,19 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
314
332
  };
315
333
 
316
334
  /** Draw `values` on the domain lo…hi (called every frame while gliding). */
317
- const paint = (values: Values, lo: number, hi: number, step: number) => {
335
+ const paint = (values: Values, lo: number, hi: number, step: number, lower: Values = lowerValues()) => {
318
336
  canvas.replaceChildren();
319
337
  const width = Math.max(120, Math.round(plot.clientWidth || root.clientWidth || 600));
320
338
  const height = Math.max(80, Math.round(finite(options.height) ? options.height : 240));
321
339
  canvas.setAttribute('viewBox', `0 0 ${width} ${height}`);
322
340
  canvas.setAttribute('width', String(width));
323
341
  canvas.setAttribute('height', String(height));
324
- const bar = type() === 'bar';
342
+ const bar = hasBars();
325
343
  const axis = Boolean(options.axis);
326
344
  const ticks: number[] = [];
327
345
  for (let t = lo; t <= hi + step / 2; t += step) ticks.push(Number(t.toPrecision(12)));
328
346
  const tickText = ticks.map(format);
329
- const withEnd = !bar && options.endLabel !== false && lastPoint(values[0]) >= 0;
347
+ const withEnd = seriesType(0) !== 'bar' && options.endLabel !== false && lastPoint(values[0]) >= 0;
330
348
  const left = axis ? Math.min(96, Math.max(28, Math.max(...tickText.map((text) => text.length)) * 7 + 12)) : 4;
331
349
  const top = 16;
332
350
  const right = withEnd ? Math.min(120, Math.max(48, format(values[0]![lastPoint(values[0])]!).length * 7.5 + 28)) : 8;
@@ -381,7 +399,8 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
381
399
  const marks = svg('g', { 'data-mk-part': 'series' });
382
400
  const defs = svg('defs');
383
401
  if (bar) {
384
- const groups = stacked() ? 1 : model.series.length;
402
+ const barIndices = model.series.flatMap((_, index) => seriesType(index) === 'bar' ? [index] : []);
403
+ const groups = stacked() ? 1 : barIndices.length;
385
404
  const barWidth = Math.max(2, Math.min(BAR_MAX, (slot * 0.62 - GAP * (groups - 1)) / groups));
386
405
  const totalsLayer = svg('g', { 'data-mk-part': 'totals' });
387
406
  model.labels.forEach((_, i) => {
@@ -419,9 +438,10 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
419
438
  } else {
420
439
  const groupWidth = groups * barWidth + GAP * (groups - 1);
421
440
  values.forEach((list, k) => {
441
+ if (seriesType(k) !== 'bar') return;
422
442
  const value = list[i];
423
443
  if (!finite(value) || value === 0) return;
424
- const x = xs[i]! - groupWidth / 2 + k * (barWidth + GAP);
444
+ const x = xs[i]! - groupWidth / 2 + barIndices.indexOf(k) * (barWidth + GAP);
425
445
  const path = svg('path', { d: barPath(x, barWidth, base, y(value), true), 'data-mk-index': i, 'data-mk-series': k });
426
446
  path.style.fill = model.series[k]!.color;
427
447
  marks.append(path);
@@ -429,20 +449,23 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
429
449
  }
430
450
  });
431
451
  if (totalsLayer.childNodes.length) canvas.append(totalsLayer);
432
- } else {
452
+ }
453
+ if (model.series.some((_, index) => seriesType(index) !== 'bar')) {
433
454
  values.forEach((list, k) => {
455
+ if (seriesType(k) === 'bar') return;
434
456
  const series = model.series[k]!;
435
- const runs: Array<Array<[number, number]>> = [];
436
- let run: Array<[number, number]> = [];
457
+ const runs: Array<Array<[number, number, number]>> = [];
458
+ let run: Array<[number, number, number]> = [];
437
459
  list.forEach((value, i) => {
438
- if (finite(value)) run.push([xs[i]!, y(value)]);
460
+ const bottomValue = lower[k]?.[i];
461
+ if (finite(value) && (!series.lowerValues || finite(bottomValue))) run.push([xs[i]!, y(value), finite(bottomValue) ? y(bottomValue) : base]);
439
462
  else if (run.length) {
440
463
  runs.push(run);
441
464
  run = [];
442
465
  }
443
466
  });
444
467
  if (run.length) runs.push(run);
445
- if (type() === 'area') {
468
+ if (seriesType(k) === 'area') {
446
469
  const gradient = svg('linearGradient', { id: `${uid}-fill-${k}`, x1: 0, x2: 0, y1: 0, y2: 1 });
447
470
  const stop = (offset: string, opacity: string) => {
448
471
  const element = svg('stop', { offset });
@@ -454,8 +477,8 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
454
477
  defs.append(gradient);
455
478
  for (const points of runs) {
456
479
  if (points.length < 2) continue;
457
- const d = `M${points.map(([px, py]) => `${round(px)},${round(py)}`).join('L')}L${round(points[points.length - 1]![0])},${round(base)}L${round(points[0]![0])},${round(base)}Z`;
458
- const area = svg('path', { d, 'data-mk-part': 'area' });
480
+ const d = `M${points.map(([px, py]) => `${round(px)},${round(py)}`).join('L')}L${[...points].reverse().map(([px, , py]) => `${round(px)},${round(py)}`).join('L')}Z`;
481
+ const area = svg('path', { d, 'data-mk-part': 'area', 'data-mk-series': k });
459
482
  area.style.fill = `url(#${uid}-fill-${k})`;
460
483
  marks.append(area);
461
484
  }
@@ -479,7 +502,7 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
479
502
  });
480
503
  // The last value of the first series: a ringed point and its pill.
481
504
  const last = lastPoint(values[0]);
482
- if (last >= 0 && !model.series[0]!.dash) {
505
+ if (last >= 0 && seriesType(0) !== 'bar' && !model.series[0]!.dash) {
483
506
  const point = svg('circle', { cx: round(xs[last]!), cy: round(y(values[0]![last]!)), r: 5, 'data-mk-part': 'end' });
484
507
  point.style.fill = model.series[0]!.color;
485
508
  marks.append(point);
@@ -564,10 +587,10 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
564
587
  const name = document.createElement('span');
565
588
  name.textContent = series.name;
566
589
  const amount = document.createElement('b');
567
- amount.textContent = finite(value) ? format(value) : '—';
590
+ amount.textContent = rangeText(series, i);
568
591
  row.append(swatch, name, amount);
569
592
  tooltip.append(row);
570
- rows.push(`${series.name ? `${series.name}: ` : ''}${finite(value) ? format(value) : 'sin dato'}`);
593
+ rows.push(`${series.name ? `${series.name}: ` : ''}${finite(value) ? rangeText(series, i) : 'sin dato'}`);
571
594
  }
572
595
  if (stacked()) {
573
596
  const { up, down } = totals(i);
@@ -640,7 +663,7 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
640
663
  const target = targetValues();
641
664
  const domain = domainOf(target);
642
665
  const finish = () => {
643
- shown = { values: target, lo: domain.lo, hi: domain.hi, type: type(), stacked: stacked() };
666
+ shown = { values: target, lower: lowerValues(), lo: domain.lo, hi: domain.hi, type: type(), stacked: stacked() };
644
667
  paint(target, domain.lo, domain.hi, domain.step);
645
668
  paintSelection(false);
646
669
  };
@@ -660,7 +683,11 @@ export function createChart(root: HTMLElement | null, initial: ChartOptions = {}
660
683
  const before = from.values[k]![i];
661
684
  return finite(value) && finite(before) ? before + (value - before) * t : value;
662
685
  }));
663
- paint(values, from.lo + (domain.lo - from.lo) * t, from.hi + (domain.hi - from.hi) * t, domain.step);
686
+ const lower = lowerValues().map((list, k) => list.map((value, i) => {
687
+ const before = from.lower[k]?.[i];
688
+ return finite(value) && finite(before) ? before + (value - before) * t : value;
689
+ }));
690
+ paint(values, from.lo + (domain.lo - from.lo) * t, from.hi + (domain.hi - from.hi) * t, domain.step, lower);
664
691
  paintSelection(false);
665
692
  },
666
693
  onComplete: () => {