@tobedone-de/ameva-scrollbar 0.4.2

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.
@@ -0,0 +1,890 @@
1
+ const MIN_THUMB_SIZE = 56;
2
+ const ROOT_FADE_IN_DELAY_MS = 120;
3
+ const THUMB_EDGE_PADDING = 6;
4
+ const TRANSITION_TRACKING_DURATION_MS = 420;
5
+ const ELEMENT_TRACK_SIZE = 16;
6
+ const ELEMENT_VISIBLE_TRACK_SIZE = 12;
7
+ const VIEWPORT_VISIBLE_TRACK_SIZE = 16;
8
+ const THUMB_DRAG_HIT_SLOP = 14;
9
+ let ownerSequence = 0;
10
+
11
+ export class AmevaScrollbar {
12
+ constructor({
13
+ host,
14
+ scrollElement,
15
+ viewport = false,
16
+ horizontal = false,
17
+ vertical = true,
18
+ }) {
19
+ this.host = host;
20
+ this.scrollElement = scrollElement;
21
+ this.viewport = viewport;
22
+ this.horizontalEnabled = horizontal;
23
+ this.verticalEnabled = vertical;
24
+ this.dragState = null;
25
+ this.frame = null;
26
+ this.fadeInTimer = null;
27
+ this.hasActivated = false;
28
+
29
+ this.root = null;
30
+ this.vertical = null;
31
+ this.horizontal = null;
32
+ this.resizeObserver = null;
33
+ this.mutationObserver = null;
34
+ this.transitionTrackingFrame = null;
35
+ this.transitionTrackingUntil = 0;
36
+ this.ownerId = `ameva-scrollbar-${++ownerSequence}`;
37
+
38
+ this.handleScroll = this.requestUpdate.bind(this);
39
+ this.handleResize = this.requestUpdate.bind(this);
40
+ this.handleWindowLoad = this.requestUpdate.bind(this);
41
+ this.handleHostWheel = this.forwardHostWheel.bind(this);
42
+ this.handleHostTransitionStart = this.handleLayoutTransitionStart.bind(this);
43
+ this.handleHostTransitionEnd = this.handleLayoutTransitionEnd.bind(this);
44
+ }
45
+
46
+ initialize() {
47
+ if (this.root) {
48
+ this.refresh();
49
+
50
+ return;
51
+ }
52
+
53
+ this.host.dataset.amevascrollbarInitialized = 'true';
54
+ this.host.classList.add('ameva-scrollbar-host');
55
+
56
+ if (this.viewport) {
57
+ document.documentElement.classList.add('ameva-scrollbar-host', 'ameva-scrollbar-host--viewport');
58
+ document.body.classList.add('ameva-scrollbar-host', 'ameva-scrollbar-host--viewport');
59
+ }
60
+
61
+ this.createRoot();
62
+ this.bindEvents();
63
+ this.requestUpdate();
64
+ }
65
+
66
+ createRoot() {
67
+ this.root = document.createElement('div');
68
+ this.root.className = `ameva-scrollbar ameva-scrollbar--initializing${this.viewport ? ' ameva-scrollbar--viewport' : ' ameva-scrollbar--element'}`;
69
+ this.root.setAttribute('data-amevascrollbar-root', '');
70
+ this.root.setAttribute('data-amevascrollbar-owner', this.ownerId);
71
+
72
+ if (this.verticalEnabled) {
73
+ this.vertical = this.createAxis('vertical');
74
+ this.root.appendChild(this.vertical.track);
75
+ }
76
+
77
+ if (this.horizontalEnabled) {
78
+ this.horizontal = this.createAxis('horizontal');
79
+ this.root.appendChild(this.horizontal.track);
80
+ }
81
+
82
+ document.body.appendChild(this.root);
83
+ }
84
+
85
+ readDatasetValue(...keys) {
86
+ for (const key of keys) {
87
+ const value = this.host.dataset[key];
88
+
89
+ if (typeof value === 'string' && value !== '') {
90
+ return value;
91
+ }
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ readDatasetNumber(...keys) {
98
+ const value = this.readDatasetValue(...keys);
99
+
100
+ if (value === null) {
101
+ return null;
102
+ }
103
+
104
+ const parsedValue = Number.parseFloat(value);
105
+
106
+ return Number.isFinite(parsedValue) ? parsedValue : null;
107
+ }
108
+
109
+ resolveVisibleTrackSize() {
110
+ return this.readDatasetNumber('amevascrollbarWidth', 'amevascrollbarSize', 'amevascrollbarTrackSize')
111
+ ?? (this.viewport ? VIEWPORT_VISIBLE_TRACK_SIZE : ELEMENT_VISIBLE_TRACK_SIZE);
112
+ }
113
+
114
+ resolveHitAreaSize() {
115
+ return this.readDatasetNumber('amevascrollbarHitArea', 'amevascrollbarHitSize')
116
+ ?? Math.max(this.resolveVisibleTrackSize() + 4, ELEMENT_TRACK_SIZE);
117
+ }
118
+
119
+ resolveThumbMinSize() {
120
+ return this.readDatasetNumber('amevascrollbarThumbMinSize')
121
+ ?? MIN_THUMB_SIZE;
122
+ }
123
+
124
+ resolveThumbPadding() {
125
+ return this.readDatasetNumber('amevascrollbarThumbPadding')
126
+ ?? THUMB_EDGE_PADDING;
127
+ }
128
+
129
+ setOrClearRootStyleProperty(property, value) {
130
+ if (value === null) {
131
+ this.root.style.removeProperty(property);
132
+
133
+ return;
134
+ }
135
+
136
+ this.root.style.setProperty(property, value);
137
+ }
138
+
139
+ applyConfiguredCssVars() {
140
+ this.root.style.setProperty('--ameva-scrollbar-track-size', `${this.resolveVisibleTrackSize()}px`);
141
+ this.root.style.setProperty('--ameva-scrollbar-hit-size', `${this.resolveHitAreaSize()}px`);
142
+ this.root.style.setProperty('--ameva-scrollbar-thumb-min-size', `${this.resolveThumbMinSize()}px`);
143
+ this.root.style.setProperty('--ameva-scrollbar-thumb-padding', `${this.resolveThumbPadding()}px`);
144
+
145
+ if (!this.viewport) {
146
+ return;
147
+ }
148
+
149
+ const viewportTop = this.readDatasetNumber('amevascrollbarTop');
150
+ const viewportRight = this.readDatasetNumber('amevascrollbarRight');
151
+ const viewportBottom = this.readDatasetNumber('amevascrollbarBottom');
152
+ this.setOrClearRootStyleProperty('--ameva-scrollbar-viewport-top', viewportTop === null ? null : `${viewportTop}px`);
153
+ this.setOrClearRootStyleProperty('--ameva-scrollbar-viewport-right', viewportRight === null ? null : `${viewportRight}px`);
154
+ this.setOrClearRootStyleProperty('--ameva-scrollbar-viewport-bottom', viewportBottom === null ? null : `${viewportBottom}px`);
155
+ }
156
+
157
+ createAxis(axis) {
158
+ const track = document.createElement('button');
159
+ track.className = `ameva-scrollbar__track ameva-scrollbar__track--${axis}`;
160
+ track.type = 'button';
161
+ track.tabIndex = -1;
162
+ track.setAttribute('aria-hidden', 'true');
163
+
164
+ const thumb = document.createElement('span');
165
+ thumb.className = `ameva-scrollbar__thumb ameva-scrollbar__thumb--${axis}`;
166
+ thumb.setAttribute('data-amevascrollbar-thumb', axis);
167
+
168
+ track.appendChild(thumb);
169
+
170
+ this.bindAxisEvents(axis, track, thumb);
171
+
172
+ return { axis, track, thumb };
173
+ }
174
+
175
+ bindAxisEvents(axis, track, thumb) {
176
+ const setHovering = (hovering) => {
177
+ track.classList.toggle('ameva-scrollbar__track--hovering', hovering);
178
+ };
179
+
180
+ const setPressing = (pressing) => {
181
+ track.classList.toggle('ameva-scrollbar__track--pressing', pressing);
182
+ };
183
+
184
+ track.addEventListener('pointerenter', () => {
185
+ setHovering(true);
186
+ });
187
+
188
+ track.addEventListener('pointerleave', () => {
189
+ setHovering(false);
190
+
191
+ if (!this.dragState || this.dragState.axis !== axis) {
192
+ setPressing(false);
193
+ }
194
+ });
195
+
196
+ track.addEventListener('pointerdown', (event) => {
197
+ if (event.button !== 0) {
198
+ return;
199
+ }
200
+
201
+ setPressing(true);
202
+
203
+ if (event.target === thumb || !this.isPointerNearThumb(axis, thumb, event)) {
204
+ return;
205
+ }
206
+
207
+ event.preventDefault();
208
+ this.startDrag(axis, event, track, track, 'track');
209
+ });
210
+
211
+ track.addEventListener('pointermove', (event) => {
212
+ this.updateDrag(axis, track, thumb, event, track);
213
+ });
214
+
215
+ track.addEventListener('pointerup', (event) => {
216
+ this.releaseDrag(axis, track, event, track);
217
+
218
+ if (!this.dragState || this.dragState.axis !== axis) {
219
+ setPressing(false);
220
+ }
221
+ });
222
+
223
+ track.addEventListener('pointercancel', (event) => {
224
+ this.releaseDrag(axis, track, event, track);
225
+ setPressing(false);
226
+ });
227
+
228
+ track.addEventListener('click', (event) => {
229
+ if (track.dataset.amevascrollbarSkipClick === 'true') {
230
+ delete track.dataset.amevascrollbarSkipClick;
231
+
232
+ return;
233
+ }
234
+
235
+ if (event.target === thumb) {
236
+ return;
237
+ }
238
+
239
+ this.scrollToTrackPosition(axis, track, thumb, event);
240
+ });
241
+
242
+ track.addEventListener('wheel', (event) => {
243
+ this.forwardWheel(axis, event);
244
+ }, { passive: false });
245
+
246
+ thumb.addEventListener('pointerdown', (event) => {
247
+ event.preventDefault();
248
+ this.startDrag(axis, event, track, thumb, 'thumb');
249
+ });
250
+
251
+ thumb.addEventListener('pointermove', (event) => {
252
+ this.updateDrag(axis, track, thumb, event, thumb);
253
+ });
254
+
255
+ thumb.addEventListener('pointerup', (event) => {
256
+ this.releaseDrag(axis, track, event, thumb);
257
+ });
258
+ thumb.addEventListener('pointercancel', (event) => {
259
+ this.releaseDrag(axis, track, event, thumb);
260
+ });
261
+ }
262
+
263
+ isPointerNearThumb(axis, thumb, event) {
264
+ const thumbRect = thumb.getBoundingClientRect();
265
+
266
+ if (axis === 'vertical') {
267
+ return event.clientX >= thumbRect.left - THUMB_DRAG_HIT_SLOP
268
+ && event.clientX <= thumbRect.right + THUMB_DRAG_HIT_SLOP
269
+ && event.clientY >= thumbRect.top - THUMB_DRAG_HIT_SLOP
270
+ && event.clientY <= thumbRect.bottom + THUMB_DRAG_HIT_SLOP;
271
+ }
272
+
273
+ return event.clientX >= thumbRect.left - THUMB_DRAG_HIT_SLOP
274
+ && event.clientX <= thumbRect.right + THUMB_DRAG_HIT_SLOP
275
+ && event.clientY >= thumbRect.top - THUMB_DRAG_HIT_SLOP
276
+ && event.clientY <= thumbRect.bottom + THUMB_DRAG_HIT_SLOP;
277
+ }
278
+
279
+ startDrag(axis, event, track, captureTarget, source) {
280
+ this.dragState = {
281
+ axis,
282
+ pointerId: event.pointerId,
283
+ captureTarget,
284
+ source,
285
+ };
286
+
287
+ if (typeof captureTarget.setPointerCapture === 'function') {
288
+ captureTarget.setPointerCapture(event.pointerId);
289
+ }
290
+ track.classList.add('ameva-scrollbar__track--dragging', 'ameva-scrollbar__track--pressing');
291
+ }
292
+
293
+ updateDrag(axis, track, thumb, event, captureTarget) {
294
+ if (
295
+ !this.dragState
296
+ || this.dragState.axis !== axis
297
+ || this.dragState.pointerId !== event.pointerId
298
+ || this.dragState.captureTarget !== captureTarget
299
+ ) {
300
+ return;
301
+ }
302
+
303
+ this.scrollToTrackPosition(axis, track, thumb, event, true);
304
+ }
305
+
306
+ releaseDrag(axis, track, event, captureTarget) {
307
+ if (
308
+ !this.dragState
309
+ || this.dragState.axis !== axis
310
+ || this.dragState.pointerId !== event.pointerId
311
+ || this.dragState.captureTarget !== captureTarget
312
+ ) {
313
+ return;
314
+ }
315
+
316
+ if (
317
+ typeof captureTarget.hasPointerCapture === 'function'
318
+ && captureTarget.hasPointerCapture(event.pointerId)
319
+ ) {
320
+ captureTarget.releasePointerCapture(event.pointerId);
321
+ }
322
+
323
+ if (this.dragState.source === 'track') {
324
+ track.dataset.amevascrollbarSkipClick = 'true';
325
+ }
326
+
327
+ this.dragState = null;
328
+ track.classList.remove('ameva-scrollbar__track--dragging', 'ameva-scrollbar__track--pressing');
329
+ }
330
+
331
+ bindEvents() {
332
+ if (this.viewport) {
333
+ window.addEventListener('scroll', this.handleScroll, { passive: true });
334
+ window.addEventListener('resize', this.handleResize, { passive: true });
335
+ window.addEventListener('load', this.handleWindowLoad, { once: true });
336
+ if ('ResizeObserver' in window) {
337
+ this.resizeObserver = new ResizeObserver(this.handleResize);
338
+ this.resizeObserver.observe(document.documentElement);
339
+ this.resizeObserver.observe(document.body);
340
+ }
341
+
342
+ return;
343
+ }
344
+
345
+ this.scrollElement.addEventListener('scroll', this.handleScroll, { passive: true });
346
+ this.host.addEventListener('wheel', this.handleHostWheel, { passive: false });
347
+ window.addEventListener('resize', this.handleResize, { passive: true });
348
+ window.addEventListener('scroll', this.handleResize, { passive: true });
349
+ if ('ResizeObserver' in window) {
350
+ this.resizeObserver = new ResizeObserver(this.handleResize);
351
+ this.resizeObserver.observe(this.host);
352
+ }
353
+
354
+ if ('MutationObserver' in window) {
355
+ this.mutationObserver = new MutationObserver(this.handleResize);
356
+ this.mutationObserver.observe(this.host, {
357
+ attributes: true,
358
+ attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'],
359
+ childList: true,
360
+ subtree: true,
361
+ characterData: true,
362
+ });
363
+ }
364
+
365
+ this.host.addEventListener('transitionrun', this.handleHostTransitionStart);
366
+ this.host.addEventListener('transitionstart', this.handleHostTransitionStart);
367
+ this.host.addEventListener('transitionend', this.handleHostTransitionEnd);
368
+ this.host.addEventListener('transitioncancel', this.handleHostTransitionEnd);
369
+ this.host.addEventListener('animationstart', this.handleHostTransitionStart);
370
+ this.host.addEventListener('animationend', this.handleHostTransitionEnd);
371
+ this.host.addEventListener('animationcancel', this.handleHostTransitionEnd);
372
+ }
373
+
374
+ requestUpdate() {
375
+ if (this.frame !== null) {
376
+ return;
377
+ }
378
+
379
+ this.frame = window.requestAnimationFrame(() => {
380
+ this.frame = null;
381
+ this.update();
382
+ });
383
+ }
384
+
385
+ refresh() {
386
+ this.requestUpdate();
387
+ this.trackTransientLayout();
388
+ }
389
+
390
+ handleLayoutTransitionStart() {
391
+ this.trackTransientLayout();
392
+ }
393
+
394
+ handleLayoutTransitionEnd() {
395
+ this.trackTransientLayout(140);
396
+ }
397
+
398
+ trackTransientLayout(duration = TRANSITION_TRACKING_DURATION_MS) {
399
+ this.transitionTrackingUntil = Math.max(
400
+ this.transitionTrackingUntil,
401
+ performance.now() + duration,
402
+ );
403
+
404
+ if (this.transitionTrackingFrame !== null) {
405
+ return;
406
+ }
407
+
408
+ const run = () => {
409
+ this.transitionTrackingFrame = null;
410
+ this.requestUpdate();
411
+
412
+ if (performance.now() >= this.transitionTrackingUntil) {
413
+ return;
414
+ }
415
+
416
+ this.transitionTrackingFrame = window.requestAnimationFrame(run);
417
+ };
418
+
419
+ this.transitionTrackingFrame = window.requestAnimationFrame(run);
420
+ }
421
+
422
+ getMetrics() {
423
+ const element = this.scrollElement;
424
+ const scrollTop = this.viewport ? window.scrollY : element.scrollTop;
425
+ const scrollLeft = this.viewport ? window.scrollX : element.scrollLeft;
426
+ const viewportHeight = this.viewport ? window.innerHeight : element.clientHeight;
427
+ const viewportWidth = this.viewport ? window.innerWidth : element.clientWidth;
428
+ const scrollHeight = element.scrollHeight;
429
+ const scrollWidth = element.scrollWidth;
430
+ const maxScrollTop = Math.max(scrollHeight - viewportHeight, 0);
431
+ const maxScrollLeft = Math.max(scrollWidth - viewportWidth, 0);
432
+
433
+ return {
434
+ scrollTop,
435
+ scrollLeft,
436
+ viewportHeight,
437
+ viewportWidth,
438
+ scrollHeight,
439
+ scrollWidth,
440
+ maxScrollTop,
441
+ maxScrollLeft,
442
+ };
443
+ }
444
+
445
+ update() {
446
+ this.applyConfiguredCssVars();
447
+
448
+ if (!this.viewport) {
449
+ if (!this.isHostVisible()) {
450
+ this.deactivateRoot();
451
+ return;
452
+ }
453
+
454
+ this.prepareVisibleRoot();
455
+ this.updateElementRootBounds();
456
+ } else {
457
+ this.prepareVisibleRoot();
458
+ }
459
+
460
+ const metrics = this.getMetrics();
461
+
462
+ if (this.vertical) {
463
+ this.updateAxis(this.vertical, metrics, 'vertical');
464
+ }
465
+
466
+ if (this.horizontal) {
467
+ this.updateAxis(this.horizontal, metrics, 'horizontal');
468
+ }
469
+
470
+ this.updateRootVisibility();
471
+ }
472
+
473
+ updateElementRootBounds() {
474
+ const rect = this.host.getBoundingClientRect();
475
+ const resolvedZIndex = this.resolveOverlayZIndex();
476
+ const { verticalInset, horizontalInset, inlineEndInset } = this.resolveElementInsets();
477
+ const useFlushEdge = this.host.dataset.amevascrollbarEdge === 'flush';
478
+ const hitAreaSize = this.resolveHitAreaSize();
479
+ const resolvedInlineEndInset = inlineEndInset ?? (
480
+ useFlushEdge
481
+ ? 0
482
+ : Math.min(Math.max(verticalInset * 0.45, 3), 8)
483
+ );
484
+ const verticalOnly = this.verticalEnabled && !this.horizontalEnabled;
485
+ const horizontalOnly = this.horizontalEnabled && !this.verticalEnabled;
486
+
487
+ if (verticalOnly) {
488
+ this.root.style.left = `${rect.right - hitAreaSize - resolvedInlineEndInset}px`;
489
+ this.root.style.top = `${rect.top + verticalInset}px`;
490
+ this.root.style.width = `${hitAreaSize}px`;
491
+ this.root.style.height = `${Math.max(rect.height - verticalInset * 2, 0)}px`;
492
+ } else if (horizontalOnly) {
493
+ this.root.style.left = `${rect.left + horizontalInset}px`;
494
+ this.root.style.top = `${rect.bottom - hitAreaSize}px`;
495
+ this.root.style.width = `${Math.max(rect.width - horizontalInset * 2, 0)}px`;
496
+ this.root.style.height = `${hitAreaSize}px`;
497
+ } else {
498
+ this.root.style.left = `${rect.left}px`;
499
+ this.root.style.top = `${rect.top}px`;
500
+ this.root.style.width = `${rect.width}px`;
501
+ this.root.style.height = `${rect.height}px`;
502
+ }
503
+
504
+ this.root.style.zIndex = `${resolvedZIndex}`;
505
+ this.root.style.removeProperty('border-radius');
506
+ this.root.style.setProperty('--ameva-scrollbar-element-vertical-inset', `${verticalInset}px`);
507
+ this.root.style.setProperty('--ameva-scrollbar-element-horizontal-inset', `${horizontalInset}px`);
508
+ this.root.style.setProperty('--ameva-scrollbar-element-inline-end-inset', `${resolvedInlineEndInset}px`);
509
+ }
510
+
511
+ resolveOverlayZIndex() {
512
+ let currentElement = this.host;
513
+ let highestZIndex = 4;
514
+
515
+ while (currentElement && currentElement !== document.body) {
516
+ const styles = window.getComputedStyle(currentElement);
517
+ const resolvedZIndex = Number.parseInt(styles.zIndex, 10);
518
+
519
+ if (!Number.isNaN(resolvedZIndex)) {
520
+ highestZIndex = Math.max(highestZIndex, resolvedZIndex + 1);
521
+ }
522
+
523
+ currentElement = currentElement.parentElement;
524
+ }
525
+
526
+ return highestZIndex;
527
+ }
528
+
529
+ resolveElementInsets() {
530
+ const styles = window.getComputedStyle(this.host);
531
+ const paddingTop = Number.parseFloat(styles.paddingTop) || 0;
532
+ const paddingRight = Number.parseFloat(styles.paddingRight) || 0;
533
+ const paddingBottom = Number.parseFloat(styles.paddingBottom) || 0;
534
+ const radiusTopRight = Number.parseFloat(styles.borderTopRightRadius) || 0;
535
+ const radiusBottomRight = Number.parseFloat(styles.borderBottomRightRadius) || 0;
536
+
537
+ const paddingInset = Math.min(Math.max(Math.min(paddingTop, paddingRight, paddingBottom) * 0.5, 8), 18);
538
+ const radiusInset = Math.min(Math.max(Math.min(radiusTopRight, radiusBottomRight) * 0.18, 0), 12);
539
+ const fallbackInset = Math.max(paddingInset, radiusInset);
540
+ const sharedInset = this.readDatasetNumber('amevascrollbarInset', 'amevascrollbarPadding');
541
+ const horizontalInset = this.readDatasetNumber('amevascrollbarInsetX', 'amevascrollbarPaddingX')
542
+ ?? sharedInset
543
+ ?? fallbackInset;
544
+ const verticalInset = this.readDatasetNumber('amevascrollbarInsetY', 'amevascrollbarPaddingY')
545
+ ?? sharedInset
546
+ ?? fallbackInset;
547
+ const inlineEndInset = this.readDatasetNumber('amevascrollbarInlineEndInset');
548
+
549
+ return {
550
+ horizontalInset,
551
+ verticalInset,
552
+ inlineEndInset,
553
+ };
554
+ }
555
+
556
+ isHostVisible() {
557
+ if (!this.host.isConnected) {
558
+ return false;
559
+ }
560
+
561
+ if (this.host.hidden || this.host.getAttribute('aria-hidden') === 'true') {
562
+ return false;
563
+ }
564
+
565
+ const styles = window.getComputedStyle(this.host);
566
+
567
+ if (styles.display === 'none' || styles.visibility === 'hidden') {
568
+ return false;
569
+ }
570
+
571
+ const rect = this.host.getBoundingClientRect();
572
+
573
+ return rect.width > 0 && rect.height > 0;
574
+ }
575
+
576
+ clearFadeInTimer() {
577
+ if (this.fadeInTimer !== null) {
578
+ window.clearTimeout(this.fadeInTimer);
579
+ this.fadeInTimer = null;
580
+ }
581
+ }
582
+
583
+ prepareVisibleRoot() {
584
+ this.root.classList.remove('ameva-scrollbar--hidden');
585
+ this.root.style.pointerEvents = '';
586
+
587
+ if (this.hasActivated) {
588
+ this.root.classList.add('ameva-scrollbar--active');
589
+ this.root.classList.remove('ameva-scrollbar--initializing');
590
+
591
+ return;
592
+ }
593
+
594
+ this.hasActivated = true;
595
+ this.clearFadeInTimer();
596
+
597
+ this.fadeInTimer = window.setTimeout(() => {
598
+ this.root.classList.add('ameva-scrollbar--active');
599
+ this.root.classList.remove('ameva-scrollbar--initializing');
600
+ this.fadeInTimer = null;
601
+ }, ROOT_FADE_IN_DELAY_MS);
602
+ }
603
+
604
+ deactivateRoot() {
605
+ this.clearFadeInTimer();
606
+ this.hasActivated = false;
607
+ this.root.classList.remove('ameva-scrollbar--active');
608
+ this.root.classList.add('ameva-scrollbar--hidden', 'ameva-scrollbar--initializing');
609
+ this.root.style.pointerEvents = 'none';
610
+
611
+ if (this.vertical) {
612
+ this.resetAxis(this.vertical, 'vertical');
613
+ }
614
+
615
+ if (this.horizontal) {
616
+ this.resetAxis(this.horizontal, 'horizontal');
617
+ }
618
+ }
619
+
620
+ updateRootVisibility() {
621
+ const verticalHidden = this.vertical ? this.vertical.track.classList.contains('ameva-scrollbar__track--hidden') : true;
622
+ const horizontalHidden = this.horizontal ? this.horizontal.track.classList.contains('ameva-scrollbar__track--hidden') : true;
623
+
624
+ if (verticalHidden && horizontalHidden) {
625
+ this.deactivateRoot();
626
+ return;
627
+ }
628
+
629
+ this.prepareVisibleRoot();
630
+ }
631
+
632
+ resetAxis(axisRef, axis) {
633
+ axisRef.track.classList.add('ameva-scrollbar__track--hidden');
634
+
635
+ if (axis === 'vertical') {
636
+ axisRef.thumb.style.height = '';
637
+ axisRef.thumb.style.setProperty('--ameva-scrollbar-thumb-offset-y', '0px');
638
+
639
+ return;
640
+ }
641
+
642
+ axisRef.thumb.style.width = '';
643
+ axisRef.thumb.style.setProperty('--ameva-scrollbar-thumb-offset-x', '0px');
644
+ }
645
+
646
+ updateAxis(axisRef, metrics, axis) {
647
+ const isVertical = axis === 'vertical';
648
+ const viewportSize = isVertical ? metrics.viewportHeight : metrics.viewportWidth;
649
+ const scrollSize = isVertical ? metrics.scrollHeight : metrics.scrollWidth;
650
+ const scrollOffset = isVertical ? metrics.scrollTop : metrics.scrollLeft;
651
+ const maxScroll = isVertical ? metrics.maxScrollTop : metrics.maxScrollLeft;
652
+ const trackSize = isVertical ? axisRef.track.clientHeight : axisRef.track.clientWidth;
653
+
654
+ if (!trackSize || scrollSize <= viewportSize + 1) {
655
+ this.resetAxis(axisRef, axis);
656
+
657
+ return;
658
+ }
659
+
660
+ axisRef.track.classList.remove('ameva-scrollbar__track--hidden');
661
+
662
+ const thumbPadding = this.resolveThumbPadding();
663
+ const thumbMinSize = this.resolveThumbMinSize();
664
+ const thumbSize = Math.max((viewportSize / scrollSize) * trackSize, thumbMinSize);
665
+ const availableTravel = Math.max(trackSize - thumbSize - thumbPadding * 2, 0);
666
+ const progress = maxScroll > 0 ? scrollOffset / maxScroll : 0;
667
+ const thumbOffset = thumbPadding + availableTravel * progress;
668
+
669
+ if (isVertical) {
670
+ axisRef.thumb.style.height = `${thumbSize}px`;
671
+ axisRef.thumb.style.setProperty('--ameva-scrollbar-thumb-offset-y', `${thumbOffset}px`);
672
+
673
+ return;
674
+ }
675
+
676
+ axisRef.thumb.style.width = `${thumbSize}px`;
677
+ axisRef.thumb.style.setProperty('--ameva-scrollbar-thumb-offset-x', `${thumbOffset}px`);
678
+ }
679
+
680
+ scrollToTrackPosition(axis, track, thumb, event, dragging = false) {
681
+ const rect = track.getBoundingClientRect();
682
+ const metrics = this.getMetrics();
683
+
684
+ if (axis === 'vertical') {
685
+ const thumbPadding = this.resolveThumbPadding();
686
+ const thumbHeight = thumb.offsetHeight || this.resolveThumbMinSize();
687
+ const availableTravel = Math.max(rect.height - thumbHeight - thumbPadding * 2, 0);
688
+ const targetOffset = Math.min(
689
+ Math.max(event.clientY - rect.top - thumbHeight / 2 - thumbPadding, 0),
690
+ availableTravel,
691
+ );
692
+ const progress = availableTravel > 0 ? targetOffset / availableTravel : 0;
693
+
694
+ this.scrollTo({
695
+ top: progress * metrics.maxScrollTop,
696
+ behavior: dragging ? 'auto' : 'smooth',
697
+ });
698
+
699
+ return;
700
+ }
701
+
702
+ const thumbPadding = this.resolveThumbPadding();
703
+ const thumbWidth = thumb.offsetWidth || this.resolveThumbMinSize();
704
+ const availableTravel = Math.max(rect.width - thumbWidth - thumbPadding * 2, 0);
705
+ const targetOffset = Math.min(
706
+ Math.max(event.clientX - rect.left - thumbWidth / 2 - thumbPadding, 0),
707
+ availableTravel,
708
+ );
709
+ const progress = availableTravel > 0 ? targetOffset / availableTravel : 0;
710
+
711
+ this.scrollTo({
712
+ left: progress * metrics.maxScrollLeft,
713
+ behavior: dragging ? 'auto' : 'smooth',
714
+ });
715
+ }
716
+
717
+ forwardWheel(axis, event) {
718
+ const delta = axis === 'horizontal' ? event.deltaX : event.deltaY;
719
+
720
+ if (delta === 0) {
721
+ return;
722
+ }
723
+
724
+ event.preventDefault();
725
+
726
+ if (axis === 'horizontal') {
727
+ this.scrollBy({
728
+ left: delta,
729
+ behavior: 'auto',
730
+ });
731
+
732
+ return;
733
+ }
734
+
735
+ this.scrollBy({
736
+ top: delta,
737
+ behavior: 'auto',
738
+ });
739
+ }
740
+
741
+ forwardHostWheel(event) {
742
+ if (this.viewport || event.defaultPrevented) {
743
+ return;
744
+ }
745
+
746
+ const target = event.target instanceof Element
747
+ ? event.target
748
+ : null;
749
+
750
+ if (target?.closest('[data-amevascrollbar-root]')) {
751
+ return;
752
+ }
753
+
754
+ const primaryAxis = Math.abs(event.deltaY) >= Math.abs(event.deltaX)
755
+ ? 'vertical'
756
+ : 'horizontal';
757
+
758
+ if (this.canNestedScrollableConsumeWheel(target, event, primaryAxis)) {
759
+ return;
760
+ }
761
+
762
+ const metrics = this.getMetrics();
763
+ const canScrollVertically = this.verticalEnabled
764
+ && metrics.maxScrollTop > 0
765
+ && event.deltaY !== 0;
766
+ const canScrollHorizontally = this.horizontalEnabled
767
+ && metrics.maxScrollLeft > 0
768
+ && event.deltaX !== 0;
769
+
770
+ if (!canScrollVertically && !canScrollHorizontally) {
771
+ return;
772
+ }
773
+
774
+ event.preventDefault();
775
+ this.scrollElement.scrollBy({
776
+ top: canScrollVertically ? event.deltaY : 0,
777
+ left: canScrollHorizontally ? event.deltaX : 0,
778
+ behavior: 'auto',
779
+ });
780
+ }
781
+
782
+ scrollTo(options) {
783
+ if (this.viewport) {
784
+ window.scrollTo({
785
+ top: options.top ?? window.scrollY,
786
+ left: options.left ?? window.scrollX,
787
+ behavior: options.behavior,
788
+ });
789
+
790
+ return;
791
+ }
792
+
793
+ this.scrollElement.scrollTo(options);
794
+ }
795
+
796
+ scrollBy(options) {
797
+ if (this.viewport) {
798
+ window.scrollBy(options);
799
+
800
+ return;
801
+ }
802
+
803
+ this.scrollElement.scrollBy(options);
804
+ }
805
+
806
+ canNestedScrollableConsumeWheel(target, event, primaryAxis) {
807
+ let currentElement = target;
808
+
809
+ while (currentElement && currentElement !== this.host) {
810
+ if (!(currentElement instanceof HTMLElement)) {
811
+ currentElement = currentElement.parentElement;
812
+
813
+ continue;
814
+ }
815
+
816
+ const styles = window.getComputedStyle(currentElement);
817
+ const overflowY = styles.overflowY;
818
+ const overflowX = styles.overflowX;
819
+ const scrollableVertically = /(auto|scroll|overlay)/.test(overflowY) && currentElement.scrollHeight > currentElement.clientHeight;
820
+ const scrollableHorizontally = /(auto|scroll|overlay)/.test(overflowX) && currentElement.scrollWidth > currentElement.clientWidth;
821
+
822
+ if (primaryAxis === 'vertical' && scrollableVertically) {
823
+ const nextScrollTop = currentElement.scrollTop + event.deltaY;
824
+ const maxScrollTop = currentElement.scrollHeight - currentElement.clientHeight;
825
+
826
+ if (nextScrollTop > 0 && nextScrollTop < maxScrollTop) {
827
+ return true;
828
+ }
829
+ }
830
+
831
+ if (primaryAxis === 'horizontal' && scrollableHorizontally) {
832
+ const nextScrollLeft = currentElement.scrollLeft + event.deltaX;
833
+ const maxScrollLeft = currentElement.scrollWidth - currentElement.clientWidth;
834
+
835
+ if (nextScrollLeft > 0 && nextScrollLeft < maxScrollLeft) {
836
+ return true;
837
+ }
838
+ }
839
+
840
+ currentElement = currentElement.parentElement;
841
+ }
842
+
843
+ return false;
844
+ }
845
+
846
+ destroy() {
847
+ this.clearFadeInTimer();
848
+
849
+ if (this.frame !== null) {
850
+ window.cancelAnimationFrame(this.frame);
851
+ this.frame = null;
852
+ }
853
+
854
+ if (this.transitionTrackingFrame !== null) {
855
+ window.cancelAnimationFrame(this.transitionTrackingFrame);
856
+ this.transitionTrackingFrame = null;
857
+ }
858
+
859
+ if (this.viewport) {
860
+ window.removeEventListener('scroll', this.handleScroll);
861
+ window.removeEventListener('resize', this.handleResize);
862
+ window.removeEventListener('load', this.handleWindowLoad);
863
+ document.documentElement.classList.remove('ameva-scrollbar-host', 'ameva-scrollbar-host--viewport');
864
+ document.body.classList.remove('ameva-scrollbar-host', 'ameva-scrollbar-host--viewport');
865
+ } else {
866
+ this.scrollElement.removeEventListener('scroll', this.handleScroll);
867
+ this.host.removeEventListener('wheel', this.handleHostWheel);
868
+ window.removeEventListener('resize', this.handleResize);
869
+ window.removeEventListener('scroll', this.handleResize);
870
+ this.host.removeEventListener('transitionrun', this.handleHostTransitionStart);
871
+ this.host.removeEventListener('transitionstart', this.handleHostTransitionStart);
872
+ this.host.removeEventListener('transitionend', this.handleHostTransitionEnd);
873
+ this.host.removeEventListener('transitioncancel', this.handleHostTransitionEnd);
874
+ this.host.removeEventListener('animationstart', this.handleHostTransitionStart);
875
+ this.host.removeEventListener('animationend', this.handleHostTransitionEnd);
876
+ this.host.removeEventListener('animationcancel', this.handleHostTransitionEnd);
877
+ }
878
+
879
+ this.resizeObserver?.disconnect();
880
+ this.mutationObserver?.disconnect();
881
+ this.root?.remove();
882
+
883
+ this.host.classList.remove('ameva-scrollbar-host');
884
+ delete this.host.dataset.amevascrollbarInitialized;
885
+ this.dragState = null;
886
+ this.root = null;
887
+ this.vertical = null;
888
+ this.horizontal = null;
889
+ }
890
+ }