diff --git a/documentation/storybook.md b/documentation/storybook.md index 00f7ad5d2d..89c00f6276 100644 --- a/documentation/storybook.md +++ b/documentation/storybook.md @@ -84,10 +84,12 @@ argTypes: { ## Best practices for stories -1. Use decorators and / or `args.children` before reaching for `render`. `render` can easily mess up the stories’ code view. +1. Import the Story’s component from the `src` directory so that Storybook can display its types. + Import other components from the package as usual. +2. Use decorators and / or `args.children` before reaching for `render`. `render` can easily mess up the stories’ code view. Decorators are not shown in the code view, `args.children` are. -2. Always check your stories’ code view. -3. `args.children` can be an array, separated by commas and given ascending numbers as keys. +3. Always check your stories’ code view. +4. `args.children` can be an array, separated by commas and given ascending numbers as keys. ## Future plans diff --git a/packages/css/src/components/breakout/README.md b/packages/css/src/components/breakout/README.md new file mode 100644 index 0000000000..8b9dc76a92 --- /dev/null +++ b/packages/css/src/components/breakout/README.md @@ -0,0 +1,5 @@ + + +# Breakout + +A composition that lets a figure – e.g. an image, video or map – break out of a Spotlight. diff --git a/packages/css/src/components/breakout/breakout.scss b/packages/css/src/components/breakout/breakout.scss new file mode 100644 index 0000000000..fb27c4bccb --- /dev/null +++ b/packages/css/src/components/breakout/breakout.scss @@ -0,0 +1,163 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@import "../../common/breakpoint"; +@import "../grid/grid"; +@import "../grid/mixins"; + +$ams-breakout-row-span-max: 4; + +.ams-breakout { + @include ams-grid; +} + +// Grid gap + +.ams-breakout--gap-vertical--none { + @include ams-grid--gap-vertical--none; +} + +.ams-breakout--gap-vertical--small { + @include ams-grid--gap-vertical--small; +} + +.ams-breakout--gap-vertical--large { + @include ams-grid--gap-vertical--large; +} + +// Grid padding + +.ams-breakout--padding-bottom--small { + @include ams-grid--padding-bottom--small; +} + +.ams-breakout--padding-bottom--medium { + @include ams-grid--padding-bottom--medium; +} + +.ams-breakout--padding-bottom--large { + @include ams-grid--padding-bottom--large; +} + +.ams-breakout--padding-top--small { + @include ams-grid--padding-top--small; +} + +.ams-breakout--padding-top--medium { + @include ams-grid--padding-top--medium; +} + +.ams-breakout--padding-top--large { + @include ams-grid--padding-top--large; +} + +.ams-breakout--padding-vertical--small { + @include ams-grid--padding-vertical--small; +} + +.ams-breakout--padding-vertical--medium { + @include ams-grid--padding-vertical--medium; +} + +.ams-breakout--padding-vertical--large { + @include ams-grid--padding-vertical--large; +} + +// Column span +// The order of these declaration blocks ensures the intended specificity. + +.ams-breakout__cell--col-span-all { + @include ams-grid__cell--span-all; +} + +@for $i from 1 through $ams-grid-column-count { + .ams-breakout__cell--col-span-#{$i} { + @include ams-grid__cell--span($i); + } + + .ams-breakout__cell--col-start-#{$i} { + @include ams-grid__cell--start($i); + } +} + +@media screen and (min-width: $ams-breakpoint-medium) { + @for $i from 1 through $ams-grid-column-count { + .ams-breakout__cell--col-span-#{$i}-medium { + @include ams-grid__cell--span-medium($i); + } + + .ams-breakout__cell--col-start-#{$i}-medium { + @include ams-grid__cell--start-medium($i); + } + } +} + +@media screen and (min-width: $ams-breakpoint-wide) { + @for $i from 1 through $ams-grid-column-count { + .ams-breakout__cell--col-span-#{$i}-wide { + @include ams-grid__cell--span-wide($i); + } + + .ams-breakout__cell--col-start-#{$i}-wide { + @include ams-grid__cell--start-wide($i); + } + } +} + +// Has content + +.ams-breakout__cell--has-figure { + align-self: end; +} + +.ams-breakout__cell--has-spotlight { + display: grid; /* Stretches the empty Spotlight vertically. */ + margin-block: calc(var(--ams-space-grid-md) * -1); + margin-inline: calc(var(--ams-space-grid-lg) * -1); + + .ams-breakout--gap-vertical--small > & { + margin-block: calc(var(--ams-space-grid-sm) * -1); + } + + .ams-breakout--gap-vertical--large > & { + margin-block: calc(var(--ams-space-grid-lg) * -1); + } +} + +// Row span + +@for $i from 1 through $ams-breakout-row-span-max { + .ams-breakout__cell--row-span-#{$i} { + grid-row-end: span $i; + } + + .ams-breakout__cell--row-start-#{$i} { + grid-row-start: $i; + } +} + +@media screen and (min-width: $ams-breakpoint-medium) { + @for $i from 1 through $ams-breakout-row-span-max { + .ams-breakout__cell--row-span-#{$i}-medium { + grid-row-end: span $i; + } + + .ams-breakout__cell--row-start-#{$i}-medium { + grid-row-start: $i; + } + } +} + +@media screen and (min-width: $ams-breakpoint-wide) { + @for $i from 1 through $ams-breakout-row-span-max { + .ams-breakout__cell--row-span-#{$i}-wide { + grid-row-end: span $i; + } + + .ams-breakout__cell--row-start-#{$i}-wide { + grid-row-start: $i; + } + } +} diff --git a/packages/css/src/components/grid/_mixins.scss b/packages/css/src/components/grid/_mixins.scss new file mode 100644 index 0000000000..7d1a55922f --- /dev/null +++ b/packages/css/src/components/grid/_mixins.scss @@ -0,0 +1,110 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@mixin ams-grid { + column-gap: var(--ams-grid-column-gap); + display: grid; + grid-template-columns: repeat(var(--ams-grid-column-count), 1fr); + padding-inline: var(--ams-grid-padding-inline); + row-gap: var(--ams-grid-row-gap-md); + + @media screen and (min-width: $ams-breakpoint-medium) { + grid-template-columns: repeat(var(--ams-grid-medium-column-count), 1fr); + } + + @media screen and (min-width: $ams-breakpoint-wide) { + grid-template-columns: repeat(var(--ams-grid-wide-column-count), 1fr); + } +} + +// Grid gap + +@mixin ams-grid--gap-vertical--none { + row-gap: initial; +} + +@mixin ams-grid--gap-vertical--small { + row-gap: var(--ams-grid-row-gap-sm); +} + +@mixin ams-grid--gap-vertical--large { + row-gap: var(--ams-grid-row-gap-lg); +} + +// Grid padding + +@mixin ams-grid--padding-bottom--small { + padding-block-end: var(--ams-grid-padding-block-sm); +} + +@mixin ams-grid--padding-bottom--medium { + padding-block-end: var(--ams-grid-padding-block-md); +} + +@mixin ams-grid--padding-bottom--large { + padding-block-end: var(--ams-grid-padding-block-lg); +} + +@mixin ams-grid--padding-top--small { + padding-block-start: var(--ams-grid-padding-block-sm); +} + +@mixin ams-grid--padding-top--medium { + padding-block-start: var(--ams-grid-padding-block-md); +} + +@mixin ams-grid--padding-top--large { + padding-block-start: var(--ams-grid-padding-block-lg); +} + +@mixin ams-grid--padding-vertical--small { + padding-block: var(--ams-grid-padding-block-sm); +} + +@mixin ams-grid--padding-vertical--medium { + padding-block: var(--ams-grid-padding-block-md); +} + +@mixin ams-grid--padding-vertical--large { + padding-block: var(--ams-grid-padding-block-lg); +} + +@mixin ams-grid__cell--span-all { + grid-column: 1 / -1; +} + +// Column span + +@mixin ams-grid__cell--span($i) { + grid-column-end: span $i; +} + +@mixin ams-grid__cell--start($i) { + grid-column-start: $i; +} + +@mixin ams-grid__cell--span-medium($i) { + @media screen and (min-width: $ams-breakpoint-medium) { + grid-column-end: span $i; + } +} + +@mixin ams-grid__cell--start-medium($i) { + @media screen and (min-width: $ams-breakpoint-medium) { + grid-column-start: $i; + } +} + +@mixin ams-grid__cell--span-wide($i) { + @media screen and (min-width: $ams-breakpoint-wide) { + grid-column-end: span $i; + } +} + +@mixin ams-grid__cell--start-wide($i) { + @media screen and (min-width: $ams-breakpoint-wide) { + grid-column-start: $i; + } +} diff --git a/packages/css/src/components/grid/grid.scss b/packages/css/src/components/grid/grid.scss index 4bb54dcfab..33185a2964 100644 --- a/packages/css/src/components/grid/grid.scss +++ b/packages/css/src/components/grid/grid.scss @@ -4,107 +4,103 @@ */ @import "../../common/breakpoint"; +@import "mixins"; -.ams-grid { - column-gap: var(--ams-grid-column-gap); - display: grid; - grid-template-columns: repeat(var(--ams-grid-column-count), 1fr); - padding-inline: var(--ams-grid-padding-inline); - row-gap: var(--ams-grid-row-gap-md); - - @media screen and (min-width: $ams-breakpoint-medium) { - grid-template-columns: repeat(var(--ams-grid-medium-column-count), 1fr); - } +$ams-grid-column-count: 12; - @media screen and (min-width: $ams-breakpoint-wide) { - grid-template-columns: repeat(var(--ams-grid-wide-column-count), 1fr); - } +.ams-grid { + @include ams-grid; } +// Grid gap + .ams-grid--gap-vertical--none { - row-gap: initial; + @include ams-grid--gap-vertical--none; } .ams-grid--gap-vertical--small { - row-gap: var(--ams-grid-row-gap-sm); + @include ams-grid--gap-vertical--small; } .ams-grid--gap-vertical--large { - row-gap: var(--ams-grid-row-gap-lg); + @include ams-grid--gap-vertical--large; } +// Grid padding + .ams-grid--padding-bottom--small { - padding-block-end: var(--ams-grid-padding-block-sm); + @include ams-grid--padding-bottom--small; } .ams-grid--padding-bottom--medium { - padding-block-end: var(--ams-grid-padding-block-md); + @include ams-grid--padding-bottom--medium; } .ams-grid--padding-bottom--large { - padding-block-end: var(--ams-grid-padding-block-lg); + @include ams-grid--padding-bottom--large; } .ams-grid--padding-top--small { - padding-block-start: var(--ams-grid-padding-block-sm); + @include ams-grid--padding-top--small; } .ams-grid--padding-top--medium { - padding-block-start: var(--ams-grid-padding-block-md); + @include ams-grid--padding-top--medium; } .ams-grid--padding-top--large { - padding-block-start: var(--ams-grid-padding-block-lg); + @include ams-grid--padding-top--large; } .ams-grid--padding-vertical--small { - padding-block: var(--ams-grid-padding-block-sm); + @include ams-grid--padding-vertical--small; } .ams-grid--padding-vertical--medium { - padding-block: var(--ams-grid-padding-block-md); + @include ams-grid--padding-vertical--medium; } .ams-grid--padding-vertical--large { - padding-block: var(--ams-grid-padding-block-lg); + @include ams-grid--padding-vertical--large; } +// Column span +// The order of these declaration blocks ensures the intended specificity. + .ams-grid__cell--span-all { - grid-column: 1 / -1; + @include ams-grid__cell--span-all; } -// The order of the following declaration blocks ensures the intended specificity. - -@for $i from 1 through 12 { +@for $i from 1 through $ams-grid-column-count { .ams-grid__cell--span-#{$i} { - grid-column-end: span $i; + @include ams-grid__cell--span($i); } .ams-grid__cell--start-#{$i} { - grid-column-start: $i; + @include ams-grid__cell--start($i); } } @media screen and (min-width: $ams-breakpoint-medium) { - @for $i from 1 through 12 { + @for $i from 1 through $ams-grid-column-count { .ams-grid__cell--span-#{$i}-medium { - grid-column-end: span $i; + @include ams-grid__cell--span-medium($i); } .ams-grid__cell--start-#{$i}-medium { - grid-column-start: $i; + @include ams-grid__cell--start-medium($i); } } } @media screen and (min-width: $ams-breakpoint-wide) { - @for $i from 1 through 12 { + @for $i from 1 through $ams-grid-column-count { .ams-grid__cell--span-#{$i}-wide { - grid-column-end: span $i; + @include ams-grid__cell--span-wide($i); } .ams-grid__cell--start-#{$i}-wide { - grid-column-start: $i; + @include ams-grid__cell--start-wide($i); } } } diff --git a/packages/css/src/components/hint/README.md b/packages/css/src/components/hint/README.md index 51db7c603a..ed846ca7fe 100644 --- a/packages/css/src/components/hint/README.md +++ b/packages/css/src/components/hint/README.md @@ -2,7 +2,7 @@ # Hint -Shows a hint text. Only used internally by Label and Field Set. +Shows a hint text. Only used internally by Label and Field Set. ## Class name diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index 5555aaaf54..cfc30781c9 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -4,6 +4,7 @@ */ /* Append here */ +@import "./breakout/breakout"; @import "./hint/hint"; @import "./password-input/password-input"; @import "./form-error-list/form-error-list"; diff --git a/packages/react/src/Breakout/Breakout.test.tsx b/packages/react/src/Breakout/Breakout.test.tsx new file mode 100644 index 0000000000..88e0c1699f --- /dev/null +++ b/packages/react/src/Breakout/Breakout.test.tsx @@ -0,0 +1,98 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { Breakout } from './Breakout' +import type { GridPaddingSize } from '../Grid/Grid' +import '@testing-library/jest-dom' + +const paddingSizes = ['small', 'medium', 'large'] + +describe('Breakout', () => { + 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-breakout') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout extra') + }) + + it('renders the correct class name for a zero gap', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout--gap-vertical--none') + }) + + it(`renders the correct class name for a small gap`, () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout--gap-vertical--small') + }) + + it(`renders the correct class name for a large gap`, () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout--gap-vertical--large') + }) + + paddingSizes.forEach((size) => { + it(`renders the correct class name for a ${size} bottom padding`, () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass(`ams-breakout--padding-bottom--${size}`) + }) + }) + + paddingSizes.forEach((size) => { + it(`renders the correct class name for a ${size} top padding`, () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass(`ams-breakout--padding-top--${size}`) + }) + }) + + paddingSizes.forEach((size) => { + it(`renders the correct class name for a ${size} vertical padding`, () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass(`ams-breakout--padding-vertical--${size}`) + }) + }) + + 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/Breakout/Breakout.tsx b/packages/react/src/Breakout/Breakout.tsx new file mode 100644 index 0000000000..cf670797dd --- /dev/null +++ b/packages/react/src/Breakout/Breakout.tsx @@ -0,0 +1,40 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef } from 'react' +import { BreakoutCell } from './BreakoutCell' +import type { GridProps } from '../Grid' +import { paddingClasses } from '../Grid/paddingClasses' + +export type BreakoutRowNumber = 1 | 2 | 3 | 4 +export type BreakoutRowNumbers = { narrow: BreakoutRowNumber; medium: BreakoutRowNumber; wide: BreakoutRowNumber } + +export type BreakoutProps = GridProps + +const BreakoutRoot = forwardRef( + ( + { children, className, gapVertical, paddingBottom, paddingTop, paddingVertical, ...restProps }: BreakoutProps, + ref: ForwardedRef, + ) => ( +
+ {children} +
+ ), +) + +BreakoutRoot.displayName = 'Breakout' + +export const Breakout = Object.assign(BreakoutRoot, { Cell: BreakoutCell }) diff --git a/packages/react/src/Breakout/BreakoutCell.test.tsx b/packages/react/src/Breakout/BreakoutCell.test.tsx new file mode 100644 index 0000000000..116e34c3b4 --- /dev/null +++ b/packages/react/src/Breakout/BreakoutCell.test.tsx @@ -0,0 +1,199 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { Breakout } from './Breakout' +import '@testing-library/jest-dom' + +describe('Breakout cell', () => { + 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-breakout__cell') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout__cell extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) + + it('renders no class names for undefined values for colStart and colSpan', () => { + const { container } = render() + + const elementWithColSpanClass = container.querySelector('[class*="ams-breakout__cell--col-span"]') + const elementWithColStartClass = container.querySelector('[class*="ams-breakout__cell--col-start"]') + const elementWithRowSpanClass = container.querySelector('[class*="ams-breakout__cell--row-span"]') + const elementWithRowStartClass = container.querySelector('[class*="ams-breakout__cell--row-start"]') + + expect(elementWithColSpanClass).not.toBeInTheDocument() + expect(elementWithColStartClass).not.toBeInTheDocument() + expect(elementWithRowSpanClass).not.toBeInTheDocument() + expect(elementWithRowStartClass).not.toBeInTheDocument() + }) + + it('renders class names for single number values for colStart and colSpan', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout__cell--col-span-4 ams-breakout__cell--col-start-2') + }) + + it('renders class names for a single number value for colStart', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout__cell--col-span-8') + }) + + it('renders class names for a single number value for colSpan', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout__cell--col-start-6') + }) + + it('renders class names for a single number for colSpan and an object for colStart', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass( + 'ams-breakout__cell--col-span-8 ams-breakout__cell--col-start-2 ams-breakout__cell--col-start-4-medium ams-breakout__cell--col-start-6-wide', + ) + }) + + it('renders class names for an object for colSpan and a single number for colStart', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass( + 'ams-breakout__cell--col-span-3 ams-breakout__cell--col-span-5-medium ams-breakout__cell--col-span-7-wide ams-breakout__cell--col-start-2', + ) + }) + + it('renders class names for an object for both colSpan and colStart', () => { + const { container } = render( + , + ) + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass( + 'ams-breakout__cell--col-span-2 ams-breakout__cell--col-span-4-medium ams-breakout__cell--col-span-6-wide ams-breakout__cell--col-start-1 ams-breakout__cell--col-start-3-medium ams-breakout__cell--col-start-5-wide', + ) + }) + + it('renders the correct class name for the “all” value of colSpan', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout__cell--col-span-all') + }) + + it('renders class names for single number values for rowStart and rowSpan', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout__cell--row-span-3 ams-breakout__cell--row-start-2') + }) + + it('renders class names for a single number value for rowStart', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout__cell--row-start-4') + }) + + it('renders class names for a single number value for rowSpan', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-breakout__cell--row-span-2') + }) + + it('renders class names for a single number for rowSpan and an object for rowStart', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass( + 'ams-breakout__cell--row-span-3 ams-breakout__cell--row-start-1 ams-breakout__cell--row-start-2-medium ams-breakout__cell--row-start-3-wide', + ) + }) + + it('renders class names for an object for rowSpan and a single number for rowStart', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass( + 'ams-breakout__cell--row-span-2 ams-breakout__cell--row-span-3-medium ams-breakout__cell--row-span-4-wide ams-breakout__cell--row-start-1', + ) + }) + + it('renders class names for an object for both rowSpan and rowStart', () => { + const { container } = render( + , + ) + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass( + 'ams-breakout__cell--row-span-2 ams-breakout__cell--row-span-3-medium ams-breakout__cell--row-span-4-wide ams-breakout__cell--row-start-1 ams-breakout__cell--row-start-2-medium ams-breakout__cell--row-start-3-wide', + ) + }) +}) + +it(`renders the correct class if it has a Figure`, () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass(`ams-breakout__cell--has-figure`) +}) + +it(`renders the correct class if it has a Spotlight`, () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass(`ams-breakout__cell--has-spotlight`) +}) + +it('renders a custom tag', () => { + render() + + const cell = screen.getByRole('article') + + expect(cell).toBeInTheDocument() +}) diff --git a/packages/react/src/Breakout/BreakoutCell.tsx b/packages/react/src/Breakout/BreakoutCell.tsx new file mode 100644 index 0000000000..76855d267b --- /dev/null +++ b/packages/react/src/Breakout/BreakoutCell.tsx @@ -0,0 +1,74 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { HTMLAttributes, PropsWithChildren } from 'react' +import type { BreakoutRowNumber, BreakoutRowNumbers } from './Breakout' +import { breakoutCellClasses } from './breakoutCellClasses' +import type { GridColumnNumber, GridColumnNumbers } from '../Grid/Grid' + +type BreakoutCellSpanAllProp = { + /** Lets the cell span the full width of all grid variants. */ + colSpan: 'all' + colStart?: never + /** The content of this cell. + * The Cell containing the Spotlight expands horizontally and vertically to cover the adjacent gaps and margins. + * The Cell containing the Image aligns itself to the bottom of the row, in case it is less tall than the text. */ + has?: 'spotlight' +} + +type BreakoutCellSpanAndStartProps = { + /** The amount of grid columns the cell spans. */ + colSpan?: 'all' | GridColumnNumber | GridColumnNumbers + /** The index of the grid column the cell starts at. */ + colStart?: GridColumnNumber | GridColumnNumbers + has?: 'figure' +} + +type BreakoutCellRowSpanAndStartProps = { + /** The amount of grid rows the cell spans. */ + rowSpan?: BreakoutRowNumber | BreakoutRowNumbers + /** The index of the grid row the cell starts at. */ + rowStart?: BreakoutRowNumber | BreakoutRowNumbers +} + +export type BreakoutCellProps = { + /** The HTML element to use. */ + as?: 'article' | 'div' | 'section' +} & (BreakoutCellSpanAllProp | BreakoutCellSpanAndStartProps) & + BreakoutCellRowSpanAndStartProps & + PropsWithChildren> + +export const BreakoutCell = forwardRef( + ( + { + as: Tag = 'div', + children, + className, + colSpan, + colStart, + has, + rowSpan, + rowStart, + ...restProps + }: BreakoutCellProps, + ref: any, + ) => ( + + {children} + + ), +) + +BreakoutCell.displayName = 'Breakout.Cell' diff --git a/packages/react/src/Breakout/README.md b/packages/react/src/Breakout/README.md new file mode 100644 index 0000000000..738ec650da --- /dev/null +++ b/packages/react/src/Breakout/README.md @@ -0,0 +1,5 @@ + + +# React Breakout component + +[Breakout documentation](../../../css/src/components/breakout/README.md) diff --git a/packages/react/src/Breakout/breakoutCellClasses.ts b/packages/react/src/Breakout/breakoutCellClasses.ts new file mode 100644 index 0000000000..75bb0e92ed --- /dev/null +++ b/packages/react/src/Breakout/breakoutCellClasses.ts @@ -0,0 +1,19 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import type { BreakoutCellProps } from './BreakoutCell' +import { addGridClass } from '../Grid/gridCellClasses' + +export const breakoutCellClasses = ( + colSpan?: BreakoutCellProps['colSpan'], + colStart?: BreakoutCellProps['colStart'], + rowSpan?: BreakoutCellProps['rowSpan'], + rowStart?: BreakoutCellProps['rowStart'], +): string[] => [ + ...addGridClass('ams-breakout__cell--col-span-', colSpan), + ...addGridClass('ams-breakout__cell--col-start-', colStart), + ...addGridClass('ams-breakout__cell--row-span-', rowSpan), + ...addGridClass('ams-breakout__cell--row-start-', rowStart), +] diff --git a/packages/react/src/Breakout/index.ts b/packages/react/src/Breakout/index.ts new file mode 100644 index 0000000000..a355f7189d --- /dev/null +++ b/packages/react/src/Breakout/index.ts @@ -0,0 +1,3 @@ +export { Breakout } from './Breakout' +export type { BreakoutProps } from './Breakout' +export type { BreakoutCellProps } from './BreakoutCell' diff --git a/packages/react/src/Grid/Grid.tsx b/packages/react/src/Grid/Grid.tsx index 666d6fdf46..f0b8227dee 100644 --- a/packages/react/src/Grid/Grid.tsx +++ b/packages/react/src/Grid/Grid.tsx @@ -7,15 +7,10 @@ import clsx from 'clsx' import { forwardRef } from 'react' import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' import { GridCell } from './GridCell' +import { paddingClasses } from './paddingClasses' export type GridColumnNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 - -export type GridColumnNumbers = { - narrow: GridColumnNumber - medium: GridColumnNumber - wide: GridColumnNumber -} - +export type GridColumnNumbers = { narrow: GridColumnNumber; medium: GridColumnNumber; wide: GridColumnNumber } export type GridPaddingSize = 'small' | 'medium' | 'large' type GridPaddingVerticalProp = { @@ -39,28 +34,6 @@ export type GridProps = { } & (GridPaddingVerticalProp | GridPaddingTopAndBottomProps) & PropsWithChildren> -const paddingClasses = ( - paddingBottom?: GridPaddingSize, - paddingTop?: GridPaddingSize, - paddingVertical?: GridPaddingSize, -): string[] => { - const classes = [] as string[] - - if (paddingVertical) { - return [`ams-grid--padding-vertical--${paddingVertical}`] - } - - if (paddingBottom) { - classes.push(`ams-grid--padding-bottom--${paddingBottom}`) - } - - if (paddingTop) { - classes.push(`ams-grid--padding-top--${paddingTop}`) - } - - return classes -} - const GridRoot = forwardRef( ( { children, className, gapVertical, paddingBottom, paddingTop, paddingVertical, ...restProps }: GridProps, @@ -72,7 +45,7 @@ const GridRoot = forwardRef( className={clsx( 'ams-grid', gapVertical && `ams-grid--gap-vertical--${gapVertical}`, - paddingClasses(paddingBottom, paddingTop, paddingVertical), + paddingClasses('grid', paddingBottom, paddingTop, paddingVertical), className, )} > diff --git a/packages/react/src/Grid/GridCell.test.tsx b/packages/react/src/Grid/GridCell.test.tsx index 5d2befce42..d96fee4ec9 100644 --- a/packages/react/src/Grid/GridCell.test.tsx +++ b/packages/react/src/Grid/GridCell.test.tsx @@ -73,7 +73,7 @@ describe('Grid cell', () => { expect(component).toHaveClass('ams-grid__cell--start-6') }) - it('renders class names for a single number for span and array values for start', () => { + it('renders class names for a single number for span and an object for start', () => { const { container } = render() const component = container.querySelector(':only-child') @@ -83,7 +83,7 @@ describe('Grid cell', () => { ) }) - it('renders class names for array values for span and a single number for start', () => { + it('renders class names for an object for span and a single number for start', () => { const { container } = render() const component = container.querySelector(':only-child') @@ -93,7 +93,7 @@ describe('Grid cell', () => { ) }) - it('renders class names for array values for both start and span', () => { + it('renders class names for an object for both start and span', () => { const { container } = render( , ) diff --git a/packages/react/src/Grid/gridCellClasses.ts b/packages/react/src/Grid/gridCellClasses.ts index 97de65d7f0..43dbe55c15 100644 --- a/packages/react/src/Grid/gridCellClasses.ts +++ b/packages/react/src/Grid/gridCellClasses.ts @@ -5,34 +5,20 @@ import type { GridCellProps } from './GridCell' -export const gridCellClasses = (span?: GridCellProps['span'], start?: GridCellProps['start']): string[] => { - if (!span && !start) { - return [] +export const addGridClass = ( + prefix: string, + value?: number | { narrow: number; medium: number; wide: number } | 'all', +): string[] => { + if (value === 'all' || typeof value === 'number') { + return [`${prefix}${value}`] + } else if (value) { + const { narrow, medium, wide } = value + return [`${prefix}${narrow}`, `${prefix}${medium}-medium`, `${prefix}${wide}-wide`] } - - const classes = [] as string[] - - if (span === 'all' || typeof span === 'number') { - classes.push(`ams-grid__cell--span-${span}`) - } else if (span) { - const { narrow, medium, wide } = span - classes.push( - `ams-grid__cell--span-${narrow}`, - `ams-grid__cell--span-${medium}-medium`, - `ams-grid__cell--span-${wide}-wide`, - ) - } - - if (typeof start === 'number') { - classes.push(`ams-grid__cell--start-${start}`) - } else if (start) { - const { narrow, medium, wide } = start - classes.push( - `ams-grid__cell--start-${narrow}`, - `ams-grid__cell--start-${medium}-medium`, - `ams-grid__cell--start-${wide}-wide`, - ) - } - - return classes + return [] } + +export const gridCellClasses = (colSpan?: GridCellProps['span'], colStart?: GridCellProps['start']): string[] => [ + ...addGridClass('ams-grid__cell--span-', colSpan), + ...addGridClass('ams-grid__cell--start-', colStart), +] diff --git a/packages/react/src/Grid/paddingClasses.ts b/packages/react/src/Grid/paddingClasses.ts new file mode 100644 index 0000000000..6889678a14 --- /dev/null +++ b/packages/react/src/Grid/paddingClasses.ts @@ -0,0 +1,24 @@ +import type { GridPaddingSize } from './Grid' + +export const paddingClasses = ( + componentName: string, + paddingBottom?: GridPaddingSize, + paddingTop?: GridPaddingSize, + paddingVertical?: GridPaddingSize, +): string[] => { + const classes: string[] = [] + + if (paddingVertical) { + return [`ams-${componentName}--padding-vertical--${paddingVertical}`] + } + + if (paddingBottom) { + classes.push(`ams-${componentName}--padding-bottom--${paddingBottom}`) + } + + if (paddingTop) { + classes.push(`ams-${componentName}--padding-top--${paddingTop}`) + } + + return classes +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 81a3b474c0..f2f19c3c20 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './Breakout' export * from './Hint' export * from './PasswordInput' export * from './FormErrorList' diff --git a/proprietary/tokens/src/components/ams/breakout.tokens.json b/proprietary/tokens/src/components/ams/breakout.tokens.json new file mode 100644 index 0000000000..117d2f1ce6 --- /dev/null +++ b/proprietary/tokens/src/components/ams/breakout.tokens.json @@ -0,0 +1,11 @@ +{ + "ams": { + "breakout": { + "row-gap": { + "sm": { "value": "{ams.space.grid.sm}" }, + "md": { "value": "{ams.space.grid.md}" }, + "lg": { "value": "{ams.space.grid.lg}" } + } + } + } +} diff --git a/storybook/src/components/Breakout/Breakout.docs.mdx b/storybook/src/components/Breakout/Breakout.docs.mdx new file mode 100644 index 0000000000..54ff6f86dc --- /dev/null +++ b/storybook/src/components/Breakout/Breakout.docs.mdx @@ -0,0 +1,37 @@ +{/* @license CC0-1.0 */} + +import { Canvas, Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as BreakoutStories from "./Breakout.stories.tsx"; +import README from "../../../../packages/css/src/components/breakout/README.md?raw"; + + + +{README} + +### How to use + +This component is an extension of `Grid`. +It offers the same props, although `span` and `start` have been renamed to `colSpan` and `colStart`. +Additionally, `Breakout` allows cells to span multiple rows. + +The Cell containing the Spotlight must have `has="spotlight"` to extend to the gaps and margins around it. +This prop can be used only on Cells that span all columns of the grid, so `span="all"` is required as well. + +The Cell containing the figure must have `has="figure"` to ensure it aligns to the bottom. + +The content that doesn’t break out defines the height of the Spotlight. +Ensure that the text is short, or the image tall, to prevent losing the break-out effect. + +On narrower screens, let the text move below the image. + + + + + +## Examples + +### Vertical layout + +A large figure can be placed at the top of the Spotlight, with related text positioned underneath. + + diff --git a/storybook/src/components/Breakout/Breakout.stories.tsx b/storybook/src/components/Breakout/Breakout.stories.tsx new file mode 100644 index 0000000000..383261a874 --- /dev/null +++ b/storybook/src/components/Breakout/Breakout.stories.tsx @@ -0,0 +1,81 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { AspectRatio, Image, Paragraph, Screen, Spotlight } from '@amsterdam/design-system-react' +import { Breakout } from '@amsterdam/design-system-react/src' +import { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: 'Components/Layout/Breakout', + component: Breakout, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + children: [ + + + , + + + Het doel van deze club is om ervoor te zorgen dat de Zuidas steeds duurzamer wordt. + + , + + + + + , + ], + }, +} + +export const VerticalLayout: Story = { + args: { + children: [ + + + + + , + + + , + + + Vertel ons in het evenementenformulier wat u wilt gaan doen. U checkt daarmee of u een vergunning nodig hebt. + + + Daarna vraagt u de vergunning aan in hetzelfde formulier. Of doet u een gratis melding of vooraankondiging. + + , + ], + }, +} diff --git a/storybook/src/components/Grid/Grid.stories.tsx b/storybook/src/components/Grid/Grid.stories.tsx index 536acd6c48..0e4da31dd1 100644 --- a/storybook/src/components/Grid/Grid.stories.tsx +++ b/storybook/src/components/Grid/Grid.stories.tsx @@ -15,6 +15,9 @@ const meta = { className: 'ams-docs-grid', }, argTypes: { + className: { + table: { disable: true }, + }, gapVertical: { control: { type: 'radio', diff --git a/storybook/src/components/Spotlight/Spotlight.stories.tsx b/storybook/src/components/Spotlight/Spotlight.stories.tsx index 4383b9f57d..1e924f9f43 100644 --- a/storybook/src/components/Spotlight/Spotlight.stories.tsx +++ b/storybook/src/components/Spotlight/Spotlight.stories.tsx @@ -3,7 +3,7 @@ * Copyright Gemeente Amsterdam */ -import { Blockquote, Grid } from '@amsterdam/design-system-react' +import { Blockquote, Grid, Screen } from '@amsterdam/design-system-react' import { Spotlight } from '@amsterdam/design-system-react/src' import { Meta, StoryObj } from '@storybook/react' import { exampleQuote } from '../shared/exampleContent' @@ -13,6 +13,13 @@ const quote = exampleQuote() const meta = { title: 'Components/Containers/Spotlight', component: Spotlight, + decorators: [ + (Story) => ( + + + + ), + ], render: ({ as, color }) => ( diff --git a/storybook/src/styles/docs.css b/storybook/src/styles/docs.css index c9d2950169..3d9319e14d 100644 --- a/storybook/src/styles/docs.css +++ b/storybook/src/styles/docs.css @@ -1,6 +1,7 @@ :root { --ams-docs-grey: rgb(0 0 0 / 6.25%); --ams-docs-pink: rgb(229 0 130 / 25%); + --ams-docs-lightblue: rgb(0 130 229 / 25%); } .ams-docs-figure { @@ -95,12 +96,14 @@ font-family: "Amsterdam Sans", sans-serif; font-size: var(--ams-paragraph-small-font-size); line-height: var(--ams-paragraph-small-line-height); - margin-block: 0; - margin-inline: 0; padding-block: 1.5rem; text-align: center; } +.ams-docs-item--highlight { + background-color: var(--ams-docs-lightblue); +} + .ams-docs-row > .ams-docs-item { flex-basis: 8rem; padding-inline: 1.5rem;