import { Accessor, batch, children, createComputed, createEffect, createMemo, createSelector, createSignal, For, JSX, mergeProps, onCleanup, splitProps, } from "solid-js"; import {Anchor, callEventHandler, createControlledProp, resolveClasses} from "solid-bootstrap-core"; import classNames from "./classnames"; import CarouselCaption from "./CarouselCaption"; import CarouselItem, {CarouselItemReturnType} from "./CarouselItem"; import {useBootstrapPrefix, useIsRTL} from "./ThemeProvider"; import transitionEndListener from "./transitionEndListener"; import triggerBrowserReflow from "./triggerBrowserReflow"; import {BsPrefixProps, BsPrefixRefForwardingComponent} from "./helpers"; import {Dynamic} from "solid-js/web"; import TransitionWrapper from "./TransitionWrapper"; import {TransitionStatus} from "solid-react-transition"; export type CarouselVariant = "dark"; export interface CarouselProps extends BsPrefixProps, Omit, "onSelect" | "ref"> { slide?: boolean; fade?: boolean; controls?: boolean; indicators?: boolean; indicatorLabels?: string[]; activeIndex?: number; onSelect?: (eventKey: number, event: Record | null) => void; defaultActiveIndex?: number; onSlide?: (eventKey: number, direction: "start" | "end") => void; onSlid?: (eventKey: number, direction: "start" | "end") => void; interval?: number; keyboard?: boolean; pause?: "hover" | false; wrap?: boolean; touch?: boolean; prevIcon?: JSX.Element; prevLabel?: JSX.Element; nextIcon?: JSX.Element; nextLabel?: JSX.Element; variant?: CarouselVariant; ref?: (r: { element: () => HTMLElement; prev: (event?: any) => void; next: (event?: any) => void; }) => void; } type ItemWithClasses = CarouselItemReturnType & { prevClasses: string; }; const SWIPE_THRESHOLD = 40; const defaultProps: Partial = { as: "div", slide: true, fade: false, controls: true, indicators: true, indicatorLabels: [], defaultActiveIndex: 0, interval: 5000, keyboard: true, pause: "hover" as CarouselProps["pause"], wrap: true, touch: true, prevLabel: "Previous", nextLabel: "Next", }; function isVisible(element: HTMLElement) { if ( !element || !element.style || !element.parentNode || // @ts-ignore !element.parentNode.style ) { return false; } const elementStyle = getComputedStyle(element); return ( elementStyle.display !== "none" && elementStyle.visibility !== "hidden" && getComputedStyle(element.parentNode as HTMLElement).display !== "none" ); } const Carousel: BsPrefixRefForwardingComponent<"div", CarouselProps> = (p: CarouselProps) => { const [local, props] = splitProps(mergeProps(defaultProps, p), [ "as", "bsPrefix", "slide", "fade", "controls", "indicators", "indicatorLabels", "activeIndex", "defaultActiveIndex", "onSelect", "onSlide", "onSlid", "interval", "keyboard", "onKeyDown", "pause", "onMouseOver", "onMouseOut", "wrap", "touch", "onTouchStart", "onTouchMove", "onTouchEnd", "prevIcon", "prevLabel", "nextIcon", "nextLabel", "variant", "class", "children", "ref", ]); const [activeIndex, onSelect] = createControlledProp( () => local.activeIndex, () => local.defaultActiveIndex, local.onSelect, ); const prefix = useBootstrapPrefix(local.bsPrefix, "carousel"); const isRTL = useIsRTL(); const resolvedChildren = children(() => local.children); const items = createMemo(() => { const c = resolvedChildren(); return (Array.isArray(c) ? c : [c]) as unknown as CarouselItemReturnType[]; }); const [nextDirectionRef, setNextDirectionRef] = createSignal(null); const [direction, setDirection] = createSignal("next"); const [paused, setPaused] = createSignal(false); const [isSliding, setIsSliding] = createSignal(false); const [renderedActiveIndex, setRenderedActiveIndex] = createSignal(activeIndex() || 0); createComputed(() => batch(() => { if (!isSliding() && activeIndex() !== renderedActiveIndex()) { if (nextDirectionRef()) { setDirection(nextDirectionRef()!); } else { setDirection((activeIndex() || 0) > renderedActiveIndex() ? "next" : "prev"); } if (local.slide) { setIsSliding(true); } setRenderedActiveIndex(activeIndex() || 0); } }), ); createEffect(() => { if (nextDirectionRef()) { setNextDirectionRef(null); } }); // Iterate to grab all of the children's interval values // (and count them, too) const activeChildInterval = createMemo(() => { for (let index = 0; index < items().length; index++) { if (index === activeIndex()) { const item = items()[index]; return item.interval; } } return undefined; }); const prev = (event?: any) => { if (isSliding()) { return; } let nextActiveIndex = renderedActiveIndex() - 1; if (nextActiveIndex < 0) { if (!local.wrap) { return; } nextActiveIndex = items().length - 1; } setNextDirectionRef("prev"); onSelect?.(nextActiveIndex, event); }; // This is used in the setInterval, so it should not invalidate. const next = (event?: any) => { if (isSliding()) { return; } let nextActiveIndex = renderedActiveIndex() + 1; if (nextActiveIndex >= items().length) { if (!local.wrap) { return; } nextActiveIndex = 0; } setNextDirectionRef("next"); onSelect?.(nextActiveIndex, event); }; const [elementRef, setElementRef] = createSignal(); const mergedRef = (ref: HTMLElement) => { setElementRef(ref); if (typeof local.ref === "function") { local.ref({ get element() { return elementRef(); }, prev, next, } as any); } }; // This is used in the setInterval, so it should not invalidate. const nextWhenVisible = () => { if (!document.hidden && isVisible(elementRef()!)) { if (isRTL()) { prev(); } else { next(); } } }; const slideDirection = createMemo(() => (direction() === "next" ? "start" : "end")); createEffect(() => { if (local.slide) { // These callbacks will be handled by the callbacks. return; } local.onSlide?.(renderedActiveIndex(), slideDirection()); local.onSlid?.(renderedActiveIndex(), slideDirection()); }); const orderClass = createMemo(() => `${prefix}-item-${direction()}`); const directionalClass = createMemo(() => `${prefix}-item-${slideDirection()}`); const handleEnter = (node: any) => { triggerBrowserReflow(node!); local.onSlide?.(renderedActiveIndex(), slideDirection()); }; const handleEntered = () => { setIsSliding(false); local.onSlid?.(renderedActiveIndex(), slideDirection()); }; const handleKeyDown = (event: KeyboardEvent) => { if ( local.keyboard && !/input|textarea/i.test( //@ts-ignore event.target.tagName, ) ) { switch (event.key) { case "ArrowLeft": event.preventDefault(); if (isRTL()) { next(event); } else { prev(event); } return; case "ArrowRight": event.preventDefault(); if (isRTL()) { prev(event); } else { next(event); } return; default: } } callEventHandler(local.onKeyDown, event); }; const handleMouseOver = (event: Event) => { if (local.pause === "hover") { setPaused(true); } callEventHandler(local.onMouseOver, event); }; const handleMouseOut = (event: Event) => { setPaused(false); callEventHandler(local.onMouseOut, event); }; let touchStartXRef: number; let touchDeltaXRef: number; const handleTouchStart = (event: TouchEvent) => { touchStartXRef = event.touches[0].clientX; touchDeltaXRef = 0; if (local.pause === "hover") { setPaused(true); } callEventHandler(local.onTouchStart, event); }; const handleTouchMove = (event: TouchEvent) => { if (event.touches && event.touches.length > 1) { touchDeltaXRef = 0; } else { touchDeltaXRef = event.touches[0].clientX - touchStartXRef; } callEventHandler(local.onTouchMove, event); }; const handleTouchEnd = (event: TouchEvent) => { if (local.touch) { const touchDeltaX = touchDeltaXRef; if (Math.abs(touchDeltaX) > SWIPE_THRESHOLD) { if (touchDeltaX > 0) { prev(event); } else { next(event); } } } if (local.pause === "hover") { let touchUnpauseTimeout = window.setTimeout(() => { setPaused(false); }, local.interval); onCleanup(() => { window.clearTimeout(touchUnpauseTimeout); }); } callEventHandler(local.onTouchEnd, event); }; const shouldPlay = createMemo(() => local.interval != null && !paused() && !isSliding()); const [intervalHandleRef, setIntervalHandleRef] = createSignal(); createEffect(() => { if (!shouldPlay()) { return undefined; } const nextFunc = isRTL() ? prev : next; setIntervalHandleRef( window.setInterval( document.visibilityState ? nextWhenVisible : nextFunc, activeChildInterval() ?? local.interval ?? undefined, ), ); onCleanup(() => { if (intervalHandleRef() !== null) { clearInterval(intervalHandleRef()); } }); }); const isActive = createSelector(renderedActiveIndex); return ( {local.indicators && (
{(_, index: Accessor) => (
)}
each={items() as ItemWithClasses[]}> {(child, index: Accessor) => { const el = typeof child.item === "function" ? child.item() : child.item; return local.slide ? ( { ((status: TransitionStatus, innerProps: {ref: (el: HTMLElement) => void}) => { innerProps.ref(el); const newClasses = classNames( isActive(index()) && status !== "entered" && orderClass(), (status === "entered" || status === "exiting") && "active", (status === "entering" || status === "exiting") && directionalClass(), ); resolveClasses(el, child.prevClasses, newClasses); child.prevClasses = newClasses; return el; }) as unknown as JSX.FunctionElement } ) : ( () => { createEffect(() => { el.classList.toggle("active", isActive(index())); }); return el; } ); }}
{local.controls && ( <> {(local.wrap || activeIndex() !== 0) && ( {local.prevIcon ?? )} {(local.wrap || activeIndex() !== items().length - 1) && ( {local.nextIcon ?? )} )}
); }; export default Object.assign(Carousel, { Caption: CarouselCaption, Item: CarouselItem, });