@yewdiel21/motion-kit 0.17.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.
- package/README.md +23 -0
- package/dist/auto/index.d.ts +18 -3
- package/dist/auto/index.js +32 -2
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-VJFPTLAK.js → chunk-AWDEKDEJ.js} +2 -2
- package/dist/{chunk-BZLMCHUD.js → chunk-EDMXWUW3.js} +2 -2
- package/dist/{chunk-7IQTMF7Y.js → chunk-Q7IW4FV3.js} +2580 -58
- package/dist/chunk-Q7IW4FV3.js.map +1 -0
- package/dist/css/components/badge-button.css +16 -0
- package/dist/css/components/bento-grid.css +13 -0
- package/dist/css/components/drilldown-menu.css +11 -7
- package/dist/css/components/equalizer-slider.css +26 -0
- package/dist/css/components/handwriting-text.css +15 -0
- package/dist/css/components/magnetize-button.css +18 -0
- package/dist/css/components/morphing-page-dots.css +13 -0
- package/dist/css/components/not-found.css +22 -0
- package/dist/css/components/notification-stack.css +42 -0
- package/dist/css/components/particle-button.css +14 -0
- package/dist/css/components/radio-group.css +29 -0
- package/dist/css/components/rating-scale.css +23 -0
- package/dist/css/components/scroll-preview-dock.css +18 -0
- package/dist/css/components/select.css +16 -0
- package/dist/css/components/signature-pad.css +130 -103
- package/dist/css/components/stepper.css +36 -0
- package/dist/css/components/sticky-header.css +10 -0
- package/dist/css/components/temperature-slider.css +29 -0
- package/dist/css/components/voice-message-bubble.css +62 -45
- package/dist/css/components/waveform-player.css +49 -34
- package/dist/css/components/wheel-picker.css +16 -15
- package/dist/css/tokens.css +4 -0
- package/dist/elements/index.d.ts +206 -4
- package/dist/elements/index.js +581 -6
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Bn6hC87X.d.ts → engine-t8d7brjn.d.ts} +341 -1
- package/dist/index.css +578 -170
- package/dist/index.d.ts +23 -23
- package/dist/index.js +33 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +113 -7
- package/dist/react/index.js +669 -3
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-YS6TZjpa.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
- package/dist/{store-CZ6vdtIE.d.ts → store-6hZNKiee.d.ts} +1 -1
- package/package.json +1 -1
- package/registry.json +180 -0
- package/src/auto/auto-init.ts +31 -0
- package/src/components/badge-button/README.md +28 -0
- package/src/components/badge-button/badge-button.css +15 -0
- package/src/components/badge-button/element.ts +16 -0
- package/src/components/badge-button/engine.ts +85 -0
- package/src/components/badge-button/react.tsx +31 -0
- package/src/components/bento-grid/README.md +26 -0
- package/src/components/bento-grid/bento-grid.css +12 -0
- package/src/components/bento-grid/element.ts +22 -0
- package/src/components/bento-grid/engine.ts +153 -0
- package/src/components/bento-grid/react.tsx +25 -0
- package/src/components/drilldown-menu/drilldown-menu.css +11 -7
- package/src/components/drilldown-menu/engine.ts +44 -4
- package/src/components/equalizer-slider/README.md +43 -0
- package/src/components/equalizer-slider/element.ts +41 -0
- package/src/components/equalizer-slider/engine.ts +32 -0
- package/src/components/equalizer-slider/equalizer-slider.css +26 -0
- package/src/components/equalizer-slider/react.tsx +60 -0
- package/src/components/equalizer-slider/shared.ts +161 -0
- package/src/components/handwriting-text/README.md +29 -0
- package/src/components/handwriting-text/element.ts +14 -0
- package/src/components/handwriting-text/engine.ts +108 -0
- package/src/components/handwriting-text/glyphs.ts +71 -0
- package/src/components/handwriting-text/handwriting-text.css +14 -0
- package/src/components/handwriting-text/react.tsx +23 -0
- package/src/components/magnetize-button/README.md +26 -0
- package/src/components/magnetize-button/element.ts +13 -0
- package/src/components/magnetize-button/engine.ts +84 -0
- package/src/components/magnetize-button/magnetize-button.css +17 -0
- package/src/components/magnetize-button/react.tsx +22 -0
- package/src/components/morphing-page-dots/README.md +28 -0
- package/src/components/morphing-page-dots/element.ts +16 -0
- package/src/components/morphing-page-dots/engine.ts +89 -0
- package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
- package/src/components/morphing-page-dots/react.tsx +32 -0
- package/src/components/not-found/README.md +26 -0
- package/src/components/not-found/element.ts +15 -0
- package/src/components/not-found/engine.ts +57 -0
- package/src/components/not-found/not-found.css +21 -0
- package/src/components/not-found/react.tsx +23 -0
- package/src/components/notification-stack/README.md +24 -0
- package/src/components/notification-stack/element.ts +26 -0
- package/src/components/notification-stack/engine.ts +329 -0
- package/src/components/notification-stack/notification-stack.css +41 -0
- package/src/components/notification-stack/react.tsx +28 -0
- package/src/components/particle-button/README.md +26 -0
- package/src/components/particle-button/element.ts +13 -0
- package/src/components/particle-button/engine.ts +52 -0
- package/src/components/particle-button/particle-button.css +13 -0
- package/src/components/particle-button/react.tsx +22 -0
- package/src/components/radio-group/README.md +43 -0
- package/src/components/radio-group/element.ts +41 -0
- package/src/components/radio-group/engine.ts +11 -0
- package/src/components/radio-group/radio-group.css +28 -0
- package/src/components/radio-group/react.tsx +60 -0
- package/src/components/radio-group/shared.ts +118 -0
- package/src/components/rating-scale/README.md +43 -0
- package/src/components/rating-scale/element.ts +41 -0
- package/src/components/rating-scale/engine.ts +63 -0
- package/src/components/rating-scale/rating-scale.css +22 -0
- package/src/components/rating-scale/react.tsx +60 -0
- package/src/components/scroll-preview-dock/README.md +24 -0
- package/src/components/scroll-preview-dock/element.ts +20 -0
- package/src/components/scroll-preview-dock/engine.ts +228 -0
- package/src/components/scroll-preview-dock/react.tsx +26 -0
- package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
- package/src/components/select/README.md +23 -9
- package/src/components/select/select.css +16 -0
- package/src/components/signature-pad/engine.ts +550 -437
- package/src/components/signature-pad/signature-pad.css +130 -103
- package/src/components/stepper/README.md +46 -0
- package/src/components/stepper/element.ts +42 -0
- package/src/components/stepper/engine.ts +103 -0
- package/src/components/stepper/react.tsx +60 -0
- package/src/components/stepper/stepper.css +35 -0
- package/src/components/sticky-header/README.md +30 -0
- package/src/components/sticky-header/element.ts +19 -0
- package/src/components/sticky-header/engine.ts +95 -0
- package/src/components/sticky-header/react.tsx +25 -0
- package/src/components/sticky-header/sticky-header.css +9 -0
- package/src/components/temperature-slider/README.md +42 -0
- package/src/components/temperature-slider/element.ts +53 -0
- package/src/components/temperature-slider/engine.ts +44 -0
- package/src/components/temperature-slider/react.tsx +66 -0
- package/src/components/temperature-slider/temperature-slider.css +29 -0
- package/src/components/voice-message-bubble/engine.ts +11 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +14 -12
- package/src/components/waveform-player/README.md +1 -1
- package/src/components/waveform-player/engine.ts +344 -261
- package/src/components/waveform-player/waveform-player.css +50 -35
- package/src/components/wheel-picker/README.md +3 -1
- package/src/components/wheel-picker/element.ts +12 -5
- package/src/components/wheel-picker/engine.ts +37 -8
- package/src/components/wheel-picker/wheel-picker.css +17 -16
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +45 -0
- package/src/index.ts +311 -295
- package/src/react/index.ts +16 -0
- package/dist/chunk-7IQTMF7Y.js.map +0 -1
- /package/dist/{chunk-VJFPTLAK.js.map → chunk-AWDEKDEJ.js.map} +0 -0
- /package/dist/{chunk-BZLMCHUD.js.map → chunk-EDMXWUW3.js.map} +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Flip, 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 } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface BentoGridOptions {
|
|
8
|
+
columns?: number;
|
|
9
|
+
gap?: number;
|
|
10
|
+
/** Matches a whitespace-separated data-mk-category. Null shows every item. */
|
|
11
|
+
filter?: string | null;
|
|
12
|
+
duration?: number;
|
|
13
|
+
onChange?: (keys: string[]) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface BentoGridInstance extends Instance {
|
|
16
|
+
refresh(): void;
|
|
17
|
+
setFilter(filter: string | null): void;
|
|
18
|
+
reorder(keys: string[]): void;
|
|
19
|
+
/** Capture before an authored DOM mutation (add/remove/replace), then animate to its layout. */
|
|
20
|
+
mutate(change: () => void): void;
|
|
21
|
+
setOptions(options: Partial<BentoGridOptions>): void;
|
|
22
|
+
readonly filter: string | null;
|
|
23
|
+
readonly keys: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Authored direct children are the grid items. Spans live in data-mk-col-span / data-mk-row-span. */
|
|
27
|
+
export function createBentoGrid(root: HTMLElement | null, options: BentoGridOptions = {}): BentoGridInstance {
|
|
28
|
+
if (!root) return inert({ refresh() {}, setFilter() {}, reorder() {}, mutate() {}, setOptions() {}, filter: null, keys: [] });
|
|
29
|
+
registerMotion();
|
|
30
|
+
const scope = createScope();
|
|
31
|
+
let opts = { ...options };
|
|
32
|
+
let filter = opts.filter ?? null;
|
|
33
|
+
let animation: gsap.core.Timeline | null = null;
|
|
34
|
+
const micro = new Set<gsap.core.Animation>();
|
|
35
|
+
const track = (value: gsap.core.Animation | null) => { if (value) micro.add(value); };
|
|
36
|
+
const originals = [...root.childNodes];
|
|
37
|
+
const originalItems = new Set(root.children);
|
|
38
|
+
const remembered = new Set<HTMLElement>();
|
|
39
|
+
let queued = 0;
|
|
40
|
+
scope.attr(root, 'class', `${root.className} mk-bento-grid`.trim());
|
|
41
|
+
scope.attr(root, 'style', root.getAttribute('style'));
|
|
42
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
43
|
+
const items = () => [...root.children].filter((node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute('data-mk-bento-item'));
|
|
44
|
+
const key = (item: HTMLElement) => item.dataset.mkBentoItem!;
|
|
45
|
+
const visible = () => items().filter((item) => !item.hidden);
|
|
46
|
+
const save = (item: HTMLElement) => {
|
|
47
|
+
if (remembered.has(item)) return;
|
|
48
|
+
remembered.add(item);
|
|
49
|
+
for (const attr of ['style', 'hidden', 'inert', 'aria-hidden', 'data-flip-id']) scope.attr(item, attr, item.getAttribute(attr));
|
|
50
|
+
};
|
|
51
|
+
items().forEach(save);
|
|
52
|
+
let last: ReturnType<typeof Flip.getState> | undefined;
|
|
53
|
+
const notify = () => {
|
|
54
|
+
const keys = visible().map(key);
|
|
55
|
+
opts.onChange?.(keys);
|
|
56
|
+
root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { keys, filter } }));
|
|
57
|
+
};
|
|
58
|
+
const paint = () => {
|
|
59
|
+
const desired = Number.isFinite(opts.columns) ? Math.max(1, Math.min(12, Math.round(opts.columns!))) : 3;
|
|
60
|
+
const cols = root.clientWidth > 0 && root.clientWidth < 280 ? 1 : root.clientWidth > 0 && root.clientWidth < 440 ? Math.min(2, desired) : desired;
|
|
61
|
+
root.style.setProperty('--mk-bento-columns', String(cols));
|
|
62
|
+
root.style.setProperty('--mk-bento-gap', `${Math.max(0, opts.gap ?? 12)}px`);
|
|
63
|
+
items().forEach((item) => {
|
|
64
|
+
save(item);
|
|
65
|
+
item.style.setProperty('--mk-bento-col-span', String(Math.max(1, Math.min(cols, Number(item.dataset.mkColSpan) || 1))));
|
|
66
|
+
item.style.setProperty('--mk-bento-row-span', String(Math.max(1, Math.min(8, Number(item.dataset.mkRowSpan) || 1))));
|
|
67
|
+
const show = !filter || (item.dataset.mkCategory ?? '').split(/\s+/).includes(filter);
|
|
68
|
+
if (!show && item.contains(document.activeElement)) { scope.attr(root, 'tabindex', root.getAttribute('tabindex')); root.tabIndex = -1; root.focus(); }
|
|
69
|
+
item.hidden = !show;
|
|
70
|
+
item.inert = !show;
|
|
71
|
+
item.setAttribute('aria-hidden', String(!show));
|
|
72
|
+
});
|
|
73
|
+
scope.attr(root, 'data-mk-empty', visible().length ? null : '');
|
|
74
|
+
if (!root.hasAttribute('data-mk-empty-label')) scope.attr(root, 'data-mk-empty-label', 'No hay elementos en esta vista.');
|
|
75
|
+
};
|
|
76
|
+
const transition = (state = last, entering: HTMLElement[] = []) => {
|
|
77
|
+
if (scope.disposed) return;
|
|
78
|
+
animation?.kill();
|
|
79
|
+
paint();
|
|
80
|
+
if (!reduced()) {
|
|
81
|
+
animation = Flip.from(state ?? Flip.getState([]), { targets: visible(), duration: Math.max(0, opts.duration ?? .55), ease: EASE.signature, stagger: .025, absolute: false, scale: false, prune: true, onComplete: () => { if (!scope.disposed) last = Flip.getState(visible()); } });
|
|
82
|
+
track(blurIn(entering.filter((item) => !item.hidden), { from: 'soft', stagger: .04, duration: .35 }));
|
|
83
|
+
}
|
|
84
|
+
if (!reduced()) last = Flip.getState(visible());
|
|
85
|
+
};
|
|
86
|
+
const capture = () => {
|
|
87
|
+
animation?.kill();
|
|
88
|
+
if (reduced()) return undefined;
|
|
89
|
+
const focused = document.activeElement as HTMLElement | null;
|
|
90
|
+
Flip.killFlipsOf(items());
|
|
91
|
+
const state = Flip.getState(visible());
|
|
92
|
+
if (focused?.isConnected && focused !== document.activeElement) focused.focus({ preventScroll: true });
|
|
93
|
+
return state;
|
|
94
|
+
};
|
|
95
|
+
const mutate = (change: () => void) => {
|
|
96
|
+
if (scope.disposed) return;
|
|
97
|
+
const focused = document.activeElement as HTMLElement | null;
|
|
98
|
+
const state = capture();
|
|
99
|
+
const before = new Set(items());
|
|
100
|
+
change();
|
|
101
|
+
transition(state, items().filter((item) => !before.has(item)));
|
|
102
|
+
if (focused && focused !== document.body && root.contains(focused) && !focused.closest('[hidden], [inert]')) focused.focus({ preventScroll: true });
|
|
103
|
+
else if (focused && !focused.isConnected) { scope.attr(root, 'tabindex', root.getAttribute('tabindex')); root.tabIndex = -1; root.focus(); }
|
|
104
|
+
observer?.takeRecords();
|
|
105
|
+
notify();
|
|
106
|
+
};
|
|
107
|
+
const setFilter = (next: string | null) => {
|
|
108
|
+
if (scope.disposed || next === filter) return;
|
|
109
|
+
const state = capture();
|
|
110
|
+
const before = new Set(visible());
|
|
111
|
+
filter = next;
|
|
112
|
+
paint();
|
|
113
|
+
transition(state, visible().filter((item) => !before.has(item)));
|
|
114
|
+
notify();
|
|
115
|
+
};
|
|
116
|
+
const refresh = () => {
|
|
117
|
+
if (scope.disposed) return;
|
|
118
|
+
transition(last, items().filter((item) => !remembered.has(item)));
|
|
119
|
+
};
|
|
120
|
+
const observer = typeof MutationObserver === 'function' ? scope.observe(new MutationObserver((records) => {
|
|
121
|
+
// Observe authored data only: engine style/hidden writes and nested content
|
|
122
|
+
// changes must not schedule another layout or create an observer loop.
|
|
123
|
+
if (!records.some((record) => record.type === 'childList' ? record.target === root : record.target instanceof HTMLElement && record.target.parentElement === root)) return;
|
|
124
|
+
cancelAnimationFrame(queued);
|
|
125
|
+
queued = requestAnimationFrame(refresh);
|
|
126
|
+
})) : null;
|
|
127
|
+
observer?.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-mk-category', 'data-mk-col-span', 'data-mk-row-span'] });
|
|
128
|
+
scope.on(root, 'click', (event) => {
|
|
129
|
+
const control = (event.target as Element).closest<HTMLElement>('button, a');
|
|
130
|
+
if (control && root.contains(control) && !control.matches(':disabled, [aria-disabled="true"]')) { scope.attr(control, 'style', control.getAttribute('style')); track(press(control)); }
|
|
131
|
+
});
|
|
132
|
+
if (typeof ResizeObserver === 'function') {
|
|
133
|
+
let width = root.clientWidth;
|
|
134
|
+
const resize = scope.observe(new ResizeObserver(() => { if (root.clientWidth !== width) { width = root.clientWidth; animation?.kill(); paint(); last = Flip.getState(visible()); } }));
|
|
135
|
+
resize.observe(root);
|
|
136
|
+
}
|
|
137
|
+
paint(); if (!reduced()) last = Flip.getState(visible());
|
|
138
|
+
track(blurIn(visible(), { from: 'soft', stagger: .04 }));
|
|
139
|
+
return {
|
|
140
|
+
refresh, mutate, setFilter,
|
|
141
|
+
reorder(keys) { mutate(() => { const byKey = new Map(items().map((item) => [key(item), item])); for (const id of new Set(keys)) { const item = byKey.get(id); if (item) { root.append(item); byKey.delete(id); } } byKey.forEach((item) => root.append(item)); }); },
|
|
142
|
+
setOptions(next) { if (scope.disposed) return; const state = capture(); opts = { ...opts, ...next }; if (next.filter !== undefined) filter = next.filter; transition(state); },
|
|
143
|
+
get filter() { return filter; }, get keys() { return visible().map(key); },
|
|
144
|
+
destroy() {
|
|
145
|
+
if (scope.disposed) return;
|
|
146
|
+
cancelAnimationFrame(queued); animation?.kill(); micro.forEach((value) => value.kill()); if (!reduced()) Flip.killFlipsOf(items()); gsap.killTweensOf([...remembered]);
|
|
147
|
+
scope.dispose();
|
|
148
|
+
// Preserve externally added content while restoring the initial authored order.
|
|
149
|
+
const added = [...root.childNodes].filter((node) => !originals.includes(node) && !(node instanceof Element && originalItems.has(node)));
|
|
150
|
+
root.replaceChildren(...originals, ...added);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createBentoGrid, type BentoGridOptions, type BentoGridInstance } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface BentoGridProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>, BentoGridOptions {
|
|
7
|
+
instanceRef?: MutableRefObject<BentoGridInstance | null>;
|
|
8
|
+
|
|
9
|
+
}
|
|
10
|
+
export function BentoGrid({ columns, gap, filter, duration, onChange, instanceRef, className, children, ...props }: BentoGridProps) {
|
|
11
|
+
const root = useRef<HTMLDivElement>(null);
|
|
12
|
+
const instance = useRef<BentoGridInstance | null>(null);
|
|
13
|
+
const onChangeRef = useLatest(onChange);
|
|
14
|
+
useIsomorphicLayoutEffect(() => {
|
|
15
|
+
const created = createBentoGrid(root.current, { columns, gap, filter, duration, onChange: (...args) => onChangeRef.current?.(...args) });
|
|
16
|
+
instance.current = created;
|
|
17
|
+
return () => { created.destroy(); instance.current = null; };
|
|
18
|
+
// Options update the existing engine so timers, selection and scroll state survive.
|
|
19
|
+
}, []);
|
|
20
|
+
useIsomorphicLayoutEffect(() => {
|
|
21
|
+
instance.current?.setOptions({ columns, gap, filter, duration });
|
|
22
|
+
}, [columns, gap, filter, duration]);
|
|
23
|
+
useIsomorphicLayoutEffect(() => { if (instanceRef) instanceRef.current = instance.current; return () => { if (instanceRef) instanceRef.current = null; }; }, [instanceRef]);
|
|
24
|
+
return <div {...props} ref={root} className={['mk-bento-grid', className].filter(Boolean).join(' ')}>{children}</div>;
|
|
25
|
+
}
|
|
@@ -8,13 +8,13 @@ mk-drilldown-menu {
|
|
|
8
8
|
width: 100%;
|
|
9
9
|
max-width: 26rem;
|
|
10
10
|
min-width: 0;
|
|
11
|
-
padding:
|
|
12
|
-
border:
|
|
11
|
+
padding: 12px;
|
|
12
|
+
border: 0;
|
|
13
13
|
border-radius: var(--mk-drilldown-radius);
|
|
14
14
|
background: var(--mk-surface-1);
|
|
15
15
|
color: var(--mk-text);
|
|
16
16
|
font-family: var(--md-sys-typescale-body-large-font, inherit);
|
|
17
|
-
|
|
17
|
+
font-family: var(--mk-font);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
[data-mk-drilldown-header] {
|
|
@@ -85,8 +85,9 @@ mk-drilldown-menu {
|
|
|
85
85
|
cursor: pointer;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
[data-mk-drilldown-viewport] { overflow: hidden; border-radius: var(--mk-drilldown-item-radius); }
|
|
89
|
-
[data-mk-drilldown-
|
|
88
|
+
[data-mk-drilldown-viewport] { position: relative; isolation: isolate; overflow: hidden; border-radius: var(--mk-drilldown-item-radius); }
|
|
89
|
+
[data-mk-drilldown-highlight] { position: absolute; top: 0; left: 0; z-index: -1; border-radius: var(--mk-radius-lg); background: var(--mk-surface-3); pointer-events: none; opacity: 0; }
|
|
90
|
+
[data-mk-drilldown-level] { position: relative; display: grid; gap: 6px; }
|
|
90
91
|
[data-mk-drilldown-item] {
|
|
91
92
|
display: flex;
|
|
92
93
|
align-items: center;
|
|
@@ -104,16 +105,19 @@ mk-drilldown-menu {
|
|
|
104
105
|
line-height: 1.4;
|
|
105
106
|
text-align: start;
|
|
106
107
|
cursor: pointer;
|
|
107
|
-
transition:
|
|
108
|
+
transition: color .15s, border-radius .3s var(--mk-ease-playful);
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
[data-mk-drilldown-item]:enabled:hover,
|
|
111
|
-
[data-mk-drilldown-item]:focus-visible { background: var(--mk-
|
|
112
|
+
[data-mk-drilldown-item]:focus-visible { background: color-mix(in srgb, var(--mk-text) 3%, transparent); border-radius: var(--mk-radius-xl); }
|
|
113
|
+
[data-mk-drilldown-item][data-mk-chosen]::after { content: '✓'; display: grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; color: var(--mk-on-accent); background: var(--mk-accent); }
|
|
112
114
|
[data-mk-drilldown-item]:disabled { opacity: 0.42; cursor: default; }
|
|
113
115
|
[data-mk-drilldown-text] { display: grid; gap: 3px; min-width: 0; overflow-wrap: anywhere; }
|
|
114
116
|
[data-mk-drilldown-label] { font-weight: 550; }
|
|
115
117
|
[data-mk-drilldown-description] { color: var(--mk-muted); font-size: 0.8rem; }
|
|
116
118
|
[data-mk-drilldown-arrow] { display: inline-flex; flex: none; color: var(--mk-muted); }
|
|
119
|
+
[data-mk-drilldown-arrow] svg { transition: transform .35s var(--mk-ease-swatch); }
|
|
120
|
+
[data-mk-highlighted] [data-mk-drilldown-arrow] svg { transform: translateX(3px); }
|
|
117
121
|
[data-mk-drilldown-empty] { margin: 0; padding: 24px 16px; color: var(--mk-muted); font-size: 0.875rem; }
|
|
118
122
|
|
|
119
123
|
[data-mk-drilldown-item]:focus-visible,
|
|
@@ -2,6 +2,7 @@ import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
|
2
2
|
import { reduced } from '../../core/reduced';
|
|
3
3
|
import { createScope } from '../../core/scope';
|
|
4
4
|
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { press, ringPulse } from '../../motion/micro';
|
|
5
6
|
|
|
6
7
|
export interface DrilldownMenuItem {
|
|
7
8
|
/** Unique among siblings; IDs form the navigation path. */
|
|
@@ -66,6 +67,12 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
66
67
|
let focused: string | null = null;
|
|
67
68
|
const remembered = new Map<string, string>();
|
|
68
69
|
let animation: gsap.core.Timeline | null = null;
|
|
70
|
+
let hoverMotion: gsap.core.Tween | null = null;
|
|
71
|
+
const effects = new Set<gsap.core.Animation>();
|
|
72
|
+
const keep = (effect: gsap.core.Animation | null) => {
|
|
73
|
+
for (const animation of effects) if (animation.totalProgress() === 1) effects.delete(animation);
|
|
74
|
+
if (effect) effects.add(effect);
|
|
75
|
+
};
|
|
69
76
|
|
|
70
77
|
const header = document.createElement('div');
|
|
71
78
|
header.dataset.mkDrilldownHeader = '';
|
|
@@ -91,7 +98,9 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
91
98
|
const empty = document.createElement('p');
|
|
92
99
|
empty.dataset.mkDrilldownEmpty = '';
|
|
93
100
|
empty.setAttribute('role', 'status');
|
|
94
|
-
|
|
101
|
+
const highlight = document.createElement('span');
|
|
102
|
+
highlight.dataset.mkDrilldownHighlight = ''; highlight.setAttribute('aria-hidden', 'true');
|
|
103
|
+
viewport.append(highlight, menu, empty);
|
|
95
104
|
backButton.setAttribute('aria-controls', menu.id);
|
|
96
105
|
root.append(header, breadcrumb, viewport);
|
|
97
106
|
scope.attr(root, 'data-mk-ready', '');
|
|
@@ -112,10 +121,19 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
112
121
|
const level = () => levelAt(path);
|
|
113
122
|
const enabled = () => [...menu.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-item]')].filter((item) => !item.disabled);
|
|
114
123
|
const buttonOf = (id: string) => enabled().find((item) => item.dataset.mkDrilldownItem === id);
|
|
124
|
+
const highlightItem = (button?: HTMLButtonElement | null) => {
|
|
125
|
+
hoverMotion?.kill();
|
|
126
|
+
for (const item of enabled()) item.toggleAttribute('data-mk-highlighted', item === button);
|
|
127
|
+
if (!button) { highlight.style.opacity = '0'; return; }
|
|
128
|
+
const geometry = { x: button.offsetLeft + menu.offsetLeft, y: button.offsetTop + menu.offsetTop, width: button.offsetWidth, height: button.offsetHeight, opacity: 1 };
|
|
129
|
+
if (reduced()) gsap.set(highlight, geometry);
|
|
130
|
+
else hoverMotion = gsap.to(highlight, { ...geometry, duration: .35, ease: EASE.swatch });
|
|
131
|
+
};
|
|
115
132
|
const cancelMotion = () => {
|
|
116
133
|
animation?.kill();
|
|
134
|
+
hoverMotion?.kill();
|
|
117
135
|
animation = null;
|
|
118
|
-
gsap.set(menu, { clearProps: 'transform,opacity,filter' });
|
|
136
|
+
gsap.set([menu, heading, breadcrumb, ...menu.children], { clearProps: 'transform,opacity,filter' });
|
|
119
137
|
gsap.set(viewport, { clearProps: 'height' });
|
|
120
138
|
};
|
|
121
139
|
const roving = (id: string | null, focus = false) => {
|
|
@@ -127,6 +145,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
127
145
|
menu.tabIndex = target ? -1 : 0;
|
|
128
146
|
if (focused) remembered.set(JSON.stringify(path), focused);
|
|
129
147
|
if (focus) (target ?? menu).focus({ preventScroll: true });
|
|
148
|
+
if (focus) highlightItem(target);
|
|
130
149
|
};
|
|
131
150
|
const notifyPath = () => {
|
|
132
151
|
const next = [...path];
|
|
@@ -137,6 +156,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
137
156
|
const render = (direction: number, focus: boolean, preferred?: string | null) => {
|
|
138
157
|
const oldHeight = viewport.getBoundingClientRect().height;
|
|
139
158
|
cancelMotion();
|
|
159
|
+
highlightItem(null);
|
|
140
160
|
const current = level();
|
|
141
161
|
const label = config.label ?? 'Actions';
|
|
142
162
|
const title = current.ancestors.at(-1)?.label ?? label;
|
|
@@ -144,6 +164,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
144
164
|
backButton.hidden = path.length === 0;
|
|
145
165
|
backButton.setAttribute('aria-label', config.backLabel ?? 'Back');
|
|
146
166
|
breadcrumb.setAttribute('aria-label', `${label} path`);
|
|
167
|
+
// At the root the heading already names the only crumb.
|
|
168
|
+
breadcrumb.hidden = path.length === 0;
|
|
147
169
|
const labels = [label, ...current.ancestors.map((item) => item.label)];
|
|
148
170
|
crumbs.replaceChildren(...labels.map((text, index) => {
|
|
149
171
|
const li = document.createElement('li');
|
|
@@ -203,6 +225,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
203
225
|
// duplicated menuitems and allows rapid navigation without stale callbacks.
|
|
204
226
|
animation = gsap.timeline({ onComplete: () => { animation = null; } })
|
|
205
227
|
.fromTo(menu, { x: 28 * direction, opacity: 0, filter: 'blur(6px)' }, { x: 0, opacity: 1, filter: 'blur(0px)', duration: 0.36, ease: EASE.signature, clearProps: 'transform,opacity,filter' });
|
|
228
|
+
animation.fromTo([heading, breadcrumb], { y: -6, opacity: .3, filter: 'blur(6px)' }, { y: 0, opacity: 1, filter: 'blur(0px)', duration: .3, ease: EASE.pop, clearProps: 'transform,opacity,filter' }, 0)
|
|
229
|
+
.fromTo([...menu.children], { x: 12 * direction, opacity: 0 }, { x: 0, opacity: 1, duration: .3, stagger: .035, ease: EASE.swatchLight, clearProps: 'transform,opacity' }, .04);
|
|
206
230
|
const nextHeight = viewport.getBoundingClientRect().height;
|
|
207
231
|
if (oldHeight > 0 && nextHeight > 0 && oldHeight !== nextHeight) {
|
|
208
232
|
animation.fromTo(viewport, { height: oldHeight }, { height: nextHeight, duration: 0.36, ease: EASE.signature, clearProps: 'height' }, 0);
|
|
@@ -237,6 +261,11 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
237
261
|
const item = level().items.find((entry) => entry.id === id);
|
|
238
262
|
if (!item || item.disabled) return;
|
|
239
263
|
if (branch(item)) return void open(id);
|
|
264
|
+
const button = buttonOf(id);
|
|
265
|
+
if (button) {
|
|
266
|
+
for (const row of enabled()) row.toggleAttribute('data-mk-chosen', row === button);
|
|
267
|
+
keep(press(button)); keep(ringPulse(button));
|
|
268
|
+
}
|
|
240
269
|
const selected = copyItems([item])[0]!;
|
|
241
270
|
const selectedPath = [...path, item.id];
|
|
242
271
|
root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { item: selected, path: [...selectedPath] } }));
|
|
@@ -251,8 +280,17 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
251
280
|
});
|
|
252
281
|
scope.on(menu, 'focusin', (event) => {
|
|
253
282
|
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
|
|
254
|
-
if (button && !button.disabled) roving(button.dataset.mkDrilldownItem!, false);
|
|
283
|
+
if (button && !button.disabled) { roving(button.dataset.mkDrilldownItem!, false); highlightItem(button); }
|
|
284
|
+
});
|
|
285
|
+
scope.on(menu, 'pointerover', event => {
|
|
286
|
+
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
|
|
287
|
+
if (button && !button.disabled) highlightItem(button);
|
|
255
288
|
});
|
|
289
|
+
scope.on(menu, 'pointerleave', () => highlightItem(menu.contains(document.activeElement) ? buttonOf(focused ?? '') : null));
|
|
290
|
+
if (typeof ResizeObserver === 'function') scope.observe(new ResizeObserver(() => {
|
|
291
|
+
const current = menu.querySelector<HTMLButtonElement>('[data-mk-highlighted]');
|
|
292
|
+
if (current) highlightItem(current);
|
|
293
|
+
})).observe(menu);
|
|
256
294
|
scope.on(root, 'keydown', (event) => {
|
|
257
295
|
const key = (event as KeyboardEvent).key;
|
|
258
296
|
if ((key === 'ArrowLeft' || key === 'Escape') && path.length) {
|
|
@@ -279,7 +317,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
279
317
|
activate(button.dataset.mkDrilldownItem!);
|
|
280
318
|
}
|
|
281
319
|
});
|
|
282
|
-
scope.on(backButton, 'click', () => back());
|
|
320
|
+
scope.on(backButton, 'click', () => { keep(press(backButton)); back(); });
|
|
283
321
|
scope.on(breadcrumb, 'click', (event) => {
|
|
284
322
|
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-crumb]');
|
|
285
323
|
if (!button || !breadcrumb.contains(button)) return;
|
|
@@ -288,6 +326,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
288
326
|
});
|
|
289
327
|
scope.add(() => {
|
|
290
328
|
cancelMotion();
|
|
329
|
+
for (const effect of effects) effect.kill();
|
|
330
|
+
gsap.killTweensOf([heading, breadcrumb, highlight]);
|
|
291
331
|
remembered.clear();
|
|
292
332
|
header.remove();
|
|
293
333
|
breadcrumb.remove();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# EqualizerSlider
|
|
2
|
+
|
|
3
|
+
Un control de nivel con barras de onda y un cursor que acompaña inmediatamente al rango nativo. Las barras que cambian de estado responden con swatch; el número rueda con las teclas o la API y el cursor responde al presionarlo.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createEqualizerSlider } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/equalizer-slider';
|
|
8
|
+
|
|
9
|
+
const control = createEqualizerSlider(document.querySelector('.mk-equalizer-slider'), {
|
|
10
|
+
value: 60, name: 'volume', label: 'Volumen',
|
|
11
|
+
onInput: (value) => { audio.volume = value / 100; },
|
|
12
|
+
});
|
|
13
|
+
control.setValue(35);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Opciones y contrato
|
|
17
|
+
|
|
18
|
+
`min` (0), `max` (100), `step` (1), `value` / `defaultValue` (60), `waveform: number[]` (alturas 0–1), `bars` (40, 12–72), `label`, `name`, `disabled`, `error`, `formatValue`, `onInput(value)`, `onChange(value)`.
|
|
19
|
+
|
|
20
|
+
Es un control numérico; no reproduce, captura ni analiza audio. waveform es una forma decorativa estática. onInput se emite durante el ajuste; onChange al confirmar, una sola vez por valor. En React se puede actualizar con onInput o confirmar con onChange. El arrastre siempre cambia la geometría sin esperar a una animación.
|
|
21
|
+
|
|
22
|
+
La instancia expone `value: number`, `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:input / mk:change` con `detail: { value }`.
|
|
23
|
+
|
|
24
|
+
## React y HTML
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { EqualizerSlider } from '@yewdiel21/motion-kit/react';
|
|
28
|
+
|
|
29
|
+
<EqualizerSlider value={level} onInput={setLevel} label="Nivel" name="level" />
|
|
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-equalizer-slider class="mk-equalizer-slider" value="60" label="Nivel" name="level" bars="44"></mk-equalizer-slider>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Registra con `defineEqualizerSlider()` 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 { createEqualizerSlider, type EqualizerSliderInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
/** Light DOM; attribute changes keep the current imperative value and reset baseline. */
|
|
5
|
+
export class MkEqualizerSliderElement extends MkElement<EqualizerSliderInstance> {
|
|
6
|
+
static options: Record<string, OptionType> = {"min":"number","max":"number","step":"number","value":"number","label":"string","name":"string","disabled":"boolean","error":"string","waveform":"json","bars":"number"};
|
|
7
|
+
private baseline: number | undefined;
|
|
8
|
+
private saved: number | undefined;
|
|
9
|
+
protected create(options: Record<string, unknown>) {
|
|
10
|
+
const created = createEqualizerSlider(this, { ...options, value: this.saved !== undefined ? this.saved : options.value as number | 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 ?? 60 : Number.isFinite(Number(next)) ? Number(next) : this.baseline ?? 60;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!this.instance || !this.isConnected) return;
|
|
26
|
+
if (name === 'value') {
|
|
27
|
+
const parsed = next === null ? this.baseline ?? 60 : Number(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(): number { return this.instance?.value ?? this.saved ?? 60; }
|
|
35
|
+
set value(next: number) { this.setValue(next); }
|
|
36
|
+
setValue(value: number, 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 defineEqualizerSlider = () => defineElement('mk-equalizer-slider', MkEqualizerSliderElement);
|
|
41
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { gsap, EASE } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createNumericRange, type NumericOptions, type NumericInstance } from './shared';
|
|
4
|
+
|
|
5
|
+
export interface EqualizerSliderOptions extends NumericOptions {
|
|
6
|
+
/** Static waveform heights 0…1. This control does not analyze or play audio. */
|
|
7
|
+
waveform?: number[];
|
|
8
|
+
/** Number of decorative bars, 12…72; default 40. */
|
|
9
|
+
bars?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface EqualizerSliderInstance extends NumericInstance {}
|
|
12
|
+
export function createEqualizerSlider(root: HTMLElement | null, options: EqualizerSliderOptions = {}): EqualizerSliderInstance {
|
|
13
|
+
return createNumericRange(root, options, {
|
|
14
|
+
prefix: 'equalizer', label: 'Volumen', min: 0, max: 100, value: 60, format: (n) => `${n} %`,
|
|
15
|
+
decorate({ track, keep }) {
|
|
16
|
+
const wave = document.createElement('span'); wave.dataset.mkEqualizerWave = ''; wave.setAttribute('aria-hidden', 'true'); track.prepend(wave);
|
|
17
|
+
const count = Math.max(12, Math.min(72, Math.round(Number.isFinite(options.bars) ? options.bars! : 40)));
|
|
18
|
+
const bars = Array.from({ length: count }, (_, i) => {
|
|
19
|
+
const bar = document.createElement('span');
|
|
20
|
+
const supplied = options.waveform?.[i % options.waveform.length];
|
|
21
|
+
const height = Number.isFinite(supplied) ? Math.max(0.1, Math.min(1, supplied!)) : 0.25 + 0.75 * Math.abs(Math.sin(i * 1.73) * Math.cos(i * 0.41));
|
|
22
|
+
bar.style.height = `${height * 100}%`; wave.append(bar); return bar;
|
|
23
|
+
});
|
|
24
|
+
return (ratio, _value, animate) => bars.forEach((bar, i) => {
|
|
25
|
+
const on = i / Math.max(1, count - 1) <= ratio;
|
|
26
|
+
const changed = bar.hasAttribute('data-mk-on') !== on;
|
|
27
|
+
bar.toggleAttribute('data-mk-on', on);
|
|
28
|
+
if (animate && changed && !reduced()) keep(gsap.fromTo(bar, { scaleY: 0.72 }, { scaleY: 1, duration: 0.35, ease: EASE.swatch, overwrite: 'auto', clearProps: 'transform' }));
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider) { display: block; width: 100%; min-width: 0; color: var(--mk-text); font: 500 .875rem/1.4 var(--mk-font); }
|
|
2
|
+
[data-mk-equalizer-body] { display: grid; gap: .875rem; }
|
|
3
|
+
[data-mk-equalizer-header] { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
|
|
4
|
+
[data-mk-equalizer-output] { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
5
|
+
[data-mk-equalizer-track] { position: relative; height: 76px; min-width: 0; border-radius: 24px; background: var(--mk-surface-2); }
|
|
6
|
+
[data-mk-equalizer-input] { position: absolute; z-index: 3; inset: 0; box-sizing: border-box; width: 100%; height: 100%; margin: 0; appearance: none; -webkit-appearance: none; background: transparent; cursor: ew-resize; opacity: 0; }
|
|
7
|
+
[data-mk-equalizer-input]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 32px; height: 76px; background: transparent; }
|
|
8
|
+
[data-mk-equalizer-input]::-moz-range-thumb { width: 32px; height: 76px; border: 0; background: transparent; }
|
|
9
|
+
[data-mk-equalizer-thumb] { position: absolute; z-index: 2; top: 50%; left: calc(16px + (100% - 32px) * var(--mk-equalizer-ratio, .5)); translate: -50% -50%; pointer-events: none; }
|
|
10
|
+
[data-mk-equalizer-track]:has(:focus-visible) { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
|
|
11
|
+
[data-mk-equalizer-ends] { display: flex; justify-content: space-between; margin-top: -.375rem; color: var(--mk-muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
|
|
12
|
+
[data-mk-equalizer-message] { color: var(--mk-error); font-size: .8125rem; font-weight: 400; }
|
|
13
|
+
[data-mk-equalizer-message]:empty { display: none; }
|
|
14
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-disabled] { opacity: .4; }
|
|
15
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-disabled] [data-mk-equalizer-input] { cursor: not-allowed; }
|
|
16
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-error] [data-mk-equalizer-track] { outline: 1px solid var(--mk-error); }
|
|
17
|
+
[data-mk-equalizer-output] { padding: .375rem .75rem; border-radius: 64px; background: var(--mk-surface-2); font-size: .8125rem; }
|
|
18
|
+
[data-mk-equalizer-wave] { position: absolute; inset: 14px 16px; display: flex; align-items: center; gap: 3px; pointer-events: none; }
|
|
19
|
+
[data-mk-equalizer-wave] > span { flex: 1; min-width: 1px; border-radius: 64px; background: var(--mk-outline-soft); transform-origin: center; transition: background .16s; }
|
|
20
|
+
[data-mk-equalizer-wave] > span[data-mk-on] { background: var(--mk-text); }
|
|
21
|
+
[data-mk-equalizer-thumb] { width: 4px; height: 54px; border: 3px solid var(--mk-surface-2); border-radius: 64px; background: var(--mk-text); box-sizing: content-box; transition: height .35s var(--mk-ease-swatch), border-width .35s var(--mk-ease-swatch); }
|
|
22
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-held] [data-mk-equalizer-thumb] { height: 66px; border-width: 5px; }
|
|
23
|
+
@media (hover: hover) { [data-mk-equalizer-track]:has(:not(:disabled):hover) [data-mk-equalizer-thumb] { height: 62px; } }
|
|
24
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-equalizer-thumb], [data-mk-equalizer-wave] > span { transition: none; } }
|
|
25
|
+
@media (forced-colors: active) { [data-mk-equalizer-wave] > span { background: GrayText; } [data-mk-equalizer-wave] > span[data-mk-on], [data-mk-equalizer-thumb] { background: Highlight; } }
|
|
26
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createEqualizerSlider, type EqualizerSliderOptions, type EqualizerSliderInstance } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface EqualizerSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, Omit<EqualizerSliderOptions, 'value' | 'defaultValue'> {
|
|
7
|
+
value?: number;
|
|
8
|
+
defaultValue?: number;
|
|
9
|
+
instanceRef?: MutableRefObject<EqualizerSliderInstance | null>;
|
|
10
|
+
/** The engine owns this component's DOM. */
|
|
11
|
+
children?: never;
|
|
12
|
+
dangerouslySetInnerHTML?: never;
|
|
13
|
+
}
|
|
14
|
+
export function EqualizerSlider({ value, defaultValue, min, max, step, label, name, disabled, error, formatValue, waveform, bars, onChange, onInput, instanceRef, className, children: _children, dangerouslySetInnerHTML: _html, ...props }: EqualizerSliderProps) {
|
|
15
|
+
const root = useRef<HTMLDivElement>(null);
|
|
16
|
+
const engine = useRef<EqualizerSliderInstance | null>(null);
|
|
17
|
+
const latestValue = useLatest(value);
|
|
18
|
+
const change = useLatest(onChange);
|
|
19
|
+
const input = useLatest(onInput);
|
|
20
|
+
const baseline = useRef(value !== undefined ? value : defaultValue);
|
|
21
|
+
const current = useRef(baseline.current);
|
|
22
|
+
const reconcile = () => queueMicrotask(() => {
|
|
23
|
+
if (latestValue.current !== undefined) engine.current?.setValue(latestValue.current, false);
|
|
24
|
+
});
|
|
25
|
+
useIsomorphicLayoutEffect(() => {
|
|
26
|
+
const created = createEqualizerSlider(root.current, {
|
|
27
|
+
min, max, step, label, name, disabled, error, formatValue, waveform, bars, value: latestValue.current !== undefined ? latestValue.current : current.current, defaultValue: baseline.current,
|
|
28
|
+
onChange: (next) => { change.current?.(next); reconcile(); },
|
|
29
|
+
onInput: (next) => { input.current?.(next); if (input.current || !change.current) reconcile(); },
|
|
30
|
+
});
|
|
31
|
+
engine.current = created;
|
|
32
|
+
const form = root.current?.closest('form');
|
|
33
|
+
const pending = new Set<ReturnType<typeof setTimeout>>();
|
|
34
|
+
const reset = (event: Event) => {
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
pending.delete(timer);
|
|
37
|
+
if (!event.defaultPrevented && latestValue.current !== undefined) created.setValue(latestValue.current, false);
|
|
38
|
+
}, 0);
|
|
39
|
+
pending.add(timer);
|
|
40
|
+
};
|
|
41
|
+
form?.addEventListener('reset', reset);
|
|
42
|
+
return () => {
|
|
43
|
+
form?.removeEventListener('reset', reset);
|
|
44
|
+
pending.forEach((timer) => clearTimeout(timer));
|
|
45
|
+
current.current = created.value; created.destroy(); engine.current = null;
|
|
46
|
+
};
|
|
47
|
+
// Callbacks and controlled value are read through refs.
|
|
48
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
49
|
+
}, [min, max, step, label, name, disabled, error, formatValue, waveform, bars]);
|
|
50
|
+
useIsomorphicLayoutEffect(() => {
|
|
51
|
+
if (value !== undefined) engine.current?.setValue(value, false);
|
|
52
|
+
});
|
|
53
|
+
useIsomorphicLayoutEffect(() => {
|
|
54
|
+
if (!instanceRef) return;
|
|
55
|
+
instanceRef.current = engine.current;
|
|
56
|
+
return () => { instanceRef.current = null; };
|
|
57
|
+
}, [instanceRef, min, max, step, label, name, disabled, error, formatValue, waveform, bars]);
|
|
58
|
+
return <div {...props} ref={root} className={['mk-equalizer-slider', className].filter(Boolean).join(' ')} />;
|
|
59
|
+
}
|
|
60
|
+
|