@expofp/ui
Advanced tools
| import './Gallery.scss'; | ||
| import React from 'react'; | ||
| import type { GalleryImage } from './types'; | ||
| export interface GalleryProps { | ||
| images: GalleryImage[]; | ||
| leading?: boolean; | ||
| closeLabel: string; | ||
| zoomInLabel: string; | ||
| zoomOutLabel: string; | ||
| nextSlideLabel: string; | ||
| prevSlideLabel: string; | ||
| fullscreenLabel: string; | ||
| itemLabel: (index: number) => string; | ||
| renderTarget?: HTMLElement | null; | ||
| onImageLoadHeightUpdate?: () => void; | ||
| /** Fired with the index of the slide shown fullscreen, on open and on slide change. */ | ||
| onSlideActivate?: (index: number) => void; | ||
| onOpenGallery?: () => void; | ||
| onCloseGallery?: () => void; | ||
| className?: string; | ||
| } | ||
| declare const Gallery: (props: GalleryProps) => React.JSX.Element; | ||
| export default Gallery; | ||
| //# sourceMappingURL=Gallery.d.ts.map |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import './Gallery.scss'; | ||
| import classNames from 'classnames'; | ||
| import React, { useState } from 'react'; | ||
| import GalleryBadges from './GalleryBadges/GalleryBadges'; | ||
| import GalleryItem from './GalleryItem/GalleryItem'; | ||
| import GalleryModal from './GalleryModal/GalleryModal'; | ||
| const Gallery = (props) => { | ||
| const { images, leading = false, closeLabel, zoomInLabel, zoomOutLabel, nextSlideLabel, prevSlideLabel, fullscreenLabel, itemLabel, renderTarget, onImageLoadHeightUpdate, onSlideActivate, onOpenGallery, onCloseGallery, className, } = props; | ||
| const [isModalOpen, setIsModalOpen] = useState(false); | ||
| const [currentSlideIndex, setCurrentSlideIndex] = useState(0); | ||
| const openModal = (initialSlideIndex) => { | ||
| // No portal target, no modal — and no opening to report either. | ||
| if (!renderTarget) | ||
| return; | ||
| setCurrentSlideIndex(initialSlideIndex); | ||
| setIsModalOpen(true); | ||
| onOpenGallery?.(); | ||
| }; | ||
| const closeModal = () => { | ||
| setIsModalOpen(false); | ||
| onCloseGallery?.(); | ||
| }; | ||
| return (_jsxs(React.Fragment, { children: [_jsxs("div", { className: classNames('efp-gallery', { | ||
| 'efp-gallery--leading': leading, | ||
| }, className), children: [_jsx("div", { className: "efp-gallery__wrapper", children: leading ? (_jsx(GalleryItem, { url: images[0].thumbnailUrl, index: 0, itemLabel: itemLabel, leading: leading, autoHeight: true, fillMode: "contain", onClick: () => openModal(0), onImageLoadHeightUpdate: onImageLoadHeightUpdate })) : (images.map((image, i) => (_jsx(GalleryItem, { url: image.thumbnailUrl, index: i, itemLabel: itemLabel, position: "top", fillMode: "cover", onClick: () => openModal(i), onImageLoadHeightUpdate: onImageLoadHeightUpdate }, image.thumbnailUrl + i)))) }), !leading && (_jsx(GalleryBadges, { fullscreenLabel: fullscreenLabel, onFullscreen: () => openModal(0), count: images.length }))] }), isModalOpen && renderTarget && (_jsx(GalleryModal, { className: className, images: images, leading: leading, initialSlideIndex: currentSlideIndex, closeLabel: closeLabel, zoomInLabel: zoomInLabel, zoomOutLabel: zoomOutLabel, nextSlideLabel: nextSlideLabel, prevSlideLabel: prevSlideLabel, itemLabel: itemLabel, renderTarget: renderTarget, onSlideActivate: onSlideActivate, onClose: closeModal }))] })); | ||
| }; | ||
| export default Gallery; |
| export interface GalleryBadgesProps { | ||
| count: number; | ||
| fullscreenLabel: string; | ||
| onFullscreen: () => void; | ||
| } | ||
| declare const GalleryBadges: ({ count, fullscreenLabel, onFullscreen }: GalleryBadgesProps) => import("react").JSX.Element; | ||
| export default GalleryBadges; | ||
| //# sourceMappingURL=GalleryBadges.d.ts.map |
| import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| const GalleryBadges = ({ count, fullscreenLabel, onFullscreen }) => { | ||
| return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "efp-gallery__badge efp-gallery__badge-count", children: [_jsx("i", { className: "icon-image", "aria-hidden": "true" }), _jsx("span", { children: count })] }), _jsx("button", { type: "button", className: "efp-gallery__badge efp-gallery__badge-fullscreen", "aria-label": fullscreenLabel, title: fullscreenLabel, onClick: onFullscreen, children: _jsx("i", { className: "icon-maximize", "aria-hidden": "true" }) })] })); | ||
| }; | ||
| export default GalleryBadges; |
| export interface GalleryControlsProps { | ||
| closeLabel: string; | ||
| zoomInLabel: string; | ||
| zoomOutLabel: string; | ||
| zoomIn: () => void; | ||
| zoomOut: () => void; | ||
| onClose: () => void; | ||
| } | ||
| declare const GalleryControls: ({ closeLabel, zoomInLabel, zoomOutLabel, zoomIn, zoomOut, onClose, }: GalleryControlsProps) => import("react").JSX.Element; | ||
| export default GalleryControls; | ||
| //# sourceMappingURL=GalleryControls.d.ts.map |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| const GalleryControls = ({ closeLabel, zoomInLabel, zoomOutLabel, zoomIn, zoomOut, onClose, }) => { | ||
| return (_jsxs("div", { className: "efp-gallery-slider__controls", children: [_jsx("button", { type: "button", className: "efp-gallery-slider__btn efp-gallery-slider__btn--close", title: closeLabel, "aria-label": closeLabel, onClick: onClose, children: _jsx("i", { className: "icon-close", "aria-hidden": "true" }) }), _jsx("button", { type: "button", className: "efp-gallery-slider__btn efp-gallery-slider__btn--zoom-in", title: zoomInLabel, "aria-label": zoomInLabel, onClick: () => zoomIn(), children: _jsx("i", { className: "icon-zoom-in", "aria-hidden": "true" }) }), _jsx("button", { type: "button", className: "efp-gallery-slider__btn efp-gallery-slider__btn--zoom-out", title: zoomOutLabel, "aria-label": zoomOutLabel, onClick: () => zoomOut(), children: _jsx("i", { className: "icon-zoom-out", "aria-hidden": "true" }) })] })); | ||
| }; | ||
| export default GalleryControls; |
| type FillMode = 'cover' | 'contain'; | ||
| export interface GalleryImgProps { | ||
| url: string; | ||
| fillMode?: FillMode; | ||
| leading?: boolean; | ||
| autoHeight?: boolean; | ||
| fullscreen?: boolean; | ||
| position?: 'center' | 'top'; | ||
| onImageLoadHeightUpdate?: () => void; | ||
| } | ||
| declare const GalleryImg: ({ url, autoHeight, position, fullscreen, leading, fillMode, onImageLoadHeightUpdate, }: GalleryImgProps) => import("react").JSX.Element; | ||
| export default GalleryImg; | ||
| //# sourceMappingURL=GalleryImg.d.ts.map |
| import { jsx as _jsx } from "react/jsx-runtime"; | ||
| import { useCallback, useEffect, useRef } from 'react'; | ||
| import GalleryPreLoader from '../GalleryPreLoader'; | ||
| const GalleryImg = ({ url, autoHeight = false, position = 'center', fullscreen = false, leading = false, fillMode = 'contain', onImageLoadHeightUpdate, }) => { | ||
| const imgRef = useRef(null); | ||
| const containerRef = useRef(null); | ||
| const loadImage = useCallback(async (imageUrl) => { | ||
| if (!autoHeight || !containerRef.current) | ||
| return; | ||
| const container = containerRef.current; | ||
| try { | ||
| const loadedImage = await GalleryPreLoader.load(imageUrl); | ||
| if (!loadedImage) | ||
| return; | ||
| container.style.height = | ||
| (loadedImage.height * container.clientWidth) / loadedImage.width + 'px'; | ||
| onImageLoadHeightUpdate?.(); | ||
| } | ||
| catch { | ||
| // image failed to load — keep default height | ||
| } | ||
| }, [autoHeight, onImageLoadHeightUpdate]); | ||
| useEffect(() => { | ||
| if (!imgRef.current || !containerRef.current || !leading) | ||
| return; | ||
| void loadImage(url); | ||
| }, [url, leading, loadImage]); | ||
| const style = { | ||
| backgroundImage: `url("${url}")`, | ||
| backgroundSize: fillMode, | ||
| backgroundRepeat: 'no-repeat', | ||
| transition: leading ? 'all 0.5s ease 0s' : 'none', | ||
| backgroundPosition: position, | ||
| }; | ||
| return (_jsx("div", { ref: containerRef, style: { width: '100%', height: leading && !fullscreen ? '250px' : '100%' }, children: _jsx("div", { ref: imgRef, className: "efp-gallery__img", style: style }) })); | ||
| }; | ||
| export default GalleryImg; |
| export interface GalleryItemProps { | ||
| url: string; | ||
| index: number; | ||
| itemLabel: (index: number) => string; | ||
| leading?: boolean; | ||
| autoHeight?: boolean; | ||
| fillMode?: 'cover' | 'contain'; | ||
| position?: 'center' | 'top'; | ||
| onClick: () => void; | ||
| onImageLoadHeightUpdate?: () => void; | ||
| } | ||
| declare const GalleryItem: ({ url, index, itemLabel, position, leading, autoHeight, fillMode, onClick, onImageLoadHeightUpdate, }: GalleryItemProps) => import("react").JSX.Element; | ||
| export default GalleryItem; | ||
| //# sourceMappingURL=GalleryItem.d.ts.map |
| import { jsx as _jsx } from "react/jsx-runtime"; | ||
| import GalleryImg from '../GalleryImg/GalleryImg'; | ||
| const GalleryItem = ({ url, index, itemLabel, position = 'center', leading = false, autoHeight = false, fillMode, onClick, onImageLoadHeightUpdate, }) => { | ||
| return (_jsx("button", { type: "button", className: "efp-gallery__item", "aria-label": itemLabel(index), onClick: onClick, children: _jsx(GalleryImg, { position: position, fillMode: fillMode, url: url, leading: leading, autoHeight: autoHeight, onImageLoadHeightUpdate: onImageLoadHeightUpdate }) })); | ||
| }; | ||
| export default GalleryItem; |
| import './GalleryModal.scss'; | ||
| import type { GalleryImage } from '../types'; | ||
| export interface GalleryModalProps { | ||
| images: GalleryImage[]; | ||
| leading: boolean; | ||
| initialSlideIndex: number; | ||
| closeLabel: string; | ||
| zoomInLabel: string; | ||
| zoomOutLabel: string; | ||
| nextSlideLabel: string; | ||
| prevSlideLabel: string; | ||
| itemLabel: (index: number) => string; | ||
| renderTarget: HTMLElement; | ||
| onSlideActivate?: (index: number) => void; | ||
| onClose: () => void; | ||
| className?: string; | ||
| } | ||
| declare const GalleryModal: (props: GalleryModalProps) => import("react").ReactPortal; | ||
| export default GalleryModal; | ||
| //# sourceMappingURL=GalleryModal.d.ts.map |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import './GalleryModal.scss'; | ||
| import classNames from 'classnames'; | ||
| import { useEffect, useRef, useState } from 'react'; | ||
| import { createPortal } from 'react-dom'; | ||
| import { TransformWrapper } from 'react-zoom-pan-pinch-sr'; | ||
| import { Navigation } from 'swiper'; | ||
| import { Swiper as SwiperComponent, SwiperSlide } from 'swiper/react'; | ||
| import { getActiveElement } from '../../../shared/activeElement'; | ||
| import useOnEscape from '../../../shared/hooks/useOnEscape'; | ||
| import GalleryControls from '../GalleryControls/GalleryControls'; | ||
| import TransformImg from '../TransformImg/TransformImg'; | ||
| const isElementVisible = (el) => el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden'; | ||
| const GalleryModal = (props) => { | ||
| const { images, leading, initialSlideIndex, closeLabel, zoomInLabel, zoomOutLabel, nextSlideLabel, prevSlideLabel, itemLabel, renderTarget, onSlideActivate, onClose, className, } = props; | ||
| const [currentSlideIndex, setCurrentSlideIndex] = useState(initialSlideIndex); | ||
| const [zoomUtils, setZoomUtils] = useState([]); | ||
| const swiperRef = useRef(null); | ||
| const prevRef = useRef(null); | ||
| const nextRef = useRef(null); | ||
| const modalRef = useRef(null); | ||
| useOnEscape(onClose); | ||
| // Scroll lock + focus trap/restore. Runs once for the modal's whole mounted | ||
| // lifetime — the parent only mounts this component while the modal is open. | ||
| useEffect(() => { | ||
| const previousFocus = getActiveElement(modalRef.current); | ||
| const previousBodyOverflow = document.body.style.overflow; | ||
| document.body.style.overflow = 'hidden'; | ||
| let inner = 0; | ||
| const outer = requestAnimationFrame(() => { | ||
| inner = requestAnimationFrame(() => modalRef.current?.focus()); | ||
| }); | ||
| const handleKeyDown = (e) => { | ||
| if (e.key !== 'Tab') | ||
| return; | ||
| const container = modalRef.current; | ||
| if (!container) | ||
| return; | ||
| const focusable = Array.from(container.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter(isElementVisible); | ||
| if (focusable.length === 0) { | ||
| e.preventDefault(); | ||
| return; | ||
| } | ||
| const first = focusable[0]; | ||
| const last = focusable[focusable.length - 1]; | ||
| const active = getActiveElement(container); | ||
| if (e.shiftKey) { | ||
| if (active === first || active === container) { | ||
| e.preventDefault(); | ||
| last.focus(); | ||
| } | ||
| } | ||
| else if (active === last) { | ||
| e.preventDefault(); | ||
| first.focus(); | ||
| } | ||
| }; | ||
| document.addEventListener('keydown', handleKeyDown); | ||
| return () => { | ||
| cancelAnimationFrame(outer); | ||
| cancelAnimationFrame(inner); | ||
| document.removeEventListener('keydown', handleKeyDown); | ||
| document.body.style.overflow = previousBodyOverflow; | ||
| previousFocus?.focus(); | ||
| }; | ||
| }, []); | ||
| useEffect(() => { | ||
| onSlideActivate?.(currentSlideIndex); | ||
| }, [currentSlideIndex, onSlideActivate]); | ||
| const mainSliderOptions = { | ||
| initialSlide: initialSlideIndex, | ||
| draggable: false, | ||
| modules: [Navigation], | ||
| spaceBetween: 50, | ||
| slidesPerView: 1, | ||
| navigation: { | ||
| prevEl: prevRef.current, | ||
| nextEl: nextRef.current, | ||
| }, | ||
| grabCursor: true, | ||
| }; | ||
| const modalContent = (_jsx("div", { className: classNames('efp-gallery-modal', className), role: "dialog", "aria-modal": "true", "aria-label": itemLabel(currentSlideIndex), ref: modalRef, tabIndex: -1, children: _jsxs(SwiperComponent, { onSwiper: (swiper) => (swiperRef.current = swiper), className: "efp-gallery-slider", onSlideChange: (swiper) => { | ||
| setCurrentSlideIndex(swiper.activeIndex); | ||
| zoomUtils[swiper.previousIndex]?.resetTransform(); | ||
| }, ...mainSliderOptions, children: [zoomUtils[currentSlideIndex] ? (_jsx(GalleryControls, { closeLabel: closeLabel, zoomInLabel: zoomInLabel, zoomOutLabel: zoomOutLabel, onClose: onClose, zoomIn: zoomUtils[currentSlideIndex].zoomIn, zoomOut: zoomUtils[currentSlideIndex].zoomOut })) : null, images.length > 1 && (_jsx("button", { type: "button", ref: nextRef, title: nextSlideLabel, "aria-label": nextSlideLabel, className: "efp-gallery-slider__btn efp-gallery-slider__btn--next", children: _jsx("i", { className: "icon-chevron-right", "aria-hidden": "true" }) })), images.length > 1 && (_jsx("button", { type: "button", ref: prevRef, title: prevSlideLabel, "aria-label": prevSlideLabel, className: "efp-gallery-slider__btn efp-gallery-slider__btn--prev", children: _jsx("i", { className: "icon-chevron-left", "aria-hidden": "true" }) })), images.map((image, i) => (_jsx(SwiperSlide, { children: _jsx(TransformWrapper, { initialScale: 1, alignmentAnimation: { sizeX: 0, sizeY: 0 }, onInit: (controls) => { | ||
| setZoomUtils((state) => [...state, controls]); | ||
| }, children: _jsx(TransformImg, { leading: leading, swiperRef: swiperRef, url: image.fullUrl }) }) }, image.thumbnailUrl + i)))] }) })); | ||
| return createPortal(modalContent, renderTarget); | ||
| }; | ||
| export default GalleryModal; |
| declare class GalleryPreLoader { | ||
| private cache; | ||
| load: (url: string) => Promise<HTMLImageElement | null>; | ||
| } | ||
| declare const _default: GalleryPreLoader; | ||
| export default _default; | ||
| //# sourceMappingURL=GalleryPreLoader.d.ts.map |
| class GalleryPreLoader { | ||
| cache = []; | ||
| load = (url) => { | ||
| return new Promise((resolve, reject) => { | ||
| if (!url) { | ||
| resolve(null); | ||
| return; | ||
| } | ||
| const cached = this.cache.find((i) => i.src === url); | ||
| if (cached) { | ||
| resolve(cached); | ||
| return; | ||
| } | ||
| const image = new Image(); | ||
| image.crossOrigin = 'anonymous'; | ||
| image.src = url; | ||
| image.onload = () => { | ||
| if (this.cache.length > 10) | ||
| this.cache.shift(); | ||
| this.cache.push(image); | ||
| resolve(image); | ||
| }; | ||
| image.onerror = () => { | ||
| reject(new Error(`Failed to load image: ${url}`)); | ||
| }; | ||
| }); | ||
| }; | ||
| } | ||
| export default new GalleryPreLoader(); |
| export type { GalleryProps } from './Gallery'; | ||
| export { default } from './Gallery'; | ||
| export type { GalleryImage } from './types'; | ||
| //# sourceMappingURL=index.d.ts.map |
| export { default } from './Gallery'; |
| import React from 'react'; | ||
| import { type Swiper as SwiperInstance } from 'swiper'; | ||
| export interface TransformImgProps { | ||
| swiperRef: React.RefObject<SwiperInstance | null>; | ||
| url: string; | ||
| leading: boolean; | ||
| } | ||
| declare const TransformImg: ({ swiperRef, leading, url }: TransformImgProps) => React.JSX.Element; | ||
| export default TransformImg; | ||
| //# sourceMappingURL=TransformImg.d.ts.map |
| import { jsx as _jsx } from "react/jsx-runtime"; | ||
| import { TransformComponent, useTransformEffect } from 'react-zoom-pan-pinch-sr'; | ||
| import GalleryImg from '../GalleryImg/GalleryImg'; | ||
| const TransformImg = ({ swiperRef, leading, url }) => { | ||
| useTransformEffect(({ state, instance }) => { | ||
| if (!swiperRef.current) | ||
| return; | ||
| swiperRef.current.allowTouchMove = state.scale === 1; | ||
| if (state.scale === 1 && state.positionX !== 0 && state.positionY !== 0) { | ||
| instance.setCenter(); | ||
| } | ||
| }); | ||
| return (_jsx(TransformComponent, { wrapperClass: "efp-gallery-slider__zoom", contentClass: "efp-gallery-slider__zoom-content", children: _jsx(GalleryImg, { fullscreen: true, leading: leading, url: url }) })); | ||
| }; | ||
| export default TransformImg; |
| export interface GalleryImage { | ||
| thumbnailUrl: string; | ||
| /** Shown fullscreen; may be the thumbnail when no larger source exists. */ | ||
| fullUrl: string; | ||
| } | ||
| //# sourceMappingURL=types.d.ts.map |
| export {}; |
| export type { OverlayProps, Size } from './Overlay'; | ||
| export { default } from './Overlay'; | ||
| export { OverlayScrollContext } from './OverlayScrollContext'; | ||
| //# sourceMappingURL=index.d.ts.map |
| export { default } from './Overlay'; | ||
| export { OverlayScrollContext } from './OverlayScrollContext'; |
| import './Overlay.scss'; | ||
| import { type ReactNode } from 'react'; | ||
| export type Size = 'small' | 'medium' | 'full'; | ||
| export interface OverlayProps { | ||
| children?: ReactNode; | ||
| open: boolean; | ||
| size?: Size; | ||
| /** Slot rendered above the content (e.g. a particles canvas); nothing when omitted. */ | ||
| particles?: ReactNode; | ||
| className?: string; | ||
| smallSizeMultiplier?: number; | ||
| mediumSizeMultiplier?: number; | ||
| fullSizeOffset?: number; | ||
| scrollThreshold?: number; | ||
| disableDrag?: boolean; | ||
| forceFull?: boolean; | ||
| scrollResetKey?: unknown; | ||
| /** Viewport width (px) at or below which the overlay switches to its mobile bottom-sheet mode. */ | ||
| mobileBreakpoint?: number; | ||
| /** Shown when `children` is empty. */ | ||
| noContentLabel: string; | ||
| onChangeSize?: (size: Size) => void; | ||
| onScrollStateChange?: (isScrolled: boolean) => void; | ||
| /** Fired (debounced) on scroll — lets the host react to user activity. */ | ||
| onScrollActivity?: () => void; | ||
| } | ||
| declare const Overlay: ({ children, open, size, particles, className, onChangeSize, onScrollStateChange, onScrollActivity, smallSizeMultiplier, mediumSizeMultiplier, fullSizeOffset, scrollThreshold, disableDrag, forceFull, scrollResetKey, mobileBreakpoint, noContentLabel, }: OverlayProps) => import("react").JSX.Element | null; | ||
| export default Overlay; | ||
| //# sourceMappingURL=Overlay.d.ts.map |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import './Overlay.scss'; | ||
| import cn from 'classnames'; | ||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||
| import debounce from '../../shared/debounce'; | ||
| import { useDragGesture } from '../../shared/hooks/useDragGesture'; | ||
| import { useWindowSize } from '../../shared/hooks/useWindowSize'; | ||
| import { OverlayScrollContext } from './OverlayScrollContext'; | ||
| const GAP = 70; | ||
| const TRANSITION_DURATION = '0.3s'; | ||
| const DEFAULT_SCROLL_THRESHOLD = 10; | ||
| const DEFAULT_MOBILE_BREAKPOINT = 768; | ||
| const getActualViewportHeight = () => { | ||
| const isInIframe = window !== window.top; | ||
| if (isInIframe) { | ||
| return (document.documentElement.clientHeight || document.body.clientHeight || window.innerHeight); | ||
| } | ||
| return window.innerHeight; | ||
| }; | ||
| const generateSizes = (height = getActualViewportHeight(), smallMultiplier, mediumMultiplier, fullSizeOffset) => ({ | ||
| small: height / smallMultiplier, | ||
| medium: height / mediumMultiplier, | ||
| full: height - fullSizeOffset, | ||
| }); | ||
| const Overlay = ({ children, open, size = 'medium', particles, className, onChangeSize, onScrollStateChange, onScrollActivity, smallSizeMultiplier = 12.6, mediumSizeMultiplier = 2.6, fullSizeOffset = 0, scrollThreshold = DEFAULT_SCROLL_THRESHOLD, disableDrag = false, forceFull = false, scrollResetKey, mobileBreakpoint = DEFAULT_MOBILE_BREAKPOINT, noContentLabel, }) => { | ||
| const { width: windowWidth, height: windowHeight } = useWindowSize(); | ||
| // Pure derivations of the window size — computed in render, not stored/synced via an effect. | ||
| const mobile = windowWidth <= mobileBreakpoint; | ||
| const sizes = useMemo(() => generateSizes(windowHeight, smallSizeMultiplier, mediumSizeMultiplier, fullSizeOffset), [windowHeight, smallSizeMultiplier, mediumSizeMultiplier, fullSizeOffset]); | ||
| const [dragging, setDragging] = useState(false); | ||
| const [panelHeight, setPanelHeight] = useState(() => sizes[size]); | ||
| const [overlayHeight, setOverlayHeight] = useState(() => sizes[size]); | ||
| const [isAnimating, setIsAnimating] = useState(false); | ||
| const [shouldRender, setShouldRender] = useState(open); | ||
| const [isScrolled, setIsScrolled] = useState(false); | ||
| const touchStartRef = useRef(undefined); | ||
| const dragOffsetRef = useRef(undefined); | ||
| const overlayPanelRef = useRef(null); | ||
| const triggerRef = useRef(null); | ||
| const scrollRef = useRef(null); | ||
| // Mirror frequently-changing values into refs so the drag callbacks can stay | ||
| // identity-stable and the listener effect subscribes only when mobile/disableDrag change. | ||
| const sizesRef = useRef(sizes); | ||
| const panelHeightRef = useRef(panelHeight); | ||
| const overlayHeightRef = useRef(overlayHeight); | ||
| const draggingRef = useRef(dragging); | ||
| const sizeRef = useRef(size); | ||
| useEffect(() => { | ||
| sizesRef.current = sizes; | ||
| panelHeightRef.current = panelHeight; | ||
| overlayHeightRef.current = overlayHeight; | ||
| draggingRef.current = dragging; | ||
| sizeRef.current = size; | ||
| }); | ||
| const setSize = useCallback((newSize) => { | ||
| const height = sizesRef.current[newSize]; | ||
| setPanelHeight(height); | ||
| setOverlayHeight(height); | ||
| onChangeSize?.(newSize); | ||
| }, [onChangeSize]); | ||
| const positioningStyles = useMemo(() => { | ||
| if (mobile) { | ||
| if (forceFull && open) { | ||
| return { height: getActualViewportHeight() }; | ||
| } | ||
| return { | ||
| height: open || isAnimating ? overlayHeight : 0, | ||
| }; | ||
| } | ||
| return { | ||
| height: open ? 'auto' : undefined, | ||
| }; | ||
| }, [mobile, open, overlayHeight, isAnimating, forceFull]); | ||
| const setSizeByValue = useCallback((heightVal) => { | ||
| const sizes = sizesRef.current; | ||
| const sizesArray = [sizes.small, sizes.medium, sizes.full].sort(); | ||
| if (heightVal >= sizes.full) { | ||
| setSize('full'); | ||
| return; | ||
| } | ||
| if (heightVal <= sizes.small) { | ||
| setSize('small'); | ||
| return; | ||
| } | ||
| const currentHeight = panelHeightRef.current || sizes.medium; | ||
| const targetSize = heightVal > currentHeight | ||
| ? (sizesArray.find((val) => val + GAP >= heightVal) ?? sizes.full) | ||
| : (sizesArray.reverse().find((val) => val - GAP <= heightVal) ?? sizes.small); | ||
| if (targetSize === sizes.full) { | ||
| setSize('full'); | ||
| } | ||
| else if (targetSize === sizes.medium) { | ||
| setSize('medium'); | ||
| } | ||
| else { | ||
| setSize('small'); | ||
| } | ||
| }, [setSize]); | ||
| const handleDragStart = useCallback((clientY) => { | ||
| setDragging(true); | ||
| touchStartRef.current = clientY; | ||
| }, []); | ||
| const handleDragMove = useCallback((clientY) => { | ||
| const touchStart = touchStartRef.current; | ||
| if (touchStart === undefined) | ||
| return; | ||
| const sizes = sizesRef.current; | ||
| const panelHeight = panelHeightRef.current; | ||
| const scrollEl = scrollRef.current; | ||
| if ((scrollEl?.scrollTop ?? 0) > 0 && panelHeight === sizes.full) | ||
| return; | ||
| const offset = touchStart - clientY; | ||
| const currentHeight = panelHeight || sizes.medium; | ||
| let nextHeight = currentHeight + offset; | ||
| if (scrollEl && panelHeight === sizes.full && nextHeight < panelHeight) { | ||
| scrollEl.style.overflow = 'hidden'; | ||
| } | ||
| nextHeight = Math.min(Math.max(nextHeight, sizes.small), sizes.full); | ||
| dragOffsetRef.current = offset; | ||
| overlayHeightRef.current = nextHeight; | ||
| setOverlayHeight(nextHeight); | ||
| }, []); | ||
| const handleDragEnd = useCallback(() => { | ||
| setDragging(false); | ||
| touchStartRef.current = undefined; | ||
| const scrollEl = scrollRef.current; | ||
| if (scrollEl) { | ||
| scrollEl.style.removeProperty('overflow'); | ||
| } | ||
| const dragOffset = dragOffsetRef.current; | ||
| dragOffsetRef.current = undefined; | ||
| const finalHeight = overlayHeightRef.current; | ||
| if (dragOffset !== undefined && finalHeight !== undefined) { | ||
| setSizeByValue(finalHeight); | ||
| } | ||
| }, [setSizeByValue]); | ||
| const handleTriggerClick = useCallback(() => { | ||
| if (draggingRef.current) | ||
| return; | ||
| const currentSize = sizeRef.current; | ||
| const nextSize = currentSize === 'small' ? 'medium' : currentSize === 'medium' ? 'full' : 'small'; | ||
| setSize(nextSize); | ||
| }, [setSize]); | ||
| const { touchHandlers, mouseHandlers } = useDragGesture({ | ||
| onDragStart: handleDragStart, | ||
| onDragMove: handleDragMove, | ||
| onDragEnd: handleDragEnd, | ||
| }); | ||
| const handleScroll = useCallback(() => { | ||
| const scrollEl = scrollRef.current; | ||
| if (!scrollEl) | ||
| return; | ||
| const scrollTop = scrollEl.scrollTop; | ||
| const shouldAddClass = scrollTop >= scrollThreshold; | ||
| if (isScrolled !== shouldAddClass) { | ||
| setIsScrolled(shouldAddClass); | ||
| onScrollStateChange?.(shouldAddClass); | ||
| } | ||
| }, [scrollThreshold, isScrolled, onScrollStateChange]); | ||
| const debouncedScrollActivity = useMemo(() => debounce(() => { | ||
| onScrollActivity?.(); | ||
| }, 250), [onScrollActivity]); | ||
| useEffect(() => { | ||
| if (!mobile || disableDrag) | ||
| return; | ||
| const overlayEl = overlayPanelRef.current; | ||
| const triggerEl = triggerRef.current; | ||
| const scrollEl = scrollRef.current; | ||
| const preventOverscroll = () => { | ||
| const panelHeight = panelHeightRef.current; | ||
| if (panelHeight && panelHeight < sizesRef.current.full && scrollEl) { | ||
| scrollEl.style.overflow = 'hidden'; | ||
| } | ||
| }; | ||
| if (overlayEl) { | ||
| overlayEl.addEventListener('touchstart', touchHandlers.onTouchStart); | ||
| overlayEl.addEventListener('touchend', touchHandlers.onTouchEnd); | ||
| window.addEventListener('touchmove', touchHandlers.onTouchMove); | ||
| overlayEl.addEventListener('mousedown', mouseHandlers.onMouseDown); | ||
| overlayEl.addEventListener('mouseup', mouseHandlers.onMouseUp); | ||
| window.addEventListener('mousemove', mouseHandlers.onMouseMove); | ||
| } | ||
| if (triggerEl) { | ||
| triggerEl.addEventListener('mouseup', handleTriggerClick); | ||
| } | ||
| if (scrollEl) { | ||
| scrollEl.addEventListener('touchstart', preventOverscroll); | ||
| } | ||
| return () => { | ||
| if (overlayEl) { | ||
| overlayEl.removeEventListener('touchstart', touchHandlers.onTouchStart); | ||
| overlayEl.removeEventListener('touchend', touchHandlers.onTouchEnd); | ||
| window.removeEventListener('touchmove', touchHandlers.onTouchMove); | ||
| overlayEl.removeEventListener('mousedown', mouseHandlers.onMouseDown); | ||
| overlayEl.removeEventListener('mouseup', mouseHandlers.onMouseUp); | ||
| window.removeEventListener('mousemove', mouseHandlers.onMouseMove); | ||
| } | ||
| if (triggerEl) { | ||
| triggerEl.removeEventListener('mouseup', handleTriggerClick); | ||
| } | ||
| if (scrollEl) { | ||
| scrollEl.removeEventListener('touchstart', preventOverscroll); | ||
| } | ||
| }; | ||
| }, [mobile, disableDrag, touchHandlers, mouseHandlers, handleTriggerClick]); | ||
| useEffect(() => { | ||
| if (open) { | ||
| setShouldRender(true); | ||
| setIsAnimating(true); | ||
| const timer = setTimeout(() => { | ||
| setSize(size); | ||
| setIsAnimating(false); | ||
| }, 10); | ||
| return () => clearTimeout(timer); | ||
| } | ||
| else { | ||
| setIsAnimating(true); | ||
| if (mobile) { | ||
| setOverlayHeight(0); | ||
| } | ||
| const timer = setTimeout(() => { | ||
| setShouldRender(false); | ||
| setIsAnimating(false); | ||
| setIsScrolled(false); | ||
| }, parseFloat(TRANSITION_DURATION) * 1000); | ||
| return () => clearTimeout(timer); | ||
| } | ||
| }, [open, size, setSize, mobile]); | ||
| useEffect(() => { | ||
| if (open && !isAnimating) { | ||
| scrollRef.current?.scrollTo({ top: 0 }); | ||
| setIsScrolled(false); | ||
| onScrollStateChange?.(false); | ||
| } | ||
| }, [open, isAnimating, onScrollStateChange, scrollResetKey]); | ||
| if (!shouldRender) | ||
| return null; | ||
| return (_jsx(OverlayScrollContext, { value: scrollRef.current, children: _jsxs("div", { className: cn('efp-overlay', { | ||
| 'efp-overlay--draggable': dragging, | ||
| 'efp-overlay--full': size === 'full', | ||
| 'efp-overlay--mobile': mobile, | ||
| 'efp-overlay--desktop': !mobile, | ||
| 'efp-overlay--hidden': !mobile && !open, | ||
| 'efp-overlay--scrolled': isScrolled, | ||
| 'efp-overlay--forced-full': forceFull && mobile, | ||
| }, className), style: positioningStyles, ref: overlayPanelRef, children: [!disableDrag && _jsx("div", { className: "efp-overlay__draghandle", ref: triggerRef }), _jsxs("div", { className: "efp-overlay__scroll", ref: scrollRef, onScroll: () => { | ||
| handleScroll(); | ||
| debouncedScrollActivity(); | ||
| }, children: [particles, _jsx("div", { className: "efp-overlay__content", children: children ? children : noContentLabel })] })] }) })); | ||
| }; | ||
| export default Overlay; |
| /** | ||
| * Exposes the overlay's inner scroll container to descendants so they can | ||
| * observe / drive it (e.g. a virtualized list syncing its scroll position). | ||
| * Provided by {@link Overlay}; `null` when rendered outside an overlay. | ||
| */ | ||
| export declare const OverlayScrollContext: import("react").Context<HTMLDivElement | null>; | ||
| //# sourceMappingURL=OverlayScrollContext.d.ts.map |
| import { createContext } from 'react'; | ||
| /** | ||
| * Exposes the overlay's inner scroll container to descendants so they can | ||
| * observe / drive it (e.g. a virtualized list syncing its scroll position). | ||
| * Provided by {@link Overlay}; `null` when rendered outside an overlay. | ||
| */ | ||
| export const OverlayScrollContext = createContext(null); |
| /** | ||
| * Focused element within `element`'s tree, descending into nested shadow trees. | ||
| * `document.activeElement` retargets to the shadow host, so it cannot be | ||
| * compared against elements rendered inside a shadow root. | ||
| */ | ||
| export declare function getActiveElement(element: Element | null): Element | null; | ||
| //# sourceMappingURL=activeElement.d.ts.map |
| const isFocusRoot = (node) => !!node && 'activeElement' in node; | ||
| /** | ||
| * Focused element within `element`'s tree, descending into nested shadow trees. | ||
| * `document.activeElement` retargets to the shadow host, so it cannot be | ||
| * compared against elements rendered inside a shadow root. | ||
| */ | ||
| export function getActiveElement(element) { | ||
| const root = element?.getRootNode(); | ||
| let active = isFocusRoot(root) ? root.activeElement : document.activeElement; | ||
| while (active?.shadowRoot?.activeElement) { | ||
| active = active.shadowRoot.activeElement; | ||
| } | ||
| return active; | ||
| } |
| export default function debounce<Params extends unknown[]>(func: (...args: Params) => unknown, timeoutMs: number): (...args: Params) => void; | ||
| //# sourceMappingURL=debounce.d.ts.map |
| export default function debounce(func, timeoutMs) { | ||
| let timer; | ||
| return (...args) => { | ||
| clearTimeout(timer); | ||
| timer = setTimeout(() => { | ||
| func(...args); | ||
| }, timeoutMs); | ||
| }; | ||
| } |
| interface DragGestureConfig { | ||
| onDragStart: (clientY: number) => void; | ||
| onDragMove: (clientY: number) => void; | ||
| onDragEnd: () => void; | ||
| } | ||
| export declare const useDragGesture: ({ onDragStart, onDragMove, onDragEnd }: DragGestureConfig) => { | ||
| touchHandlers: { | ||
| onTouchStart: (event: TouchEvent) => void; | ||
| onTouchMove: (event: TouchEvent) => void; | ||
| onTouchEnd: () => void; | ||
| }; | ||
| mouseHandlers: { | ||
| onMouseDown: (event: MouseEvent) => void; | ||
| onMouseMove: (event: MouseEvent) => void; | ||
| onMouseUp: () => void; | ||
| }; | ||
| }; | ||
| export {}; | ||
| //# sourceMappingURL=useDragGesture.d.ts.map |
| import { useCallback, useMemo, useRef } from 'react'; | ||
| export const useDragGesture = ({ onDragStart, onDragMove, onDragEnd }) => { | ||
| const isDraggingRef = useRef(false); | ||
| const handleTouchStart = useCallback((event) => { | ||
| isDraggingRef.current = true; | ||
| onDragStart(event.touches[0].clientY); | ||
| }, [onDragStart]); | ||
| const handleTouchMove = useCallback((event) => { | ||
| if (!isDraggingRef.current) | ||
| return; | ||
| onDragMove(event.touches[0].clientY); | ||
| }, [onDragMove]); | ||
| const handleTouchEnd = useCallback(() => { | ||
| if (!isDraggingRef.current) | ||
| return; | ||
| isDraggingRef.current = false; | ||
| onDragEnd(); | ||
| }, [onDragEnd]); | ||
| const handleMouseStart = useCallback((event) => { | ||
| isDraggingRef.current = true; | ||
| onDragStart(event.clientY); | ||
| }, [onDragStart]); | ||
| const handleMouseMove = useCallback((event) => { | ||
| if (!isDraggingRef.current) | ||
| return; | ||
| onDragMove(event.clientY); | ||
| }, [onDragMove]); | ||
| const handleMouseEnd = useCallback(() => { | ||
| if (!isDraggingRef.current) | ||
| return; | ||
| isDraggingRef.current = false; | ||
| onDragEnd(); | ||
| }, [onDragEnd]); | ||
| const touchHandlers = useMemo(() => ({ | ||
| onTouchStart: handleTouchStart, | ||
| onTouchMove: handleTouchMove, | ||
| onTouchEnd: handleTouchEnd, | ||
| }), [handleTouchStart, handleTouchMove, handleTouchEnd]); | ||
| const mouseHandlers = useMemo(() => ({ | ||
| onMouseDown: handleMouseStart, | ||
| onMouseMove: handleMouseMove, | ||
| onMouseUp: handleMouseEnd, | ||
| }), [handleMouseStart, handleMouseMove, handleMouseEnd]); | ||
| return { touchHandlers, mouseHandlers }; | ||
| }; |
| export declare const useWindowSize: () => { | ||
| width: number; | ||
| height: number; | ||
| }; | ||
| //# sourceMappingURL=useWindowSize.d.ts.map |
| import { useEffect, useState } from 'react'; | ||
| export const useWindowSize = () => { | ||
| const [windowSize, setWindowSize] = useState(() => { | ||
| const isInIframe = window !== window.top; | ||
| if (isInIframe) { | ||
| return { | ||
| width: document.documentElement.clientWidth || window.innerWidth, | ||
| height: document.documentElement.clientHeight || window.innerHeight, | ||
| }; | ||
| } | ||
| return { | ||
| width: window.innerWidth, | ||
| height: window.innerHeight, | ||
| }; | ||
| }); | ||
| useEffect(() => { | ||
| const handleResize = () => { | ||
| const isInIframe = window !== window.top; | ||
| if (isInIframe) { | ||
| setWindowSize({ | ||
| width: document.documentElement.clientWidth || window.innerWidth, | ||
| height: document.documentElement.clientHeight || window.innerHeight, | ||
| }); | ||
| } | ||
| else { | ||
| setWindowSize({ | ||
| width: window.innerWidth, | ||
| height: window.innerHeight, | ||
| }); | ||
| } | ||
| }; | ||
| window.addEventListener('resize', handleResize); | ||
| window.addEventListener('orientationchange', handleResize); | ||
| return () => { | ||
| window.removeEventListener('resize', handleResize); | ||
| window.removeEventListener('orientationchange', handleResize); | ||
| }; | ||
| }, []); | ||
| return windowSize; | ||
| }; |
@@ -6,2 +6,3 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { createPortal } from 'react-dom'; | ||
| import { getActiveElement } from '../../shared/activeElement'; | ||
| import Button from '../Button'; | ||
@@ -16,3 +17,4 @@ const MODAL_EXIT_TRANSITION_MS = 200; | ||
| const [isVisible, setIsVisible] = useState(open); | ||
| // Focus restore + body scroll lock + enter/exit animation. Driven solely by `open`. | ||
| // Focus restore + body scroll lock + enter/exit animation. Driven by `open` | ||
| // and by the portal target, without which nothing is rendered at all. | ||
| useEffect(() => { | ||
@@ -29,3 +31,5 @@ if (!open) { | ||
| } | ||
| previousFocusRef.current ??= document.activeElement; | ||
| if (!renderTarget) | ||
| return; | ||
| previousFocusRef.current ??= getActiveElement(modalRef.current); | ||
| const previousBodyOverflow = document.body.style.overflow; | ||
@@ -43,3 +47,3 @@ document.body.style.overflow = 'hidden'; | ||
| }; | ||
| }, [open]); | ||
| }, [open, renderTarget]); | ||
| // Keydown handling (Escape + focus trap). Kept separate from the lifecycle | ||
@@ -49,3 +53,3 @@ // effect above so a new `onClose` identity only re-subscribes this listener | ||
| useEffect(() => { | ||
| if (!open) | ||
| if (!open || !renderTarget) | ||
| return; | ||
@@ -71,3 +75,3 @@ const handleKeyDown = (e) => { | ||
| const last = focusable[focusable.length - 1]; | ||
| const active = document.activeElement; | ||
| const active = getActiveElement(container); | ||
| if (e.shiftKey) { | ||
@@ -86,3 +90,3 @@ if (active === first || active === container) { | ||
| return () => document.removeEventListener('keydown', handleKeyDown); | ||
| }, [open, onClose]); | ||
| }, [open, renderTarget, onClose]); | ||
| useEffect(() => { | ||
@@ -89,0 +93,0 @@ if (!isOpen) |
+4
-0
@@ -21,2 +21,4 @@ export * from './components/Alert'; | ||
| export { default as EventBadge } from './components/EventBadge'; | ||
| export * from './components/Gallery'; | ||
| export { default as Gallery } from './components/Gallery'; | ||
| export * from './components/HeatmapLegend'; | ||
@@ -37,2 +39,4 @@ export { default as HeatmapLegend } from './components/HeatmapLegend'; | ||
| export { default as MultiSelectGroups } from './components/MultiSelectGroups'; | ||
| export * from './components/Overlay'; | ||
| export { default as Overlay } from './components/Overlay'; | ||
| export * from './components/OverlayGrip'; | ||
@@ -39,0 +43,0 @@ export { default as OverlayGrip } from './components/OverlayGrip'; |
+4
-0
@@ -26,2 +26,4 @@ // Public entry point for @expofp/ui — pure, presentational React components | ||
| export { default as EventBadge } from './components/EventBadge'; | ||
| export * from './components/Gallery'; | ||
| export { default as Gallery } from './components/Gallery'; | ||
| export * from './components/HeatmapLegend'; | ||
@@ -42,2 +44,4 @@ export { default as HeatmapLegend } from './components/HeatmapLegend'; | ||
| export { default as MultiSelectGroups } from './components/MultiSelectGroups'; | ||
| export * from './components/Overlay'; | ||
| export { default as Overlay } from './components/Overlay'; | ||
| export * from './components/OverlayGrip'; | ||
@@ -44,0 +48,0 @@ export { default as OverlayGrip } from './components/OverlayGrip'; |
+3
-1
| { | ||
| "name": "@expofp/ui", | ||
| "version": "3.23.0", | ||
| "version": "3.23.1", | ||
| "type": "module", | ||
@@ -35,3 +35,5 @@ "description": "ExpoFP SDK internal: shared pure React UI components", | ||
| "copy-to-clipboard": "^3.2.0", | ||
| "react-zoom-pan-pinch-sr": "^0.2.0", | ||
| "resize-observer": "^1.0.4", | ||
| "swiper": "^9.4.1", | ||
| "tslib": "^2.3.0" | ||
@@ -38,0 +40,0 @@ }, |
159833
30.62%176
23.94%2920
36.64%8
33.33%+ Added
+ Added
+ Added
+ Added