dbl-components
Version:
Framework based on bootstrap 5
383 lines (344 loc) • 14.1 kB
JSX
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { eventHandler } from 'dbl-utils';
import { ptClasses } from "../prop-types";
import useEventHandler from "../hooks/use-event-handler";
import Container from "./container";
let timeoutDispatchPosition;
/**
* Custom horizontal scroll component with a custom scrollbar.
* @param {Object} props - Component properties.
* @param {string} props.breakpoint - The breakpoint value for responsive design.
* @param {string} props.orientation - The orientation of the scroll (horizontal/vertical).
* @param {number} props.width - The width of the scroll container.
* @param {number} props.height - The height of the scroll container.
* @param {Array<string>} props.scrollTrackClasses - Array of CSS classes for the scroll track.
* @param {Array<string>} props.scrollBarClasses - Array of CSS classes for the scroll bar.
* @param {Object} props.scrollTrackStyle - Inline styles for the scroll track.
* @param {Object} props.scrollBarStyle - Inline styles for the scroll bar.
* @param {React.ReactNode} props.children - Child elements to be rendered inside the scroll container.
* @returns {React.Component} - The ScrollX component with a custom scrollbar.
*/
function ScrollXNode({
name,
scrollTrackClasses = [], // Scroll track classes.
scrollBarClasses = [], // Scroll bar classes.
scrollTrackStyle = {}, // Inline styles for the scroll track.
scrollBarStyle = {}, // Inline styles for the scroll bar.
breakpoint,
orientation,
width,
height,
children, // Child elements of the component.
}) {
const scrollBarPosition = useCallback((percentagePosition) => {
if (!(scrollTrackRef.current && scrollBarRef.current)) return;
const scrollBarrPercentage = percentagePosition
* (scrollTrackRef.current.clientWidth - scrollBarRef.current.clientWidth);
setScrollBarLeft(scrollBarrPercentage / scrollTrackRef.current.clientWidth);
});
const containerPosition = useCallback((step) => {
const newTranslate = Math.min(Math.max(initialTranslate.current + step, -diffContentWidth), 0);
initialTranslate.current = newTranslate;
const newPercentage = Math.abs(newTranslate / diffContentWidth);
setPercentage(newPercentage);
setTranslate(newTranslate);
scrollBarPosition(newPercentage);
clearTimeout(timeoutDispatchPosition);
timeoutDispatchPosition = setTimeout(() => {
eventHandler.dispatch(name, { [name]: { position: Math.abs(newTranslate), percentage: newPercentage, size: diffContentWidth } });
}, 660);
});
const updateScroll = useCallback((update) => {
if (update.position !== undefined) {
initialTranslate.current = 0;
containerPosition(update.position);
}
if (update.percentage !== undefined) {
initialTranslate.current = 0;
const position = -update.percentage * diffContentWidth;
containerPosition(position);
}
if (update.resize) {
const container = containerRef.current;
const newContentWidth = container.scrollWidth;
const newDiffContentWidth = container.scrollWidth - container.clientWidth;
setContentWidth(newContentWidth);
setDiffContentWidth(newDiffContentWidth);
initialTranslate.current = 0;
const position = -percentage * newDiffContentWidth;
containerPosition(position);
}
});
/**
* Handles the scroll event on the container.
*/
const handleScroll = useCallback(() => {
if (!scrollTrackRef.current) return;
const container = containerRef.current;
const scrollLeft = container.scrollLeft;
const scrollBarPosition = (scrollLeft / diffContentWidth) * (scrollTrackRef.current.clientWidth - scrollBarRef.current.clientWidth);
setScrollBarLeft(scrollBarPosition / scrollTrackRef.current.clientWidth);
});
/**
* Handles the wheel event for scrolling.
* @param {Event} event - The wheel event.
*/
const handleWheel = useCallback((event) => {
let speed = event.deltaX;
if (speed === 0 && event.shiftKey) {
event.preventDefault();
speed = -event.deltaY / Math.abs(event.deltaY);
}
speed *= 10;
const container = containerRef.current;
if (!container) return;
const containerRatio = container.scrollWidth / container.clientWidth;
containerPosition(speed * containerRatio);
});
/**
* Handles the start of a drag event.
* @param {Event} e - The drag event.
*/
const handleDragStart = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const initialPosValue = e.clientX || (e.touches ? e.touches[0].clientX : 0);
containerRef.current.removeEventListener('scroll', handleScroll);
document.addEventListener('mousemove', handleDrag);
document.addEventListener('touchmove', handleDrag);
document.addEventListener('mouseup', handleDragEnd);
document.addEventListener('touchend', handleDragEnd);
initialMouseX.current = initialPosValue;
initialScrollLeft.current = scrollBarLeft;
});
/**
* Handles the scrollBar drag event.
* @param {Event} e - The drag event.
*/
const handleDrag = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const posValue = e.clientX || (e.touches ? e.touches[0].clientX : 0);
const initialValue = initialMouseX.current;
if (!containerRef.current || posValue === 0) return;
if (Math.abs(posValue - initialValue) < 40) return;
const barPercent = (scrollBarRef.current.clientWidth / scrollTrackRef.current.clientWidth);
const deltaX = (posValue - initialValue) * barPercent;
containerPosition(-deltaX);
});
/**
* Handles the end of a drag event.
* @param {Event} e - The drag event.
*/
const handleDragEnd = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
containerRef.current.addEventListener('scroll', handleScroll);
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('touchmove', handleDrag);
document.removeEventListener('mouseup', handleDragEnd);
document.removeEventListener('touchend', handleDragEnd);
initialMouseX.current = 0;
initialScrollLeft.current = 0;
});
/**
* Handles the touch start event for touch devices.
* @param {Event} e - The touch event.
*/
const handleTouchStart = useCallback((e) => {
initialTouchX.current = e.touches[0].clientX;
setIsTouching(true);
});
/**
* Handles the touch move event for touch devices.
* @param {Event} e - The touch event.
*/
const handleTouchMove = useCallback((event) => {
const vector = event.touches[0].clientX - initialTouchX.current;
initialTouchX.current = event.touches[0].clientX;
containerPosition(vector);
});
/**
* Handles the touch end event for touch devices.
* @param {Event} e - The touch event.
*/
const handleTouchEnd = useCallback((e) => {
setIsTouching(false);
});
const containerRef = useRef(null); // Reference to the scroll container.
const scrollTrackRef = useRef(null); // Reference to the scroll track.
const scrollBarRef = useRef(null); // Reference to the scroll bar.
const initialMouseX = useRef(null); // Initial mouse X position for drag events.
const initialScrollLeft = useRef(null); // Left position of the scroll bar.
const initialTouchX = useRef(null); // Initial touch X position for touch events.
const initialTranslate = useRef(0); // Initial transform.
// State variables.
const [isTouchDevice, setIsTouchDevice] = useState(false); // Flag for touch device detection.
const [isTouching, setIsTouching] = useState(false); // Flag for touch device detection.
const [scrollBarLeft, setScrollBarLeft] = useState(0); // Left position of the scroll bar.
const [wScrollBar, setWScrollBar] = useState(0); // Width of the scroll bar.
const [showBar, setShowBar] = useState(false); // Flag to show or hide the scroll bar.
const [contentWidth, setContentWidth] = useState(0); // Width of the scrollable content.
const [diffContentWidth, setDiffContentWidth] = useState(0); // difference Width of the scrollable content.
const [translate, setTranslate] = useState(initialTranslate.current); // Transform value for the scroll bar.
const [percentage, setPercentage] = useState(initialTranslate.current); // percentage value for the scroll bar.
// Custom hook to handle events
useEventHandler([
[`update.${name}`, updateScroll]
], [name, ScrollContainer.jsClass].join('-'));
// Effect to detect if the device supports touch events.
useLayoutEffect(() => {
setIsTouchDevice('ontouchstart' in window);
setScrollBarLeft(0);
setTranslate(0);
}, []);
// Effect to update container and scroll bar properties.
useEffect(() => {
if (containerRef.current) {
const container = containerRef.current;
const contentWidth = container.scrollWidth;
const containerWidth = container.clientWidth;
const diffContentWidth = container.scrollWidth - container.clientWidth;
const isContentOverflowing = contentWidth > containerWidth;
setShowBar(isContentOverflowing && (!isTouchDevice || isTouching));
setContentWidth(contentWidth);
setDiffContentWidth(diffContentWidth);
const wp = (containerWidth / contentWidth) * 100;
setWScrollBar(Math.max(Math.min(wp, 90), 20)); // Ensure scrollbar is not too small
eventHandler.dispatch(name, {
[name]: {
position: Math.abs(initialTranslate.current),
percentage: Math.abs(initialTranslate.current / diffContentWidth),
size: diffContentWidth
}
});
}
}, [contentWidth, breakpoint, orientation, width, height, isTouchDevice, isTouching]);
// useEffect to add/remove event listeners
useEffect(() => {
const container = containerRef.current;
container.addEventListener("wheel", handleWheel, { passive: false });
container.addEventListener('touchstart', handleTouchStart);
container.addEventListener('touchmove', handleTouchMove);
container.addEventListener('touchend', handleTouchEnd);
return () => {
container.removeEventListener("wheel", handleWheel);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
}
}, [contentWidth]);
// Effect to handle scroll events.
useEffect(() => {
const container = containerRef.current;
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [wScrollBar, contentWidth]);
const stc = [scrollTrackClasses]; // Scroll track classes.
const sbc = ['cursor-pointer', scrollBarClasses]; // Scroll bar classes.
const fc = (input) => [input].flat().filter(Boolean).join(' '); // Function to flatten and filter class names.
const styleSbc = {
height: '100%',
backgroundColor: '#888',
...scrollBarStyle,
width: `${wScrollBar}%`,
marginLeft: `${scrollBarLeft * 100}%`
};
return (
<>
<div
style={{
overflowX: 'clip'
}}
>
<div
ref={containerRef}
id={`${name}-container`}
style={{
paddingBottom: "2rem",
marginBottom: "-2rem",
transform: `translate(${translate}px)`,
"--dbl-scroll-x-position": `${Math.abs(translate)}px`
}}
>
{children}
</div>
</div>
{showBar && (
<div
className={fc(stc)}
ref={scrollTrackRef}
style={{
bottom: 0,
left: 0,
right: 0,
height: '20px',
backgroundColor: '#ccc',
...scrollTrackStyle,
position: 'sticky',
}}
>
<div
ref={scrollBarRef}
className={fc(sbc)}
style={styleSbc}
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
role="scrollbar"
aria-controls={`${name}-container`}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={percentage * 100}
tabIndex="0"
/>
</div>
)}
</>
);
}
ScrollXNode.propTypes = {
name: PropTypes.string,
breakpoint: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
orientation: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
width: PropTypes.number,
height: PropTypes.number,
scrollTrackClasses: ptClasses,
scrollBarClasses: ptClasses,
scrollTrackStyle: PropTypes.object,
scrollBarStyle: PropTypes.object,
children: PropTypes.node
}
/**
* Container class for the ScrollContainer component.
*/
export default class ScrollContainer extends Container {
static jsClass = 'ScrollContainer';
/**
* Renders the ScrollXNode component with the provided properties.
* @param {React.ReactNode} children - Child elements to be rendered inside the scroll container.
* @returns {React.ReactElement} - The rendered ScrollXNode component.
*/
content(children = this.props.children) {
const {
scrollTrackClasses,
scrollBarClasses,
scrollTrackStyle,
scrollBarStyle,
} = this.props;
return <ScrollXNode
{...{
name: this.props.name,
breakpoint: this.breakpoint,
orientation: this.orientation,
width: this.width,
height: this.height,
scrollTrackClasses,
scrollBarClasses,
scrollTrackStyle,
scrollBarStyle,
children
}}
/>
}
}