Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.cjs.js",
"import": "./dist/index.esm.js"
},
"./navigation": {
"types": "./dist/navigation.d.ts",
"require": "./dist/navigation.cjs.js",
"import": "./dist/navigation.esm.js"
},
"./themes": {
"types": "./dist/themes.d.ts",
"require": "./dist/themes.cjs.js",
"import": "./dist/themes.esm.js"
},
"./controls": {
"types": "./dist/controls.d.ts",
"require": "./dist/controls.cjs.js",
"import": "./dist/controls.esm.js"
}
},
"scripts": {
"build": "tsc -b && rollup --config rollup.config.mjs",
"docs:build": "typedoc src",
Expand All @@ -29,16 +51,16 @@
"dependencies": {
"keycloak-js": "^26.2.1",
"react-icons": "^5.3.0",
"utif": "^3.1.0"
"utif": "^3.1.0",
"@mui/icons-material": "^7.0.0"
},
"peerDependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@jsonforms/core": "^3.6.0",
"@jsonforms/material-renderers": "^3.6.0",
"@jsonforms/react": "^3.6.0",
"@mui/icons-material": "^6.1.7",
"@mui/material": "^6.1.7",
"@jsonforms/core": "^3.7.0",
"@jsonforms/material-renderers": "^3.7.0",
"@jsonforms/react": "^3.7.0",
"@mui/material": "^7.0.0",
"react": "^18.3.1"
},
"devDependencies": {
Expand Down
323 changes: 227 additions & 96 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

41 changes: 13 additions & 28 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,20 @@ import packageJson from "./package.json" with { type: "json" };

export default [
{
input: "src/index.ts",
output: {
format: "cjs",
file: packageJson.main,
},
plugins: [
peerDepsExternal({
includeDependencies: true,
}),
image(),
resolve(),
commonjs(),
terser(),
typescript({
tsconfig: "./tsconfig.json",
exclude: ["**/*.stories.*", "**/*.test.*"],
}),
postcss({
extensions: [".css"],
}),
input: ["src/index.ts", "src/navigation.ts", "src/themes.ts", "src/controls.ts"],
output: [
{
format: "cjs",
dir: "dist",
entryFileNames: "[name].cjs.js",
},
{
format: "esm",
sourcemap: true,
dir: "dist",
entryFileNames: "[name].esm.js",
},
],
},
{
input: "src/index.ts",
output: {
format: "esm",
sourcemap: true,
file: packageJson.module,
},
plugins: [
peerDepsExternal({
includeDependencies: true,
Expand Down
3 changes: 1 addition & 2 deletions src/__test-utils__/helpers.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React from "react";

import { ThemeProvider } from "@mui/material/styles";
import { ThemeProvider, ThemeProviderProps } from "@mui/material/styles";
import { DiamondTheme } from "../themes/DiamondTheme";
import { render, RenderResult } from "@testing-library/react";
import { ThemeProviderProps } from "@mui/material/styles/ThemeProvider";

type ThemeProviderPropsWithOptionalTheme = Omit<ThemeProviderProps, "theme"> &
Partial<Pick<ThemeProviderProps, "theme">>;
Expand Down
6 changes: 2 additions & 4 deletions src/components/controls/ColourSchemeButton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { useColorScheme, useTheme } from "@mui/material";
import { IconButton, IconButtonProps } from "@mui/material";

import {
LightMode as LightModeIcon,
Bedtime as BedtimeIcon,
} from "@mui/icons-material";
import LightModeIcon from "@mui/icons-material/LightMode";
import BedtimeIcon from "@mui/icons-material/Bedtime";

import { ColourSchemes } from "../../utils/globals";

Expand Down
42 changes: 42 additions & 0 deletions src/components/controls/Image.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Meta, StoryObj } from "@storybook/react";
import { Image } from "./Image";

import diamond from "../../public/images/diamond.jpg";

const meta: Meta<typeof Image> = {
title: "Components/Controls/Image",
component: Image,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: "Image with placeholder, fallback and loading indicator",
},
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const BasicImage: Story = {
args: { src: diamond, style: { width: "20vw" } },
parameters: {
docs: {
description: {
story: "Basic Image",
},
},
},
};

export const ErrorImage: Story = {
args: { src: "doesnotexist.jpg", style: { width: "20vw" } },
parameters: {
docs: {
description: {
story: "Image displayed when original image fails to load",
},
},
},
};
27 changes: 27 additions & 0 deletions src/components/controls/Image.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { Image } from "./Image";

import placeholderStaticImport from "../../public/images/diamond.jpg";

describe("Image", () => {
it("should render spinner while image isn't loaded", () => {
render(<Image src={placeholderStaticImport} alt={"foo"} />);

const image = screen.getByAltText("foo");

expect(image).toHaveAttribute("aria-busy", "true");

fireEvent.load(image);

expect(image).toHaveAttribute("aria-busy", "false");
});

it("should render placeholder image if an error occurs while loading image", () => {
render(<Image src={placeholderStaticImport} alt={"foo"} />);

const image = screen.getByAltText("foo");
fireEvent.error(image);

expect(image).toHaveAttribute("aria-errormessage", "Image not available");
});
});
87 changes: 87 additions & 0 deletions src/components/controls/Image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";
import {
DetailedHTMLProps,
ImgHTMLAttributes,
SyntheticEvent,
useState,
} from "react";
import placeholder from "../../public/generic/no-image.png";
import CircularProgress from "@mui/material/CircularProgress";
import Box from "@mui/material/Box";

export interface ImageProps
extends Omit<
Omit<
DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>,
"onLoad" | "onError"
>,
"src"
> {
src?: string | null;
onLoad?: () => void;
onError?: () => void;
}

/**
* Smart image component that displays a placeholder on error, and a loading indicator if the image is still loading
*/
export const Image = ({ src, alt, onLoad, onError, ...props }: ImageProps) => {
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);

const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
if (onError) {
onError();
}

e.currentTarget.src = placeholder;
setIsError(true);
};

const handleLoad = () => {
if (onLoad) {
onLoad();
}

setIsLoading(false);
};

return (
<Box
display="flex"
alignItems="center"
justifyContent="center"
width={props.style?.width}
height={props.style?.height}
>
{isLoading && (
<Box
display="flex"
width={props.style?.width}
paddingY="3em"
justifyContent="center"
alignItems="center"
>
<CircularProgress />
</Box>
)}
<img
aria-busy={isLoading}
src={src ?? placeholder}
aria-errormessage={isError ? "Image not available" : undefined}
onError={handleError}
onLoad={handleLoad}
alt={alt ?? "Placeholder Image"}
{...props}
style={{
width: "100%",
height: "auto",
maxHeight: "100%",
objectFit: "contain",
display: isLoading ? "none" : "block",
...props.style,
}}
/>
</Box>
);
};
42 changes: 42 additions & 0 deletions src/components/controls/ImageWithZoom.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Meta, StoryObj } from "@storybook/react";
import { ImageWithZoom } from "./ImageWithZoom";

import diamond from "../../public/images/diamond.jpg";

const meta: Meta<typeof ImageWithZoom> = {
title: "Components/Controls/ImageWithZoom",
component: ImageWithZoom,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: "Image with user-controlled magnified area",
},
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const BasicImage: Story = {
args: { src: diamond, alt: "Diamond" },
parameters: {
docs: {
description: {
story: "Basic image with magnified view on side",
},
},
},
};

export const Brightness: Story = {
args: { src: diamond, alt: "Diamond", brightness: 0.5 },
parameters: {
docs: {
description: {
story: "Image with brightness filter applied",
},
},
},
};
68 changes: 68 additions & 0 deletions src/components/controls/ImageWithZoom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ImageWithZoom } from "./ImageWithZoom";
import { render, screen } from "@testing-library/react";

/**
* This is particularly hard to test without visual testing (screenshot matching)
* With unit tests, refs don't work properly, nor is it particularly useful because there might be visual changes,
* but CSS remains the same. We should revisit this once we implement visual matching through Playwright/Vitest browser mode.
*/

vi.mock("./Image", () => ({
Image: ({
onLoad,
alt,
src,
onClick,
}: {
onClick?: (e: Record<string, unknown>) => void;
onLoad?: () => void;
alt: string;
src: string;
}) => {
if (onLoad) {
onLoad();
}
if (onClick) {
onClick({
currentTarget: {
getBoundingClientRect: () => ({
left: 0,
top: 0,
width: 100,
height: 100,
}),
},
});
}
return <img alt={alt} src={src} />;
},
}));

describe("Image with Zoom Viewer", () => {
it("should update brightness/contrast", () => {
render(
<ImageWithZoom src="foo.jpg" alt="foo" brightness={1.5} contrast={0.5} />,
);

// https://github.com/vitest-dev/vitest/issues/9797
const zoomView = screen.getByLabelText("Zoom View");
expect(zoomView).toHaveAttribute(
"style",
expect.stringContaining("brightness(1.5)"),
);
expect(zoomView).toHaveAttribute(
"style",
expect.stringContaining("contrast(0.5)"),
);
});

it("should update image colour inversion", () => {
render(<ImageWithZoom src="foo.jpg" alt="foo" invert={true} />);

const zoomView = screen.getByLabelText("Zoom View");
expect(zoomView).toHaveAttribute(
"style",
expect.stringContaining("invert(1)"),
);
});
});
Loading
Loading