import { addEventListener, addMeltEventListener, makeElement, createElHelpers, effect, executeCallbacks, generateIds, isHTMLElement, noop, styleToString, toWritableStores, omit, withGet, } from '../../internal/helpers/index.js'; import { derived, writable } from 'svelte/store'; import { addUnlinkedScrollListener, getScrollPositionFromPointer, getThumbOffsetFromScroll, getThumbRatio, isScrollingWithinScrollbarBounds, toInt, } from './helpers.js'; import { createScrollbarX, createScrollbarY, getScrollbarActionByType } from './scrollbars.js'; export const { name } = createElHelpers('scroll-area'); export const scrollAreaIdParts = [ 'root', 'viewport', 'content', 'scrollbarX', 'scrollbarY', 'thumbX', 'thumbY', ]; const defaults = { type: 'hover', hideDelay: 600, dir: 'ltr', }; export function createScrollArea(props) { const withDefaults = { ...defaults, ...props }; const options = toWritableStores(omit(withDefaults, 'ids')); const cornerWidth = withGet.writable(0); const cornerHeight = withGet.writable(0); const scrollbarXEnabled = withGet.writable(false); const scrollbarYEnabled = withGet.writable(false); const scrollAreaEl = withGet.writable(null); const viewportEl = withGet.writable(null); const contentEl = withGet.writable(null); const scrollbarXEl = withGet.writable(null); const scrollbarYEl = withGet.writable(null); const ids = toWritableStores({ ...generateIds(scrollAreaIdParts), ...withDefaults.ids }); const rootState = { cornerWidth, cornerHeight, scrollbarXEnabled, scrollbarYEnabled, viewportEl, contentEl, options, scrollbarXEl, scrollbarYEl, scrollAreaEl, ids, }; const root = makeElement(name(), { stores: [cornerWidth, cornerHeight, ids.root], returned: ([$cornerWidth, $cornderHeight, $rootId]) => { return { style: styleToString({ position: 'relative', '--melt-scroll-area-corner-width': `${$cornerWidth}px`, '--melt-scroll-area-corner-height': `${$cornderHeight}px`, }), id: $rootId, }; }, action: (node) => { scrollAreaEl.set(node); return { destroy() { scrollAreaEl.set(null); }, }; }, }); const viewport = makeElement(name('viewport'), { stores: [scrollbarXEnabled, scrollbarYEnabled, ids.viewport], returned: ([$scrollbarXEnabled, $scrollbarYEnabled, $viewportId]) => { return { style: styleToString({ 'scrollbar-width': 'none', '-ms-overflow-style': 'none', '-webkit-overflow-scrolling': 'touch', '-webkit-scrollbar': 'none', 'overflow-x': $scrollbarXEnabled ? 'scroll' : 'hidden', 'overflow-y': $scrollbarYEnabled ? 'scroll' : 'hidden', }), id: $viewportId, }; }, action: (node) => { // Ensure we hide any native scrollbars on the viewport element const styleNode = document.createElement('style'); styleNode.innerHTML = ` /* Hide scrollbars cross-browser and enable momentum scroll for touch devices */ [data-melt-scroll-area-viewport] { scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch; } [data-melt-scroll-area-viewport]::-webkit-scrollbar { display: none; } `; node.parentElement?.insertBefore(styleNode, node); viewportEl.set(node); return { destroy() { styleNode.remove(); viewportEl.set(null); }, }; }, }); const content = makeElement(name('content'), { stores: [ids.content], returned: ([$contentId]) => { return { style: styleToString({ 'min-width': '100%', display: 'table', }), id: $contentId, }; }, action: (node) => { contentEl.set(node); return { destroy() { contentEl.set(null); }, }; }, }); function createScrollbar(orientationProp = 'vertical') { const orientation = withGet.writable(orientationProp); const isHorizontal = withGet.writable(orientationProp === 'horizontal'); const domRect = withGet.writable(null); const prevWebkitUserSelect = withGet.writable(''); const pointerOffset = withGet.writable(0); const thumbEl = withGet.writable(null); const thumbOffset = withGet.writable(0); const scrollbarEl = withGet.writable(null); const sizes = withGet.writable({ content: 0, viewport: 0, scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0, }, }); const isVisible = withGet.writable(false); const hasThumb = withGet.derived(sizes, ($sizes) => { const thumbRatio = getThumbRatio($sizes.viewport, $sizes.content); return Boolean(thumbRatio > 0 && thumbRatio < 1); }); function getScrollPosition(pointerPos, dir) { return getScrollPositionFromPointer(pointerPos, pointerOffset.get(), sizes.get(), dir); } function handleWheelScroll(e, payload) { const $viewportEl = viewportEl.get(); if (!$viewportEl) return; if (isHorizontal.get()) { const scrollPos = $viewportEl.scrollLeft + e.deltaY; $viewportEl.scrollLeft = scrollPos; if (isScrollingWithinScrollbarBounds(scrollPos, payload)) { e.preventDefault(); } } else { const scrollPos = $viewportEl.scrollTop + e.deltaY; $viewportEl.scrollTop = scrollPos; if (isScrollingWithinScrollbarBounds(scrollPos, payload)) { e.preventDefault(); } } } function handleThumbDown(payload) { if (isHorizontal.get()) { pointerOffset.set(payload.x); } else { pointerOffset.set(payload.y); } } function handleThumbUp() { pointerOffset.set(0); } function onThumbPositionChange() { const $viewportEl = viewportEl.get(); const $thumbEl = thumbEl.get(); if (!$viewportEl || !$thumbEl) return; const scrollPos = isHorizontal.get() ? $viewportEl.scrollLeft : $viewportEl.scrollTop; const offset = getThumbOffsetFromScroll(scrollPos, sizes.get(), rootState.options.dir.get()); thumbOffset.set(offset); } function onDragScroll(payload) { const $viewportEl = viewportEl.get(); if (!$viewportEl) return; if (isHorizontal.get()) { $viewportEl.scrollLeft = getScrollPosition(payload, rootState.options.dir.get()); } else { $viewportEl.scrollTop = getScrollPosition(payload); } } function handleSizeChange() { const $scrollbarEl = scrollbarState.scrollbarEl.get(); if (!$scrollbarEl) return; const $isHorizontal = scrollbarState.isHorizontal.get(); const $viewportEl = rootState.viewportEl.get(); if ($isHorizontal) { scrollbarState.sizes.set({ content: $viewportEl?.scrollWidth ?? 0, viewport: $viewportEl?.offsetWidth ?? 0, scrollbar: { size: $scrollbarEl.clientWidth ?? 0, paddingStart: toInt(getComputedStyle($scrollbarEl).paddingLeft), paddingEnd: toInt(getComputedStyle($scrollbarEl).paddingRight), }, }); } else { scrollbarState.sizes.set({ content: $viewportEl?.scrollHeight ?? 0, viewport: $viewportEl?.offsetHeight ?? 0, scrollbar: { size: $scrollbarEl.clientHeight ?? 0, paddingStart: toInt(getComputedStyle($scrollbarEl).paddingLeft), paddingEnd: toInt(getComputedStyle($scrollbarEl).paddingRight), }, }); } } const scrollbarState = { isHorizontal, domRect, prevWebkitUserSelect, pointerOffset, thumbEl, thumbOffset, sizes, orientation, handleThumbDown, handleThumbUp, onThumbPositionChange, onDragScroll, handleWheelScroll, hasThumb, scrollbarEl, isVisible, handleSizeChange, }; const scrollbarActionByType = getScrollbarActionByType(options.type.get()); const scrollAreaState = { rootState, scrollbarState }; const scrollbar = orientationProp === 'horizontal' ? createScrollbarX(scrollAreaState, scrollbarActionByType) : createScrollbarY(scrollAreaState, scrollbarActionByType); const thumb = createScrollbarThumb(scrollAreaState); return { scrollbar, thumb, }; } const { scrollbar: scrollbarX, thumb: thumbX } = createScrollbar('horizontal'); const { scrollbar: scrollbarY, thumb: thumbY } = createScrollbar('vertical'); const corner = createScrollAreaCorner(rootState); return { options, elements: { root, viewport, content, corner, scrollbarX, scrollbarY, thumbX, thumbY, }, }; } function createScrollbarThumb(state) { const { scrollbarState, rootState } = state; function handlePointerDown(e) { const thumb = e.target; if (!isHTMLElement(thumb)) return; const thumbRect = thumb.getBoundingClientRect(); const x = e.clientX - thumbRect.left; const y = e.clientY - thumbRect.top; scrollbarState.handleThumbDown({ x, y }); } function handlePointerUp(e) { scrollbarState.handleThumbUp(e); } let unsubListener = undefined; function handleScroll() { if (unsubListener) return; const $viewportEl = rootState.viewportEl.get(); if ($viewportEl) { unsubListener = addUnlinkedScrollListener($viewportEl, scrollbarState.onThumbPositionChange); } scrollbarState.onThumbPositionChange(); } const thumb = makeElement(name('thumb'), { stores: [scrollbarState.hasThumb, scrollbarState.isHorizontal, scrollbarState.thumbOffset], returned: ([$hasThumb, $isHorizontal, $offset]) => { return { style: styleToString({ width: 'var(--melt-scroll-area-thumb-width)', height: 'var(--melt-scroll-area-thumb-height)', transform: $isHorizontal ? `translate3d(${Math.round($offset)}px, 0, 0)` : `translate3d(0, ${Math.round($offset)}px, 0)`, }), 'data-state': $hasThumb ? 'visible' : 'hidden', }; }, action: (node) => { scrollbarState.thumbEl.set(node); const unsubEffect = effect([scrollbarState.sizes], ([_]) => { const $viewportEl = rootState.viewportEl.get(); if (!$viewportEl) return noop; scrollbarState.onThumbPositionChange(); return addEventListener($viewportEl, 'scroll', handleScroll); }); const unsubEvents = executeCallbacks(addMeltEventListener(node, 'pointerdown', handlePointerDown), addMeltEventListener(node, 'pointerup', handlePointerUp)); return { destroy() { unsubListener?.(); unsubEvents(); unsubEffect(); }, }; }, }); return thumb; } function createScrollAreaCorner(rootState) { const width = writable(0); const height = writable(0); const hasSize = derived([width, height], ([$width, $height]) => !!$width && !!$height); function setCornerHeight() { const offsetHeight = rootState.scrollbarXEl.get()?.offsetHeight || 0; rootState.cornerHeight.set(offsetHeight); height.set(offsetHeight); } function setCornerWidth() { const offsetWidth = rootState.scrollbarYEl.get()?.offsetWidth || 0; rootState.cornerWidth.set(offsetWidth); width.set(offsetWidth); } effect([rootState.scrollbarXEl], ([$scrollbarXEl]) => { if ($scrollbarXEl) { setCornerHeight(); } }); effect([rootState.scrollbarYEl], ([$scrollbarYEl]) => { if ($scrollbarYEl) { setCornerWidth(); } }); const hasBothScrollbarsVisible = derived([rootState.scrollbarXEl, rootState.scrollbarYEl], ([$scrollbarXEl, $scrollbarYEl]) => { return !!$scrollbarXEl && !!$scrollbarYEl; }); const hasCorner = derived([rootState.options.type, hasBothScrollbarsVisible], ([$type, $hasBoth]) => { return $type !== 'scroll' && $hasBoth; }); const shouldDisplay = derived([hasCorner, hasSize], ([$hasCorner, $hasSize]) => $hasCorner && $hasSize); const corner = makeElement(name('corner'), { stores: [width, height, rootState.options.dir, shouldDisplay], returned: ([$width, $height, $dir, $shouldDisplay]) => { return { style: styleToString({ display: $shouldDisplay ? 'block' : 'none', width: `${$width}px`, height: `${$height}px`, position: 'absolute', right: $dir === 'ltr' ? 0 : undefined, left: $dir === 'rtl' ? 0 : undefined, bottom: 0, }), }; }, }); return corner; }