@yewdiel21/motion-kit 0.18.0 → 0.19.1
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 +245 -222
- package/bin/motion-kit.mjs +0 -0
- package/dist/auto/index.d.ts +4 -3
- package/dist/auto/index.js +5 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-QFBOHPWZ.js → chunk-CQP66AZK.js} +2 -2
- package/dist/{chunk-QP4QJOEN.js → chunk-H4STLMCM.js} +2 -2
- package/dist/{chunk-AWDEKDEJ.js → chunk-LIXSOGS2.js} +8 -22
- package/dist/chunk-LIXSOGS2.js.map +1 -0
- package/dist/{chunk-Q7IW4FV3.js → chunk-NCGNIPJE.js} +1430 -968
- package/dist/chunk-NCGNIPJE.js.map +1 -0
- package/dist/{chunk-DOOMULVV.js → chunk-OLT3CWDJ.js} +4 -2
- package/dist/chunk-OLT3CWDJ.js.map +1 -0
- package/dist/{chunk-EDMXWUW3.js → chunk-PVN7YVKV.js} +3 -3
- package/dist/css/components/code-block.css +267 -0
- package/dist/css/components/command-search.css +274 -2
- package/dist/css/components/dialog.css +264 -0
- package/dist/css/components/heatmap-chart.css +267 -1
- package/dist/css/components/pill-button.css +7 -5
- package/dist/css/components/scroll-area.css +261 -0
- package/dist/css/components/scroll-island.css +6 -0
- package/dist/css/components/select.css +284 -4
- package/dist/elements/index.d.ts +15 -4
- package/dist/elements/index.js +36 -6
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-t8d7brjn.d.ts → engine-D6XV-WNt.d.ts} +50 -1
- package/dist/gl/index.js +2 -2
- package/dist/index.css +321 -12
- package/dist/index.d.ts +3 -3
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +20 -7
- package/dist/react/index.js +56 -7
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-dPsZYqtI.d.ts → reduced-4croIXl-.d.ts} +1 -1
- package/dist/{store-6hZNKiee.d.ts → store-CAHWkurn.d.ts} +1 -1
- package/package.json +117 -110
- package/registry.json +1357 -1340
- package/src/auto/auto-init.ts +341 -339
- package/src/components/code-block/README.md +1 -1
- package/src/components/code-block/code-block.css +5 -0
- package/src/components/code-block/engine.ts +5 -0
- package/src/components/command-search/README.md +1 -1
- package/src/components/command-search/command-search.css +12 -2
- package/src/components/command-search/engine.ts +6 -0
- package/src/components/dialog/README.md +104 -103
- package/src/components/dialog/dialog.css +2 -0
- package/src/components/dialog/engine.ts +465 -449
- package/src/components/heatmap-chart/README.md +48 -48
- package/src/components/heatmap-chart/engine.ts +6 -1
- package/src/components/heatmap-chart/heatmap-chart.css +5 -1
- package/src/components/icons.ts +1 -0
- package/src/components/pill-button/engine.ts +1 -1
- package/src/components/pill-button/pill-button.css +7 -5
- package/src/components/scroll-area/README.md +89 -0
- package/src/components/scroll-area/element.ts +35 -0
- package/src/components/scroll-area/engine.ts +528 -0
- package/src/components/scroll-area/react.tsx +67 -0
- package/src/components/scroll-area/scroll-area.css +260 -0
- package/src/components/scroll-island/engine.ts +10 -1
- package/src/components/scroll-island/scroll-island.css +6 -0
- package/src/components/select/README.md +1 -1
- package/src/components/select/engine.ts +16 -4
- package/src/components/select/select.css +22 -4
- package/src/core/timing.ts +2 -0
- package/src/elements/index.ts +321 -318
- package/src/index.ts +1 -0
- package/src/motion/morph.ts +5 -2
- package/src/react/index.ts +2 -1
- package/dist/chunk-AWDEKDEJ.js.map +0 -1
- package/dist/chunk-DOOMULVV.js.map +0 -1
- package/dist/chunk-Q7IW4FV3.js.map +0 -1
- /package/dist/{chunk-QFBOHPWZ.js.map → chunk-CQP66AZK.js.map} +0 -0
- /package/dist/{chunk-QP4QJOEN.js.map → chunk-H4STLMCM.js.map} +0 -0
- /package/dist/{chunk-EDMXWUW3.js.map → chunk-PVN7YVKV.js.map} +0 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { requestFrame } from '../../core/frame';
|
|
3
|
+
import { reduced } from '../../core/reduced';
|
|
4
|
+
import { createScope } from '../../core/scope';
|
|
5
|
+
import { TIMING } from '../../core/timing';
|
|
6
|
+
import { inert, warnOnce, type Instance } from '../../core/types';
|
|
7
|
+
import { blurIn, blurOut, press } from '../../motion/micro';
|
|
8
|
+
import { iconSvg } from '../icons';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Scroll area: the scroller keeps its native scrolling (keyboard, touch,
|
|
12
|
+
* momentum, snap, find in page) and hides its system bar; the kit's bars ride
|
|
13
|
+
* on top. At rest a bar is a 6 px pill without a rail; it shows while the
|
|
14
|
+
* pointer is over the area or the content scrolls and fades .9 s after. Over
|
|
15
|
+
* the bar a tonal rail comes in and the pill swells to 10 px on Melonmind's
|
|
16
|
+
* swing (CSS, so it is interrupted at once); grabbing it presses it
|
|
17
|
+
* (buttonClickAnimation) and turns it the accent while it follows the pointer
|
|
18
|
+
* with no easing. A click on the rail glides there (0.6 s, signature), cut
|
|
19
|
+
* short by the wheel, a touch, a key or another grab. The edges with more
|
|
20
|
+
* content fade out (a mask as deep as what is hidden, up to 24 px), and an
|
|
21
|
+
* optional "back to top" pill pops in out of a blur once the view has gone a
|
|
22
|
+
* screen down. The content is wrapped in a viewport, or an existing scroller
|
|
23
|
+
* inside the root is used as it is; destroy() leaves the DOM as it was.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type ScrollAreaVisibility = 'hover' | 'scroll' | 'always';
|
|
27
|
+
export type ScrollAreaAxis = 'x' | 'y' | 'both';
|
|
28
|
+
|
|
29
|
+
export interface ScrollAreaOptions {
|
|
30
|
+
/** When the bars show: 'hover' (pointer over the area or scrolling, default), 'scroll' (only scrolling) or 'always'. */
|
|
31
|
+
visibility?: ScrollAreaVisibility;
|
|
32
|
+
/** Which bars it can draw (default 'both'); each one shows only when its axis overflows. */
|
|
33
|
+
axis?: ScrollAreaAxis;
|
|
34
|
+
/** ms the bars stay after the last scroll or the pointer leaving (default 900). */
|
|
35
|
+
idle?: number;
|
|
36
|
+
/** Fade the edges that have more content: true (24 px, default), false, or the depth in px. */
|
|
37
|
+
fade?: boolean | number;
|
|
38
|
+
/** A "back to top" button: true (after one view's height), or the scroll distance in px that shows it. */
|
|
39
|
+
backToTop?: boolean | number;
|
|
40
|
+
/** Accessible name of the scrolling region. */
|
|
41
|
+
label?: string;
|
|
42
|
+
/** The viewport takes Tab focus so the keyboard can scroll it (default true); false when its content is focusable (a menu). */
|
|
43
|
+
focusable?: boolean;
|
|
44
|
+
/** Keep the bars out of assistive tech (default false): inside a menu or a listbox, whose children must be its items. */
|
|
45
|
+
decorative?: boolean;
|
|
46
|
+
/** An existing scroller inside the root; without it the root's content is wrapped in one. */
|
|
47
|
+
viewport?: HTMLElement | null;
|
|
48
|
+
/** Each scroll, with the progress along each axis (0 → 1). */
|
|
49
|
+
onScroll?: (progress: { x: number; y: number }) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ScrollAreaInstance extends Instance {
|
|
53
|
+
/** Scroll to a position in px; it glides unless `animate` is false or motion is reduced. */
|
|
54
|
+
scrollTo(position: { top?: number; left?: number }, options?: { animate?: boolean }): void;
|
|
55
|
+
/** Measure again (after the content or the size changed without the observers noticing). */
|
|
56
|
+
refresh(): void;
|
|
57
|
+
/** The element that scrolls. */
|
|
58
|
+
readonly viewport: HTMLElement | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Axis = 'x' | 'y';
|
|
62
|
+
|
|
63
|
+
interface Bar {
|
|
64
|
+
axis: Axis;
|
|
65
|
+
rail: HTMLElement;
|
|
66
|
+
thumb: HTMLElement;
|
|
67
|
+
pill: HTMLElement;
|
|
68
|
+
overflow: boolean;
|
|
69
|
+
/** Length of the rail and of the thumb, in px. */
|
|
70
|
+
track: number;
|
|
71
|
+
size: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let sequence = 0;
|
|
75
|
+
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
|
76
|
+
|
|
77
|
+
export function createScrollArea(root: HTMLElement | null, options: ScrollAreaOptions = {}): ScrollAreaInstance {
|
|
78
|
+
if (!root) {
|
|
79
|
+
warnOnce('scroll-area: missing root');
|
|
80
|
+
return inert({ scrollTo() {}, refresh() {}, viewport: null });
|
|
81
|
+
}
|
|
82
|
+
registerMotion();
|
|
83
|
+
const scope = createScope();
|
|
84
|
+
const visibility: ScrollAreaVisibility = options.visibility === 'scroll' || options.visibility === 'always' ? options.visibility : 'hover';
|
|
85
|
+
const axis: ScrollAreaAxis = options.axis === 'x' || options.axis === 'y' ? options.axis : 'both';
|
|
86
|
+
const idle = Number.isFinite(options.idle) ? Math.max(0, options.idle!) : TIMING.scrollArea.idleMs;
|
|
87
|
+
const fade = options.fade === false ? 0 : typeof options.fade === 'number' && Number.isFinite(options.fade) ? Math.max(0, options.fade) : TIMING.scrollArea.fade;
|
|
88
|
+
|
|
89
|
+
let viewport = options.viewport && options.viewport !== root && root.contains(options.viewport) ? options.viewport : null;
|
|
90
|
+
if (!viewport) {
|
|
91
|
+
const own = document.createElement('div');
|
|
92
|
+
own.append(...root.childNodes);
|
|
93
|
+
root.append(own);
|
|
94
|
+
// Last out: the rails and the button are gone by then, so the content goes back alone.
|
|
95
|
+
scope.add(() => own.replaceWith(...own.childNodes));
|
|
96
|
+
viewport = own;
|
|
97
|
+
}
|
|
98
|
+
const view = viewport;
|
|
99
|
+
// The mask depths and a held scroll-behavior are written inline: the original style comes back.
|
|
100
|
+
scope.attr(view, 'style', view.getAttribute('style'));
|
|
101
|
+
scope.attr(view, 'data-mk-scroll-viewport', '');
|
|
102
|
+
// aria-controls needs it; decorative bars do not, and a dialog's closing copy would repeat it.
|
|
103
|
+
if (!options.decorative && !view.id) scope.attr(view, 'id', `mk-scroll-${++sequence}`);
|
|
104
|
+
if (options.focusable !== false && !view.hasAttribute('tabindex')) scope.attr(view, 'tabindex', '0');
|
|
105
|
+
if (options.label) {
|
|
106
|
+
scope.attr(view, 'role', 'region');
|
|
107
|
+
scope.attr(view, 'aria-label', options.label);
|
|
108
|
+
}
|
|
109
|
+
scope.attr(root, 'data-mk-scroll-area', '');
|
|
110
|
+
scope.attr(root, 'data-mk-scroll-axis', axis);
|
|
111
|
+
if (fade) scope.attr(root, 'data-mk-fade', '');
|
|
112
|
+
// The bars are placed against the root: a static one becomes their containing block.
|
|
113
|
+
if (getComputedStyle(root).position === 'static') scope.attr(root, 'data-mk-scroll-anchor', '');
|
|
114
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
115
|
+
scope.add(() => {
|
|
116
|
+
for (const name of ['data-mk-active', 'data-mk-dragging', 'data-mk-overflow-x', 'data-mk-overflow-y']) root.removeAttribute(name);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const effects = new Set<gsap.core.Animation>();
|
|
120
|
+
const keep = (animation: gsap.core.Animation | null) => {
|
|
121
|
+
if (!animation) return;
|
|
122
|
+
effects.add(animation);
|
|
123
|
+
// Finished ones leave as new ones arrive: the animation's own onComplete stays untouched.
|
|
124
|
+
for (const effect of effects) if (effect.totalProgress() === 1) effects.delete(effect);
|
|
125
|
+
};
|
|
126
|
+
scope.add(() => {
|
|
127
|
+
for (const effect of effects) effect.kill();
|
|
128
|
+
effects.clear();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const makeBar = (which: Axis): Bar => {
|
|
132
|
+
const rail = document.createElement('div');
|
|
133
|
+
rail.dataset.mkScrollRail = '';
|
|
134
|
+
rail.dataset.mkAxis = which;
|
|
135
|
+
if (options.decorative) rail.setAttribute('aria-hidden', 'true');
|
|
136
|
+
else {
|
|
137
|
+
rail.setAttribute('role', 'scrollbar');
|
|
138
|
+
rail.setAttribute('aria-controls', view.id);
|
|
139
|
+
rail.setAttribute('aria-orientation', which === 'y' ? 'vertical' : 'horizontal');
|
|
140
|
+
rail.setAttribute('aria-valuemin', '0');
|
|
141
|
+
rail.setAttribute('aria-valuemax', '100');
|
|
142
|
+
rail.setAttribute('aria-valuenow', '0');
|
|
143
|
+
}
|
|
144
|
+
rail.hidden = true;
|
|
145
|
+
// A dialog being dragged to close leaves its bars to the scroll.
|
|
146
|
+
rail.setAttribute('data-mk-no-drag', '');
|
|
147
|
+
const thumb = document.createElement('div');
|
|
148
|
+
thumb.dataset.mkScrollThumb = '';
|
|
149
|
+
const pill = document.createElement('span');
|
|
150
|
+
pill.dataset.mkScrollPill = '';
|
|
151
|
+
thumb.append(pill);
|
|
152
|
+
rail.append(thumb);
|
|
153
|
+
root.append(rail);
|
|
154
|
+
scope.add(() => rail.remove());
|
|
155
|
+
return { axis: which, rail, thumb, pill, overflow: false, track: 0, size: 0 };
|
|
156
|
+
};
|
|
157
|
+
const bars = (axis === 'both' ? (['y', 'x'] as const) : [axis]).map(makeBar);
|
|
158
|
+
|
|
159
|
+
let rtl = false;
|
|
160
|
+
const range = (which: Axis) => Math.max(0, which === 'y' ? view.scrollHeight - view.clientHeight : view.scrollWidth - view.clientWidth);
|
|
161
|
+
/** Distance from the start of the axis (the right edge in right-to-left text). */
|
|
162
|
+
const travelled = (which: Axis) => (which === 'y' ? view.scrollTop : rtl ? -view.scrollLeft : view.scrollLeft);
|
|
163
|
+
const progress = (which: Axis) => {
|
|
164
|
+
const total = range(which);
|
|
165
|
+
return total > 0 ? clamp(travelled(which) / total, 0, 1) : 0;
|
|
166
|
+
};
|
|
167
|
+
/** The scroll position for a share of the axis (0 = start). */
|
|
168
|
+
const positionAt = (which: Axis, share: number) => {
|
|
169
|
+
const at = clamp(share, 0, 1) * range(which);
|
|
170
|
+
return which === 'x' && rtl ? -at : at;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Visibility.
|
|
174
|
+
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
|
175
|
+
scope.add(() => clearTimeout(hideTimer));
|
|
176
|
+
let hovering = false;
|
|
177
|
+
let overRail: HTMLElement | null = null;
|
|
178
|
+
let drag: { bar: Bar; id: number; offset: number; start: number } | null = null;
|
|
179
|
+
const anyOverflow = () => bars.some((bar) => bar.overflow);
|
|
180
|
+
const show = () => {
|
|
181
|
+
if (!anyOverflow()) return;
|
|
182
|
+
clearTimeout(hideTimer);
|
|
183
|
+
root.setAttribute('data-mk-active', '');
|
|
184
|
+
};
|
|
185
|
+
const settle = () => {
|
|
186
|
+
clearTimeout(hideTimer);
|
|
187
|
+
if (visibility === 'always') return;
|
|
188
|
+
hideTimer = setTimeout(() => {
|
|
189
|
+
if (drag || overRail || (visibility === 'hover' && hovering)) return;
|
|
190
|
+
root.removeAttribute('data-mk-active');
|
|
191
|
+
}, idle);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// A drag or a glide moves the scroll directly even when the page asks for smooth scrolling.
|
|
195
|
+
let held: { value: string; priority: string } | null = null;
|
|
196
|
+
const holdBehavior = () => {
|
|
197
|
+
if (held) return;
|
|
198
|
+
held = { value: view.style.getPropertyValue('scroll-behavior'), priority: view.style.getPropertyPriority('scroll-behavior') };
|
|
199
|
+
view.style.setProperty('scroll-behavior', 'auto', 'important');
|
|
200
|
+
};
|
|
201
|
+
const releaseBehavior = () => {
|
|
202
|
+
if (!held) return;
|
|
203
|
+
if (held.value) view.style.setProperty('scroll-behavior', held.value, held.priority);
|
|
204
|
+
else view.style.removeProperty('scroll-behavior');
|
|
205
|
+
held = null;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
let glide: gsap.core.Tween | null = null;
|
|
209
|
+
const stopGlide = () => {
|
|
210
|
+
if (!glide) return;
|
|
211
|
+
glide.kill();
|
|
212
|
+
glide = null;
|
|
213
|
+
releaseBehavior();
|
|
214
|
+
};
|
|
215
|
+
scope.add(stopGlide);
|
|
216
|
+
const glideTo = (top: number | undefined, left: number | undefined, animate = true) => {
|
|
217
|
+
stopGlide();
|
|
218
|
+
const to = { top: top === undefined ? view.scrollTop : top, left: left === undefined ? view.scrollLeft : left };
|
|
219
|
+
if (!animate || reduced()) {
|
|
220
|
+
view.scrollTop = to.top;
|
|
221
|
+
view.scrollLeft = to.left;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
holdBehavior();
|
|
225
|
+
const at = { top: view.scrollTop, left: view.scrollLeft };
|
|
226
|
+
glide = gsap.to(at, {
|
|
227
|
+
...to,
|
|
228
|
+
duration: TIMING.scrollArea.glide,
|
|
229
|
+
ease: EASE.signature,
|
|
230
|
+
onUpdate: () => {
|
|
231
|
+
view.scrollTop = at.top;
|
|
232
|
+
view.scrollLeft = at.left;
|
|
233
|
+
},
|
|
234
|
+
onComplete: () => {
|
|
235
|
+
glide = null;
|
|
236
|
+
releaseBehavior();
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
// The wheel, a touch, a key or a press on the content takes over from a glide.
|
|
241
|
+
for (const type of ['wheel', 'touchstart', 'keydown', 'pointerdown']) scope.on(view, type, stopGlide, { passive: true });
|
|
242
|
+
|
|
243
|
+
// Back to top.
|
|
244
|
+
let topButton: HTMLButtonElement | null = null;
|
|
245
|
+
let topShown = false;
|
|
246
|
+
if (options.backToTop) {
|
|
247
|
+
const button = document.createElement('button');
|
|
248
|
+
button.type = 'button';
|
|
249
|
+
button.dataset.mkScrollTop = '';
|
|
250
|
+
button.setAttribute('aria-label', 'Volver arriba');
|
|
251
|
+
button.innerHTML = iconSvg('toTop', 20);
|
|
252
|
+
button.hidden = true;
|
|
253
|
+
button.setAttribute('data-mk-no-drag', '');
|
|
254
|
+
root.append(button);
|
|
255
|
+
scope.add(() => button.remove());
|
|
256
|
+
scope.on(button, 'click', () => {
|
|
257
|
+
keep(press(button));
|
|
258
|
+
glideTo(0, undefined);
|
|
259
|
+
});
|
|
260
|
+
topButton = button;
|
|
261
|
+
}
|
|
262
|
+
const topThreshold = () => (typeof options.backToTop === 'number' && Number.isFinite(options.backToTop) ? Math.max(0, options.backToTop) : view.clientHeight);
|
|
263
|
+
const toggleTop = (next: boolean) => {
|
|
264
|
+
const button = topButton;
|
|
265
|
+
if (!button || next === topShown) return;
|
|
266
|
+
topShown = next;
|
|
267
|
+
gsap.killTweensOf(button);
|
|
268
|
+
if (next) {
|
|
269
|
+
gsap.set(button, { clearProps: 'opacity,transform,filter' });
|
|
270
|
+
button.hidden = false;
|
|
271
|
+
keep(blurIn(button, { from: 'pop' }));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Focus on a button that goes away would fall to the page: it stays in the area.
|
|
275
|
+
if (document.activeElement === button) (view.hasAttribute('tabindex') ? view : root).focus({ preventScroll: true });
|
|
276
|
+
const hide = () => {
|
|
277
|
+
if (topShown) return;
|
|
278
|
+
button.hidden = true;
|
|
279
|
+
gsap.set(button, { clearProps: 'opacity,transform,filter' });
|
|
280
|
+
};
|
|
281
|
+
const out = reduced() ? null : blurOut(button, { to: 'down', onComplete: hide });
|
|
282
|
+
if (out) keep(out);
|
|
283
|
+
else hide();
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Geometry.
|
|
287
|
+
/**
|
|
288
|
+
* Where the viewport sits inside the root (a palette's list between its field and its footer, a dialog's body):
|
|
289
|
+
* the bars and the button are placed against its box. Layout offsets, so a surface still scaling in measures right.
|
|
290
|
+
*/
|
|
291
|
+
const placeOverlays = () => {
|
|
292
|
+
let top = 0;
|
|
293
|
+
let left = 0;
|
|
294
|
+
for (let node: HTMLElement | null = view; node && node !== root; ) {
|
|
295
|
+
top += node.offsetTop;
|
|
296
|
+
left += node.offsetLeft;
|
|
297
|
+
const parent = node.offsetParent as HTMLElement | null;
|
|
298
|
+
node = parent && root.contains(parent) ? parent : null;
|
|
299
|
+
}
|
|
300
|
+
const edges = {
|
|
301
|
+
top,
|
|
302
|
+
left,
|
|
303
|
+
right: root.clientWidth - left - view.offsetWidth,
|
|
304
|
+
bottom: root.clientHeight - top - view.offsetHeight,
|
|
305
|
+
};
|
|
306
|
+
for (const element of [...bars.map((bar) => bar.rail), topButton]) {
|
|
307
|
+
if (!element) continue;
|
|
308
|
+
for (const [edge, value] of Object.entries(edges)) element.style.setProperty(`--mk-scroll-view-${edge}`, `${Math.max(0, Math.round(value))}px`);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
let measured = false;
|
|
313
|
+
const measure = () => {
|
|
314
|
+
rtl = getComputedStyle(view).direction === 'rtl';
|
|
315
|
+
const had = anyOverflow();
|
|
316
|
+
for (const bar of bars) {
|
|
317
|
+
bar.overflow = range(bar.axis) > 1;
|
|
318
|
+
bar.rail.hidden = !bar.overflow;
|
|
319
|
+
root.toggleAttribute(`data-mk-overflow-${bar.axis}`, bar.overflow);
|
|
320
|
+
}
|
|
321
|
+
placeOverlays();
|
|
322
|
+
// Read the rails once both are in place: each one stops short of the other in the corner.
|
|
323
|
+
for (const bar of bars) {
|
|
324
|
+
if (!bar.overflow) continue;
|
|
325
|
+
const vertical = bar.axis === 'y';
|
|
326
|
+
bar.track = vertical ? bar.rail.clientHeight : bar.rail.clientWidth;
|
|
327
|
+
const seen = vertical ? view.clientHeight : view.clientWidth;
|
|
328
|
+
const total = vertical ? view.scrollHeight : view.scrollWidth;
|
|
329
|
+
bar.size = clamp((bar.track * seen) / Math.max(1, total), Math.min(TIMING.scrollArea.minThumb, bar.track), bar.track);
|
|
330
|
+
bar.thumb.style[vertical ? 'height' : 'width'] = `${bar.size}px`;
|
|
331
|
+
}
|
|
332
|
+
const now = anyOverflow();
|
|
333
|
+
if (!now) {
|
|
334
|
+
clearTimeout(hideTimer);
|
|
335
|
+
root.removeAttribute('data-mk-active');
|
|
336
|
+
} else if (visibility === 'always') show();
|
|
337
|
+
else if (measured && !had) {
|
|
338
|
+
// Content that starts to overflow (a menu opening) shows its bar for a moment.
|
|
339
|
+
show();
|
|
340
|
+
settle();
|
|
341
|
+
}
|
|
342
|
+
measured = true;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const paint = () => {
|
|
346
|
+
for (const bar of bars) {
|
|
347
|
+
if (!bar.overflow) continue;
|
|
348
|
+
const share = progress(bar.axis);
|
|
349
|
+
const room = Math.max(0, bar.track - bar.size);
|
|
350
|
+
const offset = bar.axis === 'x' && rtl ? room * (1 - share) : room * share;
|
|
351
|
+
bar.thumb.style.transform = bar.axis === 'y' ? `translate3d(0, ${offset}px, 0)` : `translate3d(${offset}px, 0, 0)`;
|
|
352
|
+
if (!options.decorative) bar.rail.setAttribute('aria-valuenow', String(Math.round(share * 100)));
|
|
353
|
+
}
|
|
354
|
+
if (fade) {
|
|
355
|
+
const down = range('y');
|
|
356
|
+
const across = range('x');
|
|
357
|
+
const fromStart = Math.max(0, travelled('x'));
|
|
358
|
+
const depth = (hidden: number) => `${Math.round(clamp(hidden, 0, fade))}px`;
|
|
359
|
+
view.style.setProperty('--mk-scroll-fade-top', depth(view.scrollTop));
|
|
360
|
+
view.style.setProperty('--mk-scroll-fade-bottom', depth(down - view.scrollTop));
|
|
361
|
+
view.style.setProperty('--mk-scroll-fade-left', depth(rtl ? across - fromStart : fromStart));
|
|
362
|
+
view.style.setProperty('--mk-scroll-fade-right', depth(rtl ? fromStart : across - fromStart));
|
|
363
|
+
}
|
|
364
|
+
if (topButton) toggleTop(view.scrollTop > topThreshold());
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
let cancelFrame: (() => void) | null = null;
|
|
368
|
+
const schedule = () => {
|
|
369
|
+
if (cancelFrame) return;
|
|
370
|
+
cancelFrame = requestFrame(() => {
|
|
371
|
+
cancelFrame = null;
|
|
372
|
+
if (scope.disposed) return;
|
|
373
|
+
measure();
|
|
374
|
+
paint();
|
|
375
|
+
});
|
|
376
|
+
};
|
|
377
|
+
scope.add(() => cancelFrame?.());
|
|
378
|
+
|
|
379
|
+
scope.on(
|
|
380
|
+
view,
|
|
381
|
+
'scroll',
|
|
382
|
+
() => {
|
|
383
|
+
paint();
|
|
384
|
+
if (visibility !== 'always') {
|
|
385
|
+
show();
|
|
386
|
+
settle();
|
|
387
|
+
}
|
|
388
|
+
const detail = { x: progress('x'), y: progress('y') };
|
|
389
|
+
options.onScroll?.(detail);
|
|
390
|
+
root.dispatchEvent(new CustomEvent('mk:scroll', { bubbles: true, detail }));
|
|
391
|
+
},
|
|
392
|
+
{ passive: true },
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Pointer: the area shows its bars while hovered; a bar under the pointer always shows.
|
|
396
|
+
scope.on(root, 'pointerenter', (event) => {
|
|
397
|
+
if ((event as PointerEvent).pointerType === 'touch') return;
|
|
398
|
+
hovering = true;
|
|
399
|
+
if (visibility === 'hover') show();
|
|
400
|
+
});
|
|
401
|
+
scope.on(root, 'pointerleave', () => {
|
|
402
|
+
hovering = false;
|
|
403
|
+
settle();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
for (const bar of bars) {
|
|
407
|
+
const vertical = bar.axis === 'y';
|
|
408
|
+
scope.on(bar.rail, 'pointerenter', (event) => {
|
|
409
|
+
if ((event as PointerEvent).pointerType === 'touch') return;
|
|
410
|
+
overRail = bar.rail;
|
|
411
|
+
show();
|
|
412
|
+
});
|
|
413
|
+
scope.on(bar.rail, 'pointerleave', () => {
|
|
414
|
+
if (overRail === bar.rail) overRail = null;
|
|
415
|
+
settle();
|
|
416
|
+
});
|
|
417
|
+
// Grab: the scroll follows the pointer from where the thumb was taken.
|
|
418
|
+
scope.on(bar.thumb, 'pointerdown', (event) => {
|
|
419
|
+
const down = event as PointerEvent;
|
|
420
|
+
if (down.button !== 0) return;
|
|
421
|
+
down.preventDefault();
|
|
422
|
+
down.stopPropagation();
|
|
423
|
+
stopGlide();
|
|
424
|
+
holdBehavior();
|
|
425
|
+
const thumbBox = bar.thumb.getBoundingClientRect();
|
|
426
|
+
const railBox = bar.rail.getBoundingClientRect();
|
|
427
|
+
drag = {
|
|
428
|
+
bar,
|
|
429
|
+
id: down.pointerId,
|
|
430
|
+
offset: vertical ? down.clientY - thumbBox.top : down.clientX - thumbBox.left,
|
|
431
|
+
start: vertical ? railBox.top : railBox.left,
|
|
432
|
+
};
|
|
433
|
+
bar.rail.setAttribute('data-mk-dragging', '');
|
|
434
|
+
root.setAttribute('data-mk-dragging', '');
|
|
435
|
+
try {
|
|
436
|
+
bar.thumb.setPointerCapture(down.pointerId);
|
|
437
|
+
} catch {
|
|
438
|
+
// jsdom and synthetic events have no active pointer to capture.
|
|
439
|
+
}
|
|
440
|
+
show();
|
|
441
|
+
keep(press(bar.pill));
|
|
442
|
+
});
|
|
443
|
+
// A click on the rail glides so the thumb lands centred under the pointer.
|
|
444
|
+
scope.on(bar.rail, 'pointerdown', (event) => {
|
|
445
|
+
const down = event as PointerEvent;
|
|
446
|
+
if (down.button !== 0 || bar.thumb.contains(down.target as Node)) return;
|
|
447
|
+
down.preventDefault();
|
|
448
|
+
const box = bar.rail.getBoundingClientRect();
|
|
449
|
+
const at = vertical ? down.clientY - box.top : down.clientX - box.left;
|
|
450
|
+
const share = (at - bar.size / 2) / Math.max(1, bar.track - bar.size);
|
|
451
|
+
const target = positionAt(bar.axis, bar.axis === 'x' && rtl ? 1 - share : share);
|
|
452
|
+
if (vertical) glideTo(target, undefined);
|
|
453
|
+
else glideTo(undefined, target);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
scope.on(window, 'pointermove', (event) => {
|
|
458
|
+
const move = event as PointerEvent;
|
|
459
|
+
if (!drag || move.pointerId !== drag.id) return;
|
|
460
|
+
const { bar } = drag;
|
|
461
|
+
const at = (bar.axis === 'y' ? move.clientY : move.clientX) - drag.start - drag.offset;
|
|
462
|
+
const share = at / Math.max(1, bar.track - bar.size);
|
|
463
|
+
const target = positionAt(bar.axis, bar.axis === 'x' && rtl ? 1 - share : share);
|
|
464
|
+
if (bar.axis === 'y') view.scrollTop = target;
|
|
465
|
+
else view.scrollLeft = target;
|
|
466
|
+
// The thumb answers in this same event, not when the scroll event comes.
|
|
467
|
+
paint();
|
|
468
|
+
});
|
|
469
|
+
const endDrag = () => {
|
|
470
|
+
if (!drag) return;
|
|
471
|
+
const { bar, id } = drag;
|
|
472
|
+
drag = null;
|
|
473
|
+
try {
|
|
474
|
+
if (bar.thumb.hasPointerCapture?.(id)) bar.thumb.releasePointerCapture(id);
|
|
475
|
+
} catch {
|
|
476
|
+
// Already released.
|
|
477
|
+
}
|
|
478
|
+
bar.rail.removeAttribute('data-mk-dragging');
|
|
479
|
+
root.removeAttribute('data-mk-dragging');
|
|
480
|
+
releaseBehavior();
|
|
481
|
+
if (!scope.disposed) settle();
|
|
482
|
+
};
|
|
483
|
+
const release = (event: Event) => {
|
|
484
|
+
if (drag && (event as PointerEvent).pointerId === drag.id) endDrag();
|
|
485
|
+
};
|
|
486
|
+
scope.on(window, 'pointerup', release);
|
|
487
|
+
scope.on(window, 'pointercancel', release);
|
|
488
|
+
for (const bar of bars) scope.on(bar.thumb, 'lostpointercapture', release);
|
|
489
|
+
scope.add(() => {
|
|
490
|
+
endDrag();
|
|
491
|
+
releaseBehavior();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Size changes of the viewport or its content, and content added later.
|
|
495
|
+
if (typeof ResizeObserver === 'function') {
|
|
496
|
+
const resize = scope.observe(new ResizeObserver(schedule));
|
|
497
|
+
const watch = () => {
|
|
498
|
+
resize.observe(view);
|
|
499
|
+
for (const child of view.children) resize.observe(child);
|
|
500
|
+
};
|
|
501
|
+
watch();
|
|
502
|
+
if (typeof MutationObserver === 'function')
|
|
503
|
+
scope
|
|
504
|
+
.observe(
|
|
505
|
+
new MutationObserver(() => {
|
|
506
|
+
watch();
|
|
507
|
+
schedule();
|
|
508
|
+
}),
|
|
509
|
+
)
|
|
510
|
+
.observe(view, { childList: true });
|
|
511
|
+
}
|
|
512
|
+
scope.on(window, 'resize', schedule);
|
|
513
|
+
|
|
514
|
+
measure();
|
|
515
|
+
paint();
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
scrollTo: ({ top, left } = {}, { animate = true } = {}) => glideTo(top, left, animate),
|
|
519
|
+
refresh: () => {
|
|
520
|
+
measure();
|
|
521
|
+
paint();
|
|
522
|
+
},
|
|
523
|
+
get viewport() {
|
|
524
|
+
return view;
|
|
525
|
+
},
|
|
526
|
+
destroy: () => scope.dispose(),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, type CSSProperties, type HTMLAttributes, type MutableRefObject } from 'react';
|
|
4
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
5
|
+
import { createScrollArea, type ScrollAreaInstance, type ScrollAreaOptions } from './engine';
|
|
6
|
+
|
|
7
|
+
export interface ScrollAreaProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onScroll'>, Omit<ScrollAreaOptions, 'viewport'> {
|
|
8
|
+
/** Class and style of the element that scrolls (padding, gap, layout of the content). */
|
|
9
|
+
viewportClassName?: string;
|
|
10
|
+
viewportStyle?: CSSProperties;
|
|
11
|
+
unstyled?: boolean;
|
|
12
|
+
instanceRef?: MutableRefObject<ScrollAreaInstance | null>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* <ScrollArea style={{ height: 320 }} backToTop>…</ScrollArea>: the children scroll in a viewport with the kit's bars.
|
|
17
|
+
*/
|
|
18
|
+
export function ScrollArea({
|
|
19
|
+
visibility,
|
|
20
|
+
axis,
|
|
21
|
+
idle,
|
|
22
|
+
fade,
|
|
23
|
+
backToTop,
|
|
24
|
+
label,
|
|
25
|
+
focusable,
|
|
26
|
+
decorative,
|
|
27
|
+
onScroll,
|
|
28
|
+
viewportClassName,
|
|
29
|
+
viewportStyle,
|
|
30
|
+
unstyled,
|
|
31
|
+
instanceRef,
|
|
32
|
+
className,
|
|
33
|
+
children,
|
|
34
|
+
...props
|
|
35
|
+
}: ScrollAreaProps) {
|
|
36
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
37
|
+
const viewport = useRef<HTMLDivElement>(null);
|
|
38
|
+
const scrolled = useLatest(onScroll);
|
|
39
|
+
useIsomorphicLayoutEffect(() => {
|
|
40
|
+
const instance = createScrollArea(ref.current, {
|
|
41
|
+
visibility,
|
|
42
|
+
axis,
|
|
43
|
+
idle,
|
|
44
|
+
fade,
|
|
45
|
+
backToTop,
|
|
46
|
+
label,
|
|
47
|
+
focusable,
|
|
48
|
+
decorative,
|
|
49
|
+
viewport: viewport.current,
|
|
50
|
+
onScroll: (progress) => scrolled.current?.(progress),
|
|
51
|
+
});
|
|
52
|
+
if (instanceRef) instanceRef.current = instance;
|
|
53
|
+
return () => {
|
|
54
|
+
instance.destroy();
|
|
55
|
+
if (instanceRef) instanceRef.current = null;
|
|
56
|
+
};
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
+
}, [visibility, axis, idle, fade, backToTop, label, focusable, decorative]);
|
|
59
|
+
const classes = [unstyled ? null : 'mk-scroll-area', className].filter(Boolean).join(' ');
|
|
60
|
+
return (
|
|
61
|
+
<div {...props} ref={ref} className={classes || undefined}>
|
|
62
|
+
<div ref={viewport} data-mk-scroll-viewport="" className={viewportClassName} style={viewportStyle}>
|
|
63
|
+
{children}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|