UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

358 lines 15.9 kB
import { Shade, createComponent } from '@furystack/shades'; import { buildTransition, cssVariableTheme } from '../services/css-variable-theme.js'; import { promisifyAnimation } from '../utils/promisify-animation.js'; import { Icon } from './icons/icon.js'; import { close, imageBroken, rotate, search, zoomIn, zoomOut } from './icons/icon-definitions.js'; const LIGHTBOX_Z_INDEX = '2000'; const lightboxCleanupMap = new WeakMap(); const closeLightbox = async (backdrop) => { const storedCleanup = lightboxCleanupMap.get(backdrop); storedCleanup?.(); lightboxCleanupMap.delete(backdrop); const panel = backdrop.querySelector('.lightbox-panel'); if (panel) { try { await promisifyAnimation(panel, [{ opacity: 1 }, { opacity: 0 }], { duration: 150, easing: 'ease-out', fill: 'forwards', }); } catch { // Animation may not be available (e.g. in jsdom) } } backdrop.remove(); }; const createLightbox = (src, alt, groupSrcs, initialIndex) => { let zoom = 1; let rotation = 0; let currentIndex = initialIndex ?? 0; const getCurrentSrc = () => (groupSrcs ? groupSrcs[currentIndex].src : src); const getCurrentAlt = () => (groupSrcs ? groupSrcs[currentIndex].alt : alt); const updateTransform = (img) => { img.style.transform = `scale(${zoom}) rotate(${rotation}deg)`; }; const backdrop = (createComponent("div", { className: "lightbox-backdrop", style: { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: cssVariableTheme.action.backdrop, zIndex: LIGHTBOX_Z_INDEX, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'default', }, onclick: async (ev) => { if (ev.target.classList.contains('lightbox-backdrop')) { await closeLightbox(ev.target); } } }, createComponent("div", { className: "lightbox-panel", style: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', pointerEvents: 'none', } }, createComponent("img", { className: "lightbox-image", src: getCurrentSrc(), alt: getCurrentAlt(), style: { maxWidth: '85vw', maxHeight: 'calc(100vh - 100px)', objectFit: 'contain', transition: 'transform 0.2s ease', userSelect: 'none', pointerEvents: 'auto', }, draggable: false })), createComponent("div", { className: "lightbox-toolbar", style: { position: 'fixed', bottom: cssVariableTheme.spacing.lg, left: '50%', transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', gap: cssVariableTheme.spacing.sm, padding: `${cssVariableTheme.spacing.sm} ${cssVariableTheme.spacing.md}`, borderRadius: cssVariableTheme.shape.borderRadius.md, backgroundColor: cssVariableTheme.action.backdrop, backdropFilter: `blur(${cssVariableTheme.effects.blurMd})`, zIndex: '1', } }, createComponent("button", { className: "lightbox-zoom-in", title: "Zoom in", style: toolbarButtonStyle(), onclick: () => { zoom = Math.min(zoom + 0.25, 5); const img = backdrop.querySelector('.lightbox-image'); if (img) updateTransform(img); } }, createComponent(Icon, { icon: zoomIn, size: "small" })), createComponent("button", { className: "lightbox-zoom-out", title: "Zoom out", style: toolbarButtonStyle(), onclick: () => { zoom = Math.max(zoom - 0.25, 0.25); const img = backdrop.querySelector('.lightbox-image'); if (img) updateTransform(img); } }, createComponent(Icon, { icon: zoomOut, size: "small" })), createComponent("button", { className: "lightbox-rotate", title: "Rotate", style: toolbarButtonStyle(), onclick: () => { rotation = (rotation + 90) % 360; const img = backdrop.querySelector('.lightbox-image'); if (img) updateTransform(img); } }, createComponent(Icon, { icon: rotate, size: "small" })), createComponent("div", { style: { width: '1px', height: '20px', backgroundColor: cssVariableTheme.action.subtleBorder } }), createComponent("button", { className: "lightbox-close", title: "Close", style: toolbarButtonStyle(), onclick: async () => { await closeLightbox(backdrop); } }, createComponent(Icon, { icon: close, size: "small" }))), groupSrcs && groupSrcs.length > 1 ? (createComponent(createComponent, null, createComponent("button", { className: "lightbox-prev", title: "Previous image", style: { ...navButtonStyle(), position: 'fixed', left: cssVariableTheme.spacing.md, top: '50%', zIndex: '1', }, onclick: () => { currentIndex = (currentIndex - 1 + groupSrcs.length) % groupSrcs.length; const img = backdrop.querySelector('.lightbox-image'); if (img) { img.src = getCurrentSrc(); img.alt = getCurrentAlt(); zoom = 1; rotation = 0; updateTransform(img); } const counter = backdrop.querySelector('.lightbox-counter'); if (counter) counter.textContent = `${currentIndex + 1} / ${groupSrcs.length}`; } }, "\u2039"), createComponent("button", { className: "lightbox-next", title: "Next image", style: { ...navButtonStyle(), position: 'fixed', right: cssVariableTheme.spacing.md, top: '50%', zIndex: '1', }, onclick: () => { currentIndex = (currentIndex + 1) % groupSrcs.length; const img = backdrop.querySelector('.lightbox-image'); if (img) { img.src = getCurrentSrc(); img.alt = getCurrentAlt(); zoom = 1; rotation = 0; updateTransform(img); } const counter = backdrop.querySelector('.lightbox-counter'); if (counter) counter.textContent = `${currentIndex + 1} / ${groupSrcs.length}`; } }, "\u203A"), createComponent("div", { className: "lightbox-counter", style: { position: 'fixed', bottom: '72px', left: '50%', transform: 'translateX(-50%)', color: cssVariableTheme.text.secondary, fontSize: cssVariableTheme.typography.fontSize.md, zIndex: '1', } }, currentIndex + 1, " / ", groupSrcs.length))) : null)); document.body.appendChild(backdrop); const handleKeydown = (ev) => { if (ev.key === 'Escape') { void closeLightbox(backdrop); } if (groupSrcs && groupSrcs.length > 1) { if (ev.key === 'ArrowLeft') { const prevBtn = backdrop.querySelector('.lightbox-prev'); prevBtn?.click(); } if (ev.key === 'ArrowRight') { const nextBtn = backdrop.querySelector('.lightbox-next'); nextBtn?.click(); } } }; document.addEventListener('keydown', handleKeydown); lightboxCleanupMap.set(backdrop, () => { document.removeEventListener('keydown', handleKeydown); }); // Animate in (may not be available in test environments) const panel = backdrop.querySelector('.lightbox-panel'); if (panel) { void promisifyAnimation(panel, [{ opacity: 0 }, { opacity: 1 }], { duration: 200, easing: 'ease-out', fill: 'forwards', }).catch(() => { // Animation may not be available }); } }; const toolbarButtonStyle = () => ({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: cssVariableTheme.spacing.xl, height: cssVariableTheme.spacing.xl, border: 'none', background: 'transparent', color: cssVariableTheme.background.paper, borderRadius: cssVariableTheme.shape.borderRadius.sm, cursor: 'pointer', fontSize: cssVariableTheme.typography.fontSize.lg, padding: '0', transition: `background ${cssVariableTheme.transitions.duration.fast} ease`, }); const navButtonStyle = () => ({ transform: 'translateY(-50%)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '40px', height: '40px', border: 'none', background: cssVariableTheme.action.backdrop, color: cssVariableTheme.background.paper, borderRadius: cssVariableTheme.shape.borderRadius.full, cursor: 'pointer', fontSize: cssVariableTheme.typography.fontSize.xl, padding: '0', transition: `background ${cssVariableTheme.transitions.duration.fast} ease`, }); /** * Image component with preview lightbox, zoom/rotate, fallback, and lazy loading support. */ export const Image = Shade({ customElementName: 'shade-image', css: { display: 'inline-block', fontFamily: cssVariableTheme.typography.fontFamily, position: 'relative', overflow: 'hidden', borderRadius: cssVariableTheme.shape.borderRadius.sm, transition: buildTransition([ 'box-shadow', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default, ]), '&[data-preview] img': { cursor: 'pointer', }, '&[data-preview]:hover': { boxShadow: cssVariableTheme.shadows.md, }, '& img': { display: 'block', transition: buildTransition([ 'opacity', cssVariableTheme.transitions.duration.normal, cssVariableTheme.transitions.easing.default, ]), }, '& .image-fallback': { display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: `color-mix(in srgb, ${cssVariableTheme.palette.primary.main} 8%, ${cssVariableTheme.background.paper})`, color: cssVariableTheme.text.secondary, fontSize: cssVariableTheme.typography.fontSize.sm, fontFamily: cssVariableTheme.typography.fontFamily, border: `1px dashed ${cssVariableTheme.divider}`, borderRadius: cssVariableTheme.shape.borderRadius.sm, }, '& .image-preview-icon': { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%) scale(0.8)', opacity: '0', transition: buildTransition(['opacity', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default], ['transform', cssVariableTheme.transitions.duration.fast, cssVariableTheme.transitions.easing.default]), pointerEvents: 'none', backgroundColor: cssVariableTheme.action.backdrop, color: cssVariableTheme.background.paper, borderRadius: cssVariableTheme.shape.borderRadius.full, width: '40px', height: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: cssVariableTheme.typography.fontSize.lg, }, '&[data-preview]:hover .image-preview-icon': { opacity: '1', transform: 'translate(-50%, -50%) scale(1)', }, }, render: ({ props, useHostProps, useRef, useState }) => { const imageHostRef = useRef('imageHost'); const { src, alt = '', width, height, objectFit = 'cover', fallback, preview = false, lazy = false, style: styleOverrides, } = props; const [hasError, setHasError] = useState('hasError', false); const handleClick = () => { if (!preview) return; const host = imageHostRef.current?.closest('shade-image-group'); if (host) { const images = Array.from(host.querySelectorAll('shade-image')); const groupSrcs = images.map((img) => ({ src: img.getAttribute('data-src') || '', alt: img.getAttribute('data-alt') || '', })); const self = imageHostRef.current?.closest('shade-image'); const index = self ? images.indexOf(self) : -1; createLightbox(src, alt, groupSrcs, index >= 0 ? index : 0); return; } createLightbox(src, alt); }; useHostProps({ 'data-preview': preview ? '' : undefined, 'data-src': src, 'data-alt': alt, tabIndex: preview ? 0 : undefined, onkeydown: preview ? (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); handleClick(); } } : undefined, ...(styleOverrides ? { style: styleOverrides } : {}), }); return (createComponent("div", { ref: imageHostRef, style: { display: 'contents' } }, createComponent("img", { src: src, alt: alt, loading: lazy ? 'lazy' : undefined, style: { display: hasError ? 'none' : 'block', width: width || '100%', height: height || 'auto', objectFit, }, onclick: handleClick, onerror: () => setHasError(true) }), createComponent("div", { className: "image-fallback", style: { display: hasError ? 'flex' : 'none', width: width || '200px', height: height || '150px', } }, fallback || createComponent(Icon, { icon: imageBroken, size: "large" })), preview ? (createComponent("div", { className: "image-preview-icon" }, createComponent(Icon, { icon: search, size: "small" }))) : null)); }, }); /** * ImageGroup wraps multiple Image components and enables group preview navigation. * When one image is clicked, the lightbox shows navigation controls to browse all images in the group. */ export const ImageGroup = Shade({ customElementName: 'shade-image-group', css: { display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', }, render: ({ props, children, useHostProps }) => { const { gap = cssVariableTheme.spacing.sm } = props; useHostProps({ style: { gap } }); return createComponent(createComponent, null, children); }, }); //# sourceMappingURL=image.js.map