aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/atoms
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-10-09 18:26:23 +0200
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:14:41 +0100
commit15522ec9146f6f1956620355c44dea2a6a75b67c (patch)
tree7be0c4ca96cb3e59d2ee989785a6b6a286e6169d /src/components/atoms
parent891441a76173c708c6604fa203b175aefa222333 (diff)
refactor(components): replace ResponsiveImage with Figure component
The styles applied to ResponsiveImage are related to the figure and figcaption elements. Those elements could be use with other contents than images. So I extracted them in a Figure component. The ResponsiveImage component is no longer useful: the consumer should use the Image component from `next` and wrap it in a link if needed.
Diffstat (limited to 'src/components/atoms')
-rw-r--r--src/components/atoms/figure/figure.module.scss30
-rw-r--r--src/components/atoms/figure/figure.stories.tsx74
-rw-r--r--src/components/atoms/figure/figure.test.tsx32
-rw-r--r--src/components/atoms/figure/figure.tsx72
-rw-r--r--src/components/atoms/figure/index.ts1
-rw-r--r--src/components/atoms/index.ts1
6 files changed, 210 insertions, 0 deletions
diff --git a/src/components/atoms/figure/figure.module.scss b/src/components/atoms/figure/figure.module.scss
new file mode 100644
index 0000000..e7ba5c2
--- /dev/null
+++ b/src/components/atoms/figure/figure.module.scss
@@ -0,0 +1,30 @@
+@use "../../../styles/abstracts/functions" as fun;
+
+.caption {
+ margin: 0;
+ padding: fun.convert-px(4) var(--spacing-2xs);
+ background: var(--color-bg-secondary);
+ border: fun.convert-px(1) solid var(--color-border-light);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+}
+
+.wrapper {
+ display: flex;
+ flex-flow: column;
+ width: fit-content;
+ margin: 0 auto;
+ position: relative;
+ text-align: center;
+
+ &--has-borders {
+ padding: fun.convert-px(4);
+ border: fun.convert-px(1) solid var(--color-border);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+ var(--color-shadow);
+
+ .caption {
+ margin-top: fun.convert-px(4);
+ }
+ }
+}
diff --git a/src/components/atoms/figure/figure.stories.tsx b/src/components/atoms/figure/figure.stories.tsx
new file mode 100644
index 0000000..7763641
--- /dev/null
+++ b/src/components/atoms/figure/figure.stories.tsx
@@ -0,0 +1,74 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import NextImage from 'next/image';
+import { Figure } from './figure';
+
+/**
+ * Figure - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Figure',
+ component: Figure,
+ args: {},
+ argTypes: {
+ caption: {
+ control: {
+ type: 'text',
+ },
+ description: 'A figure caption.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ hasBorders: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Add borders around the figure.',
+ table: {
+ category: 'Styles',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof Figure>;
+
+const Template: ComponentStory<typeof Figure> = (args) => <Figure {...args} />;
+
+/**
+ * Figure Stories - Illustration
+ */
+export const Illustration = Template.bind({});
+Illustration.args = {
+ children: (
+ <NextImage
+ alt="An example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ ),
+};
+
+/**
+ * Figure Stories - BorderedIllustration
+ */
+export const BorderedIllustration = Template.bind({});
+BorderedIllustration.args = {
+ children: (
+ <NextImage
+ alt="An example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ ),
+ hasBorders: true,
+};
diff --git a/src/components/atoms/figure/figure.test.tsx b/src/components/atoms/figure/figure.test.tsx
new file mode 100644
index 0000000..90a07c7
--- /dev/null
+++ b/src/components/atoms/figure/figure.test.tsx
@@ -0,0 +1,32 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { Figure } from './figure';
+
+describe('Figure', () => {
+ it('renders the figure contents', () => {
+ const body = 'tempora et quis';
+
+ render(<Figure>{body}</Figure>);
+
+ expect(rtlScreen.getByRole('figure')).toHaveTextContent(body);
+ });
+
+ it('can render its contents with a caption', () => {
+ const body = 'tempora et quis';
+ const caption = 'velit dolores magnam';
+
+ render(<Figure caption={caption}>{body}</Figure>);
+
+ expect(rtlScreen.getByRole('figure', { name: caption })).toHaveTextContent(
+ body
+ );
+ });
+
+ it('can style the figure with borders', () => {
+ const body = 'tempora et quis';
+
+ render(<Figure hasBorders>{body}</Figure>);
+
+ expect(rtlScreen.getByRole('figure')).toHaveClass('wrapper--has-borders');
+ });
+});
diff --git a/src/components/atoms/figure/figure.tsx b/src/components/atoms/figure/figure.tsx
new file mode 100644
index 0000000..4dd5b10
--- /dev/null
+++ b/src/components/atoms/figure/figure.tsx
@@ -0,0 +1,72 @@
+import {
+ forwardRef,
+ type ReactNode,
+ type ForwardRefRenderFunction,
+ type HTMLAttributes,
+ useId,
+} from 'react';
+import styles from './figure.module.scss';
+
+export type FigureProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
+ /**
+ * The contents (ie. an image, illustration, diagram, code snippet, etc.).
+ */
+ children: ReactNode;
+ /**
+ * A figure caption.
+ */
+ caption?: ReactNode;
+ /**
+ * Should we wrap the contents with borders?
+ */
+ hasBorders?: boolean;
+};
+
+const FigureWithRef: ForwardRefRenderFunction<HTMLElement, FigureProps> = (
+ {
+ 'aria-labelledby': ariaLabelledBy,
+ caption,
+ children,
+ className = '',
+ hasBorders,
+ ...props
+ },
+ ref
+) => {
+ const captionId = useId();
+ const bordersModifier = hasBorders ? styles['wrapper--has-borders'] : '';
+ const figureClass = `${styles.wrapper} ${bordersModifier} ${className}`;
+
+ /**
+ * We need to ensure that the figcaption is used as an accessible name for the
+ * figure. In Testing Library, it is not automatically associated, it could
+ * also be the case in some browsers. However if the consumer provide its own
+ * `aria-labelled-by` attribute, it should be used instead of the caption (we
+ * could combine them but we cannot know which order is the more logical).
+ */
+ const figureLabelledBy =
+ caption && !ariaLabelledBy ? captionId : ariaLabelledBy;
+
+ return (
+ <figure
+ {...props}
+ aria-labelledby={figureLabelledBy}
+ className={figureClass}
+ ref={ref}
+ >
+ {children}
+ {caption ? (
+ <figcaption className={styles.caption} id={captionId}>
+ {caption}
+ </figcaption>
+ ) : null}
+ </figure>
+ );
+};
+
+/**
+ * Figure component
+ *
+ * Render a responsive image wrapped in a figure element.
+ */
+export const Figure = forwardRef(FigureWithRef);
diff --git a/src/components/atoms/figure/index.ts b/src/components/atoms/figure/index.ts
new file mode 100644
index 0000000..0f6ad20
--- /dev/null
+++ b/src/components/atoms/figure/index.ts
@@ -0,0 +1 @@
+export * from './figure';
diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts
index e9c41ed..31beda9 100644
--- a/src/components/atoms/index.ts
+++ b/src/components/atoms/index.ts
@@ -1,4 +1,5 @@
export * from './buttons';
+export * from './figure';
export * from './flip';
export * from './forms';
export * from './heading';