New:Socket for Asana Is Now Available.Learn more
Get Started

@expofp/ui

Package Overview
Dependencies
Maintainers
7
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@expofp/ui - npm Package Compare versions

Comparing version
3.23.0
to
3.23.1
+24
dist/components/Gallery/Gallery.d.ts
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 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;
};
+10
-6

@@ -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)

@@ -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';

@@ -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';

{
"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 @@ },