@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.
- package/README.md +174 -155
- package/bin/motion-kit.mjs +3 -3
- package/dist/auto/index.d.ts +6 -3
- package/dist/auto/index.js +8 -2
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-EY4BJKC4.js → chunk-6IRJYXJU.js} +2 -2
- package/dist/{chunk-CU6SXW3E.js → chunk-A6QQCF6V.js} +1309 -310
- package/dist/chunk-A6QQCF6V.js.map +1 -0
- package/dist/chunk-JXRS3OEA.js +3638 -0
- package/dist/chunk-JXRS3OEA.js.map +1 -0
- package/dist/css/components/add-to-cart.css +72 -0
- package/dist/css/components/bidirectional-slider.css +168 -0
- package/dist/css/components/data-table.css +121 -26
- package/dist/css/components/filter-builder.css +10 -15
- package/dist/css/components/heatmap-chart.css +391 -0
- package/dist/css/components/hover-card.css +8 -2
- package/dist/css/components/image-preview.css +90 -2
- package/dist/css/components/json-viewer.css +33 -1
- package/dist/css/components/nav.css +102 -102
- package/dist/css/components/orbit-menu.css +130 -0
- package/dist/css/components/pagination.css +10 -5
- package/dist/css/components/pie-chart.css +390 -0
- package/dist/css/components/pill-button.css +135 -0
- package/dist/css/components/radar-chart.css +370 -0
- package/dist/css/components/radial-chart.css +398 -0
- package/dist/css/components/sankey-chart.css +358 -0
- package/dist/css/components/scroll-island.css +111 -0
- package/dist/css/components/select.css +136 -0
- package/dist/css/components/toolbar.css +122 -0
- package/dist/css/components/tree-view.css +79 -0
- package/dist/css/components/voice-dots.css +70 -0
- package/dist/elements/index.d.ts +180 -7
- package/dist/elements/index.js +287 -6
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
- package/dist/index.css +1832 -168
- package/dist/index.d.ts +4 -4
- package/dist/index.js +33 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +117 -11
- package/dist/react/index.js +314 -13
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-BBUusld6.d.ts → reduced-6X_bns60.d.ts} +1 -1
- package/dist/{reveal-DEvp6U6T.d.ts → reveal-BfyKKsw4.d.ts} +97 -4
- package/dist/store-CA2Qcun_.d.ts +453 -0
- package/package.json +9 -16
- package/registry.json +1096 -948
- package/src/auto/auto-init.ts +6 -0
- package/src/components/add-to-cart/README.md +34 -0
- package/src/components/add-to-cart/add-to-cart.css +71 -0
- package/src/components/add-to-cart/element.ts +20 -0
- package/src/components/add-to-cart/engine.ts +206 -0
- package/src/components/add-to-cart/react.tsx +29 -0
- package/src/components/bidirectional-slider/README.md +46 -0
- package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
- package/src/components/bidirectional-slider/element.ts +42 -0
- package/src/components/bidirectional-slider/engine.ts +289 -0
- package/src/components/bidirectional-slider/react.tsx +45 -0
- package/src/components/chart/README.md +10 -0
- package/src/components/chart/element.ts +2 -0
- package/src/components/chart/engine.ts +50 -23
- package/src/components/chart/parts.ts +237 -0
- package/src/components/chart/react.tsx +2 -2
- package/src/components/chart/use-chart.ts +55 -0
- package/src/components/data-table/README.md +3 -3
- package/src/components/data-table/data-table.css +121 -26
- package/src/components/data-table/engine.ts +81 -29
- package/src/components/editable-table/engine.ts +67 -11
- package/src/components/filter-builder/engine.ts +33 -2
- package/src/components/filter-builder/filter-builder.css +10 -15
- package/src/components/heatmap-chart/README.md +48 -0
- package/src/components/heatmap-chart/element.ts +21 -0
- package/src/components/heatmap-chart/engine.ts +281 -0
- package/src/components/heatmap-chart/heatmap-chart.css +93 -0
- package/src/components/heatmap-chart/react.tsx +14 -0
- package/src/components/hover-card/engine.ts +30 -1
- package/src/components/hover-card/hover-card.css +8 -2
- package/src/components/image-preview/element.ts +1 -1
- package/src/components/image-preview/engine.ts +95 -15
- package/src/components/image-preview/image-preview.css +90 -2
- package/src/components/image-preview/react.tsx +3 -3
- package/src/components/json-viewer/engine.ts +11 -2
- package/src/components/json-viewer/json-viewer.css +33 -1
- package/src/components/nav/README.md +12 -10
- package/src/components/nav/element.ts +5 -5
- package/src/components/nav/engine.ts +21 -14
- package/src/components/nav/nav.css +102 -102
- package/src/components/nav/react.tsx +4 -4
- package/src/components/orbit-menu/README.md +46 -0
- package/src/components/orbit-menu/element.ts +27 -0
- package/src/components/orbit-menu/engine.ts +179 -0
- package/src/components/orbit-menu/orbit-menu.css +129 -0
- package/src/components/orbit-menu/react.tsx +44 -0
- package/src/components/pagination/README.md +1 -1
- package/src/components/pagination/engine.ts +50 -4
- package/src/components/pagination/pagination.css +10 -5
- package/src/components/pie-chart/README.md +48 -0
- package/src/components/pie-chart/element.ts +30 -0
- package/src/components/pie-chart/engine.ts +283 -0
- package/src/components/pie-chart/pie-chart.css +92 -0
- package/src/components/pie-chart/react.tsx +14 -0
- package/src/components/pill-button/README.md +46 -0
- package/src/components/pill-button/element.ts +16 -0
- package/src/components/pill-button/engine.ts +77 -0
- package/src/components/pill-button/pill-button.css +134 -0
- package/src/components/pill-button/react.tsx +32 -0
- package/src/components/radar-chart/README.md +48 -0
- package/src/components/radar-chart/element.ts +30 -0
- package/src/components/radar-chart/engine.ts +327 -0
- package/src/components/radar-chart/radar-chart.css +72 -0
- package/src/components/radar-chart/react.tsx +14 -0
- package/src/components/radial-chart/README.md +48 -0
- package/src/components/radial-chart/element.ts +30 -0
- package/src/components/radial-chart/engine.ts +290 -0
- package/src/components/radial-chart/radial-chart.css +100 -0
- package/src/components/radial-chart/react.tsx +14 -0
- package/src/components/sankey-chart/README.md +48 -0
- package/src/components/sankey-chart/element.ts +31 -0
- package/src/components/sankey-chart/engine.ts +401 -0
- package/src/components/sankey-chart/react.tsx +14 -0
- package/src/components/sankey-chart/sankey-chart.css +60 -0
- package/src/components/scroll-island/README.md +34 -0
- package/src/components/scroll-island/element.ts +16 -0
- package/src/components/scroll-island/engine.ts +252 -0
- package/src/components/scroll-island/react.tsx +26 -0
- package/src/components/scroll-island/scroll-island.css +110 -0
- package/src/components/select/README.md +34 -0
- package/src/components/select/element.ts +26 -0
- package/src/components/select/engine.ts +267 -0
- package/src/components/select/react.tsx +33 -0
- package/src/components/select/select.css +135 -0
- package/src/components/toolbar/README.md +46 -0
- package/src/components/toolbar/element.ts +30 -0
- package/src/components/toolbar/engine.ts +230 -0
- package/src/components/toolbar/react.tsx +34 -0
- package/src/components/toolbar/toolbar.css +121 -0
- package/src/components/tree-view/element.ts +1 -1
- package/src/components/tree-view/engine.ts +174 -0
- package/src/components/tree-view/react.tsx +7 -3
- package/src/components/tree-view/tree-view.css +79 -0
- package/src/components/voice-dots/README.md +48 -0
- package/src/components/voice-dots/element.ts +45 -0
- package/src/components/voice-dots/engine.ts +243 -0
- package/src/components/voice-dots/react.tsx +52 -0
- package/src/components/voice-dots/voice-dots.css +69 -0
- package/src/elements/index.ts +39 -0
- package/src/index.ts +16 -0
- package/src/motion/liquid.ts +14 -4
- package/src/react/index.ts +14 -1
- package/dist/chunk-6P4P7NU6.js +0 -1401
- package/dist/chunk-6P4P7NU6.js.map +0 -1
- package/dist/chunk-CU6SXW3E.js.map +0 -1
- package/dist/store-DjkTBX7f.d.ts +0 -195
- /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,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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
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 (
|
|
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${
|
|
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 =
|
|
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) ?
|
|
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
|
-
|
|
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: () => {
|