UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

263 lines 11.6 kB
import { Shade, createComponent } from '@furystack/shades'; import { buildTransition, cssVariableTheme } from '../services/css-variable-theme.js'; import { Icon } from './icons/icon.js'; import { chevronDown, chevronLeft, chevronRight, chevronUp } from './icons/icon-definitions.js'; const TRANSITION_MS = 400; /** * A carousel/slider component for cycling through a series of content. * Supports autoplay, dot indicators, slide/fade transitions, vertical orientation, * keyboard navigation, and swipe gestures. */ export const Carousel = Shade({ customElementName: 'shade-carousel', css: { display: 'block', position: 'relative', overflow: 'hidden', fontFamily: cssVariableTheme.typography.fontFamily, userSelect: 'none', '& .carousel-viewport': { position: 'relative', overflow: 'hidden', width: '100%', }, '&[data-vertical] .carousel-viewport': { height: '100%', }, // Slide-effect track: width: 100% gives a definite size so child // percentage flex-basis resolves correctly. '& .carousel-track': { display: 'flex', width: '100%', transition: `transform ${TRANSITION_MS}ms ease-in-out`, }, '&:not([data-vertical]) .carousel-track': { flexDirection: 'row', }, '&[data-vertical] .carousel-track': { flexDirection: 'column', height: '100%', }, '& .carousel-slide': { flexShrink: '0', overflow: 'hidden', }, '&[data-vertical] .carousel-slide': { height: '100%', }, // Fade-effect layers '& .carousel-fade-container': { position: 'relative', width: '100%', }, '& .carousel-fade-slide': { position: 'absolute', inset: '0', opacity: '0', pointerEvents: 'none', transition: `opacity ${TRANSITION_MS}ms ease-in-out`, }, '& .carousel-fade-slide[data-active]': { opacity: '1', pointerEvents: 'auto', }, // The first slide stays in flow to give the container its natural height '& .carousel-fade-slide:first-child': { position: 'relative', }, // Arrow buttons '& .carousel-arrow': { position: 'absolute', zIndex: '2', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: cssVariableTheme.spacing.xl, height: cssVariableTheme.spacing.xl, border: 'none', borderRadius: cssVariableTheme.shape.borderRadius.full, background: cssVariableTheme.action.backdrop, color: cssVariableTheme.palette.primary.mainContrast, cursor: 'pointer', fontSize: cssVariableTheme.typography.fontSize.lg, lineHeight: '1', transition: buildTransition([ 'background', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default, ]), }, '& .carousel-arrow:hover': { background: `color-mix(in srgb, ${cssVariableTheme.action.backdrop} 85%, black)`, }, // Horizontal arrows '&:not([data-vertical]) .carousel-arrow-prev': { top: '50%', left: cssVariableTheme.spacing.sm, transform: 'translateY(-50%)', }, '&:not([data-vertical]) .carousel-arrow-next': { top: '50%', right: cssVariableTheme.spacing.sm, transform: 'translateY(-50%)', }, // Vertical arrows '&[data-vertical] .carousel-arrow-prev': { top: cssVariableTheme.spacing.sm, left: '50%', transform: 'translateX(-50%)', }, '&[data-vertical] .carousel-arrow-next': { bottom: cssVariableTheme.spacing.sm, left: '50%', transform: 'translateX(-50%)', }, // Dots '& .carousel-dots': { position: 'absolute', zIndex: '2', display: 'flex', gap: cssVariableTheme.spacing.sm, }, '&:not([data-vertical]) .carousel-dots': { bottom: cssVariableTheme.spacing.sm, left: '50%', transform: 'translateX(-50%)', flexDirection: 'row', }, '&[data-vertical] .carousel-dots': { right: cssVariableTheme.spacing.sm, top: '50%', transform: 'translateY(-50%)', flexDirection: 'column', }, '& .carousel-dot': { width: '10px', height: '10px', borderRadius: cssVariableTheme.shape.borderRadius.full, border: 'none', padding: '0', cursor: 'pointer', background: `color-mix(in srgb, ${cssVariableTheme.background.paper} 50%, transparent)`, transition: buildTransition([ 'background', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default, ]), }, '& .carousel-dot[data-active]': { background: cssVariableTheme.background.paper, }, '& .carousel-dot:hover:not([data-active])': { background: `color-mix(in srgb, ${cssVariableTheme.background.paper} 75%, transparent)`, }, }, render: ({ props, useState, useDisposable, useHostProps }) => { const { slides, autoplay = false, autoplayInterval = 3000, dots = true, effect = 'slide', vertical = false, defaultActiveIndex = 0, onChange, style, } = props; useHostProps({ 'data-vertical': vertical ? '' : undefined, role: 'region', 'aria-roledescription': 'carousel', tabIndex: 0, ...(style ? { style: style } : {}), }); const initial = Math.max(0, Math.min(defaultActiveIndex, slides.length - 1)); const [current, setCurrent] = useState('activeIndex', initial); // Mutable ref for the autoplay timer to access the latest index const indexRef = useDisposable('indexRef', () => ({ current: initial, [Symbol.dispose]: () => { } })); indexRef.current = current; const goTo = (index) => { if (slides.length === 0) return; const clamped = ((index % slides.length) + slides.length) % slides.length; setCurrent(clamped); onChange?.(clamped); }; const goNext = () => goTo(current + 1); const goPrev = () => goTo(current - 1); // Keyboard navigation useHostProps({ onkeydown: (e) => { const nextKey = vertical ? 'ArrowDown' : 'ArrowRight'; const prevKey = vertical ? 'ArrowUp' : 'ArrowLeft'; if (e.key === nextKey) { e.preventDefault(); goNext(); } else if (e.key === prevKey) { e.preventDefault(); goPrev(); } }, ontouchstart: (e) => { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; }, ontouchend: (e) => { const dx = e.changedTouches[0].clientX - touchStartX; const dy = e.changedTouches[0].clientY - touchStartY; const threshold = 50; if (vertical) { if (dy < -threshold) goNext(); else if (dy > threshold) goPrev(); } else { if (dx < -threshold) goNext(); else if (dx > threshold) goPrev(); } }, }); // Touch / swipe support let touchStartX = 0; let touchStartY = 0; // Autoplay (uses mutable indexRef to avoid stale closure) if (autoplay && slides.length > 1) { useDisposable('autoplay-timer', () => { const timer = setInterval(() => goTo(indexRef.current + 1), autoplayInterval); return { [Symbol.dispose]: () => clearInterval(timer) }; }); } // Clone each slide so the same element array can be safely passed to // multiple Carousel instances (DOM nodes can only have one parent). const clonedSlides = slides.map((slide) => slide instanceof Node ? slide.cloneNode(true) : slide); const slideContent = effect === 'fade' ? (createComponent("div", { className: "carousel-fade-container" }, clonedSlides.map((slide, i) => { const isFirst = i === 0; const isActive = i === current; return (createComponent("div", { className: "carousel-fade-slide", role: "group", "aria-roledescription": "slide", "aria-label": `Slide ${i + 1} of ${slides.length}`, ...(isActive ? { 'data-active': '' } : {}), style: { position: isFirst ? 'relative' : 'absolute', inset: isFirst ? undefined : '0', opacity: isActive ? '1' : '0', pointerEvents: isActive ? 'auto' : 'none', transition: `opacity ${TRANSITION_MS}ms ease-in-out`, } }, slide)); }))) : (createComponent("div", { className: "carousel-track", style: { display: 'flex', flexDirection: vertical ? 'column' : 'row', transition: `transform ${TRANSITION_MS}ms ease-in-out`, transform: vertical ? `translateY(-${current * 100}%)` : `translateX(-${current * 100}%)`, } }, clonedSlides.map((slide, i) => (createComponent("div", { className: "carousel-slide", role: "group", "aria-roledescription": "slide", "aria-label": `Slide ${i + 1} of ${slides.length}`, style: { flexShrink: '0', width: '100%', overflow: 'hidden' } }, slide))))); const prevIcon = vertical ? chevronUp : chevronLeft; const nextIcon = vertical ? chevronDown : chevronRight; return (createComponent("div", { style: { display: 'contents' } }, createComponent("div", { className: "carousel-viewport" }, slideContent), slides.length > 1 && (createComponent("button", { className: "carousel-arrow carousel-arrow-prev", "aria-label": "Previous slide", onclick: (e) => { e.stopPropagation(); goPrev(); } }, createComponent(Icon, { icon: prevIcon, size: "small" }))), slides.length > 1 && (createComponent("button", { className: "carousel-arrow carousel-arrow-next", "aria-label": "Next slide", onclick: (e) => { e.stopPropagation(); goNext(); } }, createComponent(Icon, { icon: nextIcon, size: "small" }))), dots && slides.length > 1 && (createComponent("div", { className: "carousel-dots" }, slides.map((_, i) => (createComponent("button", { className: "carousel-dot", "aria-label": `Go to slide ${i + 1}`, ...(i === current ? { 'data-active': '' } : {}), onclick: (e) => { e.stopPropagation(); goTo(i); } }))))))); }, }); //# sourceMappingURL=carousel.js.map