diff --git a/README.md b/README.md index c0a1026..954f132 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ The following plugins are bundled in the package: slideshow button - [Thumbnails](https://yet-another-react-lightbox.com/plugins/thumbnails) - adds thumbnails track +- [Filmstrip](https://yet-another-react-lightbox.com/plugins/filmstrip) - adds a + scrollable virtualized thumbnail rail - [Video](https://yet-another-react-lightbox.com/plugins/video) - adds support for video slides - [Zoom](https://yet-another-react-lightbox.com/plugins/zoom) - adds image zoom diff --git a/dev/AppWithFilmstrip.tsx b/dev/AppWithFilmstrip.tsx new file mode 100644 index 0000000..18fd9cc --- /dev/null +++ b/dev/AppWithFilmstrip.tsx @@ -0,0 +1,126 @@ +/* To test the filmstrip plugin, replace the import of App.js with AppWithFilmstrip.js in index.tsx */ + +import * as React from "react"; + +import Lightbox from "../src/index.js"; +import Counter from "../src/plugins/counter/index.js"; +import Captions from "../src/plugins/captions/index.js"; +import Download from "../src/plugins/download/index.js"; +import Fullscreen from "../src/plugins/fullscreen/index.js"; +import Share from "../src/plugins/share/index.js"; +import Slideshow from "../src/plugins/slideshow/index.js"; +import Filmstrip from "../src/plugins/filmstrip/index.js"; +import Video from "../src/plugins/video/index.js"; +import Zoom from "../src/plugins/zoom/index.js"; + +import "../src/styles.scss"; +import "../src/plugins/counter/counter.scss"; +import "../src/plugins/captions/captions.scss"; +import "../src/plugins/filmstrip/filmstrip.scss"; + +import slides from "./slides.js"; + +type Example = { + id: string; + title: string; + slides?: React.ComponentProps["slides"]; + carousel?: { finite?: boolean; preload?: number }; + filmstrip?: NonNullable["filmstrip"]>; +}; + +const EXAMPLES: Example[] = [ + { + id: "default", + title: "Default", + }, + { + id: "finite", + title: "Finite carousel", + carousel: { finite: true }, + }, + { + id: "single-slide-default-viewport", + title: "Single slide, default filmstrip viewport", + carousel: { preload: 2 }, + slides: slides.slice(0, 1), + }, + { + id: "preload-2-viewport-full", + title: "Many slides, full-width filmstrip viewport", + carousel: { preload: 2 }, + filmstrip: { scrollViewportMax: "full" }, + }, + { + id: "start", + title: "Vertical rail on the start edge", + filmstrip: { position: "start" }, + }, + { + id: "hidden-start", + title: "Hidden until the user shows it", + filmstrip: { hidden: true, showToggle: true }, + }, + { + id: "hide-scrollbar", + title: "Hide scrollbar", + filmstrip: { hideScrollbar: true }, + }, + { + id: "styling", + title: "Styling the filmstrip", + filmstrip: { + width: 72, + height: 48, + gap: 8, + padding: 2, + border: 2, + borderRadius: 8, + borderStyle: "dashed", + borderColor: "rgba(147, 197, 253, 0.95)", + }, + }, +]; + +export default function AppWithFilmstrip() { + const [openId, setOpenId] = React.useState(null); + const active = openId ? EXAMPLES.find((e) => e.id === openId) : undefined; + + const labelsForExample = + active?.id === "labels" + ? { + Filmstrip: "Slide filmstrip", + "Show filmstrip": "Reveal filmstrip", + "Hide filmstrip": "Hide filmstrip", + } + : undefined; + + return ( +
+

Filmstrip examples

+ +
+ {EXAMPLES.map((ex) => ( +
+

{ex.title}

+ +
+ ))} +
+ + {active && ( + setOpenId(null)} + slides={active.slides ?? slides} + carousel={active.carousel ?? {}} + filmstrip={active.filmstrip ?? {}} + labels={labelsForExample} + plugins={[Captions, Counter, Download, Share, Fullscreen, Slideshow, Filmstrip, Video, Zoom]} + /> + )} +
+ ); +} diff --git a/dev/index.tsx b/dev/index.tsx index 7716b57..7df5b7a 100644 --- a/dev/index.tsx +++ b/dev/index.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import * as ReactDOM from "react-dom/client"; -import App from "./App.js"; +// import App from "./App.js"; +import App from "./AppWithFilmstrip.js"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/docs/customization.md b/docs/customization.md index ca49f72..6c365e5 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -80,6 +80,22 @@ via corresponding CSS class. thumbnailsContainer yarl__thumbnails_container + + filmstripContainer + yarl__filmstrip_container + + + filmstripTrack + yarl__filmstrip_track + + + filmstripThumbnail + yarl__filmstrip_thumbnail + + + filmstripScrollViewport + yarl__filmstrip_scroll_viewport + diff --git a/docs/plugins.md b/docs/plugins.md index bc38b96..5e85f17 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -8,6 +8,7 @@ The following plugins are bundled in the package: - [Captions](/plugins/captions) - adds support for slide title and description - [Counter](/plugins/counter) - adds slides counter - [Download](/plugins/download) - adds download button +- [Filmstrip](/plugins/filmstrip) - adds a scrollable filmstrip preview rail - [Fullscreen](/plugins/fullscreen) - adds support for fullscreen mode - [Inline](/plugins/inline) - transforms the lightbox into an image carousel - [Share](/plugins/share) - adds sharing button diff --git a/docs/plugins/filmstrip.md b/docs/plugins/filmstrip.md new file mode 100644 index 0000000..d55379f --- /dev/null +++ b/docs/plugins/filmstrip.md @@ -0,0 +1,193 @@ +# Filmstrip Plugin + +The Filmstrip plugin adds a scrollable preview rail along one edge of the +lightbox. Tap a preview to jump to that slide; built-in images use +`loading="lazy"`. Choose this or [Thumbnails](/plugins/thumbnails), not both. + +The plugin comes with an additional CSS stylesheet. + +```jsx +import "yet-another-react-lightbox/plugins/filmstrip.css"; +``` + +## Documentation + +The Filmstrip plugin adds the following `Lightbox` properties: + + + + + + + + + + + + + + + + + + + + + + + + +
filmstrip + {
+   ref?: React.ForwardedRef​<FilmstripRef>;
+   position?: "top" | "bottom" | "start" | "end";
+   width?: number;
+   height?: number;
+   border?: number;
+   borderStyle?: string;
+   borderColor?: string;
+   borderRadius?: number;
+   padding?: number;
+   gap?: number;
+   imageFit?: "contain" | "cover";
+   vignette?: boolean;
+   hidden?: boolean;
+   showToggle?: boolean;
+   hideScrollbar?: boolean;
+   scrollViewportMax?: "full" | number | string;
+ } +
+

Filmstrip plugin settings:

+
    +
  • `ref` - Filmstrip plugin ref. See Filmstrip Ref for details.
  • +
  • `position` - rail position
  • +
  • `width` - preview width (px)
  • +
  • `height` - preview height (px)
  • +
  • `border` - border width
  • +
  • `borderStyle` - border style
  • +
  • `borderColor` - border color
  • +
  • `borderRadius` - border radius (px)
  • +
  • `padding` - inner padding (px)
  • +
  • `gap` - gap between previews (px)
  • +
  • `imageFit` - `object-fit` for built-in images
  • +
  • `vignette` - edge fade on the scroll viewport
  • +
  • `hidden` - if `true`, rail starts hidden
  • +
  • `showToggle` - if `true`, show filmstrip show/hide in the toolbar
  • +
  • `hideScrollbar` - if `true`, hide scrollbars (scroll still works)
  • +
  • + `scrollViewportMax` - max size of the scroll viewport on the strip axis; omit for a cap derived from + `carousel.preload`; `"full"` / number (px) / raw CSS string +
  • +
+

+ Defaults: + + { position: "bottom", width: 120, height: 80, border: 1, borderRadius: 4, padding: 4, gap: 16, + imageFit: "contain", vignette: true, hidden: false, showToggle: false, hideScrollbar: false } + +

+
render + {
+   thumbnail?: (props: RenderThumbnailProps) => React.ReactNode;
+   buttonFilmstrip?: (props: FilmstripRef) => React.ReactNode;
+   iconFilmstripVisible?: () => React.ReactNode;
+   iconFilmstripHidden?: () => React.ReactNode;
+ } +
+

`thumbnail` - custom cell content (same as Thumbnails). `buttonFilmstrip`, `iconFilmstripVisible`, `iconFilmstripHidden` - toolbar show/hide.

+
labels + {
+   Filmstrip?: string;
+   "Show filmstrip"?: string;
+   "Hide filmstrip"?: string;
+ } +
+
    +
  • `Filmstrip` - `aria-label` for the rail
  • +
  • `Show filmstrip` / `Hide filmstrip` - titles when `showToggle` is on
  • +
+
styles + {
+   filmstripContainer?: React.CSSProperties;
+   filmstripTrack?: React.CSSProperties;
+   filmstripThumbnail?: React.CSSProperties;
+   filmstripScrollViewport?: React.CSSProperties;
+ } +
Inline styles for container, track, thumbnail buttons, scroll viewport.
+ +and the following `Slide` properties: + + + + + + + + + +
thumbnailstringOptional preview URL (defaults to slide `src`; video can use `poster`).
+ +## Filmstrip Ref + +The plugin provides a ref object to control its features externally. + +```jsx +// Filmstrip ref usage example + +const filmstripRef = React.useRef(null); + +// ... + +return ( + { + (filmstripRef.current?.visible + ? filmstripRef.current?.hide + : filmstripRef.current?.show)?.(); + }, + }} + // ... + /> +); +``` + + + + + + + + + + + + + + + + + + + +
visiblebooleanIf `true`, the rail is visible.
show() => voidShow the rail.
hide() => voidHide the rail.
+ +## Example + +```jsx +import Lightbox from "yet-another-react-lightbox"; +import Filmstrip from "yet-another-react-lightbox/plugins/filmstrip"; +import "yet-another-react-lightbox/styles.css"; +import "yet-another-react-lightbox/plugins/filmstrip.css"; + +// ... + +return ( + +); +``` diff --git a/package.json b/package.json index 092a32a..b926bbf 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,10 @@ "types": "./dist/plugins/thumbnails/index.d.ts", "default": "./dist/plugins/thumbnails/index.js" }, + "./plugins/filmstrip": { + "types": "./dist/plugins/filmstrip/index.d.ts", + "default": "./dist/plugins/filmstrip/index.js" + }, "./plugins/video": { "types": "./dist/plugins/video/index.d.ts", "default": "./dist/plugins/video/index.js" @@ -63,7 +67,8 @@ "./styles.css": "./dist/styles.css", "./plugins/captions.css": "./dist/plugins/captions/captions.css", "./plugins/counter.css": "./dist/plugins/counter/counter.css", - "./plugins/thumbnails.css": "./dist/plugins/thumbnails/thumbnails.css" + "./plugins/thumbnails.css": "./dist/plugins/thumbnails/thumbnails.css", + "./plugins/filmstrip.css": "./dist/plugins/filmstrip/filmstrip.css" }, "typesVersions": { "*": { @@ -100,6 +105,9 @@ "plugins/thumbnails": [ "dist/plugins/thumbnails/index.d.ts" ], + "plugins/filmstrip": [ + "dist/plugins/filmstrip/index.d.ts" + ], "plugins/video": [ "dist/plugins/video/index.d.ts" ], diff --git a/src/components/ImageSlide.tsx b/src/components/ImageSlide.tsx index ef1c43d..1e02f4a 100644 --- a/src/components/ImageSlide.tsx +++ b/src/components/ImageSlide.tsx @@ -27,6 +27,7 @@ export type ImageSlideProps = Partial void; onError?: () => void; style?: React.CSSProperties; + loading?: React.ImgHTMLAttributes["loading"]; }; export function ImageSlide({ @@ -40,6 +41,7 @@ export function ImageSlide({ onLoad, onError, style, + loading, }: ImageSlideProps) { const [status, setStatus] = React.useState(SLIDE_STATUS_LOADING); @@ -159,6 +161,7 @@ export function ImageSlide({ sizes={sizes} srcSet={srcSet} src={image.src} + {...(loading !== undefined ? { loading } : null)} /> {status !== SLIDE_STATUS_COMPLETE && ( diff --git a/src/consts.ts b/src/consts.ts index a89e306..f22368a 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -14,6 +14,7 @@ export const PLUGIN_INLINE = "inline"; export const PLUGIN_SHARE = "share"; export const PLUGIN_SLIDESHOW = "slideshow"; export const PLUGIN_THUMBNAILS = "thumbnails"; +export const PLUGIN_FILMSTRIP = "filmstrip"; export const PLUGIN_ZOOM = "zoom"; export const SLIDE_STATUS_LOADING = "loading"; @@ -47,6 +48,7 @@ export const ACTION_PREV = "prev"; export const ACTION_NEXT = "next"; export const ACTION_SWIPE = "swipe"; export const ACTION_CLOSE = "close"; +export const ACTION_GO_TO = "goTo"; export const EVENT_ON_POINTER_DOWN = "onPointerDown"; export const EVENT_ON_POINTER_MOVE = "onPointerMove"; diff --git a/src/contexts/LightboxState.tsx b/src/contexts/LightboxState.tsx index e009921..089c09b 100644 --- a/src/contexts/LightboxState.tsx +++ b/src/contexts/LightboxState.tsx @@ -1,10 +1,16 @@ import * as React from "react"; -import { LightboxProps, LightboxState, LightboxStateSwipeAction, LightboxStateUpdateAction } from "../types.js"; +import { + LightboxProps, + LightboxState, + LightboxStateGoToAction, + LightboxStateSwipeAction, + LightboxStateUpdateAction, +} from "../types.js"; import { getSlideIfPresent, getSlideIndex, makeUseContext } from "../utils.js"; import { UNKNOWN_ACTION_TYPE } from "../consts.js"; -export type LightboxStateAction = LightboxStateSwipeAction | LightboxStateUpdateAction; +export type LightboxStateAction = LightboxStateSwipeAction | LightboxStateUpdateAction | LightboxStateGoToAction; export type LightboxStateContextType = LightboxState & { /** @deprecated - use `useLightboxState` props directly */ @@ -55,6 +61,24 @@ function reducer(state: LightboxState, action: LightboxStateAction): LightboxSta }; } return state; + case "goTo": { + const { slides } = state; + const n = slides.length; + if (n === 0) { + return state; + } + let t = action.index % n; + if (t < 0) { + t += n; + } + return { + slides, + currentIndex: t, + globalIndex: t, + currentSlide: getSlideIfPresent(slides, t), + animation: undefined, + }; + } default: throw new Error(UNKNOWN_ACTION_TYPE); } diff --git a/src/modules/Controller/Controller.tsx b/src/modules/Controller/Controller.tsx index a8cb937..43cef5a 100644 --- a/src/modules/Controller/Controller.tsx +++ b/src/modules/Controller/Controller.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { ACTION_CLOSE, + ACTION_GO_TO, ACTION_NEXT, ACTION_PREV, ACTION_SWIPE, @@ -350,6 +351,7 @@ export function Controller({ children, ...props }: ComponentProps) { subscribe(ACTION_PREV, (action) => swipe({ direction: ACTION_PREV, ...action })), subscribe(ACTION_NEXT, (action) => swipe({ direction: ACTION_NEXT, ...action })), subscribe(ACTION_SWIPE, (action) => dispatch(action)), + subscribe(ACTION_GO_TO, (action) => dispatch({ type: "goTo", index: action.index })), ), [subscribe, swipe, dispatch], ); diff --git a/src/plugins/filmstrip/Filmstrip.tsx b/src/plugins/filmstrip/Filmstrip.tsx new file mode 100644 index 0000000..6bfe1be --- /dev/null +++ b/src/plugins/filmstrip/Filmstrip.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +import { + addToolbarButton, + createModule, + MODULE_CONTROLLER, + PLUGIN_FILMSTRIP, + PLUGIN_FULLSCREEN, + PluginProps, +} from "../../index.js"; +import { FilmstripContextProvider } from "./FilmstripContext.js"; +import { FilmstripButton } from "./FilmstripButton.js"; +import { resolveFilmstripProps } from "./props.js"; + +/** Filmstrip plugin */ +export function Filmstrip({ augment, contains, append, addParent }: PluginProps) { + augment(({ filmstrip: fp, toolbar, ...rest }) => { + const f = resolveFilmstripProps(fp); + return { + filmstrip: f, + toolbar: addToolbarButton(toolbar, PLUGIN_FILMSTRIP, f.showToggle ? : null), + ...rest, + }; + }); + + const module = createModule(PLUGIN_FILMSTRIP, FilmstripContextProvider); + if (contains(PLUGIN_FULLSCREEN)) { + append(PLUGIN_FULLSCREEN, module); + } else { + addParent(MODULE_CONTROLLER, module); + } +} diff --git a/src/plugins/filmstrip/FilmstripButton.tsx b/src/plugins/filmstrip/FilmstripButton.tsx new file mode 100644 index 0000000..89bf513 --- /dev/null +++ b/src/plugins/filmstrip/FilmstripButton.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; + +import { createIcon, createIconDisabled, IconButton, useLightboxProps } from "../../index.js"; +import { useFilmstrip } from "./FilmstripContext.js"; + +const filmstripIcon = () => ( + <> + + + +); + +const FilmstripVisible = createIcon("FilmstripVisible", filmstripIcon()); + +const FilmstripHidden = createIconDisabled("FilmstripHidden", filmstripIcon()); + +export function FilmstripButton() { + const { visible, show, hide } = useFilmstrip(); + const { render } = useLightboxProps(); + + if (render.buttonFilmstrip) { + return <>{render.buttonFilmstrip({ visible, show, hide })}; + } + + return ( + + ); +} diff --git a/src/plugins/filmstrip/FilmstripContext.tsx b/src/plugins/filmstrip/FilmstripContext.tsx new file mode 100644 index 0000000..f06f219 --- /dev/null +++ b/src/plugins/filmstrip/FilmstripContext.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; + +import { clsx, ComponentProps, cssClass, LightboxPropsProvider, makeUseContext, type Callback } from "../../index.js"; +import { FilmstripTrack } from "./FilmstripTrack.js"; +import { resolveFilmstripProps } from "./props.js"; +import { cssFilmstripPrefix } from "./utils.js"; + +export interface FilmstripRef { + visible: boolean; + show: Callback; + hide: Callback; +} + +export const FilmstripContext = React.createContext(null); + +export const useFilmstrip = makeUseContext("useFilmstrip", "FilmstripContext", FilmstripContext); + +/** Filmstrip plugin component */ +export function FilmstripContextProvider({ children, ...props }: ComponentProps) { + const { ref, position, hidden } = resolveFilmstripProps(props.filmstrip); + + const [visible, setVisible] = React.useState(!hidden); + + const context = React.useMemo( + () => ({ + visible, + show: () => setVisible(true), + hide: () => setVisible(false), + }), + [visible], + ); + + React.useImperativeHandle(ref, () => context, [context]); + + return ( + + +
+ {["start", "top"].includes(position) && } +
{children}
+ {["end", "bottom"].includes(position) && } +
+
+
+ ); +} diff --git a/src/plugins/filmstrip/FilmstripThumbnail.tsx b/src/plugins/filmstrip/FilmstripThumbnail.tsx new file mode 100644 index 0000000..d6ff85d --- /dev/null +++ b/src/plugins/filmstrip/FilmstripThumbnail.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; + +import { + CLASS_FLEX_CENTER, + clsx, + createIcon, + cssClass, + ELEMENT_ICON, + ImageSlide, + isImageSlide, + makeComposePrefix, + RenderThumbnailProps, + Slide, + translateSlideCounter, + useEventCallback, + useLightboxProps, + useLightboxState, +} from "../../index.js"; +import { cssFilmstripThumbnailPrefix } from "./utils.js"; +import { useFilmstripProps } from "./props.js"; + +const VideoThumbnailIcon = createIcon( + "VideoThumbnail", + , +); + +const UnknownThumbnailIcon = createIcon( + "UnknownThumbnail", + , +); + +type RenderArgs = RenderThumbnailProps & { + imageLoading?: React.ImgHTMLAttributes["loading"]; +}; + +function renderCell({ slide, render, rect, imageFit, imageLoading }: RenderArgs) { + const customThumbnail = render.thumbnail?.({ + slide, + render, + rect, + imageFit, + imageLoading, + } as RenderThumbnailProps); + if (customThumbnail) { + return customThumbnail; + } + + const imageSlideProps = { render, rect, imageFit, loading: imageLoading }; + + if (slide.thumbnail) { + return ; + } + + if (isImageSlide(slide)) { + return ; + } + + const iconClass = cssClass(cssFilmstripThumbnailPrefix(ELEMENT_ICON)); + + if (slide.type === "video") { + return ( + <> + {slide.poster && } + + + + ); + } + + return ; +} + +const activePrefix = makeComposePrefix("active"); + +export type FilmstripThumbnailProps = { + slide: Slide; + index: number; + /** Stable callback (e.g. `useEventCallback`); receives this cell's slide index. */ + onActivate: (index: number) => void; + onLoseFocus: () => void; + active?: boolean; + imageLazy?: boolean; +}; + +type FilmstripThumbnailComponentProps = FilmstripThumbnailProps; + +function FilmstripThumbnailInner({ + slide, + index, + onActivate, + onLoseFocus, + active: activeProp, + imageLazy, +}: FilmstripThumbnailComponentProps) { + const { render, styles, labels } = useLightboxProps(); + const { slides, globalIndex } = useLightboxState(); + const { width, height, imageFit } = useFilmstripProps(); + const rect = { width, height }; + const active = activeProp ?? index === globalIndex; + const imageLoading = imageLazy ? ("lazy" as const) : undefined; + + const handleClick = useEventCallback(() => { + onActivate(index); + }); + + const handleBlur = useEventCallback(() => { + onLoseFocus(); + }); + + return ( + + ); +} + +export const FilmstripThumbnail = React.memo(FilmstripThumbnailInner); +FilmstripThumbnail.displayName = "FilmstripThumbnail"; diff --git a/src/plugins/filmstrip/FilmstripTrack.tsx b/src/plugins/filmstrip/FilmstripTrack.tsx new file mode 100644 index 0000000..97e747a --- /dev/null +++ b/src/plugins/filmstrip/FilmstripTrack.tsx @@ -0,0 +1,321 @@ +import * as React from "react"; + +import { + ACTION_GO_TO, + calculatePreload, + clsx, + cssClass, + cssVar, + getSlideIndex, + getSlideKey, + hasSlides, + Slide, + translateLabel, + useEventCallback, + useEvents, + useKeyboardNavigation, + useLayoutEffect, + useLightboxProps, + useLightboxState, + useSensors, +} from "../../index.js"; +import { FilmstripThumbnail } from "./FilmstripThumbnail.js"; +import { cssFilmstripPrefix, cssFilmstripThumbnailPrefix } from "./utils.js"; +import { defaultFilmstripProps, useFilmstripProps } from "./props.js"; + +/** Extra slides mounted on each side of the visible window (not configurable). */ +const VIRTUALIZATION_BUFFER = 2; + +function isHorizontal(position: ReturnType["position"]) { + return ["top", "bottom"].includes(position); +} + +function getThumbnailKey(slide?: Slide | null) { + const { thumbnail, poster } = (slide as { + thumbnail?: unknown; + poster?: unknown; + }) || { thumbnail: "placeholder" }; + + return ( + (typeof thumbnail === "string" && thumbnail) || + (typeof poster === "string" && poster) || + (slide && getSlideKey(slide)) || + undefined + ); +} + +export type FilmstripTrackProps = { + visible: boolean; +}; + +export function FilmstripTrack({ visible }: FilmstripTrackProps) { + const scrollRef = React.useRef(null); + const trackRef = React.useRef(null); + const prevSlidesRef = React.useRef([]); + const rafScrollRef = React.useRef(0); + + const { publish } = useEvents(); + const { carousel, styles, labels } = useLightboxProps(); + const { slides, globalIndex } = useLightboxState(); + const { registerSensors, subscribeSensors } = useSensors(); + + useKeyboardNavigation(subscribeSensors); + + const filmstrip = useFilmstripProps(); + const { + position, + width, + height, + border, + borderStyle, + borderColor, + borderRadius, + padding, + gap, + vignette, + hideScrollbar, + scrollViewportMax, + } = filmstrip; + + const horizontal = isHorizontal(position); + const dim = horizontal ? width : height; + const crossDim = horizontal ? height : width; + const cellOuter = dim + 2 * (border + padding); + const crossOuter = crossDim + 2 * (border + padding); + const slotStep = cellOuter + gap; + + const preload = calculatePreload(carousel, slides); + const slidesLength = hasSlides(slides) ? slides.length : 0; + const activeSlideIndex = slidesLength > 0 ? getSlideIndex(globalIndex, slidesLength) : null; + + const defaultViewportCapPx = React.useMemo(() => { + if (slidesLength === 0) return 0; + const visibleSlots = Math.min(slidesLength, 2 * preload + 1); + return visibleSlots * slotStep - gap; + }, [slidesLength, preload, slotStep, gap]); + + const scrollViewportStyle = React.useMemo((): React.CSSProperties => { + const user = styles.filmstripScrollViewport; + const axisKey = horizontal ? "maxWidth" : "maxHeight"; + + let axisValue: string | undefined; + if (scrollViewportMax === "full") { + axisValue = "100%"; + } else if (typeof scrollViewportMax === "number") { + axisValue = `min(${scrollViewportMax}px, 100%)`; + } else if (typeof scrollViewportMax === "string") { + axisValue = scrollViewportMax; + } else if (defaultViewportCapPx > 0) { + axisValue = `min(${defaultViewportCapPx}px, 100%)`; + } + + const capVar = + defaultViewportCapPx > 0 && scrollViewportMax === undefined + ? { [cssVar("filmstrip_scroll_viewport_max")]: `${defaultViewportCapPx}px` } + : scrollViewportMax !== undefined && scrollViewportMax !== "full" && typeof scrollViewportMax === "number" + ? { [cssVar("filmstrip_scroll_viewport_max")]: `${scrollViewportMax}px` } + : null; + + const base: React.CSSProperties = { ...user, ...capVar }; + if (axisValue) { + return { ...base, [axisKey]: axisValue } as React.CSSProperties; + } + return base; + }, [horizontal, scrollViewportMax, defaultViewportCapPx, styles.filmstripScrollViewport]); + + const [scrollPos, setScrollPos] = React.useState(0); + + const onScroll = useEventCallback((event: React.UIEvent) => { + if (rafScrollRef.current) { + cancelAnimationFrame(rafScrollRef.current); + } + const el = event.currentTarget; + rafScrollRef.current = requestAnimationFrame(() => { + rafScrollRef.current = 0; + setScrollPos(horizontal ? el.scrollLeft : el.scrollTop); + }); + }); + + const scrollActiveIntoView = useEventCallback(() => { + const el = scrollRef.current; + if (!el || activeSlideIndex === null || slidesLength === 0) return; + + const thumbStart = activeSlideIndex * slotStep; + const thumbEnd = thumbStart + cellOuter; + const view = horizontal ? el.clientWidth : el.clientHeight; + const scroll = horizontal ? el.scrollLeft : el.scrollTop; + let target = scroll; + if (thumbStart < scroll) { + target = thumbStart; + } else if (thumbEnd > scroll + view) { + target = thumbEnd - view; + } + const scrollable = (horizontal ? el.scrollWidth : el.scrollHeight) - view; + target = Math.max(0, Math.min(target, Math.max(0, scrollable))); + if (horizontal) { + el.scrollLeft = target; + } else { + el.scrollTop = target; + } + setScrollPos(target); + }); + + useLayoutEffect(() => { + scrollActiveIntoView(); + }, [activeSlideIndex, scrollActiveIntoView]); + + useLayoutEffect(() => { + const el = scrollRef.current; + const prev = prevSlidesRef.current; + if (el && slides.length > prev.length && prev.length > 0) { + const d = slides.length - prev.length; + if (slides[d] === prev[0]) { + const delta = d * slotStep; + if (horizontal) { + el.scrollLeft += delta; + } else { + el.scrollTop += delta; + } + } + } + prevSlidesRef.current = slides; + if (el) { + setScrollPos(horizontal ? el.scrollLeft : el.scrollTop); + } + }, [slides, horizontal, slotStep]); + + const { startIndex, endIndex } = React.useMemo(() => { + if (slidesLength === 0) { + return { startIndex: 0, endIndex: -1 }; + } + const el = scrollRef.current; + const measured = el ? (horizontal ? el.clientWidth : el.clientHeight) : 0; + const view = Math.max(1, measured || defaultViewportCapPx || cellOuter * 3); + const scroll = scrollPos; + const start = Math.max(0, Math.floor(scroll / slotStep) - VIRTUALIZATION_BUFFER); + const lastVisible = Math.min(slidesLength - 1, Math.max(0, Math.ceil((scroll + view) / slotStep) - 1)); + const end = Math.min(slidesLength - 1, lastVisible + VIRTUALIZATION_BUFFER); + return { startIndex: start, endIndex: end }; + }, [slidesLength, scrollPos, slotStep, horizontal, defaultViewportCapPx, cellOuter]); + + const totalAxisSize = slidesLength > 0 ? slidesLength * slotStep - gap : 0; + + const visibleIndices = React.useMemo(() => { + if (slidesLength === 0 || endIndex < startIndex) { + return []; + } + const out: number[] = []; + for (let i = startIndex; i <= endIndex; i += 1) { + out.push(i); + } + return out; + }, [slidesLength, startIndex, endIndex]); + + const containerStyle = React.useMemo( + () => + ({ + ...(!visible ? { display: "none" } : null), + [cssVar(cssFilmstripThumbnailPrefix("width"))]: `${width}px`, + [cssVar(cssFilmstripThumbnailPrefix("height"))]: `${height}px`, + ...(border !== defaultFilmstripProps.border + ? { [cssVar(cssFilmstripThumbnailPrefix("border"))]: `${border}px` } + : null), + ...(borderStyle ? { [cssVar(cssFilmstripThumbnailPrefix("border_style"))]: borderStyle } : null), + ...(borderColor ? { [cssVar(cssFilmstripThumbnailPrefix("border_color"))]: borderColor } : null), + ...(borderRadius !== defaultFilmstripProps.borderRadius + ? { [cssVar(cssFilmstripThumbnailPrefix("border_radius"))]: `${borderRadius}px` } + : null), + ...(padding !== defaultFilmstripProps.padding + ? { [cssVar(cssFilmstripThumbnailPrefix("padding"))]: `${padding}px` } + : null), + ...(gap !== defaultFilmstripProps.gap ? { [cssVar(cssFilmstripThumbnailPrefix("gap"))]: `${gap}px` } : null), + ...styles.filmstripContainer, + }) as React.CSSProperties, + [visible, width, height, border, borderStyle, borderColor, borderRadius, padding, gap, styles.filmstripContainer], + ); + + const handleStripWheel = useEventCallback((event: React.WheelEvent) => { + if (horizontal) { + if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + } else if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { + return; + } + event.stopPropagation(); + }); + + const stopStripPointerBubble = useEventCallback((event: React.PointerEvent) => { + event.stopPropagation(); + }); + + const handleThumbnailClick = useEventCallback((index: number) => { + publish(ACTION_GO_TO, { index }); + }); + + const focusTrack = useEventCallback(() => { + trackRef.current?.focus(); + }); + + return ( +
+
+ +
+ {vignette &&
} +
+ ); +} diff --git a/src/plugins/filmstrip/filmstrip.scss b/src/plugins/filmstrip/filmstrip.scss new file mode 100644 index 0000000..a07f671 --- /dev/null +++ b/src/plugins/filmstrip/filmstrip.scss @@ -0,0 +1,223 @@ +@use "../../common"; + +$vignette-size: 12%; +$thumbnail-focus-box-shadow: + common.$color-black 0 0 0 2px, + common.$color-button 0 0 0 4px; + +@function localVar($key, $default) { + @return common.globalVar(filmstrip_ + $key, $default); +} + +.yarl__filmstrip { + display: flex; + height: 100%; + min-height: 0; + min-width: 0; + + &_top, + &_bottom { + flex-direction: column; + } + + &_start &_track, + &_end &_track { + flex-direction: column; + } + + &_wrapper { + flex: 1; + min-height: 0; + min-width: 0; + position: relative; + } + + &_container { + flex: 0 0 auto; + background-color: localVar(container_background_color, common.$color-backdrop); + padding: localVar(container_padding, 16px); + position: relative; + overflow: hidden; + user-select: none; + -webkit-touch-callout: none; + } + + &_top &_container_scrollable, + &_bottom &_container_scrollable { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + } + + &_top &_scroll_viewport, + &_bottom &_scroll_viewport { + overflow-x: auto; + overflow-y: hidden; + max-width: min(var(--yarl__filmstrip_scroll_viewport_max, 3000px), 100%); + width: 100%; + min-width: 0; + contain: layout; + -webkit-overflow-scrolling: touch; + touch-action: pan-x; + overscroll-behavior-x: contain; + overscroll-behavior: contain; + direction: ltr; + } + + &_start &_container_scrollable, + &_end &_container_scrollable { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-height: 100%; + min-height: 0; + align-self: stretch; + } + + &_start &_scroll_viewport, + &_end &_scroll_viewport { + overflow-y: auto; + overflow-x: hidden; + max-height: min(var(--yarl__filmstrip_scroll_viewport_max, 3000px), 100%); + width: 100%; + min-height: 0; + contain: layout; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior-y: contain; + overscroll-behavior: contain; + direction: ltr; + } + + &_scroll_viewport_hide_scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + scroll-behavior: smooth; + &::-webkit-scrollbar { + display: none; + } + } + + &_scroll_viewport &_track { + display: block; + position: relative; + touch-action: inherit; + flex-shrink: 0; + gap: 0; + } + + &_top &_scroll_viewport &_track, + &_bottom &_scroll_viewport &_track { + margin-inline: auto; + } + + &_start &_scroll_viewport &_track, + &_end &_scroll_viewport &_track { + margin-block: auto; + } + + &_thumbnail { + flex: 0 0 auto; + min-width: localVar(thumbnail_width, 120px); + min-height: localVar(thumbnail_height, 80px); + cursor: pointer; + appearance: none; + background: localVar(thumbnail_background, common.$color-black); + border-width: localVar(thumbnail_border, 1px); + border-style: localVar(thumbnail_border_style, solid); + border-color: localVar(thumbnail_border_color, common.$color-button); + border-radius: localVar(thumbnail_border_radius, 4px); + -webkit-tap-highlight-color: transparent; + position: relative; + overflow: hidden; + padding: localVar(thumbnail_padding, 4px); + outline: none; + + width: localVar(thumbnail_width, 120px); + height: localVar(thumbnail_height, 80px); + box-sizing: content-box; + + &_active { + border-color: localVar(thumbnail_active_border_color, common.$color-button-active); + } + + &:focus { + box-shadow: localVar(thumbnail_focus_box_shadow, $thumbnail-focus-box-shadow); + } + + &:focus:not(:focus-visible) { + box-shadow: unset; + } + + &:focus-visible { + box-shadow: localVar(thumbnail_focus_box_shadow, $thumbnail-focus-box-shadow); + } + } + + &_scroll_viewport &_thumbnail { + touch-action: inherit; + } + + &_thumbnail_icon { + color: localVar(thumbnail_icon_color, common.$color-button); + filter: localVar(thumbnail_icon_filter, common.$filter-drop-shadow); + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + width: localVar(thumbnail_icon_size, common.$icon-size); + height: localVar(thumbnail_icon_size, common.$icon-size); + } + + &_vignette { + position: absolute; + pointer-events: none; + --yarl__filmstrip_vignette_size: 12%; + } + + @media (min-width: 1200px) { + &_vignette { + --yarl__filmstrip_vignette_size: 8%; + } + } + + @media (min-width: 2000px) { + &_vignette { + --yarl__filmstrip_vignette_size: 5%; + } + } + + &_top &_vignette, + &_bottom &_vignette { + height: 100%; + left: 0; + right: 0; + background: linear-gradient( + to right, + common.$color-backdrop 0px, + transparent localVar(vignette_size, $vignette-size) calc(100% - localVar(vignette_size, $vignette-size)), + common.$color-backdrop 100% + ); + } + + &_start &_vignette, + &_end &_vignette { + width: 100%; + top: 0; + bottom: 0; + background: linear-gradient( + to bottom, + common.$color-backdrop 0px, + transparent localVar(vignette_size, $vignette-size) calc(100% - localVar(vignette_size, $vignette-size)), + common.$color-backdrop 100% + ); + } + + &_track { + display: flex; + outline: none; + gap: localVar(thumbnail_gap, 16px); + } +} diff --git a/src/plugins/filmstrip/index.ts b/src/plugins/filmstrip/index.ts new file mode 100644 index 0000000..934930a --- /dev/null +++ b/src/plugins/filmstrip/index.ts @@ -0,0 +1,72 @@ +import * as React from "react"; + +import { ImageFit, PLUGIN_FILMSTRIP, RenderFunction } from "../../index.js"; +import { Filmstrip } from "./Filmstrip.js"; +import type { FilmstripRef } from "./FilmstripContext.js"; +import type { FilmstripScrollViewportMax } from "./props.js"; + +declare module "../../types.js" { + interface LightboxProps { + /** Filmstrip plugin settings (scrollable virtualized rail). */ + filmstrip?: { + ref?: React.ForwardedRef; + position?: "top" | "bottom" | "start" | "end"; + width?: number; + height?: number; + border?: number; + borderStyle?: string; + borderColor?: string; + borderRadius?: number; + padding?: number; + gap?: number; + imageFit?: ImageFit; + vignette?: boolean; + hidden?: boolean; + showToggle?: boolean; + /** + * When `true`, hides scrollbars on the filmstrip viewport while keeping scroll. + * Default `false` (native scrollbar visible). + */ + hideScrollbar?: boolean; + /** + * Max size of the inner scroll viewport on the strip axis. + * Omit for default compact cap; `'full'` for 100%; `number` for px (clamped with `min(..., 100%)`); `string` for raw CSS. + */ + scrollViewportMax?: FilmstripScrollViewportMax; + }; + } + + interface Render { + buttonFilmstrip?: RenderFunction; + iconFilmstripVisible?: RenderFunction; + iconFilmstripHidden?: RenderFunction; + } + + interface Labels { + /** Filmstrip nav `aria-label` */ + Filmstrip?: string; + /** Show filmstrip toolbar button title */ + "Show filmstrip"?: string; + /** Hide filmstrip toolbar button title */ + "Hide filmstrip"?: string; + } + + interface SlotType { + /** filmstrip rail shell (outer padding / background) */ + filmstripContainer: "filmstripContainer"; + /** filmstrip virtual track (width/height of the scrollable content) */ + filmstripTrack: "filmstripTrack"; + /** filmstrip cell button (`yarl__filmstrip_thumbnail`) */ + filmstripThumbnail: "filmstripThumbnail"; + /** inner scroll viewport of the filmstrip (max width / height, overflow) */ + filmstripScrollViewport: "filmstripScrollViewport"; + } + + interface ToolbarButtonKeys { + [PLUGIN_FILMSTRIP]: null; + } +} + +export type { FilmstripRef } from "./FilmstripContext.js"; +export type { FilmstripScrollViewportMax } from "./props.js"; +export default Filmstrip; diff --git a/src/plugins/filmstrip/props.ts b/src/plugins/filmstrip/props.ts new file mode 100644 index 0000000..8360d56 --- /dev/null +++ b/src/plugins/filmstrip/props.ts @@ -0,0 +1,32 @@ +import { LightboxProps, useLightboxProps } from "../../index.js"; + +export type FilmstripScrollViewportMax = "full" | number | string | undefined; + +export const defaultFilmstripProps = { + ref: null, + position: "bottom" as const, + width: 120, + height: 80, + border: 1, + borderRadius: 4, + padding: 4, + gap: 16, + imageFit: "contain" as const, + vignette: true, + hidden: false, + showToggle: false, + hideScrollbar: false, + scrollViewportMax: undefined as FilmstripScrollViewportMax, +}; + +export const resolveFilmstripProps = (filmstrip?: LightboxProps["filmstrip"]) => ({ + ...defaultFilmstripProps, + ...filmstrip, +}); + +export type ResolvedFilmstripProps = ReturnType; + +export function useFilmstripProps() { + const { filmstrip } = useLightboxProps(); + return resolveFilmstripProps(filmstrip); +} diff --git a/src/plugins/filmstrip/utils.ts b/src/plugins/filmstrip/utils.ts new file mode 100644 index 0000000..a4bdb25 --- /dev/null +++ b/src/plugins/filmstrip/utils.ts @@ -0,0 +1,6 @@ +import { composePrefix, PLUGIN_FILMSTRIP } from "../../index.js"; + +export const cssFilmstripPrefix = (value?: string) => composePrefix(PLUGIN_FILMSTRIP, value); + +export const cssFilmstripThumbnailPrefix = (value?: string) => + composePrefix(PLUGIN_FILMSTRIP, composePrefix("thumbnail", value)); diff --git a/src/plugins/fullscreen/Fullscreen.tsx b/src/plugins/fullscreen/Fullscreen.tsx index 087021d..9d08c0f 100644 --- a/src/plugins/fullscreen/Fullscreen.tsx +++ b/src/plugins/fullscreen/Fullscreen.tsx @@ -4,6 +4,7 @@ import { addToolbarButton, createModule, MODULE_CONTROLLER, + PLUGIN_FILMSTRIP, PLUGIN_FULLSCREEN, PLUGIN_THUMBNAILS, PluginProps, @@ -21,7 +22,7 @@ export function Fullscreen({ augment, contains, addParent }: PluginProps) { })); addParent( - contains(PLUGIN_THUMBNAILS) ? PLUGIN_THUMBNAILS : MODULE_CONTROLLER, + contains(PLUGIN_THUMBNAILS) ? PLUGIN_THUMBNAILS : contains(PLUGIN_FILMSTRIP) ? PLUGIN_FILMSTRIP : MODULE_CONTROLLER, createModule(PLUGIN_FULLSCREEN, FullscreenContextProvider), ); } diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 4cf6da6..2eac3f8 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -6,5 +6,6 @@ export { default as Inline } from "./inline/index.js"; export { default as Share } from "./share/index.js"; export { default as Slideshow } from "./slideshow/index.js"; export { default as Thumbnails } from "./thumbnails/index.js"; +export { default as Filmstrip } from "./filmstrip/index.js"; export { default as Video } from "./video/index.js"; export { default as Zoom } from "./zoom/index.js"; diff --git a/src/types.ts b/src/types.ts index e55dc4b..90a5063 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { ACTION_CLOSE, + ACTION_GO_TO, ACTION_NEXT, ACTION_PREV, ACTION_SWIPE, @@ -287,6 +288,12 @@ export type LightboxStateUpdateAction = { index: number; }; +/** Lightbox state instant go-to action */ +export type LightboxStateGoToAction = { + type: "goTo"; + index: number; +}; + /** Lightbox state */ export interface LightboxState { /** lightbox slides */ @@ -429,6 +436,7 @@ export interface EventTypes { [ACTION_PREV]: NavigationAction | void; [ACTION_NEXT]: NavigationAction | void; [ACTION_SWIPE]: LightboxStateSwipeAction; + [ACTION_GO_TO]: { index: number }; [ACTION_CLOSE]: void; [ACTIVE_SLIDE_LOADING]: void; diff --git a/test/unit/plugins/Filmstrip.spec.ts b/test/unit/plugins/Filmstrip.spec.ts new file mode 100644 index 0000000..e8ef87a --- /dev/null +++ b/test/unit/plugins/Filmstrip.spec.ts @@ -0,0 +1,129 @@ +import * as React from "react"; +import { act, render, screen } from "@testing-library/react"; + +import { clickButton, expectCurrentImageToBe, expectToContainButton, lightbox, querySelector } from "../test-utils.js"; +import { Filmstrip, Fullscreen } from "../../../src/plugins/index.js"; +import { LightboxExternalProps } from "../../../src/index.js"; + +function renderLightbox(props?: LightboxExternalProps) { + return render( + lightbox({ + slides: [{ src: "image1" }, { src: "image2" }, { src: "image3" }], + plugins: [Filmstrip], + ...props, + }), + ); +} + +function queryFilmstripThumbs(withImage = true) { + return screen + .queryAllByRole("button") + .filter((el) => !withImage || Boolean(el.querySelector("img"))) + .filter((el) => el.classList.contains("yarl__filmstrip_thumbnail")); +} + +function clickFilmstripThumb(image: string) { + act(() => { + const thumb = queryFilmstripThumbs().find((el) => Boolean(el.querySelector(`img[src='${image}']`))); + if (!thumb) { + throw new Error(`No filmstrip thumb for ${image}`); + } + thumb.click(); + }); +} + +describe("Filmstrip", () => { + it("uses native lazy loading on preview images so the browser can defer work", () => { + renderLightbox(); + clickButton("Next"); + + const viewport = querySelector(".yarl__filmstrip_scroll_viewport"); + const images = viewport?.querySelectorAll("img") ?? []; + + expect(images.length).toBeGreaterThan(0); + Array.from(images).forEach((img) => { + expect(img.getAttribute("loading")).toBe("lazy"); + }); + }); + + it("renders a small window of previews instead of one button per slide", () => { + const slides = Array.from({ length: 40 }, (_, i) => ({ src: `slide-${i}.jpg` })); + render(lightbox({ slides, plugins: [Filmstrip] })); + clickButton("Next"); + + const mounted = queryFilmstripThumbs().length; + + expect(slides.length).toBe(40); + expect(mounted).toBeLessThan(slides.length); + expect(mounted).toBe(7); + }); + + it("renders without crashing", () => { + renderLightbox(); + clickButton("Next"); + expect(queryFilmstripThumbs().length).toBeGreaterThan(0); + }); + + it("uses scroll viewport and default cap CSS variable when omitted", () => { + renderLightbox(); + const viewport = querySelector(".yarl__filmstrip_scroll_viewport"); + expect(viewport).toBeInTheDocument(); + expect(viewport?.getAttribute("style")).toContain("--yarl__filmstrip_scroll_viewport_max"); + }); + + it("merges styles.filmstripScrollViewport", () => { + renderLightbox({ + styles: { filmstripScrollViewport: { outline: "2px solid red" } }, + }); + const viewport = querySelector(".yarl__filmstrip_scroll_viewport") as HTMLElement; + expect(viewport).toBeInTheDocument(); + expect(viewport.style.outline).toBe("2px solid red"); + }); + + it("applies hide-scrollbar class when filmstrip.hideScrollbar is true", () => { + renderLightbox({ filmstrip: { hideScrollbar: true } }); + clickButton("Next"); + const viewport = querySelector(".yarl__filmstrip_scroll_viewport"); + expect(viewport).toHaveClass("yarl__filmstrip_scroll_viewport_hide_scrollbar"); + }); + + it("jumps instantly to another slide without stepping the carousel (goTo)", () => { + renderLightbox({ carousel: { preload: 0 } }); + expectCurrentImageToBe("image1"); + clickFilmstripThumb("image3"); + expectCurrentImageToBe("image3"); + clickFilmstripThumb("image1"); + expectCurrentImageToBe("image1"); + }); + + it("works with Fullscreen when Fullscreen is listed first", () => { + render( + lightbox({ + slides: [{ src: "a" }, { src: "b" }], + plugins: [Fullscreen, Filmstrip], + }), + ); + expect(queryFilmstripThumbs().length).toBeGreaterThan(0); + }); + + it("supports filmstrip toggle button", () => { + renderLightbox({ filmstrip: { showToggle: true } }); + expect(queryFilmstripThumbs().length).toBeGreaterThan(0); + + clickButton("Hide filmstrip"); + const hiddenShell = querySelector(".yarl__filmstrip_container") as HTMLElement | null; + expect(hiddenShell?.style.display).toBe("none"); + + clickButton("Show filmstrip"); + const shownShell = querySelector(".yarl__filmstrip_container") as HTMLElement | null; + expect(shownShell?.style.display).not.toBe("none"); + }); + + it("supports custom filmstrip button", () => { + renderLightbox({ + filmstrip: { showToggle: true }, + render: { buttonFilmstrip: () => React.createElement("button", { type: "button" }, "Custom filmstrip") }, + }); + expectToContainButton("Custom filmstrip"); + }); +});