@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE.md +21 -0
- package/README.md +55 -0
- package/dist/Gallery/Gallery.d.ts +3 -0
- package/dist/Gallery/Gallery.js +45 -0
- package/dist/Gallery/Gallery.styles.d.ts +1 -0
- package/dist/Gallery/Gallery.styles.js +8 -0
- package/dist/Gallery/Gallery.types.d.ts +39 -0
- package/dist/Gallery/Gallery.types.js +1 -0
- package/dist/Preview/ImagePreview.const.d.ts +16 -0
- package/dist/Preview/ImagePreview.const.js +10 -0
- package/dist/Preview/ImagePreview.d.ts +4 -0
- package/dist/Preview/ImagePreview.js +208 -0
- package/dist/Preview/ImagePreview.styles.d.ts +13 -0
- package/dist/Preview/ImagePreview.styles.js +44 -0
- package/dist/Preview/ImagePreview.types.d.ts +39 -0
- package/dist/Preview/ImagePreview.types.js +1 -0
- package/dist/Preview/PreviewToolbar.d.ts +21 -0
- package/dist/Preview/PreviewToolbar.js +60 -0
- package/dist/Thumbnail/Thumbnail.const.d.ts +7 -0
- package/dist/Thumbnail/Thumbnail.const.js +22 -0
- package/dist/Thumbnail/Thumbnail.d.ts +3 -0
- package/dist/Thumbnail/Thumbnail.js +60 -0
- package/dist/Thumbnail/Thumbnail.styles.d.ts +24 -0
- package/dist/Thumbnail/Thumbnail.styles.js +37 -0
- package/dist/Thumbnail/Thumbnail.types.d.ts +34 -0
- package/dist/Thumbnail/Thumbnail.types.js +1 -0
- package/dist/context/ImageGalleryContext.d.ts +16 -0
- package/dist/context/ImageGalleryContext.js +7 -0
- package/dist/hooks/useImagePreview.d.ts +33 -0
- package/dist/hooks/useImagePreview.js +48 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +10 -0
- package/dist/modules.d.js +1 -0
- package/dist/modules.d.ts +0 -0
- package/dist/shared/Image.shared.types.d.ts +42 -0
- package/dist/shared/Image.shared.types.js +1 -0
- package/dist/shared/ImageContent.d.ts +27 -0
- package/dist/shared/ImageContent.js +36 -0
- package/dist/shared/ImageContent.styles.d.ts +2 -0
- package/dist/shared/ImageContent.styles.js +8 -0
- package/dist/shared/useImageStatus.d.ts +17 -0
- package/dist/shared/useImageStatus.js +28 -0
- package/dist/shared/useImageTexts.d.ts +8 -0
- package/dist/shared/useImageTexts.js +38 -0
- package/package.json +58 -0
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { ThumbnailProps } from './Thumbnail.types';
|
|
2
|
+
declare const Thumbnail: ({ src, alt, aspectRatio, size, height, background, objectFit, loading, deletable, onDelete, openZoom, onClick, fallback, texts, className, "data-testid": dataTestId, }: ThumbnailProps) => JSX.Element;
|
|
3
|
+
export default Thumbnail;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
+
import Icon, { ImageM, ShowS, Close3FullBackgroundM } from "@synerise/ds-icon";
|
|
3
|
+
import Tooltip from "@synerise/ds-tooltip";
|
|
4
|
+
import { useImageGalleryContext } from "../context/ImageGalleryContext.js";
|
|
5
|
+
import { useImageTexts } from "../shared/useImageTexts.js";
|
|
6
|
+
import { DEFAULT_ASPECT_RATIO, DEFAULT_SIZE, ASPECT_RATIO_MAP, SIZE_MAP } from "./Thumbnail.const.js";
|
|
7
|
+
import { Tile, Clip, ThumbnailImage, EmptyPlaceholder, HoverOverlay, DeleteButton } from "./Thumbnail.styles.js";
|
|
8
|
+
const Thumbnail = ({
|
|
9
|
+
src,
|
|
10
|
+
alt,
|
|
11
|
+
aspectRatio,
|
|
12
|
+
size,
|
|
13
|
+
height,
|
|
14
|
+
background,
|
|
15
|
+
objectFit,
|
|
16
|
+
loading,
|
|
17
|
+
deletable = false,
|
|
18
|
+
onDelete,
|
|
19
|
+
openZoom = false,
|
|
20
|
+
onClick,
|
|
21
|
+
fallback,
|
|
22
|
+
texts,
|
|
23
|
+
className,
|
|
24
|
+
"data-testid": dataTestId = "image-thumbnail"
|
|
25
|
+
}) => {
|
|
26
|
+
const context = useImageGalleryContext();
|
|
27
|
+
const labels = useImageTexts(texts ?? context?.texts);
|
|
28
|
+
const resolvedAspectRatio = aspectRatio ?? context?.aspectRatio ?? DEFAULT_ASPECT_RATIO;
|
|
29
|
+
const resolvedSize = size ?? context?.size ?? DEFAULT_SIZE;
|
|
30
|
+
const resolvedHeight = height ?? context?.height;
|
|
31
|
+
const resolvedBackground = background ?? context?.background ?? "none";
|
|
32
|
+
const resolvedObjectFit = objectFit ?? context?.objectFit ?? "contain";
|
|
33
|
+
const resolvedLoading = loading ?? context?.loading ?? "lazy";
|
|
34
|
+
const resolvedFallback = fallback ?? context?.fallback;
|
|
35
|
+
const interactive = Boolean(openZoom || onClick);
|
|
36
|
+
const isSource = resolvedAspectRatio === "source";
|
|
37
|
+
const cssAspectRatio = resolvedAspectRatio === "source" ? void 0 : ASPECT_RATIO_MAP[resolvedAspectRatio];
|
|
38
|
+
const pixelHeight = resolvedSize === "custom" ? resolvedHeight ?? SIZE_MAP[DEFAULT_SIZE] : SIZE_MAP[resolvedSize];
|
|
39
|
+
const handleKeyDown = (event) => {
|
|
40
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
event.currentTarget.click();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const handleDelete = (event) => {
|
|
46
|
+
event.stopPropagation();
|
|
47
|
+
event.currentTarget.blur();
|
|
48
|
+
onDelete?.();
|
|
49
|
+
};
|
|
50
|
+
return /* @__PURE__ */ jsxs(Tile, { className, role: interactive ? "button" : void 0, tabIndex: interactive ? 0 : void 0, $height: pixelHeight, $aspectRatio: cssAspectRatio, $interactive: interactive, onClick: interactive ? onClick : void 0, onKeyDown: interactive ? handleKeyDown : void 0, "data-testid": dataTestId, children: [
|
|
51
|
+
/* @__PURE__ */ jsxs(Clip, { $background: resolvedBackground, $source: isSource, children: [
|
|
52
|
+
src ? /* @__PURE__ */ jsx(ThumbnailImage, { src, alt, loading: resolvedLoading, fallback: resolvedFallback, $objectFit: resolvedObjectFit, $source: isSource, "data-testid": `${dataTestId}-image` }) : /* @__PURE__ */ jsx(EmptyPlaceholder, { "data-testid": `${dataTestId}-empty`, children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(ImageM, {}) }) }),
|
|
53
|
+
interactive && src && /* @__PURE__ */ jsx(HoverOverlay, { "data-testid": `${dataTestId}-overlay`, children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(ShowS, {}) }) })
|
|
54
|
+
] }),
|
|
55
|
+
deletable && /* @__PURE__ */ jsx(Tooltip, { title: labels.delete, placement: "top", children: /* @__PURE__ */ jsx(DeleteButton, { type: "button", "aria-label": labels.delete, onClick: handleDelete, "data-testid": `${dataTestId}-delete`, children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(Close3FullBackgroundM, {}) }) }) })
|
|
56
|
+
] });
|
|
57
|
+
};
|
|
58
|
+
export {
|
|
59
|
+
Thumbnail as default
|
|
60
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ThemeProps } from '@synerise/ds-core';
|
|
2
|
+
import { ObjectFit, ThumbnailBackground } from '../shared/Image.shared.types';
|
|
3
|
+
/**
|
|
4
|
+
* Outer box — owns sizing and the focus ring but does NOT clip, so the delete
|
|
5
|
+
* affordance can overhang the corner. Clipping lives on `Clip`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const Tile: import('styled-components').StyledComponent<"div", any, ThemeProps & {
|
|
8
|
+
$height: number;
|
|
9
|
+
$aspectRatio?: string;
|
|
10
|
+
$interactive: boolean;
|
|
11
|
+
}, never>;
|
|
12
|
+
/** Inner box — clips the image + hover overlay to the rounded corners. */
|
|
13
|
+
export declare const Clip: import('styled-components').StyledComponent<"div", any, ThemeProps & {
|
|
14
|
+
$background: ThumbnailBackground;
|
|
15
|
+
$source: boolean;
|
|
16
|
+
}, never>;
|
|
17
|
+
export declare const ThumbnailImage: import('styled-components').StyledComponent<({ src, alt, fallback, loading, draggable, className, onClick, onLoad, onError, "data-testid": dataTestId, }: import('../shared/ImageContent').ImageContentProps) => JSX.Element, any, {
|
|
18
|
+
$objectFit: ObjectFit;
|
|
19
|
+
$source: boolean;
|
|
20
|
+
}, never>;
|
|
21
|
+
export declare const EmptyPlaceholder: import('styled-components').StyledComponent<"div", any, ThemeProps, never>;
|
|
22
|
+
/** Hover affordance: a dim layer over the whole tile with a centered show icon. */
|
|
23
|
+
export declare const HoverOverlay: import('styled-components').StyledComponent<"div", any, ThemeProps, never>;
|
|
24
|
+
export declare const DeleteButton: import('styled-components').StyledComponent<"button", any, ThemeProps, never>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
import ImageContent from "../shared/ImageContent.js";
|
|
3
|
+
const TILE_BORDER_RADIUS = 4;
|
|
4
|
+
const HOVER_OVERLAY_ALPHA = "4D";
|
|
5
|
+
const DELETE_BUTTON_SIZE = 24;
|
|
6
|
+
const Tile = /* @__PURE__ */ styled.div.withConfig({
|
|
7
|
+
displayName: "Thumbnailstyles__Tile",
|
|
8
|
+
componentId: "sc-jzlfaw-0"
|
|
9
|
+
})(["position:relative;display:inline-flex;margin:0;padding:0;border-radius:", "px;height:", "px;", " cursor:", ";&:focus-visible{outline:none;box-shadow:0 0 0 2px ", ";}"], TILE_BORDER_RADIUS, (props) => props.$height, (props) => props.$aspectRatio ? `aspect-ratio: ${props.$aspectRatio};` : "width: auto;", (props) => props.$interactive ? "pointer" : "default", (props) => props.theme.palette["blue-600"]);
|
|
10
|
+
const Clip = /* @__PURE__ */ styled.div.withConfig({
|
|
11
|
+
displayName: "Thumbnailstyles__Clip",
|
|
12
|
+
componentId: "sc-jzlfaw-1"
|
|
13
|
+
})(["position:relative;display:inline-flex;overflow:hidden;height:100%;", " border-radius:", "px;background-color:", ";"], (props) => props.$source ? "width: auto;" : "width: 100%;", TILE_BORDER_RADIUS, (props) => props.$background === "subtle-grey" ? props.theme.palette["grey-050"] : "transparent");
|
|
14
|
+
const ThumbnailImage = /* @__PURE__ */ styled(ImageContent).withConfig({
|
|
15
|
+
displayName: "Thumbnailstyles__ThumbnailImage",
|
|
16
|
+
componentId: "sc-jzlfaw-2"
|
|
17
|
+
})(["display:block;object-fit:", ";", ""], (props) => props.$objectFit, (props) => props.$source ? "height: 100%; width: auto;" : "width: 100%; height: 100%;");
|
|
18
|
+
const EmptyPlaceholder = /* @__PURE__ */ styled.div.withConfig({
|
|
19
|
+
displayName: "Thumbnailstyles__EmptyPlaceholder",
|
|
20
|
+
componentId: "sc-jzlfaw-3"
|
|
21
|
+
})(["display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:", ";background-color:", ";"], (props) => props.theme.palette["grey-600"], (props) => props.theme.palette["grey-050"]);
|
|
22
|
+
const HoverOverlay = /* @__PURE__ */ styled.div.withConfig({
|
|
23
|
+
displayName: "Thumbnailstyles__HoverOverlay",
|
|
24
|
+
componentId: "sc-jzlfaw-4"
|
|
25
|
+
})(["position:absolute;inset:0;z-index:1;display:flex;align-items:center;justify-content:center;color:", ";background-color:", ";opacity:0;transition:opacity 0.15s ease;", ":hover &{opacity:1;}"], (props) => props.theme.palette.white, (props) => `${props.theme.palette["grey-900"]}${HOVER_OVERLAY_ALPHA}`, Tile);
|
|
26
|
+
const DeleteButton = /* @__PURE__ */ styled.button.withConfig({
|
|
27
|
+
displayName: "Thumbnailstyles__DeleteButton",
|
|
28
|
+
componentId: "sc-jzlfaw-5"
|
|
29
|
+
})(["position:absolute;top:0;right:0;z-index:2;transform:translate(50%,-50%);display:flex;align-items:center;justify-content:center;width:", "px;height:", "px;padding:0;border:none;background:none;cursor:pointer;color:", ";opacity:0;transition:opacity 0.15s ease;", ":hover &,", ":focus-within &{opacity:1;}"], DELETE_BUTTON_SIZE, DELETE_BUTTON_SIZE, (props) => props.theme.palette["red-600"], Tile, Tile);
|
|
30
|
+
export {
|
|
31
|
+
Clip,
|
|
32
|
+
DeleteButton,
|
|
33
|
+
EmptyPlaceholder,
|
|
34
|
+
HoverOverlay,
|
|
35
|
+
ThumbnailImage,
|
|
36
|
+
Tile
|
|
37
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { MouseEvent, ReactNode } from 'react';
|
|
2
|
+
import { AspectRatio, ImageTexts, ObjectFit, ThumbnailBackground, ThumbnailSize } from '../shared/Image.shared.types';
|
|
3
|
+
export type ThumbnailProps = {
|
|
4
|
+
/** Image source URL. Omit (or leave empty) to render the Empty state. */
|
|
5
|
+
src?: string;
|
|
6
|
+
/** Alternative text — required for accessibility. */
|
|
7
|
+
alt: string;
|
|
8
|
+
/** Aspect ratio of the tile. Defaults to gallery context, else `1:1`. */
|
|
9
|
+
aspectRatio?: AspectRatio;
|
|
10
|
+
/** Height token. Defaults to gallery context, else `m`. */
|
|
11
|
+
size?: ThumbnailSize;
|
|
12
|
+
/** Explicit height in px, used when `size` is `custom`. */
|
|
13
|
+
height?: number;
|
|
14
|
+
/** Tile backdrop. Defaults to gallery context, else `none` (transparent). */
|
|
15
|
+
background?: ThumbnailBackground;
|
|
16
|
+
/** How the image fills the tile. Defaults to gallery context, else `contain`. */
|
|
17
|
+
objectFit?: ObjectFit;
|
|
18
|
+
/** Native loading hint. Defaults to gallery context, else `lazy`. */
|
|
19
|
+
loading?: 'lazy' | 'eager';
|
|
20
|
+
/** Show a delete affordance on hover/focus. */
|
|
21
|
+
deletable?: boolean;
|
|
22
|
+
/** Called when the delete affordance is activated. */
|
|
23
|
+
onDelete?: () => void;
|
|
24
|
+
/** Render the tile as an interactive trigger (e.g. opens a preview). */
|
|
25
|
+
openZoom?: boolean;
|
|
26
|
+
/** Click handler — fired for mouse and keyboard (Enter/Space) activation. */
|
|
27
|
+
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
|
|
28
|
+
/** Fallback rendered when the image fails to load. Defaults to gallery context. */
|
|
29
|
+
fallback?: ReactNode;
|
|
30
|
+
/** Override tooltip / accessible labels (uses `delete`). Defaults to gallery context. */
|
|
31
|
+
texts?: Partial<ImageTexts>;
|
|
32
|
+
className?: string;
|
|
33
|
+
'data-testid'?: string;
|
|
34
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { AspectRatio, ImageTexts, ObjectFit, ThumbnailBackground, ThumbnailSize } from '../shared/Image.shared.types';
|
|
3
|
+
/** Visual settings a `Gallery` broadcasts to every `Thumbnail` it renders. */
|
|
4
|
+
export type ImageGalleryContextValue = {
|
|
5
|
+
aspectRatio?: AspectRatio;
|
|
6
|
+
size?: ThumbnailSize;
|
|
7
|
+
height?: number;
|
|
8
|
+
background?: ThumbnailBackground;
|
|
9
|
+
objectFit?: ObjectFit;
|
|
10
|
+
loading?: 'lazy' | 'eager';
|
|
11
|
+
fallback?: ReactNode;
|
|
12
|
+
texts?: Partial<ImageTexts>;
|
|
13
|
+
};
|
|
14
|
+
export declare const ImageGalleryContext: import('react').Context<ImageGalleryContextValue | undefined>;
|
|
15
|
+
/** Reads gallery-level defaults; returns `undefined` for a standalone `Thumbnail`. */
|
|
16
|
+
export declare const useImageGalleryContext: () => ImageGalleryContextValue | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ImagePreviewProps } from '../Preview/ImagePreview.types';
|
|
2
|
+
import { ImageSource } from '../shared/Image.shared.types';
|
|
3
|
+
export type UseImagePreviewReturn = {
|
|
4
|
+
/** Whether the preview is currently open. */
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
/** Index of the currently shown image. */
|
|
7
|
+
index: number;
|
|
8
|
+
/** Open the preview, optionally at a specific index. */
|
|
9
|
+
open: (index?: number) => void;
|
|
10
|
+
/** Close the preview. */
|
|
11
|
+
close: () => void;
|
|
12
|
+
/** Go to the next image (wraps). */
|
|
13
|
+
next: () => void;
|
|
14
|
+
/** Go to the previous image (wraps). */
|
|
15
|
+
prev: () => void;
|
|
16
|
+
/** Jump to a specific index. */
|
|
17
|
+
setIndex: (index: number) => void;
|
|
18
|
+
/** Spread onto `<ImagePreview>` to wire it up. */
|
|
19
|
+
previewProps: Pick<ImagePreviewProps, 'open' | 'images' | 'index' | 'onIndexChange' | 'onClose'>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Drives an `<ImagePreview>` from any trigger (a list row, a button, a table
|
|
23
|
+
* cell) without a global provider. Owns the open/index state and exposes
|
|
24
|
+
* handlers plus `previewProps` to spread onto the component.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const preview = useImagePreview(images);
|
|
28
|
+
* <Row onClick={() => preview.open(2)} />
|
|
29
|
+
* <ImagePreview {...preview.previewProps} />
|
|
30
|
+
*/
|
|
31
|
+
export declare const useImagePreview: (images: ImageSource[], options?: {
|
|
32
|
+
initialIndex?: number;
|
|
33
|
+
}) => UseImagePreviewReturn;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from "react";
|
|
2
|
+
const useImagePreview = (images, options) => {
|
|
3
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
4
|
+
const [index, setIndexState] = useState(options?.initialIndex ?? 0);
|
|
5
|
+
const total = images.length;
|
|
6
|
+
const open = useCallback((nextIndex) => {
|
|
7
|
+
if (typeof nextIndex === "number") {
|
|
8
|
+
setIndexState(nextIndex);
|
|
9
|
+
}
|
|
10
|
+
setIsOpen(true);
|
|
11
|
+
}, []);
|
|
12
|
+
const close = useCallback(() => {
|
|
13
|
+
setIsOpen(false);
|
|
14
|
+
}, []);
|
|
15
|
+
const setIndex = useCallback((nextIndex) => {
|
|
16
|
+
setIndexState(nextIndex);
|
|
17
|
+
}, []);
|
|
18
|
+
const next = useCallback(() => {
|
|
19
|
+
if (total > 1) {
|
|
20
|
+
setIndexState((current) => (current + 1) % total);
|
|
21
|
+
}
|
|
22
|
+
}, [total]);
|
|
23
|
+
const prev = useCallback(() => {
|
|
24
|
+
if (total > 1) {
|
|
25
|
+
setIndexState((current) => (current - 1 + total) % total);
|
|
26
|
+
}
|
|
27
|
+
}, [total]);
|
|
28
|
+
const previewProps = useMemo(() => ({
|
|
29
|
+
open: isOpen,
|
|
30
|
+
images,
|
|
31
|
+
index,
|
|
32
|
+
onIndexChange: setIndexState,
|
|
33
|
+
onClose: close
|
|
34
|
+
}), [isOpen, images, index, close]);
|
|
35
|
+
return {
|
|
36
|
+
isOpen,
|
|
37
|
+
index,
|
|
38
|
+
open,
|
|
39
|
+
close,
|
|
40
|
+
next,
|
|
41
|
+
prev,
|
|
42
|
+
setIndex,
|
|
43
|
+
previewProps
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
export {
|
|
47
|
+
useImagePreview
|
|
48
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as Thumbnail } from './Thumbnail/Thumbnail';
|
|
2
|
+
export { default as Gallery } from './Gallery/Gallery';
|
|
3
|
+
export { default as ImagePreview } from './Preview/ImagePreview';
|
|
4
|
+
export { useImagePreview, type UseImagePreviewReturn, } from './hooks/useImagePreview';
|
|
5
|
+
export type { ThumbnailProps } from './Thumbnail/Thumbnail.types';
|
|
6
|
+
export type { GalleryProps } from './Gallery/Gallery.types';
|
|
7
|
+
export type { ImagePreviewProps } from './Preview/ImagePreview.types';
|
|
8
|
+
export type { ImageSource, AspectRatio, ThumbnailSize, ThumbnailBackground, ObjectFit, InitialZoom, ImageTexts, } from './shared/Image.shared.types';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { default as default2 } from "./Thumbnail/Thumbnail.js";
|
|
2
|
+
import { default as default3 } from "./Gallery/Gallery.js";
|
|
3
|
+
import { default as default4 } from "./Preview/ImagePreview.js";
|
|
4
|
+
import { useImagePreview } from "./hooks/useImagePreview.js";
|
|
5
|
+
export {
|
|
6
|
+
default3 as Gallery,
|
|
7
|
+
default4 as ImagePreview,
|
|
8
|
+
default2 as Thumbnail,
|
|
9
|
+
useImagePreview
|
|
10
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
/** One image in a gallery or preview set. */
|
|
3
|
+
export type ImageSource = {
|
|
4
|
+
/** Image source URL. */
|
|
5
|
+
src: string;
|
|
6
|
+
/** Alternative text — required for accessibility. */
|
|
7
|
+
alt: string;
|
|
8
|
+
/** Per-image override for the broken-image fallback. */
|
|
9
|
+
fallback?: ReactNode;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Aspect ratio applied to a thumbnail. `source` keeps the image's intrinsic
|
|
13
|
+
* ratio; the rest force a fixed box.
|
|
14
|
+
*/
|
|
15
|
+
export type AspectRatio = '1:1' | '4:3' | '16:9' | 'source';
|
|
16
|
+
/**
|
|
17
|
+
* Thumbnail height token. `custom` defers to the `height` prop; the rest map to
|
|
18
|
+
* fixed pixel heights (see `SIZE_MAP`).
|
|
19
|
+
*/
|
|
20
|
+
export type ThumbnailSize = 'custom' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl';
|
|
21
|
+
/** Thumbnail backdrop — transparent (`none`) or a subtle grey fill. */
|
|
22
|
+
export type ThumbnailBackground = 'none' | 'subtle-grey';
|
|
23
|
+
/** How the image fills the thumbnail box. */
|
|
24
|
+
export type ObjectFit = 'contain' | 'cover';
|
|
25
|
+
/**
|
|
26
|
+
* Preview zoom start. `fit` downscales to the working area only when the image
|
|
27
|
+
* is larger than it; `real-size` starts at the image's natural size (100%).
|
|
28
|
+
*/
|
|
29
|
+
export type InitialZoom = 'real-size' | 'fit';
|
|
30
|
+
/**
|
|
31
|
+
* Tooltip / accessible labels for the image controls. DS provides translated
|
|
32
|
+
* defaults; pass a subset via the `texts` prop to override.
|
|
33
|
+
*/
|
|
34
|
+
export type ImageTexts = {
|
|
35
|
+
zoomIn: string;
|
|
36
|
+
zoomOut: string;
|
|
37
|
+
nextImage: string;
|
|
38
|
+
previousImage: string;
|
|
39
|
+
download: string;
|
|
40
|
+
close: string;
|
|
41
|
+
delete: string;
|
|
42
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { default as React, ReactNode } from 'react';
|
|
2
|
+
export type ImageContentProps = {
|
|
3
|
+
/** Image source URL. */
|
|
4
|
+
src: string;
|
|
5
|
+
/** Alternative text. */
|
|
6
|
+
alt: string;
|
|
7
|
+
/** Rendered in place of the image when the `src` fails to load. */
|
|
8
|
+
fallback?: ReactNode;
|
|
9
|
+
/** Native lazy/eager loading hint. */
|
|
10
|
+
loading?: 'lazy' | 'eager';
|
|
11
|
+
draggable?: boolean;
|
|
12
|
+
/** styled-components hook — applied to the `<img>`. */
|
|
13
|
+
className?: string;
|
|
14
|
+
onClick?: (event: React.MouseEvent<HTMLImageElement>) => void;
|
|
15
|
+
/** Forwarded after the internal load handler (e.g. for zoom measurement). */
|
|
16
|
+
onLoad?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
|
|
17
|
+
onError?: (event: React.SyntheticEvent<HTMLImageElement>) => void;
|
|
18
|
+
'data-testid'?: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Renders an `<img>` and swaps in `fallback` (or a default broken-image
|
|
22
|
+
* placeholder) when the source fails to load. The single place that knows how
|
|
23
|
+
* to detect a broken image — reused by `Thumbnail` and `ImagePreview`. Style it
|
|
24
|
+
* per consumer with `styled(ImageContent)`; the className lands on the `<img>`.
|
|
25
|
+
*/
|
|
26
|
+
declare const ImageContent: ({ src, alt, fallback, loading, draggable, className, onClick, onLoad, onError, "data-testid": dataTestId, }: ImageContentProps) => JSX.Element;
|
|
27
|
+
export default ImageContent;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx, Fragment } from "react/jsx-runtime";
|
|
2
|
+
import Icon, { ImageM } from "@synerise/ds-icon";
|
|
3
|
+
import { DefaultFallback } from "./ImageContent.styles.js";
|
|
4
|
+
import { useImageStatus } from "./useImageStatus.js";
|
|
5
|
+
const ImageContent = ({
|
|
6
|
+
src,
|
|
7
|
+
alt,
|
|
8
|
+
fallback,
|
|
9
|
+
loading = "lazy",
|
|
10
|
+
draggable,
|
|
11
|
+
className,
|
|
12
|
+
onClick,
|
|
13
|
+
onLoad,
|
|
14
|
+
onError,
|
|
15
|
+
"data-testid": dataTestId
|
|
16
|
+
}) => {
|
|
17
|
+
const {
|
|
18
|
+
ref,
|
|
19
|
+
status,
|
|
20
|
+
handleLoad,
|
|
21
|
+
handleError
|
|
22
|
+
} = useImageStatus(src);
|
|
23
|
+
if (status === "error") {
|
|
24
|
+
return /* @__PURE__ */ jsx(Fragment, { children: fallback ?? /* @__PURE__ */ jsx(DefaultFallback, { "data-testid": dataTestId && `${dataTestId}-fallback`, children: /* @__PURE__ */ jsx(Icon, { component: /* @__PURE__ */ jsx(ImageM, {}) }) }) });
|
|
25
|
+
}
|
|
26
|
+
return /* @__PURE__ */ jsx("img", { ref, src, alt, className, loading, draggable, onClick, "data-testid": dataTestId, onLoad: (event) => {
|
|
27
|
+
handleLoad(event);
|
|
28
|
+
onLoad?.(event);
|
|
29
|
+
}, onError: (event) => {
|
|
30
|
+
handleError();
|
|
31
|
+
onError?.(event);
|
|
32
|
+
} });
|
|
33
|
+
};
|
|
34
|
+
export {
|
|
35
|
+
ImageContent as default
|
|
36
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
const DefaultFallback = /* @__PURE__ */ styled.div.withConfig({
|
|
3
|
+
displayName: "ImageContentstyles__DefaultFallback",
|
|
4
|
+
componentId: "sc-ea3axh-0"
|
|
5
|
+
})(["display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:", ";background-color:", ";"], (props) => props.theme.palette["grey-600"], (props) => props.theme.palette["grey-050"]);
|
|
6
|
+
export {
|
|
7
|
+
DefaultFallback
|
|
8
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { RefObject, SyntheticEvent } from 'react';
|
|
2
|
+
export type ImageStatus = 'loading' | 'loaded' | 'error';
|
|
3
|
+
/**
|
|
4
|
+
* Tracks the load state of a single `<img>`. The single source of truth for
|
|
5
|
+
* "is this image broken?" across thumbnails and the preview.
|
|
6
|
+
*
|
|
7
|
+
* Attach `ref`, `handleLoad`, and `handleError` to the `<img>`. On `src`
|
|
8
|
+
* change the status resets to `loading`, then re-reads the element's cached
|
|
9
|
+
* `complete`/`naturalWidth` so an already-decoded (cached) image resolves
|
|
10
|
+
* without waiting for a load event that may have fired before this hook ran.
|
|
11
|
+
*/
|
|
12
|
+
export declare const useImageStatus: (src: string) => {
|
|
13
|
+
ref: RefObject<HTMLImageElement>;
|
|
14
|
+
status: ImageStatus;
|
|
15
|
+
handleLoad: (event: SyntheticEvent<HTMLImageElement>) => void;
|
|
16
|
+
handleError: () => void;
|
|
17
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, useCallback } from "react";
|
|
2
|
+
const useImageStatus = (src) => {
|
|
3
|
+
const ref = useRef(null);
|
|
4
|
+
const [status, setStatus] = useState("loading");
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const node = ref.current;
|
|
7
|
+
if (node?.complete) {
|
|
8
|
+
setStatus(node.naturalWidth > 0 ? "loaded" : "error");
|
|
9
|
+
} else {
|
|
10
|
+
setStatus("loading");
|
|
11
|
+
}
|
|
12
|
+
}, [src]);
|
|
13
|
+
const handleLoad = useCallback((event) => {
|
|
14
|
+
setStatus(event.currentTarget.naturalWidth > 0 ? "loaded" : "error");
|
|
15
|
+
}, []);
|
|
16
|
+
const handleError = useCallback(() => {
|
|
17
|
+
setStatus("error");
|
|
18
|
+
}, []);
|
|
19
|
+
return {
|
|
20
|
+
ref,
|
|
21
|
+
status,
|
|
22
|
+
handleLoad,
|
|
23
|
+
handleError
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export {
|
|
27
|
+
useImageStatus
|
|
28
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ImageTexts } from './Image.shared.types';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves the control labels: DS-translated defaults (`DS.IMAGE.*`) shallow-
|
|
4
|
+
* merged with any consumer overrides. Returns plain strings so they work as
|
|
5
|
+
* both tooltip `title` and `aria-label`. Requires an `IntlProvider` ancestor
|
|
6
|
+
* (supplied by `@synerise/ds-core`'s `DSProvider`).
|
|
7
|
+
*/
|
|
8
|
+
export declare const useImageTexts: (texts?: Partial<ImageTexts>) => ImageTexts;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useIntl } from "react-intl";
|
|
2
|
+
const useImageTexts = (texts) => {
|
|
3
|
+
const intl = useIntl();
|
|
4
|
+
return {
|
|
5
|
+
zoomIn: intl.formatMessage({
|
|
6
|
+
id: "DS.IMAGE.ZOOM-IN",
|
|
7
|
+
defaultMessage: "Zoom in"
|
|
8
|
+
}),
|
|
9
|
+
zoomOut: intl.formatMessage({
|
|
10
|
+
id: "DS.IMAGE.ZOOM-OUT",
|
|
11
|
+
defaultMessage: "Zoom out"
|
|
12
|
+
}),
|
|
13
|
+
nextImage: intl.formatMessage({
|
|
14
|
+
id: "DS.IMAGE.NEXT-IMAGE",
|
|
15
|
+
defaultMessage: "Next image"
|
|
16
|
+
}),
|
|
17
|
+
previousImage: intl.formatMessage({
|
|
18
|
+
id: "DS.IMAGE.PREVIOUS-IMAGE",
|
|
19
|
+
defaultMessage: "Previous image"
|
|
20
|
+
}),
|
|
21
|
+
download: intl.formatMessage({
|
|
22
|
+
id: "DS.IMAGE.DOWNLOAD",
|
|
23
|
+
defaultMessage: "Download"
|
|
24
|
+
}),
|
|
25
|
+
close: intl.formatMessage({
|
|
26
|
+
id: "DS.IMAGE.CLOSE",
|
|
27
|
+
defaultMessage: "Close"
|
|
28
|
+
}),
|
|
29
|
+
delete: intl.formatMessage({
|
|
30
|
+
id: "DS.IMAGE.DELETE",
|
|
31
|
+
defaultMessage: "Delete"
|
|
32
|
+
}),
|
|
33
|
+
...texts || {}
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
export {
|
|
37
|
+
useImageTexts
|
|
38
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@synerise/ds-image",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Image UI Component for the Synerise Design System",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"repository": "Synerise/synerise-design",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./dist/*.js": "./dist/*.js",
|
|
15
|
+
"./dist/*": "./dist/*.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"/dist",
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"README.md",
|
|
21
|
+
"package.json",
|
|
22
|
+
"LICENSE.md"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "vite build",
|
|
29
|
+
"build:watch": "vite build --watch",
|
|
30
|
+
"pack:ci": "pnpm pack --pack-destination ../../storybook/storybook-static/static",
|
|
31
|
+
"prepublish": "pnpm run build",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"types": "tsc --noEmit",
|
|
35
|
+
"check:circular-dependencies": "madge --circular --extensions ts,tsx,js,jsx --ts-config tsconfig.json src/ --exclude '/dist/'",
|
|
36
|
+
"upgrade:ds": "ncu -f \"@synerise/ds-*\" -u"
|
|
37
|
+
},
|
|
38
|
+
"sideEffects": [
|
|
39
|
+
"dist/style/*",
|
|
40
|
+
"*.less"
|
|
41
|
+
],
|
|
42
|
+
"types": "dist/index.d.ts",
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@synerise/ds-icon": "^1.18.4",
|
|
45
|
+
"@synerise/ds-toolbar": "^1.1.62",
|
|
46
|
+
"@synerise/ds-tooltip": "^1.5.3",
|
|
47
|
+
"@synerise/ds-utils": "^1.10.1",
|
|
48
|
+
"react-zoom-pan-pinch": "^3.6.1"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@synerise/ds-core": "*",
|
|
52
|
+
"react": ">=16.9.0 <= 18.3.1",
|
|
53
|
+
"react-intl": ">=3.12.0 <= 6.8",
|
|
54
|
+
"styled-components": "^5.3.3",
|
|
55
|
+
"vitest": "4"
|
|
56
|
+
},
|
|
57
|
+
"gitHead": "5452c886c6a70cc2847fdb50ccf376dfb93916e5"
|
|
58
|
+
}
|