@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
263 lines • 11.6 kB
JavaScript
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