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