diff --git a/packages/css/src/components/figure/README.md b/packages/css/src/components/figure/README.md new file mode 100644 index 0000000000..a17fbad134 --- /dev/null +++ b/packages/css/src/components/figure/README.md @@ -0,0 +1,5 @@ + + +# Figure + +Groups media content with a caption that describes it. diff --git a/packages/css/src/components/figure/figure.scss b/packages/css/src/components/figure/figure.scss new file mode 100644 index 0000000000..8d9d61f373 --- /dev/null +++ b/packages/css/src/components/figure/figure.scss @@ -0,0 +1,33 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@use "../../common/text-rendering" as *; + +@mixin reset-figure { + margin-block: 0; + margin-inline: 0; +} + +.ams-figure { + display: flex; + flex-direction: column; + gap: var(--ams-figure-gap); + + @include reset-figure; +} + +.ams-figure__caption { + color: var(--ams-figure-caption-color); + font-family: var(--ams-figure-caption-font-family); + font-size: var(--ams-figure-caption-font-size); + font-weight: var(--ams-figure-caption-font-weight); + line-height: var(--ams-figure-caption-line-height); + + @include text-rendering; +} + +.ams-figure__caption--inverse-color { + color: var(--ams-figure-caption-inverse-color); +} diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index 3fb749ce8c..d2c301d2cc 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -25,6 +25,7 @@ @use "error-message/error-message"; @use "field-set/field-set"; @use "field/field"; +@use "figure/figure"; @use "file-input/file-input"; @use "file-list/file-list"; @use "footer/footer"; diff --git a/packages/react/src/Figure/Figure.test.tsx b/packages/react/src/Figure/Figure.test.tsx new file mode 100644 index 0000000000..af3bac9cba --- /dev/null +++ b/packages/react/src/Figure/Figure.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { Figure } from './Figure' +import '@testing-library/jest-dom' + +describe('Figure', () => { + it('renders', () => { + render(
) + + const component = screen.getByRole('figure') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + render(
) + + const component = screen.getByRole('figure') + + expect(component).toHaveClass('ams-figure') + }) + + it('renders an additional class name', () => { + render(
) + + const component = screen.getByRole('figure') + + expect(component).toHaveClass('ams-figure extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render(
) + + const component = screen.getByRole('figure') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/Figure/Figure.tsx b/packages/react/src/Figure/Figure.tsx new file mode 100644 index 0000000000..d7867558c9 --- /dev/null +++ b/packages/react/src/Figure/Figure.tsx @@ -0,0 +1,21 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' +import { FigureCaption } from './FigureCaption' + +export type FigureProps = PropsWithChildren> + +const FigureRoot = forwardRef(({ children, className, ...restProps }: FigureProps, ref: ForwardedRef) => ( +
+ {children} +
+)) + +FigureRoot.displayName = 'Figure' + +export const Figure = Object.assign(FigureRoot, { Caption: FigureCaption }) diff --git a/packages/react/src/Figure/FigureCaption.test.tsx b/packages/react/src/Figure/FigureCaption.test.tsx new file mode 100644 index 0000000000..152a1d952b --- /dev/null +++ b/packages/react/src/Figure/FigureCaption.test.tsx @@ -0,0 +1,49 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { FigureCaption } from './FigureCaption' +import '@testing-library/jest-dom' + +describe('Figure Caption', () => { + it('renders', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-figure__caption') + }) + + it('renders the right inverse color class', () => { + const { container } = render(Caption) + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-figure__caption--inverse-color') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-figure__caption extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/Figure/FigureCaption.tsx b/packages/react/src/Figure/FigureCaption.tsx new file mode 100644 index 0000000000..1f967fba49 --- /dev/null +++ b/packages/react/src/Figure/FigureCaption.tsx @@ -0,0 +1,27 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' + +export type FigureCaptionProps = { + /** Changes the text colour for readability on a dark background. */ + inverseColor?: boolean +} & PropsWithChildren> + +export const FigureCaption = forwardRef( + ({ children, className, inverseColor, ...restProps }: FigureCaptionProps, ref: ForwardedRef) => ( +
+ {children} +
+ ), +) + +FigureCaption.displayName = 'Figure.Caption' diff --git a/packages/react/src/Figure/README.md b/packages/react/src/Figure/README.md new file mode 100644 index 0000000000..90d22b1942 --- /dev/null +++ b/packages/react/src/Figure/README.md @@ -0,0 +1,5 @@ + + +# React Figure component + +[Figure documentation](../../../css/src/components/figure/README.md) diff --git a/packages/react/src/Figure/index.ts b/packages/react/src/Figure/index.ts new file mode 100644 index 0000000000..c6b99f171e --- /dev/null +++ b/packages/react/src/Figure/index.ts @@ -0,0 +1,2 @@ +export { Figure } from './Figure' +export type { FigureProps } from './Figure' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 34dc74a18b..fc0f152621 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -23,6 +23,7 @@ export * from './Dialog' export * from './ErrorMessage' export * from './Field' export * from './FieldSet' +export * from './Figure' export * from './FileInput' export * from './FileList' export * from './Footer' diff --git a/proprietary/tokens/src/components/ams/figure.tokens.json b/proprietary/tokens/src/components/ams/figure.tokens.json new file mode 100644 index 0000000000..8c6b1b15a5 --- /dev/null +++ b/proprietary/tokens/src/components/ams/figure.tokens.json @@ -0,0 +1,15 @@ +{ + "ams": { + "figure": { + "gap": { "value": "{ams.space.sm}" }, + "caption": { + "color": { "value": "{ams.brand.color.neutral.100}" }, + "font-family": { "value": "{ams.text.font-family}" }, + "font-size": { "value": "{ams.text.level.6.font-size}" }, + "font-weight": { "value": "{ams.text.font-weight.normal}" }, + "line-height": { "value": "{ams.text.level.6.line-height}" }, + "inverse-color": { "value": "{ams.brand.color.neutral.0}" } + } + } + } +} diff --git a/storybook/src/components/Figure/Figure.docs.mdx b/storybook/src/components/Figure/Figure.docs.mdx new file mode 100644 index 0000000000..57e9c01722 --- /dev/null +++ b/storybook/src/components/Figure/Figure.docs.mdx @@ -0,0 +1,20 @@ +{/* @license CC0-1.0 */} + +import { Canvas, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as FigureStories from "./Figure.stories.tsx"; +import README from "../../../../packages/css/src/components/figure/README.md?raw"; + + + +{README} + + + +## Examples + +### Inverse colour + +Set the `inverseColor` prop if the Figure Caption sits on a dark background. +This ensures the colour of the text provides enough contrast. + + diff --git a/storybook/src/components/Figure/Figure.stories.tsx b/storybook/src/components/Figure/Figure.stories.tsx new file mode 100644 index 0000000000..ebc56dc328 --- /dev/null +++ b/storybook/src/components/Figure/Figure.stories.tsx @@ -0,0 +1,45 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { Image } from '@amsterdam/design-system-react' +import { Figure } from '@amsterdam/design-system-react/src' +import { Meta, StoryObj } from '@storybook/react' +import { exampleCaption } from '../shared/exampleContent' + +const caption = exampleCaption() + +const meta = { + title: 'Components/Media/Figure', + component: Figure, + args: { + children: caption, + inverseColor: false, + }, + render: ({ children, ...args }) => ( +
+ + {children} +
+ ), +} satisfies Meta +// We use the Caption type here to allow inverseColor. This works as long as Figure has no props of its own. + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const InverseColour: Story = { + args: { + inverseColor: true, + }, +} diff --git a/storybook/src/components/shared/exampleContent.ts b/storybook/src/components/shared/exampleContent.ts index b53be2bb3e..c251506d2e 100644 --- a/storybook/src/components/shared/exampleContent.ts +++ b/storybook/src/components/shared/exampleContent.ts @@ -17,6 +17,19 @@ export const exampleAccordionHeading = () => 'Voorgaande versies van ramingen', ]) +export const exampleCaption = () => + pickRandomContent([ + 'Een rustige Amsterdamse gracht met eeuwenoude gevels die weerspiegelen in het water, terwijl fietsen nonchalant tegen de brugleuning rusten – een alledaags tafereel vol historie en charme. Foto: Liam Dekker.', + 'Een rij geparkeerde fietsen langs een smalle gracht met klassieke Amsterdamse gevels op de achtergrond.', + 'Een klein houten bootje dobbert rustig op het water, omringd door bomen en bakstenen panden met grote ramen. Foto: Sophie van der Brugge.', + 'Een typische Amsterdamse brug met smeedijzeren leuningen, vol met fietsen en uitzicht op een grachtenpand met een klokgevel.', + 'Een stille gracht met weerspiegelende gevels, terwijl een tram in de verte over een brug rijdt. Foto: Isabel Groeneveld.', + 'Een zonovergoten terras aan de gracht, met stoelen op de kade en uitzicht op een sierlijke ophaalbrug.', + 'Een grachtenpand met vrolijke bloemenbakken op de vensterbanken en een smalle trap naar de voordeur. Foto: Joris Zandvoort.', + 'Een schuin geplaatste fiets tegen een lantaarnpaal, met op de achtergrond een karakteristiek houten bruggetje.', + 'Een groep Ajax-supporters in rood-witte sjaals verzamelt zich op een plein, klaar voor een wedstrijd in de Johan Cruijff ArenA. Foto: Louis Flitskamp.', + ]) + export const exampleHeading = () => pickRandomContent([ 'Meer plekken voor kunst en cultuur, verspreid over de stad', diff --git a/storybook/src/styles/overrides.css b/storybook/src/styles/overrides.css index 5e635314ab..e757ca4a53 100644 --- a/storybook/src/styles/overrides.css +++ b/storybook/src/styles/overrides.css @@ -10,7 +10,7 @@ .sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > :is(ol, ul) li, .sbdocs-content.sbdocs-content > table:not(.sb-unstyled) :is(td, th), .sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > table:not(.sb-unstyled) :is(td, th), -.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) figcaption { +.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > figure > figcaption { color: #000; font-family: "Amsterdam Sans", "Arial", sans-serif; } @@ -110,7 +110,7 @@ font-size: 1rem; } -.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) figcaption { +.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > figure > figcaption { font-size: 0.875rem; }