import { addEventListener, addMeltEventListener, effect, executeCallbacks, isFirefox, isHTMLElement, isTouchDevice, makeElement, noop, styleToString, } from '../../internal/helpers/index.js'; import { createStateMachine } from '../../internal/helpers/store/stateMachine.js'; import { name } from './create.js'; import { debounceCallback, getThumbSize, resizeObserver } from './helpers.js'; /** * The base scrollbar action is used for all scrollbar types, * and provides the basic functionality for dragging the scrollbar * thumb and scrolling the content. * * The other scrollbar actions will extend this one, preventing a ton * of code duplication. */ export function createBaseScrollbarAction(state) { const { rootState, scrollbarState } = state; scrollbarState.isVisible.set(true); function handleDragScroll(e) { const $domRect = scrollbarState.domRect.get(); if (!$domRect) return; const x = e.clientX - $domRect.left; const y = e.clientY - $domRect.top; const $isHorizontal = scrollbarState.isHorizontal.get(); if ($isHorizontal) { scrollbarState.onDragScroll(x); } else { scrollbarState.onDragScroll(y); } } function handlePointerDown(e) { if (e.button !== 0) return; const target = e.target; if (!isHTMLElement(target)) return; target.setPointerCapture(e.pointerId); const currentTarget = e.currentTarget; if (!isHTMLElement(currentTarget)) return; scrollbarState.domRect.set(currentTarget.getBoundingClientRect()); scrollbarState.prevWebkitUserSelect.set(document.body.style.webkitUserSelect); document.body.style.webkitUserSelect = 'none'; const $viewportEl = rootState.viewportEl.get(); if ($viewportEl) { $viewportEl.style.scrollBehavior = 'auto'; } handleDragScroll(e); } function handlePointerMove(e) { handleDragScroll(e); } function handlePointerUp(e) { const target = e.target; if (!isHTMLElement(target)) return; if (target.hasPointerCapture(e.pointerId)) { target.releasePointerCapture(e.pointerId); } document.body.style.webkitUserSelect = scrollbarState.prevWebkitUserSelect.get(); const $viewportEl = rootState.viewportEl.get(); if ($viewportEl) { $viewportEl.style.scrollBehavior = ''; } scrollbarState.domRect.set(null); } function handleWheel(e) { const target = e.target; const currentTarget = e.currentTarget; if (!isHTMLElement(target) || !isHTMLElement(currentTarget)) return; const isScrollbarWheel = currentTarget.contains(target); if (!isScrollbarWheel) return; const $sizes = scrollbarState.sizes.get(); if (!$sizes) return; const maxScrollPos = $sizes.content - $sizes.viewport; scrollbarState.handleWheelScroll(e, maxScrollPos); } function baseAction(node) { scrollbarState.scrollbarEl.set(node); const unsubEvents = executeCallbacks(addMeltEventListener(node, 'pointerdown', handlePointerDown), addMeltEventListener(node, 'pointermove', handlePointerMove), addMeltEventListener(node, 'pointerup', handlePointerUp), addEventListener(document, 'wheel', handleWheel, { passive: false })); const unsubResizeContent = effect([rootState.contentEl], ([$contentEl]) => { if (!$contentEl) return noop; return resizeObserver($contentEl, scrollbarState.handleSizeChange); }); return { destroy() { unsubEvents(); unsubResizeContent(); }, }; } return baseAction; } /** * The auto scrollbar action will show the scrollbar when the content * overflows the viewport, and hide it when it doesn't. */ export function createAutoScrollbarAction(state) { // always create the base action first, so we can override any // state mutations that occur there const baseAction = createBaseScrollbarAction(state); const { rootState, scrollbarState } = state; const handleResize = debounceCallback(() => { const $viewportEl = rootState.viewportEl.get(); if (!$viewportEl) return; const isOverflowX = $viewportEl.offsetWidth < $viewportEl.scrollWidth; const isOverflowY = $viewportEl.offsetHeight < $viewportEl.scrollHeight; scrollbarState.isVisible.set(scrollbarState.isHorizontal.get() ? isOverflowX : isOverflowY); }, 10); function scrollbarAutoAction(node) { const unsubBaseAction = baseAction(node)?.destroy; handleResize(); const unsubObservers = []; const $viewportEl = rootState.viewportEl.get(); if ($viewportEl) { unsubObservers.push(resizeObserver($viewportEl, handleResize)); } const $contentEl = rootState.contentEl.get(); if ($contentEl) { unsubObservers.push(resizeObserver($contentEl, handleResize)); } return { destroy() { unsubObservers.forEach((unsub) => unsub()); unsubBaseAction(); }, }; } return scrollbarAutoAction; } /** * The hover scrollbar action will show the scrollbar when the user * hovers over the scroll area, and hide it when they leave after * an optionally specified delay. */ export function createHoverScrollbarAction(state) { // always create the base action first, so we can override any // state mutations that occur there const baseAction = createBaseScrollbarAction(state); const { rootState, scrollbarState } = state; // with the hover scrollbar, we want it to be hidden by default // and only show it when the user hovers over the scroll area scrollbarState.isVisible.set(false); let timeout; function handlePointerEnter() { window.clearTimeout(timeout); if (scrollbarState.isVisible.get()) return; const $viewportEl = rootState.viewportEl.get(); if (!$viewportEl) return; const isOverflowX = $viewportEl.offsetWidth < $viewportEl.scrollWidth; const isOverflowY = $viewportEl.offsetHeight < $viewportEl.scrollHeight; scrollbarState.isVisible.set(scrollbarState.isHorizontal.get() ? isOverflowX : isOverflowY); } function handlePointerLeave() { timeout = window.setTimeout(() => { if (!scrollbarState.isVisible.get()) return; scrollbarState.isVisible.set(false); }, rootState.options.hideDelay.get()); } function scrollbarHoverAction(node) { const unsubBaseAction = baseAction(node)?.destroy; const scrollAreaEl = node.closest('[data-melt-scroll-area]'); let unsubScrollAreaListeners = noop; if (scrollAreaEl) { if (isTouchDevice()) { unsubScrollAreaListeners = executeCallbacks(addEventListener(scrollAreaEl, 'touchstart', handlePointerEnter), addEventListener(scrollAreaEl, 'touchend', handlePointerLeave)); } else if (isFirefox()) { /** * Firefox triggers pointerleave events if you tab away from the window * without moving the pointer, so we use mouseenter/mouseleave instead * which works as expected. * * In Firefox, mouseenter is not triggered if the pointer was over the scroll area * before events were loaded and then starts moving, * so we use pointerenter which works as expected. */ unsubScrollAreaListeners = executeCallbacks(addEventListener(scrollAreaEl, 'pointerenter', handlePointerEnter), addEventListener(scrollAreaEl, 'mouseenter', handlePointerEnter), addEventListener(scrollAreaEl, 'mouseleave', handlePointerLeave)); } else { unsubScrollAreaListeners = executeCallbacks(addEventListener(scrollAreaEl, 'pointerenter', handlePointerEnter), addEventListener(scrollAreaEl, 'pointerleave', handlePointerLeave)); } } return { destroy() { unsubBaseAction?.(); unsubScrollAreaListeners(); }, }; } return scrollbarHoverAction; } /** * The scroll scrollbar action will only show the scrollbar * when the user is actively scrolling the content. */ export function createScrollScrollbarAction(state) { // always create the base action first, so we can // override any state mutations that occur there const baseAction = createBaseScrollbarAction(state); const { rootState, scrollbarState } = state; const machine = createStateMachine('hidden', { hidden: { SCROLL: 'scrolling', }, scrolling: { SCROLL_END: 'idle', POINTER_ENTER: 'interacting', }, interacting: { SCROLL: 'interacting', POINTER_LEAVE: 'idle', }, idle: { HIDE: 'hidden', SCROLL: 'scrolling', POINTER_ENTER: 'interacting', }, }); effect([machine.state], ([$status]) => { if ($status === 'idle') { window.setTimeout(() => { machine.dispatch('HIDE'); }, rootState.options.hideDelay.get()); } if ($status === 'hidden') { scrollbarState.isVisible.set(false); } else { scrollbarState.isVisible.set(true); } }); const debounceScrollEnd = debounceCallback(() => machine.dispatch('SCROLL_END'), 100); effect([rootState.viewportEl, scrollbarState.isHorizontal], ([$viewportEl, $isHorizontal]) => { const scrollDirection = $isHorizontal ? 'scrollLeft' : 'scrollTop'; let unsub = noop; if ($viewportEl) { let prevScrollPos = $viewportEl[scrollDirection]; const handleScroll = () => { const scrollPos = $viewportEl[scrollDirection]; const hasScrollInDirectionChanged = prevScrollPos !== scrollPos; if (hasScrollInDirectionChanged) { machine.dispatch('SCROLL'); debounceScrollEnd(); } prevScrollPos = scrollPos; }; unsub = addEventListener($viewportEl, 'scroll', handleScroll); } return () => { unsub(); }; }); function scrollbarScrollAction(node) { const unsubBaseAction = baseAction(node)?.destroy; const unsubListeners = executeCallbacks(addEventListener(node, 'pointerenter', () => machine.dispatch('POINTER_ENTER')), addEventListener(node, 'pointerleave', () => machine.dispatch('POINTER_LEAVE'))); return { destroy() { unsubBaseAction?.(); unsubListeners(); }, }; } return scrollbarScrollAction; } /** * Creates the horizontal/x-axis scrollbar builder element. */ export function createScrollbarX(state, createAction) { const action = createAction(state); const { rootState, scrollbarState } = state; return makeElement(name('scrollbar'), { stores: [scrollbarState.sizes, rootState.options.dir, scrollbarState.isVisible], returned: ([$sizes, $dir, $isVisible]) => { return { style: styleToString({ position: 'absolute', bottom: 0, left: $dir === 'rtl' ? 'var(--melt-scroll-area-corner-width)' : 0, right: $dir === 'ltr' ? 'var(--melt-scroll-area-corner-width)' : 0, '--melt-scroll-area-thumb-width': `${getThumbSize($sizes)}px`, visibility: !$isVisible ? 'hidden' : undefined, }), 'data-state': $isVisible ? 'visible' : 'hidden', }; }, action: (node) => { const unsubAction = action(node)?.destroy; rootState.scrollbarXEl.set(node); rootState.scrollbarXEnabled.set(true); return { destroy() { unsubAction?.(); rootState.scrollbarXEl.set(null); }, }; }, }); } /** * Creates the vertical/y-axis scrollbar builder element. */ export function createScrollbarY(state, createAction) { const action = createAction(state); const { rootState, scrollbarState } = state; return makeElement(name('scrollbar'), { stores: [scrollbarState.sizes, rootState.options.dir, scrollbarState.isVisible], returned: ([$sizes, $dir, $isVisible]) => { return { style: styleToString({ position: 'absolute', top: 0, right: $dir === 'ltr' ? 0 : undefined, left: $dir === 'rtl' ? 0 : undefined, bottom: 'var(--melt-scroll-area-corner-height)', '--melt-scroll-area-thumb-height': `${getThumbSize($sizes)}px`, visibility: !$isVisible ? 'hidden' : undefined, }), 'data-state': $isVisible ? 'visible' : 'hidden', }; }, action: (node) => { const unsubAction = action(node)?.destroy; rootState.scrollbarYEl.set(node); rootState.scrollbarYEnabled.set(true); return { destroy() { unsubAction?.(); rootState.scrollbarYEl.set(null); }, }; }, }); } export function getScrollbarActionByType(type) { switch (type) { case 'always': return createBaseScrollbarAction; case 'auto': return createAutoScrollbarAction; case 'hover': return createHoverScrollbarAction; case 'scroll': return createScrollScrollbarAction; default: return createBaseScrollbarAction; } }