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;