Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[F] Picture component #230

Merged
merged 4 commits into from
Jun 27, 2024
Merged
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
9 changes: 9 additions & 0 deletions packages/epo-react-lib/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,12 @@
# 2.0.32

- fix: `ResponsiveImage` leaks styles to DOM

# 2.1.0

- feat: `Picture` component to support art direction
- `Image`
- feat: makes correct use of `srcset`
- feat: add support for `sizes`
- feat: add `priority` flag for LCP images
- deprecation: mark `url2x url3x` setup as deprecated, to be dropped in future versions
2 changes: 1 addition & 1 deletion packages/epo-react-lib/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@rubin-epo/epo-react-lib",
"description": "Rubin Observatory Education & Public Outreach team React UI library.",
"version": "2.0.31",
"version": "2.1.0",
"author": "Rubin EPO",
"license": "MIT",
"homepage": "https://lsst-epo.github.io/epo-react-lib",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Meta, StoryObj } from "@storybook/react";
import { children } from "@/storybook/utilities/argTypes";

import Figure from ".";
import Image from "../Image/Image";
import Image from "../Image";

const meta: Meta<typeof Figure> = {
component: Figure,
Expand Down
41 changes: 29 additions & 12 deletions packages/epo-react-lib/src/atomic/Image/Image.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { Meta, StoryFn } from "@storybook/react";
import { className } from "@/storybook/utilities/argTypes";

import Image from ".";

const meta: ComponentMeta<typeof Image> = {
const meta: Meta<typeof Image> = {
component: Image,
argTypes: {
image: {
Expand All @@ -17,15 +17,32 @@ const meta: ComponentMeta<typeof Image> = {
};
export default meta;

export const Primary: ComponentStoryObj<typeof Image> = {
args: {
image: {
altText: "A placeholder image",
url: "https://via.placeholder.com/150",
url2x: "https://via.placeholder.com/300",
url3x: "https://via.placeholder.com/450",
width: 150,
height: 150,
},
const generateCantoSrcSet = (cantoUrl: string, width?: number) => {
const sizes = [100, 240, 320, 500, 640, 800, 2050].filter((size) =>
width ? size < width : true
);

return sizes.map((size) => {
return {
src: `${cantoUrl}${size}`,
size,
};
});
};

export const Primary: StoryFn<typeof Image> = (args) => <Image {...args} />;

Primary.args = {
image: {
altText:
"The LSST Camera, the largest camera ever built for astronomy. The camera opening is a large black ring with glass lenses, a bit smaller than a person is tall. In the center of the opening is the camera's focal plane detector, which is made of 189 square CCD chips arranged in a roughly square shape. The camera is suspended on a white metal frame with white handrails. The completely white room gives the image an overall sterile feel.",
url: "https://rubin.canto.com/direct/image/e6g9n6c01h309c80qs19eslh55/WqxBY3pXe8VzEh-VuAAoxCmycAQ/original?content-type=image%2Fjpeg&name=IMG_7688.jpeg",
srcSet: generateCantoSrcSet(
"https://rubin.canto.com/direct/image/e6g9n6c01h309c80qs19eslh55/tifpy6fo0_wn9ieJy1-dVmmy_v8/m800/",
5712
),
width: 5712,
height: 4284,
},
title: "LSST Camera at SLAC",
};
112 changes: 71 additions & 41 deletions packages/epo-react-lib/src/atomic/Image/Image.test.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,79 @@
import { render, screen } from "@testing-library/react";
import Image from ".";

const props = {
image: {
altText: "A placeholder image",
url: "https://via.placeholder.com/150",
url2x: "https://via.placeholder.com/300",
url3x: "https://via.placeholder.com/450",
width: 150,
height: 150,
},
};

test("should attach url to src", () => {
render(<Image {...props} />);

const img = screen.getByRole("img");

const urls = [props.image.url, props.image.url2x, props.image.url3x]
.filter((url) => url !== undefined)
.map((url, i) => `${url} ${i + 1}x`)
.join(", ");

expect(img).toHaveAttribute("src", expect.stringMatching(props.image.url));
expect(img).toHaveAttribute("srcSet", expect.stringMatching(urls));
});
import { composeStory } from "@storybook/react";
import Meta, { Primary } from "./Image.stories";
import { stringifySizes, stringifySrcSet } from "./utils";

const Image = composeStory(Primary, Meta);

describe("Image", () => {
it("should attach url to src", () => {
render(<Image />);

const img = screen.getByRole("img");

expect(img).toHaveAttribute("src", Image.args.image?.url);
});

it("should create a srcset", () => {
render(<Image />);

const img = screen.getByRole("img");

test("should have accessible naming", () => {
render(<Image {...props} />);
expect(img).toHaveAttribute("srcset");
});

const img = screen.getByRole("img");
expect(img).toHaveAccessibleName(props.image.altText);
it("should have accessible naming", () => {
render(<Image />);

const img = screen.getByRole("img");
expect(img).toHaveAccessibleName(Image.args.image?.altText);
});

it("should have width and height attached", () => {
render(<Image />);

const img = screen.getByRole("img");

expect(img).toHaveAttribute("width", Image.args.image?.width?.toString());
expect(img).toHaveAttribute("height", Image.args.image?.height?.toString());
});
});

test("should have width and height attached", () => {
render(<Image {...props} />);
describe("stringifySrcSet", () => {
it("should transform an array of objects into a srcset string", () => {
const srcSet = [
{ src: "https://rubinobservatory.org", size: 100 },
{ src: "https://rubinobservatory.org", size: "2x" },
];

const result = stringifySrcSet(srcSet);

expect(result).toBe(
`${srcSet[0].src} ${srcSet[0].size}w, ${srcSet[1].src} ${srcSet[1].size}`
);
});
});

const img = screen.getByRole("img");
const sizes = [
{ size: "30em" },
{ size: 101, mediaCondition: "(max-width: 200px)" },
{ size: 202, mediaCondition: "(max-width: 300px)" },
];

expect(img).toHaveAttribute(
"width",
expect.stringContaining(props.image.width.toString())
);
expect(img).toHaveAttribute(
"height",
expect.stringContaining(props.image.height.toString())
);
describe("stringifySizes", () => {
it("should move sizes without media conditions to the end", () => {
const result = stringifySizes(sizes).split(",");
expect(result[0].includes("(")).toBeTruthy();
expect(result[result.length - 1].includes("(")).toBeFalsy();
});
it("should turn numeric sizes to pixels", () => {
const result = stringifySizes(sizes);
expect(result.includes(`${sizes[1].size}px`)).toBeTruthy();
});
it("should create a valid sizes string", () => {
const result = stringifySizes(sizes);
expect(result).toBe(
`${sizes[0].mediaCondition} ${sizes[0].size}px, ${sizes[1].mediaCondition} ${sizes[1].size}px, ${sizes[2].size}`
);
});
});
36 changes: 0 additions & 36 deletions packages/epo-react-lib/src/atomic/Image/Image.tsx

This file was deleted.

2 changes: 0 additions & 2 deletions packages/epo-react-lib/src/atomic/Image/index.ts

This file was deleted.

62 changes: 62 additions & 0 deletions packages/epo-react-lib/src/atomic/Image/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ImageShape, srcType } from "@/types/image";
import { FunctionComponent, ImgHTMLAttributes } from "react";
import * as Styled from "./styles";
import { stringifySizes, stringifySrcSet } from "./utils";

export type { ImageShape };

export interface ImageProps {
image: ImageShape;
className?: string;
title?: string;
}

const Image: FunctionComponent<ImageProps> = ({ image, title, className }) => {
const {
url: src,
srcSet = [],
sizes = [],
url2x,
url3x,
width,
height,
altText,
priority = false,
} = image;

const lazyProps: Partial<ImgHTMLAttributes<HTMLImageElement>> = {
decoding: "async",
loading: "lazy",
};
const lcpProps: Partial<ImgHTMLAttributes<HTMLImageElement>> = {
decoding: "sync",
loading: "eager",
};

const legacySrcSet: Array<srcType> = [url2x, url3x]
.filter((url): url is string => url !== undefined)
.map((src, i) => {
return { src, size: `${i + 1}x` };
});

const priorityProps = priority ? lcpProps : lazyProps;

const fullSrcSet = [...srcSet, ...legacySrcSet];

if (typeof width === "number") {
fullSrcSet.push({ src, size: width });
}

return (
<Styled.Image
alt={altText || title}
srcSet={stringifySrcSet(fullSrcSet)}
sizes={stringifySizes(sizes)}
{...{ src, width, height, className, ...priorityProps }}
/>
);
};

Image.displayName = "Atomic.Image";

export default Image;
5 changes: 0 additions & 5 deletions packages/epo-react-lib/src/atomic/Image/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,4 @@ export const Image = styled.img`
display: block;
width: 100%;
height: auto;

&[loading] {
opacity: 0;
transition: opacity 0.4s 0.1s;
}
`;
40 changes: 40 additions & 0 deletions packages/epo-react-lib/src/atomic/Image/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { srcSize, srcType } from "@/types/image";

export const stringifySrcSet = (srcs: Array<srcType>): string => {
return srcs
.map(({ src, size }) => {
if (typeof size === "number") {
return `${src} ${size}w`;
} else {
return `${src} ${size}`;
}
})
.join(", ");
};
export const stringifySizes = (sizes: Array<srcSize>): string => {
const addPxIfNumber = (size: string | number) => {
if (typeof size === "number") {
return `${size}px`;
}

return size;
};

return sizes
.sort((a, b) => {
const aHasMedia = Object.hasOwn(a, "mediaCondition");
const bHasMedia = Object.hasOwn(b, "mediaCondition");

if (aHasMedia && !bHasMedia) return -1;

return 0;
})
.map(({ size, mediaCondition }) => {
if (mediaCondition) {
return `${mediaCondition} ${addPxIfNumber(size)}`;
} else {
return addPxIfNumber(size);
}
})
.join(", ");
};
Loading