<div class="scrollbar">
<div class="scrollbar__thumb"></div>
</div>
<div {{ html_attributes({
id: id ?? false,
class: 'scrollbar',
'data-scrollbar-sync': sync ?? false,
'data-scrollbar-item-selector': itemSelector ?? false,
'data-scrollbar-prev-button': prevButton ?? false,
'data-scrollbar-next-button': nextButton ?? false,
}, attrs ?? {}) }}>
<div class="scrollbar__thumb"></div>
</div>
/* No context defined. */
@property --scrollbar-thumb-x {
inherits: true;
initial-value: 0;
syntax: '<length-percentage>';
}
.scrollbar {
--_scrollbar-thumb-size: var(--scrollbar-thumb-size, 0.8rem);
--_scrollbar-track-size: var(--scrollbar-track-size, 0.2rem);
isolation: isolate;
position: relative;
&::before {
background-color: var(--scrollbar-track-color, var(--color-gray-300));
block-size: var(--_scrollbar-track-size);
border-radius: calc(var(--_scrollbar-track-size) / 2);
content: '';
inset-block-start: 50%;
inset-inline: 0;
position: absolute;
translate: 0 -50%;
z-index: 1;
}
}
.scrollbar__thumb {
background-color: var(--scrollbar-thumb-color, var(--color-gray-600));
block-size: var(--_scrollbar-thumb-size);
border-radius: calc(var(--_scrollbar-thumb-size) / 2);
inline-size: clamp(4.4rem, var(--scrollbar-progress, 0%), 100%);
opacity: var(--scrollbar-thumb-opacity, 1);
position: relative;
touch-action: pan-x;
transform: translate3d(var(--scrollbar-thumb-x), 0, 0);
transform-origin: 0 0;
transition-property: opacity;
will-change: inline-size, transform;
z-index: 2;
&::after {
block-size: 4.4rem;
content: '';
inset-block-start: 50%;
inset-inline: 0;
position: absolute;
translate: 0 -50%;
}
}
import { on } from 'delegated-events';
import getInputPosition from '../../../javascripts/utils/getInputPosition';
import getTranslatePosition from '../../../javascripts/utils/getTranslatePosition';
import ScrollbarEvent from '../../../javascripts/events/ScrollbarEvent';
import abort from '../../../javascripts/utils/abort';
import isVisible from '../../../javascripts/utils/isVisible';
import steps from '../../../javascripts/utils/steps';
const handleThumbChange = (event: (MouseEvent | TouchEvent) & { currentTarget: HTMLElement }) => {
const { currentTarget: $thumb } = event;
const { parentElement: $scrollbar } = $thumb;
if (!$scrollbar) {
return;
}
event.preventDefault();
const { x: startPosition } = getInputPosition(event);
const { x: thumbX } = getTranslatePosition($thumb);
const maxPosition = $scrollbar.scrollWidth - $thumb.scrollWidth;
$scrollbar.dispatchEvent(new ScrollbarEvent('start', thumbX / maxPosition));
const moveHandler = (moveEvent: MouseEvent | TouchEvent) => {
const { x: currentPosition } = getInputPosition(moveEvent);
const newPosition = Math.min(Math.max(thumbX + currentPosition - startPosition, 0), maxPosition);
$scrollbar.style.setProperty('--scrollbar-thumb-x', `${newPosition}px`);
$scrollbar.dispatchEvent(new ScrollbarEvent('move', newPosition / maxPosition));
};
const endHandler = (moveEventName: 'mousemove' | 'touchmove') => {
window.removeEventListener(moveEventName, moveHandler);
document.body.style.pointerEvents = '';
const { x: finalThumbX } = getTranslatePosition($thumb);
$scrollbar.dispatchEvent(new ScrollbarEvent('end', finalThumbX / maxPosition));
};
document.body.style.pointerEvents = 'none';
if (window.MouseEvent && event instanceof MouseEvent) {
window.addEventListener('mousemove', moveHandler);
window.addEventListener('mouseup', () => endHandler('mousemove'), { once: true });
} else if (window.TouchEvent && event instanceof TouchEvent) {
window.addEventListener('touchmove', moveHandler);
window.addEventListener('touchend', () => endHandler('touchmove'), { once: true });
}
};
on('touchstart', '.scrollbar__thumb', handleThumbChange);
on('mousedown', '.scrollbar__thumb', handleThumbChange);
on('click', '.scrollbar', (event) => {
const { currentTarget: $scrollbar } = event;
const $thumb = $scrollbar.querySelector<HTMLElement>('.scrollbar__thumb') ?? abort();
const maxPosition = $scrollbar.scrollWidth - $thumb.scrollWidth;
const { left: leftOffset } = $scrollbar.getBoundingClientRect();
const { x: position } = getInputPosition(event);
const { x: thumbX } = getTranslatePosition($thumb);
const { offsetWidth } = $thumb;
const moveDirection = (position - leftOffset) > thumbX ? 1 : -1;
const newPosition = Math.min(Math.max(thumbX + (offsetWidth * moveDirection), 0), maxPosition);
const percent = newPosition / maxPosition;
$scrollbar.dispatchEvent(new ScrollbarEvent('start', thumbX / maxPosition));
requestAnimationFrame(() => {
const animationDuration = 200;
const updateEveryMs = 5;
const animationSteps = steps(thumbX / maxPosition, percent, animationDuration / updateEveryMs);
let currentStep = 0;
const animation = $scrollbar.animate(
[{ '--scrollbar-thumb-x': `${newPosition}px` }],
{ duration: animationDuration, fill: 'forwards' },
);
const moveDispatcher = setInterval(() => {
const nextStep = animationSteps[currentStep];
currentStep += 1;
if (nextStep !== undefined) {
$scrollbar.dispatchEvent(new ScrollbarEvent('move', nextStep));
} else {
clearInterval(moveDispatcher);
animation.cancel();
$scrollbar.style.setProperty('--scrollbar-thumb-x', `${newPosition}px`);
$scrollbar.dispatchEvent(new ScrollbarEvent('end', percent));
}
}, updateEveryMs);
});
});
document.querySelectorAll<HTMLElement>('.scrollbar[data-scrollbar-sync]').forEach(($scrollbar) => {
const {
scrollbarSync: targetId,
scrollbarItemSelector: itemSelector,
scrollbarPrevButton: prevButtonSelector,
scrollbarNextButton: nextButtonSelector,
} = $scrollbar.dataset;
const $thumb = $scrollbar.querySelector<HTMLElement>('.scrollbar__thumb') ?? abort();
const $target = document.querySelector<HTMLElement>(`#${targetId ?? abort()}`) ?? abort();
const $prevButton = prevButtonSelector ? document.querySelector<HTMLButtonElement>(`#${prevButtonSelector}`) : null;
const $nextButton = nextButtonSelector ? document.querySelector<HTMLButtonElement>(`#${nextButtonSelector}`) : null;
let inProgress = false;
const getScrollPaddingInlineStart = ($element: HTMLElement) => {
const { scrollPaddingInlineStart } = window.getComputedStyle($element);
return scrollPaddingInlineStart !== 'auto' ? parseInt(scrollPaddingInlineStart, 10) : 0;
};
const updateSize = () => {
const scrollPadding = getScrollPaddingInlineStart($target);
const visible = Math.min(($target.offsetWidth / ($target.scrollWidth - scrollPadding)) * 100, 100);
$scrollbar.style.setProperty('--scrollbar-progress', `${visible}%`);
};
const updatePosition = () => {
const scrollPosition = $target.scrollLeft / ($target.scrollWidth - $target.offsetWidth);
if (Number.isNaN(scrollPosition)) {
$scrollbar.style.setProperty('--scrollbar-thumb-opacity', '0');
$scrollbar.style.setProperty('--scrollbar-thumb-x', '0px');
} else {
const maxPosition = $scrollbar.scrollWidth - $thumb.scrollWidth;
$scrollbar.style.setProperty('--scrollbar-thumb-x', `${scrollPosition * maxPosition}px`);
$scrollbar.style.removeProperty('--scrollbar-thumb-opacity');
}
};
const updateButtons = () => {
const canPrev = $target.scrollLeft <= 1;
const canNext = $target.scrollWidth <= $target.scrollLeft + $target.offsetWidth;
$prevButton?.toggleAttribute('disabled', canPrev);
$nextButton?.toggleAttribute('disabled', canNext);
};
$scrollbar.addEventListener('scrollbarstart', () => {
inProgress = true;
$target.style.scrollSnapType = 'none';
});
$scrollbar.addEventListener('scrollbarmove', (event) => {
$target.scrollTo({
left: (event as ScrollbarEvent).percent * ($target.scrollWidth - $target.offsetWidth),
behavior: 'instant',
});
updateButtons();
});
$scrollbar.addEventListener('scrollbarend', () => {
if (itemSelector) {
const scrollPadding = getScrollPaddingInlineStart($target);
const maxScrollLeft = $target.scrollWidth - $target.offsetWidth;
const $firstVisibleItem = [...$target.querySelectorAll<HTMLElement>(itemSelector)]
.find(($item) => isVisible($item, $target));
if ($firstVisibleItem && $target.scrollLeft !== maxScrollLeft) {
$target.scrollTo({
left: $firstVisibleItem.offsetLeft - $target.offsetLeft - scrollPadding,
behavior: 'smooth',
});
}
}
$target.style.scrollSnapType = '';
inProgress = false;
updatePosition();
updateButtons();
});
$target.addEventListener('scroll', () => {
if (!inProgress) {
updatePosition();
}
updateButtons();
}, { passive: true });
$prevButton?.addEventListener('click', (clickEvent) => {
clickEvent.preventDefault();
$target.scrollBy({
left: -$target.offsetWidth,
behavior: 'smooth',
});
});
$nextButton?.addEventListener('click', (clickEvent) => {
clickEvent.preventDefault();
$target.scrollBy({
left: $target.offsetWidth,
behavior: 'smooth',
});
});
const resizeObserver = new ResizeObserver(() => {
updatePosition();
updateSize();
updateButtons();
});
requestAnimationFrame(() => {
$target.scrollTo({ left: 0, behavior: 'instant' });
updatePosition();
updateSize();
updateButtons();
});
resizeObserver.observe($target);
});
No notes defined.