react-overflow-slider
Version:
A customizable horizontal slider component for React with native scroll
273 lines (266 loc) • 9.47 kB
JavaScript
// src/slider/slider.tsx
import {
Children,
useCallback,
useEffect,
useRef,
useState
} from "react";
// src/slider/button.tsx
import { jsx } from "react/jsx-runtime";
var SliderButton = ({ left }) => {
return /* @__PURE__ */ jsx(
"div",
{
className: `react-overflow-slider-btn ${left ? "react-overflow-slider-btn--left" : ""} `,
children: /* @__PURE__ */ jsx(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 24 24",
width: "20",
height: "20",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
children: /* @__PURE__ */ jsx("polyline", { points: "8 4 16 12 8 20" })
}
)
}
);
};
var button_default = SliderButton;
// src/slider/utils/buttons-states.ts
function updateButtonsVisibility(scroller, setButtonsState) {
const { offsetWidth, scrollLeft, scrollWidth } = scroller;
setButtonsState((state) => ({
...state,
prevButtonVisible: scrollLeft > 0,
nextButtonVisible: scrollLeft + offsetWidth < scrollWidth
}));
}
function updateButtonsDisabled(setButtonsState, show) {
setButtonsState((state) => ({
...state,
prevButtonDisabled: show,
nextButtonDisabled: show
}));
}
// src/slider/utils/get-previous-offset.ts
function getPreviousOffset(scroller, items) {
const findFirstVisibleItemIndex = (scrollLeft, widths) => {
let first = 0, i = 0;
while (first < scrollLeft) {
first += widths[i];
i++;
}
return i;
};
const findPreviousItemIndex = (visibleScroll, firstVisible2, widths) => {
let i = firstVisible2, possible = 0;
if (widths[i - 1] >= visibleScroll) return i - 1;
do {
i--;
possible += widths[i];
} while (possible < visibleScroll && i > -1);
return i + 1;
};
const scrollerVisibleWidth = scroller.offsetWidth;
const scrollerScrollLeft = scroller.scrollLeft;
const itemsWidths = items.map((item) => item ? item.offsetWidth : 0);
const firstVisible = findFirstVisibleItemIndex(
scrollerScrollLeft,
itemsWidths
);
const prevItemIndex = findPreviousItemIndex(
scrollerVisibleWidth,
firstVisible,
itemsWidths
);
const itemsOffsetLeft = items.map((item) => item ? item.offsetLeft : 0);
return itemsOffsetLeft[prevItemIndex];
}
// src/slider/utils/get-next-offset.ts
function getNextOffset(scroller, items) {
const findNextItemIndex = (left, right, widths) => {
let availableWidth = 0;
let visibleWidth = 0;
let i = 0;
do {
availableWidth += widths[i];
i++;
} while (right >= availableWidth && i < widths.length);
i = i - 1;
for (let j = 0; j < i; j++) {
visibleWidth += widths[j];
}
return visibleWidth <= left ? i + 1 : i;
};
const scrollerVisibleWidth = scroller.offsetWidth;
const scrollerScrollLeft = scroller.scrollLeft;
const scrollerScrollRight = scrollerScrollLeft + scrollerVisibleWidth;
const itemsWidths = items.map((item) => item ? item.offsetWidth : 0);
const itemsOffsetLeft = items.map((item) => item ? item.offsetLeft : 0);
const nextItemIndex = findNextItemIndex(
scrollerScrollLeft,
scrollerScrollRight,
itemsWidths
);
const scrollerWidth = scroller.scrollWidth;
const possibleScroll = scrollerWidth - scrollerVisibleWidth + 1;
const nextItem = nextItemIndex !== items.length ? itemsOffsetLeft[nextItemIndex] : null;
return nextItem !== null && possibleScroll >= nextItem ? nextItem : possibleScroll;
}
// src/slider/utils/smooth-scroll.ts
var easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
var smoothScroll = (scroller, end, duration) => {
const start = scroller.scrollLeft;
const startTime = performance.now();
const step = (currentTime) => {
const elapsed = (currentTime - startTime) / duration;
const progress = Math.min(easeOutCubic(elapsed), 1);
scroller.scrollLeft = start + (end - start) * progress;
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
};
// src/slider/utils/scroll-to-first-visible.ts
function findFirstVisibleIndex(items) {
return items.findIndex((item) => {
if (!item) return false;
if (item.dataset.firstVisible === "true") return true;
if (item.firstElementChild?.getAttribute("data-first-visible") === "true")
return true;
return Array.from(item.children).some(
(el) => el.dataset.firstVisible === "true"
);
});
}
function scrollToFirstVisible(itemsRef, scrollToOffset, initialScrollDone) {
if (initialScrollDone.current) return;
const index = findFirstVisibleIndex(itemsRef.current);
if (index !== -1) {
const targetOffset = itemsRef.current[index]?.offsetLeft ?? 0;
scrollToOffset(targetOffset);
initialScrollDone.current = true;
}
}
// src/slider/slider.tsx
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
var OverflowSlider = ({
children,
prevButton,
nextButton,
gap = 0,
duration = 300
}) => {
const scrollerRef = useRef(null);
const itemsRef = useRef([]);
const scrollWidth = useRef(0);
const initialScrollDone = useRef(false);
const [buttonsState, setButtonsState] = useState({
prevButtonVisible: false,
nextButtonVisible: false,
prevButtonDisabled: false,
nextButtonDisabled: false
});
const checkShowButtons = useCallback(() => {
updateButtonsVisibility(scrollerRef.current, setButtonsState);
}, []);
const setDisabledButtons = useCallback(() => {
updateButtonsDisabled(setButtonsState, true);
setTimeout(
() => updateButtonsDisabled(setButtonsState, false),
duration + 50
);
}, [duration]);
const scrollToOffset = useCallback(
(offset) => {
smoothScroll(scrollerRef.current, offset, duration);
setDisabledButtons();
},
[duration, setDisabledButtons]
);
const handleScrollPrevious = useCallback(() => {
const offset = getPreviousOffset(scrollerRef.current, itemsRef.current);
scrollToOffset(offset);
}, [scrollToOffset]);
const handleScrollNext = useCallback(() => {
const offset = getNextOffset(scrollerRef.current, itemsRef.current);
scrollToOffset(offset);
}, [scrollToOffset]);
const scrollToFirstVisibleCallback = useCallback(() => {
scrollToFirstVisible(itemsRef, scrollToOffset, initialScrollDone);
}, [scrollToOffset]);
useEffect(() => {
const handleResize = checkShowButtons;
window.addEventListener("resize", handleResize);
checkShowButtons();
return () => window.removeEventListener("resize", handleResize);
}, [checkShowButtons]);
useEffect(() => {
const currentScrollWidth = scrollerRef.current?.scrollWidth ?? 0;
if (currentScrollWidth !== scrollWidth.current) {
scrollWidth.current = currentScrollWidth;
checkShowButtons();
scrollToFirstVisibleCallback();
}
}, [children, checkShowButtons, scrollToFirstVisibleCallback]);
return /* @__PURE__ */ jsx2("div", { className: "react-overflow-slider-container", children: /* @__PURE__ */ jsxs("div", { className: "react-overflow-slider", children: [
/* @__PURE__ */ jsx2("div", { className: "react-overflow-slider__btn-container", children: /* @__PURE__ */ jsx2(
"button",
{
type: "button",
"aria-label": "Scroll to previous",
onClick: handleScrollPrevious,
className: `react-overflow-slider__btn react-overflow-slider__btn--prev
${buttonsState.prevButtonDisabled ? "react-overflow-slider__btn--disabled" : ""}
${buttonsState.prevButtonVisible ? "react-overflow-slider__btn--visible" : ""}`,
style: { transition: `${duration}ms` },
children: prevButton || /* @__PURE__ */ jsx2(button_default, { left: true })
}
) }),
/* @__PURE__ */ jsx2(
"div",
{
ref: scrollerRef,
className: "react-overflow-slider__scroller",
onScroll: checkShowButtons,
children: /* @__PURE__ */ jsx2("div", { className: "react-overflow-slider__body", children: Children.map(children, (child, i) => {
const isObject = typeof child === "object" && child !== null;
const dataFirstVisible = isObject && "props" in child && child.props?.["data-first-visible"];
return /* @__PURE__ */ jsx2(
"div",
{
ref: (ref) => itemsRef.current[i] = ref,
className: "react-overflow-slider__item",
"data-first-visible": dataFirstVisible,
style: gap && i < children.length - 1 ? { paddingRight: gap } : {},
children: child
}
);
}) })
}
),
/* @__PURE__ */ jsx2("div", { className: "react-overflow-slider__btn-container", children: /* @__PURE__ */ jsx2(
"button",
{
type: "button",
"aria-label": "Scroll to next",
onClick: handleScrollNext,
className: `react-overflow-slider__btn react-overflow-slider__btn--next
${buttonsState.nextButtonDisabled ? "react-overflow-slider__btn--disabled" : ""}
${buttonsState.nextButtonVisible ? "react-overflow-slider__btn--visible" : ""}`,
style: { transition: `${duration}ms` },
children: nextButton || /* @__PURE__ */ jsx2(button_default, {})
}
) })
] }) });
};
var slider_default = OverflowSlider;
export {
slider_default as OverflowSlider
};
//# sourceMappingURL=index.mjs.map