UNPKG

@evermade/overflow-slider

Version:

Accessible slider that is powered by overflow: auto.

167 lines (139 loc) 6.06 kB
import { Slider, DeepPartial } from '../../core/types'; const DEFAULT_CLASS_NAMES = { scrollIndicator: 'overflow-slider__scroll-indicator', scrollIndicatorBar: 'overflow-slider__scroll-indicator-bar', scrollIndicatorButton: 'overflow-slider__scroll-indicator-button', }; export type ScrollIndicatorOptions = { classNames: { scrollIndicator: string; scrollIndicatorBar: string; scrollIndicatorButton: string; }, container: HTMLElement | null, }; export default function ScrollIndicatorPlugin(args?: DeepPartial<ScrollIndicatorOptions>) { return (slider: Slider) => { const options = <ScrollIndicatorOptions>{ classNames: { ...DEFAULT_CLASS_NAMES, ...args?.classNames || [] }, container: args?.container ?? null, }; const scrollbarContainer = document.createElement('div'); scrollbarContainer.setAttribute('class', options.classNames.scrollIndicator); scrollbarContainer.setAttribute('tabindex', '0'); scrollbarContainer.setAttribute('role', 'scrollbar'); scrollbarContainer.setAttribute('aria-controls', slider.container.getAttribute('id') ?? ''); scrollbarContainer.setAttribute('aria-orientation', 'horizontal'); scrollbarContainer.setAttribute('aria-valuemax', '100'); scrollbarContainer.setAttribute('aria-valuemin', '0'); scrollbarContainer.setAttribute('aria-valuenow', '0'); const scrollbar = document.createElement('div'); scrollbar.setAttribute('class', options.classNames.scrollIndicatorBar); const scrollbarButton = document.createElement('div'); scrollbarButton.setAttribute('class', options.classNames.scrollIndicatorButton); scrollbarButton.setAttribute('data-is-grabbed', 'false'); scrollbar.appendChild(scrollbarButton); scrollbarContainer.appendChild(scrollbar); const setDataAttributes = () => { scrollbarContainer.setAttribute('data-has-overflow', slider.details.hasOverflow.toString()); } setDataAttributes(); const getScrollbarButtonLeftOffset = () => { const contentRatio = scrollbarButton.offsetWidth / slider.details.containerWidth; const scrollAmount = slider.getScrollLeft() * contentRatio; if (slider.options.rtl) { return scrollbar.offsetWidth - scrollbarButton.offsetWidth - scrollAmount; } return scrollAmount; }; let requestId = 0; const update = () => { if (requestId) { window.cancelAnimationFrame(requestId); } requestId = window.requestAnimationFrame(() => { const scrollbarButtonWidth = (slider.details.containerWidth / slider.container.scrollWidth) * 100; const scrollLeftInPortion = getScrollbarButtonLeftOffset(); scrollbarButton.style.width = `${scrollbarButtonWidth}%`; scrollbarButton.style.transform = `translateX(${scrollLeftInPortion}px)`; const scrollLeft = slider.getScrollLeft(); const scrollWidth = slider.getInclusiveScrollWidth(); const containerWidth = slider.container.offsetWidth; const scrollPercentage = (scrollLeft / (scrollWidth - containerWidth)) * 100; scrollbarContainer.setAttribute('aria-valuenow', Math.round(Number.isNaN(scrollPercentage) ? 0 : scrollPercentage).toString()); }); }; if (options.container) { options.container.appendChild(scrollbarContainer); } else { slider.container.parentNode?.insertBefore(scrollbarContainer, slider.container.nextSibling); } update(); slider.on('scroll', update); slider.on('contentsChanged', update); slider.on('containerSizeChanged', update); slider.on('detailsChanged', setDataAttributes); scrollbarContainer.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') { slider.moveToDirection('prev'); } else if (e.key === 'ArrowRight') { slider.moveToDirection('next'); } }); let isInteractionDown = false; let startX = 0; let scrollLeft = slider.getScrollLeft(); scrollbarContainer.addEventListener('click', (e) => { if ( e.target == scrollbarButton ) { return; } const scrollbarButtonWidth = scrollbarButton.offsetWidth; const scrollbarButtonLeft = getScrollbarButtonLeftOffset(); const scrollbarButtonRight = scrollbarButtonLeft + scrollbarButtonWidth; const clickX = (e as MouseEvent).pageX - scrollbarContainer.getBoundingClientRect().left; if (Math.floor(clickX) < Math.floor(scrollbarButtonLeft)) { console.log('move left'); slider.moveToDirection(slider.options.rtl ? 'next' : 'prev'); } else if (Math.floor(clickX) > Math.floor(scrollbarButtonRight)) { console.log('move right'); slider.moveToDirection(slider.options.rtl ? 'prev' : 'next'); } }); const onInteractionDown = (e: MouseEvent | TouchEvent) => { isInteractionDown = true; const pageX = (e as MouseEvent).pageX || (e as TouchEvent).touches[0].pageX; startX = pageX - scrollbarContainer.offsetLeft; scrollLeft = slider.getScrollLeft(); scrollbarButton.style.cursor = 'grabbing'; scrollbarButton.setAttribute('data-is-grabbed', 'true'); e.preventDefault(); e.stopPropagation(); }; const onInteractionMove = (e: MouseEvent | TouchEvent) => { if (!isInteractionDown) { return; } e.preventDefault(); const pageX = (e as MouseEvent).pageX || (e as TouchEvent).touches[0].pageX; const x = pageX - scrollbarContainer.offsetLeft; const scrollingFactor = slider.details.scrollableAreaWidth / scrollbarContainer.offsetWidth; const walk = (x - startX) * scrollingFactor; const distance = slider.options.rtl ? scrollLeft - walk : scrollLeft + walk; slider.setScrollLeft(distance); }; const onInteractionUp = () => { isInteractionDown = false; scrollbarButton.style.cursor = ''; scrollbarButton.setAttribute('data-is-grabbed', 'false'); }; scrollbarButton.addEventListener('mousedown', onInteractionDown); scrollbarButton.addEventListener('touchstart', onInteractionDown); window.addEventListener('mousemove', onInteractionMove); window.addEventListener('touchmove', onInteractionMove, { passive: false }); window.addEventListener('mouseup', onInteractionUp); window.addEventListener('touchend', onInteractionUp); }; }