|
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) => { |
|
|
|
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; |
|
} |
|
|