diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-10-09 18:26:23 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:14:41 +0100 |
| commit | 15522ec9146f6f1956620355c44dea2a6a75b67c (patch) | |
| tree | 7be0c4ca96cb3e59d2ee989785a6b6a286e6169d /src/components/atoms | |
| parent | 891441a76173c708c6604fa203b175aefa222333 (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.scss | 30 | ||||
| -rw-r--r-- | src/components/atoms/figure/figure.stories.tsx | 74 | ||||
| -rw-r--r-- | src/components/atoms/figure/figure.test.tsx | 32 | ||||
| -rw-r--r-- | src/components/atoms/figure/figure.tsx | 72 | ||||
| -rw-r--r-- | src/components/atoms/figure/index.ts | 1 | ||||
| -rw-r--r-- | src/components/atoms/index.ts | 1 |
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'; |
