@synerise/ds-image 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +55 -0
  4. package/dist/Gallery/Gallery.d.ts +3 -0
  5. package/dist/Gallery/Gallery.js +45 -0
  6. package/dist/Gallery/Gallery.styles.d.ts +1 -0
  7. package/dist/Gallery/Gallery.styles.js +8 -0
  8. package/dist/Gallery/Gallery.types.d.ts +39 -0
  9. package/dist/Gallery/Gallery.types.js +1 -0
  10. package/dist/Preview/ImagePreview.const.d.ts +16 -0
  11. package/dist/Preview/ImagePreview.const.js +10 -0
  12. package/dist/Preview/ImagePreview.d.ts +4 -0
  13. package/dist/Preview/ImagePreview.js +208 -0
  14. package/dist/Preview/ImagePreview.styles.d.ts +13 -0
  15. package/dist/Preview/ImagePreview.styles.js +44 -0
  16. package/dist/Preview/ImagePreview.types.d.ts +39 -0
  17. package/dist/Preview/ImagePreview.types.js +1 -0
  18. package/dist/Preview/PreviewToolbar.d.ts +21 -0
  19. package/dist/Preview/PreviewToolbar.js +60 -0
  20. package/dist/Thumbnail/Thumbnail.const.d.ts +7 -0
  21. package/dist/Thumbnail/Thumbnail.const.js +22 -0
  22. package/dist/Thumbnail/Thumbnail.d.ts +3 -0
  23. package/dist/Thumbnail/Thumbnail.js +60 -0
  24. package/dist/Thumbnail/Thumbnail.styles.d.ts +24 -0
  25. package/dist/Thumbnail/Thumbnail.styles.js +37 -0
  26. package/dist/Thumbnail/Thumbnail.types.d.ts +34 -0
  27. package/dist/Thumbnail/Thumbnail.types.js +1 -0
  28. package/dist/context/ImageGalleryContext.d.ts +16 -0
  29. package/dist/context/ImageGalleryContext.js +7 -0
  30. package/dist/hooks/useImagePreview.d.ts +33 -0
  31. package/dist/hooks/useImagePreview.js +48 -0
  32. package/dist/index.d.ts +8 -0
  33. package/dist/index.js +10 -0
  34. package/dist/modules.d.js +1 -0
  35. package/dist/modules.d.ts +0 -0
  36. package/dist/shared/Image.shared.types.d.ts +42 -0
  37. package/dist/shared/Image.shared.types.js +1 -0
  38. package/dist/shared/ImageContent.d.ts +27 -0
  39. package/dist/shared/ImageContent.js +36 -0
  40. package/dist/shared/ImageContent.styles.d.ts +2 -0
  41. package/dist/shared/ImageContent.styles.js +8 -0
  42. package/dist/shared/useImageStatus.d.ts +17 -0
  43. package/dist/shared/useImageStatus.js +28 -0
  44. package/dist/shared/useImageTexts.d.ts +8 -0
  45. package/dist/shared/useImageTexts.js +38 -0
  46. package/package.json +58 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
6
+ # 0.1.0 (2026-07-08)
7
+
8
+ ### Features
9
+
10
+ - add ImageView component ([8bb707d](https://github.com/Synerise/synerise-design/commit/8bb707de09785d60a0cee48ae7147f3d6b117b3f))
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Synerise
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @synerise/ds-image
2
+
3
+ Image components for the Synerise Design System: inline **thumbnails**, a
4
+ **gallery** of thumbnails with uniform sizing, and a full-screen **preview**
5
+ (lightbox) with zoom, pan and navigation.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @synerise/ds-image
11
+ ```
12
+
13
+ ## Exports
14
+
15
+ - `Thumbnail` — a single image tile (aspect ratio, size, background, object-fit,
16
+ Empty state, broken-image fallback, optional delete-on-hover).
17
+ - `Gallery` — many thumbnails sharing the same visual settings; clicking one opens
18
+ the preview at that image.
19
+ - `ImagePreview` — a controlled full-screen lightbox with a bottom-center toolbar
20
+ (navigation `‹ N of M ›` + zoom/fullscreen).
21
+ - `useImagePreview` — open/close/next/prev state so the preview can be triggered
22
+ from any element (a list row, a button, a table cell).
23
+
24
+ ## Usage
25
+
26
+ ```tsx
27
+ import { Gallery, ImagePreview, Thumbnail, useImagePreview } from '@synerise/ds-image';
28
+
29
+ const images = [
30
+ { src: '/a.jpg', alt: 'A' },
31
+ { src: '/b.jpg', alt: 'B' },
32
+ ];
33
+
34
+ // A single thumbnail
35
+ <Thumbnail src="/a.jpg" alt="A" aspectRatio="1:1" size="m" objectFit="cover" />;
36
+
37
+ // A gallery — settings apply to every thumbnail; clicking opens the preview
38
+ <Gallery images={images} aspectRatio="1:1" size="s" background="subtle-grey" objectFit="cover" />;
39
+
40
+ // Preview triggered from an arbitrary element
41
+ const Example = () => {
42
+ const preview = useImagePreview(images);
43
+ return (
44
+ <>
45
+ <button type="button" onClick={() => preview.open(0)}>
46
+ Open
47
+ </button>
48
+ <ImagePreview {...preview.previewProps} />
49
+ </>
50
+ );
51
+ };
52
+ ```
53
+
54
+ See the Storybook overview page for full props tables, behavior notes, and
55
+ accessibility guidance.
@@ -0,0 +1,3 @@
1
+ import { GalleryProps } from './Gallery.types';
2
+ declare const Gallery: ({ images, aspectRatio, size, height, background, objectFit, loading, fallback, deletable, onDelete, openZoom, zoomable, zoomStep, maxZoom, initialZoom, getContainer, texts, className, }: GalleryProps) => JSX.Element;
3
+ export default Gallery;
@@ -0,0 +1,45 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import { useState, useMemo } from "react";
3
+ import ImagePreview from "../Preview/ImagePreview.js";
4
+ import Thumbnail from "../Thumbnail/Thumbnail.js";
5
+ import { ImageGalleryContext } from "../context/ImageGalleryContext.js";
6
+ import { Container } from "./Gallery.styles.js";
7
+ const Gallery = ({
8
+ images,
9
+ aspectRatio,
10
+ size,
11
+ height,
12
+ background,
13
+ objectFit,
14
+ loading,
15
+ fallback,
16
+ deletable = false,
17
+ onDelete,
18
+ openZoom = true,
19
+ zoomable,
20
+ zoomStep,
21
+ maxZoom,
22
+ initialZoom,
23
+ getContainer,
24
+ texts,
25
+ className
26
+ }) => {
27
+ const [previewIndex, setPreviewIndex] = useState(null);
28
+ const contextValue = useMemo(() => ({
29
+ aspectRatio,
30
+ size,
31
+ height,
32
+ background,
33
+ objectFit,
34
+ loading,
35
+ fallback,
36
+ texts
37
+ }), [aspectRatio, size, height, background, objectFit, loading, fallback, texts]);
38
+ return /* @__PURE__ */ jsxs(ImageGalleryContext.Provider, { value: contextValue, children: [
39
+ /* @__PURE__ */ jsx(Container, { className, "data-testid": "image-gallery", children: images.map((image, imageIndex) => /* @__PURE__ */ jsx(Thumbnail, { src: image.src, alt: image.alt, fallback: image.fallback, deletable, onDelete: onDelete ? () => onDelete(imageIndex) : void 0, openZoom, onClick: openZoom ? () => setPreviewIndex(imageIndex) : void 0 }, `${image.src}-${imageIndex}`)) }),
40
+ openZoom && /* @__PURE__ */ jsx(ImagePreview, { open: previewIndex !== null, images, index: previewIndex ?? 0, onIndexChange: setPreviewIndex, onClose: () => setPreviewIndex(null), zoomable, zoomStep, maxZoom, initialZoom, fallback, getContainer, texts })
41
+ ] });
42
+ };
43
+ export {
44
+ Gallery as default
45
+ };
@@ -0,0 +1 @@
1
+ export declare const Container: import('styled-components').StyledComponent<"div", any, {}, never>;
@@ -0,0 +1,8 @@
1
+ import styled from "styled-components";
2
+ const Container = /* @__PURE__ */ styled.div.withConfig({
3
+ displayName: "Gallerystyles__Container",
4
+ componentId: "sc-94iczo-0"
5
+ })(["display:flex;flex-wrap:wrap;gap:8px;"]);
6
+ export {
7
+ Container
8
+ };
@@ -0,0 +1,39 @@
1
+ import { ReactNode } from 'react';
2
+ import { AspectRatio, ImageSource, ImageTexts, InitialZoom, ObjectFit, ThumbnailBackground, ThumbnailSize } from '../shared/Image.shared.types';
3
+ export type GalleryProps = {
4
+ /** Images rendered as thumbnails and paged through in the preview. */
5
+ images: ImageSource[];
6
+ /** Aspect ratio applied to every thumbnail. Defaults to `1:1`. */
7
+ aspectRatio?: AspectRatio;
8
+ /** Height token applied to every thumbnail. Defaults to `m`. */
9
+ size?: ThumbnailSize;
10
+ /** Explicit height in px, used when `size` is `custom`. */
11
+ height?: number;
12
+ /** Backdrop applied to every thumbnail. Defaults to `none`. */
13
+ background?: ThumbnailBackground;
14
+ /** How images fill their tiles. Defaults to `contain`. */
15
+ objectFit?: ObjectFit;
16
+ /** Native loading hint applied to every thumbnail. Defaults to `lazy`. */
17
+ loading?: 'lazy' | 'eager';
18
+ /** Fallback for any broken image (thumbnails and preview). */
19
+ fallback?: ReactNode;
20
+ /** Show a delete affordance on each thumbnail. */
21
+ deletable?: boolean;
22
+ /** Called with the index of the thumbnail whose delete affordance fired. */
23
+ onDelete?: (index: number) => void;
24
+ /** Whether clicking a thumbnail opens the preview. Defaults to true. */
25
+ openZoom?: boolean;
26
+ /** Preview pass-through — enable zoom/pan. */
27
+ zoomable?: boolean;
28
+ /** Preview pass-through — per-click zoom multiplier. */
29
+ zoomStep?: number;
30
+ /** Preview pass-through — max zoom relative to natural size. */
31
+ maxZoom?: number;
32
+ /** Preview pass-through — where zoom starts. */
33
+ initialZoom?: InitialZoom;
34
+ /** Preview pass-through — portal target. */
35
+ getContainer?: () => HTMLElement;
36
+ /** Override tooltip / accessible labels for thumbnails and the preview. */
37
+ texts?: Partial<ImageTexts>;
38
+ className?: string;
39
+ };
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,16 @@
1
+ /** RZPP scale at which the whole image is fitted to the working area. */
2
+ export declare const FIT_SCALE = 1;
3
+ /**
4
+ * Per-click zoom multiplier (Figma: "Skok zooma"). `1.4` means each zoom step
5
+ * scales by ×1.4. Converted to the additive step react-zoom-pan-pinch expects
6
+ * via `multiplier - 1`.
7
+ */
8
+ export declare const DEFAULT_ZOOM_STEP = 1.4;
9
+ /** Max zoom relative to the image's natural size (Figma: "Max zoom" = 300%). */
10
+ export declare const DEFAULT_MAX_ZOOM = 3;
11
+ /**
12
+ * Gap (px) between the previewed image and the viewport edge. Kept constant
13
+ * (not removed on zoom) so the zoom/pan working area never resizes mid-zoom —
14
+ * resizing it makes react-zoom-pan-pinch re-fit and corrupts the first zoom step.
15
+ */
16
+ export declare const PREVIEW_PADDING = 32;
@@ -0,0 +1,10 @@
1
+ const FIT_SCALE = 1;
2
+ const DEFAULT_ZOOM_STEP = 1.4;
3
+ const DEFAULT_MAX_ZOOM = 3;
4
+ const PREVIEW_PADDING = 32;
5
+ export {
6
+ DEFAULT_MAX_ZOOM,
7
+ DEFAULT_ZOOM_STEP,
8
+ FIT_SCALE,
9
+ PREVIEW_PADDING
10
+ };
@@ -0,0 +1,4 @@
1
+ import { default as React } from 'react';
2
+ import { ImagePreviewProps } from './ImagePreview.types';
3
+ declare const ImagePreview: ({ open, images, index, onIndexChange, onClose, zoomable, zoomStep, maxZoom, initialZoom, closable, maskClosable, fallback, getContainer, destroyOnClose, onZoom, texts: textsProp, }: ImagePreviewProps) => React.ReactPortal | null;
4
+ export default ImagePreview;
@@ -0,0 +1,208 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState, useEffect, useCallback } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
5
+ import Icon, { ImageM, CloseM } from "@synerise/ds-icon";
6
+ import { ToolbarGroup, ToolbarButton } from "@synerise/ds-toolbar";
7
+ import { useFocusTrap } from "@synerise/ds-utils";
8
+ import { useImageStatus } from "../shared/useImageStatus.js";
9
+ import { useImageTexts } from "../shared/useImageTexts.js";
10
+ import { FIT_SCALE, PREVIEW_PADDING, DEFAULT_MAX_ZOOM, DEFAULT_ZOOM_STEP } from "./ImagePreview.const.js";
11
+ import { Image, Fallback, FallbackBox, Overlay, ImageWrapper, CloseWrapper, ToolbarWrapper } from "./ImagePreview.styles.js";
12
+ import PreviewToolbar from "./PreviewToolbar.js";
13
+ const SCALE_EPSILON = 0.01;
14
+ const ZOOM_ANIMATION_MS = 200;
15
+ const CLICK_MOVE_THRESHOLD = 5;
16
+ const stopPropagation = (event) => {
17
+ event.stopPropagation();
18
+ };
19
+ const getDownloadName = (src) => {
20
+ if (!src || src.startsWith("data:")) {
21
+ return "image";
22
+ }
23
+ const path = src.split(/[?#]/)[0];
24
+ return path.substring(path.lastIndexOf("/") + 1) || "image";
25
+ };
26
+ const ImagePreview = ({
27
+ open,
28
+ images,
29
+ index,
30
+ onIndexChange,
31
+ onClose,
32
+ zoomable = true,
33
+ zoomStep = DEFAULT_ZOOM_STEP,
34
+ maxZoom = DEFAULT_MAX_ZOOM,
35
+ initialZoom = "fit",
36
+ closable = true,
37
+ maskClosable = true,
38
+ fallback,
39
+ getContainer,
40
+ destroyOnClose = false,
41
+ onZoom,
42
+ texts: textsProp
43
+ }) => {
44
+ const texts = useImageTexts(textsProp);
45
+ const overlayRef = useRef(null);
46
+ const workingAreaRef = useRef(null);
47
+ const transformRef = useRef(null);
48
+ const pointerDownRef = useRef(null);
49
+ const total = images.length;
50
+ const currentImage = images[index];
51
+ const currentSrc = currentImage?.src ?? "";
52
+ const {
53
+ ref: imgRef,
54
+ status,
55
+ handleLoad,
56
+ handleError
57
+ } = useImageStatus(currentSrc);
58
+ const [naturalScaleFactor, setNaturalScaleFactor] = useState(FIT_SCALE);
59
+ const [maxScale, setMaxScale] = useState(FIT_SCALE);
60
+ const [scale, setScale] = useState(FIT_SCALE);
61
+ const [isPanning, setIsPanning] = useState(false);
62
+ useFocusTrap(overlayRef, open);
63
+ useEffect(() => {
64
+ if (open) {
65
+ overlayRef.current?.focus();
66
+ }
67
+ }, [open]);
68
+ const goTo = useCallback((next) => {
69
+ if (total < 2) {
70
+ return;
71
+ }
72
+ onIndexChange((next + total) % total);
73
+ }, [total, onIndexChange]);
74
+ const measureZoom = useCallback(() => {
75
+ const area = workingAreaRef.current;
76
+ const img = imgRef.current;
77
+ if (!area || !img || !img.naturalWidth) {
78
+ return FIT_SCALE;
79
+ }
80
+ const availableWidth = area.clientWidth - 2 * PREVIEW_PADDING;
81
+ const availableHeight = area.clientHeight - 2 * PREVIEW_PADDING;
82
+ const fitToNatural = Math.max(img.naturalWidth / availableWidth, img.naturalHeight / availableHeight);
83
+ const factor = fitToNatural > FIT_SCALE ? fitToNatural : FIT_SCALE;
84
+ setNaturalScaleFactor(factor);
85
+ setMaxScale(factor * maxZoom);
86
+ return factor;
87
+ }, [imgRef, maxZoom]);
88
+ const applyInitialZoom = useCallback((factor) => {
89
+ if (initialZoom === "real-size" && factor > FIT_SCALE) {
90
+ transformRef.current?.centerView?.(factor, 0);
91
+ }
92
+ }, [initialZoom]);
93
+ const handleImageLoad = useCallback((event) => {
94
+ handleLoad(event);
95
+ applyInitialZoom(measureZoom());
96
+ }, [handleLoad, measureZoom, applyInitialZoom]);
97
+ useEffect(() => {
98
+ if (!open) {
99
+ return;
100
+ }
101
+ transformRef.current?.resetTransform?.(0);
102
+ setScale(FIT_SCALE);
103
+ const img = imgRef.current;
104
+ if (img?.complete && img.naturalWidth) {
105
+ applyInitialZoom(measureZoom());
106
+ }
107
+ }, [open, currentSrc, imgRef, measureZoom, applyInitialZoom]);
108
+ useEffect(() => {
109
+ if (!open) {
110
+ return void 0;
111
+ }
112
+ const handleKeyDown = (event) => {
113
+ if (event.key === "Escape" && closable) {
114
+ event.stopPropagation();
115
+ onClose();
116
+ } else if (event.key === "ArrowLeft") {
117
+ goTo(index - 1);
118
+ } else if (event.key === "ArrowRight") {
119
+ goTo(index + 1);
120
+ }
121
+ };
122
+ const handleResize = () => {
123
+ measureZoom();
124
+ };
125
+ document.addEventListener("keydown", handleKeyDown);
126
+ window.addEventListener("resize", handleResize);
127
+ const previousOverflow = document.body.style.overflow;
128
+ document.body.style.overflow = "hidden";
129
+ return () => {
130
+ document.removeEventListener("keydown", handleKeyDown);
131
+ window.removeEventListener("resize", handleResize);
132
+ document.body.style.overflow = previousOverflow;
133
+ };
134
+ }, [open, closable, onClose, goTo, index, measureZoom]);
135
+ const handleTransformed = useCallback((_ref, state) => {
136
+ setScale(state.scale);
137
+ onZoom?.(state.scale);
138
+ }, [onZoom]);
139
+ const handleOverlayPointerDown = useCallback((event) => {
140
+ pointerDownRef.current = {
141
+ x: event.clientX,
142
+ y: event.clientY
143
+ };
144
+ }, []);
145
+ const handleMaskClick = useCallback((event) => {
146
+ const start = pointerDownRef.current;
147
+ pointerDownRef.current = null;
148
+ if (!maskClosable) {
149
+ return;
150
+ }
151
+ if (start && (Math.abs(event.clientX - start.x) > CLICK_MOVE_THRESHOLD || Math.abs(event.clientY - start.y) > CLICK_MOVE_THRESHOLD)) {
152
+ return;
153
+ }
154
+ onClose();
155
+ }, [maskClosable, onClose]);
156
+ const rzppStep = Math.max(zoomStep - 1, SCALE_EPSILON);
157
+ const stepZoom = useCallback((direction) => {
158
+ const level = Math.round(Math.log(scale / naturalScaleFactor) / Math.log(zoomStep));
159
+ const target = naturalScaleFactor * zoomStep ** (level + direction);
160
+ const clamped = Math.min(Math.max(target, FIT_SCALE), maxScale);
161
+ transformRef.current?.centerView?.(clamped, ZOOM_ANIMATION_MS);
162
+ }, [scale, naturalScaleFactor, zoomStep, maxScale]);
163
+ const handleZoomIn = useCallback(() => stepZoom(1), [stepZoom]);
164
+ const handleZoomOut = useCallback(() => stepZoom(-1), [stepZoom]);
165
+ if (!open && destroyOnClose) {
166
+ return null;
167
+ }
168
+ const isZoomed = scale > FIT_SCALE + SCALE_EPSILON;
169
+ const showZoomControls = zoomable && maxScale > FIT_SCALE + SCALE_EPSILON;
170
+ const getPanCursor = () => {
171
+ if (!isZoomed) {
172
+ return "default";
173
+ }
174
+ return isPanning ? "grabbing" : "grab";
175
+ };
176
+ const zoomPercent = naturalScaleFactor > 0 ? Math.round(scale / naturalScaleFactor * 100) : 100;
177
+ const hasError = status === "error" || !currentImage;
178
+ const showDownload = !hasError && Boolean(currentSrc);
179
+ const image = /* @__PURE__ */ jsx(Image, { ref: imgRef, src: currentSrc, alt: currentImage?.alt ?? "", onLoad: handleImageLoad, onError: handleError, onClick: stopPropagation, draggable: false, "data-testid": "image-preview-image" }, index);
180
+ const content = hasError ? /* @__PURE__ */ jsx(Fallback, { onClick: stopPropagation, "data-testid": "image-preview-fallback", children: currentImage?.fallback ?? fallback ?? /* @__PURE__ */ jsx(FallbackBox, { children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(ImageM, {}) }) }) }) : image;
181
+ return createPortal(/* @__PURE__ */ jsxs(Overlay, { ref: overlayRef, role: "dialog", "aria-modal": true, "aria-label": currentImage?.alt, tabIndex: -1, $hidden: !open, onMouseDown: handleOverlayPointerDown, onClick: handleMaskClick, "data-testid": "image-preview", children: [
182
+ /* @__PURE__ */ jsx(ImageWrapper, { ref: workingAreaRef, children: zoomable && !hasError ? /* @__PURE__ */ jsx(TransformWrapper, { ref: transformRef, minScale: FIT_SCALE, maxScale, initialScale: FIT_SCALE, centerOnInit: true, doubleClick: {
183
+ mode: "reset"
184
+ }, wheel: {
185
+ step: rzppStep
186
+ }, panning: {
187
+ disabled: !isZoomed
188
+ }, onPanningStart: () => setIsPanning(true), onPanningStop: () => setIsPanning(false), onTransformed: handleTransformed, children: /* @__PURE__ */ jsx(TransformComponent, { wrapperStyle: {
189
+ width: "100%",
190
+ height: "100%",
191
+ cursor: getPanCursor()
192
+ }, contentStyle: {
193
+ display: "flex",
194
+ alignItems: "center",
195
+ justifyContent: "center",
196
+ width: "100%",
197
+ height: "100%"
198
+ }, children: content }) }) : content }),
199
+ closable && /* @__PURE__ */ jsx(CloseWrapper, { onClick: stopPropagation, children: /* @__PURE__ */ jsx(ToolbarGroup, { children: /* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.close, tooltipProps: {
200
+ title: texts.close,
201
+ placement: "bottom"
202
+ }, onClick: onClose, "data-testid": "image-preview-close", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(CloseM, {}) }) }) }) }),
203
+ (total > 1 || showZoomControls || showDownload) && /* @__PURE__ */ jsx(ToolbarWrapper, { onClick: stopPropagation, children: /* @__PURE__ */ jsx(PreviewToolbar, { showNavigation: total > 1, current: index + 1, total, onPrev: () => goTo(index - 1), onNext: () => goTo(index + 1), showZoom: showZoomControls, zoomPercent, canZoomIn: scale < maxScale - SCALE_EPSILON, canZoomOut: isZoomed, onZoomIn: handleZoomIn, onZoomOut: handleZoomOut, showDownload, downloadHref: currentSrc, downloadName: getDownloadName(currentSrc), texts }) })
204
+ ] }), getContainer?.() || document.body);
205
+ };
206
+ export {
207
+ ImagePreview as default
208
+ };
@@ -0,0 +1,13 @@
1
+ import { ThemeProps } from '@synerise/ds-core';
2
+ export declare const Overlay: import('styled-components').StyledComponent<"div", any, ThemeProps & {
3
+ $hidden?: boolean;
4
+ }, never>;
5
+ export declare const ImageWrapper: import('styled-components').StyledComponent<"div", any, {}, never>;
6
+ export declare const Image: import('styled-components').StyledComponent<"img", any, ThemeProps, never>;
7
+ export declare const Fallback: import('styled-components').StyledComponent<"div", any, {}, never>;
8
+ /** Default broken-image placeholder shown when an image fails to load. */
9
+ export declare const FallbackBox: import('styled-components').StyledComponent<"div", any, ThemeProps, never>;
10
+ /** Close button — kept in the top-right corner, away from the bottom toolbar. */
11
+ export declare const CloseWrapper: import('styled-components').StyledComponent<"div", any, {}, never>;
12
+ /** Navigation + zoom controls — centered along the bottom edge. */
13
+ export declare const ToolbarWrapper: import('styled-components').StyledComponent<"div", any, {}, never>;
@@ -0,0 +1,44 @@
1
+ import styled, { keyframes } from "styled-components";
2
+ import { PREVIEW_PADDING } from "./ImagePreview.const.js";
3
+ const OVERLAY_Z_INDEX = 1050;
4
+ const imageFadeIn = /* @__PURE__ */ keyframes(["from{opacity:0;}to{opacity:1;}"]);
5
+ const BACKDROP_ALPHA = "CC";
6
+ const CONTROLS_INSET = 16;
7
+ const IMAGE_BORDER_RADIUS = 8;
8
+ const Overlay = /* @__PURE__ */ styled.div.withConfig({
9
+ displayName: "ImagePreviewstyles__Overlay",
10
+ componentId: "sc-17aatff-0"
11
+ })(["position:fixed;inset:0;z-index:", ";display:flex;align-items:center;justify-content:center;outline:none;background-color:", ";opacity:", ";visibility:", ";pointer-events:", ";transition:opacity 160ms ease,visibility 160ms ease;"], OVERLAY_Z_INDEX, (props) => `${props.theme.palette["grey-900"]}${BACKDROP_ALPHA}`, (props) => props.$hidden ? 0 : 1, (props) => props.$hidden ? "hidden" : "visible", (props) => props.$hidden ? "none" : "auto");
12
+ const ImageWrapper = /* @__PURE__ */ styled.div.withConfig({
13
+ displayName: "ImagePreviewstyles__ImageWrapper",
14
+ componentId: "sc-17aatff-1"
15
+ })(["position:relative;display:flex;flex:1 1 auto;align-items:center;justify-content:center;width:100%;height:100%;min-height:0;padding:", "px;"], PREVIEW_PADDING);
16
+ const Image = /* @__PURE__ */ styled.img.withConfig({
17
+ displayName: "ImagePreviewstyles__Image",
18
+ componentId: "sc-17aatff-2"
19
+ })(["max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;user-select:none;border-radius:", "px;box-shadow:", ";animation:", " 200ms ease;"], IMAGE_BORDER_RADIUS, (props) => props.theme.variables["box-shadow-4"], imageFadeIn);
20
+ const Fallback = /* @__PURE__ */ styled.div.withConfig({
21
+ displayName: "ImagePreviewstyles__Fallback",
22
+ componentId: "sc-17aatff-3"
23
+ })(["display:flex;align-items:center;justify-content:center;max-width:100%;max-height:100%;"]);
24
+ const FallbackBox = /* @__PURE__ */ styled.div.withConfig({
25
+ displayName: "ImagePreviewstyles__FallbackBox",
26
+ componentId: "sc-17aatff-4"
27
+ })(["display:flex;align-items:center;justify-content:center;width:240px;height:180px;border-radius:", "px;color:", ";background-color:", ";"], IMAGE_BORDER_RADIUS, (props) => props.theme.palette["grey-600"], (props) => props.theme.palette["grey-050"]);
28
+ const CloseWrapper = /* @__PURE__ */ styled.div.withConfig({
29
+ displayName: "ImagePreviewstyles__CloseWrapper",
30
+ componentId: "sc-17aatff-5"
31
+ })(["position:absolute;top:", "px;right:", "px;z-index:2;"], CONTROLS_INSET, CONTROLS_INSET);
32
+ const ToolbarWrapper = /* @__PURE__ */ styled.div.withConfig({
33
+ displayName: "ImagePreviewstyles__ToolbarWrapper",
34
+ componentId: "sc-17aatff-6"
35
+ })(["position:absolute;bottom:", "px;left:50%;transform:translateX(-50%);z-index:2;display:flex;align-items:center;gap:8px;"], CONTROLS_INSET);
36
+ export {
37
+ CloseWrapper,
38
+ Fallback,
39
+ FallbackBox,
40
+ Image,
41
+ ImageWrapper,
42
+ Overlay,
43
+ ToolbarWrapper
44
+ };
@@ -0,0 +1,39 @@
1
+ import { ReactNode } from 'react';
2
+ import { ImageSource, ImageTexts, InitialZoom } from '../shared/Image.shared.types';
3
+ export type ImagePreviewProps = {
4
+ /** Controls visibility of the viewer. */
5
+ open: boolean;
6
+ /** Images to page through. Navigation is shown only when there is more than one. */
7
+ images: ImageSource[];
8
+ /** Controlled index of the currently shown image. */
9
+ index: number;
10
+ /** Called when navigation (arrows / keyboard) requests a different image. */
11
+ onIndexChange: (index: number) => void;
12
+ /** Called when the viewer requests to close (Escape, backdrop click, close button). */
13
+ onClose: () => void;
14
+ /**
15
+ * Whether zoom and pan are enabled. Defaults to true. Zoom runs from the
16
+ * fitted view up to `maxZoom` × the image's natural size.
17
+ */
18
+ zoomable?: boolean;
19
+ /** Per-click zoom multiplier. Defaults to 1.4. */
20
+ zoomStep?: number;
21
+ /** Maximum zoom relative to natural size. Defaults to 3 (300%). */
22
+ maxZoom?: number;
23
+ /** Where zoom starts. Defaults to `fit`. */
24
+ initialZoom?: InitialZoom;
25
+ /** Whether to render the close button and allow Escape to close. Defaults to true. */
26
+ closable?: boolean;
27
+ /** Whether clicking the dimmed backdrop closes the viewer. Defaults to true. */
28
+ maskClosable?: boolean;
29
+ /** Fallback rendered when an image fails to load (per-image override via `ImageSource.fallback`). */
30
+ fallback?: ReactNode;
31
+ /** Portal target. Defaults to `document.body`. */
32
+ getContainer?: () => HTMLElement;
33
+ /** Unmount the viewer (and reset zoom state) when closed. Defaults to false. */
34
+ destroyOnClose?: boolean;
35
+ /** Called with the current scale whenever the zoom transform changes. */
36
+ onZoom?: (scale: number) => void;
37
+ /** Override the default tooltip / accessible labels for the controls. */
38
+ texts?: Partial<ImageTexts>;
39
+ };
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,21 @@
1
+ import { ImageTexts } from '../shared/Image.shared.types';
2
+ export type PreviewToolbarProps = {
3
+ showNavigation: boolean;
4
+ current: number;
5
+ total: number;
6
+ onPrev: () => void;
7
+ onNext: () => void;
8
+ showZoom: boolean;
9
+ zoomPercent: number;
10
+ canZoomIn: boolean;
11
+ canZoomOut: boolean;
12
+ onZoomIn: () => void;
13
+ onZoomOut: () => void;
14
+ showDownload: boolean;
15
+ downloadHref: string;
16
+ downloadName: string;
17
+ texts: ImageTexts;
18
+ };
19
+ /** Bottom-center controls: a navigation pill and a zoom/download pill. */
20
+ declare const PreviewToolbar: ({ showNavigation, current, total, onPrev, onNext, showZoom, zoomPercent, canZoomIn, canZoomOut, onZoomIn, onZoomOut, showDownload, downloadHref, downloadName, texts, }: PreviewToolbarProps) => JSX.Element;
21
+ export default PreviewToolbar;
@@ -0,0 +1,60 @@
1
+ import { jsxs, Fragment, jsx } from "react/jsx-runtime";
2
+ import Icon, { AngleLeftM, AngleRightM, FormulaMinusM, FormulaPlusM, FileDownloadM } from "@synerise/ds-icon";
3
+ import { ToolbarGroup, ToolbarButton, ToolbarLabel } from "@synerise/ds-toolbar";
4
+ const TOOLTIP_PLACEMENT = "top";
5
+ const PreviewToolbar = ({
6
+ showNavigation,
7
+ current,
8
+ total,
9
+ onPrev,
10
+ onNext,
11
+ showZoom,
12
+ zoomPercent,
13
+ canZoomIn,
14
+ canZoomOut,
15
+ onZoomIn,
16
+ onZoomOut,
17
+ showDownload,
18
+ downloadHref,
19
+ downloadName,
20
+ texts
21
+ }) => /* @__PURE__ */ jsxs(Fragment, { children: [
22
+ showNavigation && /* @__PURE__ */ jsxs(ToolbarGroup, { isCompact: true, children: [
23
+ /* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.previousImage, tooltipProps: {
24
+ title: texts.previousImage,
25
+ placement: TOOLTIP_PLACEMENT
26
+ }, onClick: onPrev, "data-testid": "image-preview-prev", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(AngleLeftM, {}) }) }),
27
+ /* @__PURE__ */ jsxs(ToolbarLabel, { "data-testid": "image-preview-counter", children: [
28
+ current,
29
+ " of ",
30
+ total
31
+ ] }),
32
+ /* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.nextImage, tooltipProps: {
33
+ title: texts.nextImage,
34
+ placement: TOOLTIP_PLACEMENT
35
+ }, onClick: onNext, "data-testid": "image-preview-next", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(AngleRightM, {}) }) })
36
+ ] }),
37
+ (showZoom || showDownload) && /* @__PURE__ */ jsxs(ToolbarGroup, { isCompact: true, children: [
38
+ showZoom && /* @__PURE__ */ jsxs(Fragment, { children: [
39
+ /* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.zoomOut, tooltipProps: {
40
+ title: texts.zoomOut,
41
+ placement: TOOLTIP_PLACEMENT
42
+ }, disabled: !canZoomOut, onClick: onZoomOut, "data-testid": "image-preview-zoom-out", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(FormulaMinusM, {}) }) }),
43
+ /* @__PURE__ */ jsxs(ToolbarLabel, { "data-testid": "image-preview-zoom-level", children: [
44
+ zoomPercent,
45
+ "%"
46
+ ] }),
47
+ /* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.zoomIn, tooltipProps: {
48
+ title: texts.zoomIn,
49
+ placement: TOOLTIP_PLACEMENT
50
+ }, disabled: !canZoomIn, onClick: onZoomIn, "data-testid": "image-preview-zoom-in", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(FormulaPlusM, {}) }) })
51
+ ] }),
52
+ showDownload && /* @__PURE__ */ jsx(ToolbarButton, { mode: "single-icon", "aria-label": texts.download, tooltipProps: {
53
+ title: texts.download,
54
+ placement: TOOLTIP_PLACEMENT
55
+ }, href: downloadHref, download: downloadName, target: "_blank", "data-testid": "image-preview-download", children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(FileDownloadM, {}) }) })
56
+ ] })
57
+ ] });
58
+ export {
59
+ PreviewToolbar as default
60
+ };
@@ -0,0 +1,7 @@
1
+ import { AspectRatio, ThumbnailSize } from '../shared/Image.shared.types';
2
+ /** Fixed thumbnail heights (px) per size token. `custom` defers to `height`. */
3
+ export declare const SIZE_MAP: Record<Exclude<ThumbnailSize, 'custom'>, number>;
4
+ /** CSS `aspect-ratio` values per fixed ratio. `source` keeps intrinsic ratio. */
5
+ export declare const ASPECT_RATIO_MAP: Record<Exclude<AspectRatio, 'source'>, string>;
6
+ export declare const DEFAULT_ASPECT_RATIO: AspectRatio;
7
+ export declare const DEFAULT_SIZE = "m";
@@ -0,0 +1,22 @@
1
+ const SIZE_MAP = {
2
+ xxs: 40,
3
+ xs: 80,
4
+ s: 120,
5
+ m: 180,
6
+ l: 240,
7
+ xl: 360,
8
+ xxl: 540
9
+ };
10
+ const ASPECT_RATIO_MAP = {
11
+ "1:1": "1 / 1",
12
+ "4:3": "4 / 3",
13
+ "16:9": "16 / 9"
14
+ };
15
+ const DEFAULT_ASPECT_RATIO = "1:1";
16
+ const DEFAULT_SIZE = "m";
17
+ export {
18
+ ASPECT_RATIO_MAP,
19
+ DEFAULT_ASPECT_RATIO,
20
+ DEFAULT_SIZE,
21
+ SIZE_MAP
22
+ };