Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/css/src/components/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
@use "paragraph/paragraph";
@use "password-input/password-input";
@use "progress-list/progress-list";
@use "prose/prose";
@use "radio/radio";
@use "row/row";
@use "search-field/search-field";
Expand Down
20 changes: 20 additions & 0 deletions packages/css/src/components/prose/README.md
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to point implementers to vertical spacing early in the Developer Guide. Not sure where exactly.

And would it be helpful or distracting to add a line to every single component (text, media, etc.) affected by Prose?

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!-- @license CC0-1.0 -->

# Prose

Applies the recommended vertical spacing between the direct children of a container, so editorial content flows without hand-picked margins.

## Class name

`ams-prose`

## Guidelines

- Use this utility class on a container that wraps editorial content — an article, a rich-text block, or a section built from multiple components — so its children are spaced according to the [‘Space between’](/docs/docs-designer-guide-space-between--docs) recommendations.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you mention a rich-text block. What is a rich-text block in relation to Prose? Aren't all components that can have text + various types of inline media rich-text blocks?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can consider adding an Article component that has the prose mixin applied by default.

With the as attribute we could even allow for the article element to be swapped with other element types.

- The rules cover Heading (levels 1 through 6), Paragraph (including the large variant), Blockquote, Image, Ordered List, Unordered List, Button, Call to Action Link, Standalone Link, Table, and Accordion.
Other direct children pick up the default body-to-body spacing.
- It sets `margin-block-end` on each direct child based on what follows it, using the `:has()` selector.
Browsers without `:has()` support fall back to a uniform spacing.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as with Niels’ comment: this will be out-of-date in less than 3 months when it becomes baseline widely available. This line can then be removed.

- Content inside an Accordion panel nested under `.ams-prose` is spaced the same way, so an accordion and its surroundings share one rhythm.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accordion is mentioned in the docs, but no Story example. It would be good to have one.

- To add a single ad-hoc gap between two elements, prefer the [Margin Bottom](/docs/utilities-css-margin--docs) utility class on the first one.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s add a reference to the Prose docs in the Margin utility docs.

- To add even spacing between a set of siblings, prefer the [Gap](/docs/utilities-css-gap--docs) utility class on their parent.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s also add a reference to the Prose docs in the Gap utility docs.

113 changes: 113 additions & 0 deletions packages/css/src/components/prose/prose.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

$ams-button-or-cta: ".ams-button, .ams-call-to-action-link";
$ams-heavy-body: ".ams-blockquote, .ams-image, .ams-paragraph--large, .ams-table";
Comment thread
RubenSibon marked this conversation as resolved.
$ams-list-or-paragraph: ".ams-ordered-list, .ams-paragraph:not(.ams-paragraph--large), .ams-unordered-list";
$ams-minor-heading: ".ams-heading--4, .ams-heading--5, .ams-heading--6";
$ams-plain-paragraph: ".ams-paragraph:not(.ams-paragraph--large)";
Comment thread
RubenSibon marked this conversation as resolved.

@mixin prose-spacing {
/* Fallback for older browsers: uniform spacing */
> .ams-heading {
margin-block-end: var(--ams-space-s);
}

> :not(.ams-heading) {
margin-block-end: var(--ams-space-m);
}

> :last-child {
margin-block-end: 0;
}

/* Context-aware spacing for browsers with :has() support */
/* stylelint-disable-next-line scss/operator-no-unspaced */
@supports selector(:has(+ *)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the has selector is widely available soon this might be overkill, and if a browser doesn't support the selector the rule will not be applied right?

/* Reset fallback margins */
> * {
margin-block-end: 0;
}

/* Body-to-body default */
> :not(.ams-heading):has(+ :not(.ams-heading)) {
margin-block-end: var(--ams-space-m);
}

/* Heavy body element (Blockquote, Image, Paragraph (large), Table) on either side */
> :is(#{$ams-heavy-body}):has(+ :not(.ams-heading)),
> :not(.ams-heading):has(+ :is(#{$ams-heavy-body})) {
margin-block-end: var(--ams-space-l);
}

/* Button or CTA Link followed by Button or CTA Link */
> :is(#{$ams-button-or-cta}):has(+ :is(#{$ams-button-or-cta})) {
margin-block-end: var(--ams-space-l);
}

/* Paragraph (large) before Blockquote or Image needs extra air */
> .ams-paragraph--large:has(+ :is(.ams-blockquote, .ams-image)) {
margin-block-end: var(--ams-space-xl);
}

/* Body content before an accordion needs a slightly larger gap */
> :not(.ams-heading):has(+ .ams-accordion) {
margin-block-end: var(--ams-space-l);
}

/* Body before a minor heading (level 4-6): default */
> :not(.ams-heading):has(+ :is(#{$ams-minor-heading})) {
margin-block-end: var(--ams-space-l);
}

/* List or regular Paragraph before a minor heading: tighter */
> :is(#{$ams-list-or-paragraph}):has(+ :is(#{$ams-minor-heading})) {
margin-block-end: var(--ams-space-m);
}

/* Any element before a level 3 heading (subsection boundary) */
> *:has(+ .ams-heading--3) {
margin-block-end: var(--ams-space-l);
}

/* Any element before a level 2 heading (section boundary) */
> *:has(+ .ams-heading--2) {
margin-block-end: var(--ams-space-xl);
}

/* Heading level 3 followed by its content */
> .ams-heading--3:has(+ :not(.ams-heading)) {
margin-block-end: var(--ams-space-xs);
}

/* Heading level 2 followed by its content */
> .ams-heading--2:has(+ :not(.ams-heading)) {
margin-block-end: var(--ams-space-s);
}

/* Heading level 1 followed by a regular Paragraph */
> .ams-heading--1:has(+ #{$ams-plain-paragraph}) {
margin-block-end: var(--ams-space-xs);
}

/* Heading level 1 followed by a Paragraph (large), typically lead or metadata */
> .ams-heading--1:has(+ .ams-paragraph--large) {
margin-block-end: var(--ams-space-m);
}

/* Heading level 2 or 3 followed by another heading */
> :is(.ams-heading--2, .ams-heading--3):has(+ .ams-heading) {
margin-block-end: var(--ams-space-m);
}
}
}

.ams-prose {
@include prose-spacing;

.ams-accordion__panel {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see an example of this in the story and I'm curious to see what happens. I would assume the ams-prose wrapper only has effect on direct children and this exemption is kind of weird specific inheritance. You could just wrap content in the tabs panel in a div with the className. If you have a custom component, or table, or card it should not have effect on this content either.

Copy link
Copy Markdown
Contributor

@RubenSibon RubenSibon Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it would be nice if this story shows all descendants that can be affected by Prose. Or if another story shows how an Accordion is affected.

If showing all possible permutation in Storybook is a bit too much, you could of course add that full example to Prototypes and/or a Test story for Chromatic.

Actually it might be a good idea to have snapshot tests for this specific utility class, because there are so many parts that could break at some point.

@include prose-spacing;
}
}
3 changes: 2 additions & 1 deletion storybook/config/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,12 @@ export const parameters = {
storySort: {
order: [
'Docs',
['Introduction', 'Developer Guide', ['Getting Started']],
['Introduction', ['Getting Started']],
'Brand',
Comment thread
VincentSmedinga marked this conversation as resolved.
'Components',
['Buttons', 'Containers', 'Feedback', 'Forms', 'Layout', 'Media', 'Navigation', 'Text'],
'Utilities',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having Utilities here feels wrong, because it between incrementally larger concerns: Tokens < Components < Patterns < Pages.

Either:

  1. move the entire Utilities category up so that it’s above Components and below Brand;
  2. remove the Utilities category (which has only one member anyway) and add a ‘CSS utilities’ folder to the Components category.

Option 2 makes the most sense because our CSS utilities are actually CSS components in (almost?) everyway.

'Patterns',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a good place for other common component compositions such as form fields with inputs, labels, error messages and whatnot.

'Pages',
['Introduction', 'Public', ['Introduction', 'Home Page'], 'Internal', ['Introduction', 'Home Page']],
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import './space-between-finder.css'

import type { HTMLAttributes } from 'react'

import { clsx } from 'clsx'
import { useState } from 'react'

import { componentsAbove, componentsBelow, spaceBetween } from './config'

export const SpaceBetweenFinder = ({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) => {
const [above, setAbove] = useState('')
const [below, setBelow] = useState('')

const key = `${above}|${below}`
const size = above && below ? spaceBetween[key] : undefined
const bothSelected = above !== '' && below !== ''

return (
<div {...restProps} className={clsx('_ams-space-between-finder sb-docs sb-docs-content', className)}>
Comment thread
VincentSmedinga marked this conversation as resolved.
<div className="_ams-space-between-finder__fields">
<div>
<label htmlFor="space-between-above">Component on top:</label>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed opportunity to use our own components?

<select id="space-between-above" onChange={(e) => setAbove(e.target.value)} value={above}>
<option value="">Select a component</option>
{componentsAbove.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="space-between-below">Component below:</label>
<select id="space-between-below" onChange={(e) => setBelow(e.target.value)} value={below}>
<option value="">Select a component</option>
{componentsBelow.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
</div>
{bothSelected && (
<p className="_ams-space-between-finder__result">
{size !== undefined ? (
<>
→ Recommended space size: <code>{size}</code>.
</>
Comment thread
VincentSmedinga marked this conversation as resolved.
) : (
'→ We advise against this combination.'
)}
</p>
)}
</div>
)
}
125 changes: 125 additions & 0 deletions storybook/src/_components/SpaceBetweenFinder/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

type SpaceSize = '0' | 'xs' | 's' | 'm' | 'l' | 'xl'

export const componentsAbove = [
'Heading 1',
'Heading 2',
'Heading 3',
'Heading 4',
'Blockquote',
'\u2026 List',
'Paragraph',
'Paragraph (large)',
'Button / CTA Link',
'Image',
'Standalone Link',
'Table',
]

export const componentsBelow = [
'Heading 2',
'Heading 3',
'Heading 4',
'Blockquote',
'Button / CTA Link',
'Image',
'Standalone Link',
'\u2026 List',
'Paragraph',
'Paragraph (large)',
'Table',
]

export const spaceBetween: Record<string, SpaceSize> = {
'\u2026 List|\u2026 List': 'm',
'\u2026 List|Blockquote': 'l',
'\u2026 List|Button / CTA Link': 'm',
'\u2026 List|Heading 2': 'xl',
'\u2026 List|Heading 3': 'l',
'\u2026 List|Heading 4': 'm',
'\u2026 List|Image': 'l',
'\u2026 List|Paragraph': 'm',
'\u2026 List|Standalone Link': 'm',
'\u2026 List|Table': 'l',
'Blockquote|\u2026 List': 'l',
'Blockquote|Button / CTA Link': 'l',
'Blockquote|Image': 'l',
'Blockquote|Paragraph': 'l',
'Blockquote|Standalone Link': 'l',
'Blockquote|Table': 'l',
'Button / CTA Link|\u2026 List': 'm',
'Button / CTA Link|Blockquote': 'l',
'Button / CTA Link|Button / CTA Link': 'l',
'Button / CTA Link|Heading 2': 'xl',
'Button / CTA Link|Heading 3': 'l',
'Button / CTA Link|Heading 4': 'l',
'Button / CTA Link|Image': 'l',
'Button / CTA Link|Paragraph': 'm',
'Button / CTA Link|Standalone Link': 'm',
'Button / CTA Link|Table': 'l',
'Heading 1|Heading 2': 'xl',
'Heading 1|Paragraph': 'xs',
'Heading 1|Paragraph (large)': 'm',
'Heading 2|\u2026 List': 's',
'Heading 2|Heading 3': 'm',
'Heading 2|Paragraph': 's',
'Heading 2|Table': 's',
'Heading 3|\u2026 List': 'xs',
'Heading 3|Heading 4': 'm',
'Heading 3|Paragraph': 'xs',
'Heading 3|Table': 'xs',
'Heading 4|\u2026 List': '0',
'Heading 4|Paragraph': '0',
'Heading 4|Table': '0',
'Image|\u2026 List': 'l',
'Image|Blockquote': 'l',
'Image|Button / CTA Link': 'l',
'Image|Heading 2': 'xl',
'Image|Heading 3': 'l',
'Image|Heading 4': 'l',
'Image|Image': 'l',
'Image|Paragraph': 'l',
'Image|Standalone Link': 'l',
'Image|Table': 'l',
'Paragraph (large)|\u2026 List': 'l',
'Paragraph (large)|Blockquote': 'xl',
'Paragraph (large)|Heading 2': 'xl',
'Paragraph (large)|Image': 'xl',
'Paragraph (large)|Paragraph': 'l',
'Paragraph (large)|Standalone Link': 'l',
'Paragraph (large)|Table': 'l',
'Paragraph|\u2026 List': 'm',
'Paragraph|Blockquote': 'l',
'Paragraph|Button / CTA Link': 'm',
'Paragraph|Heading 2': 'xl',
'Paragraph|Heading 3': 'l',
'Paragraph|Heading 4': 'm',
'Paragraph|Image': 'l',
'Paragraph|Paragraph': 'm',
'Paragraph|Standalone Link': 'm',
'Paragraph|Table': 'l',
'Standalone Link|\u2026 List': 'm',
'Standalone Link|Blockquote': 'l',
'Standalone Link|Button / CTA Link': 'm',
'Standalone Link|Heading 2': 'xl',
'Standalone Link|Heading 3': 'l',
'Standalone Link|Heading 4': 'l',
'Standalone Link|Image': 'l',
'Standalone Link|Paragraph': 'm',
'Standalone Link|Standalone Link': 'm',
'Standalone Link|Table': 'l',
'Table|\u2026 List': 'l',
'Table|Blockquote': 'l',
'Table|Button / CTA Link': 'l',
'Table|Heading 2': 'xl',
'Table|Heading 3': 'l',
'Table|Heading 4': 'l',
'Table|Image': 'l',
'Table|Paragraph': 'l',
'Table|Standalone Link': 'l',
'Table|Table': 'l',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

._ams-space-between-finder {
color: var(--ams-color-text);
display: flex;
flex-direction: column;
gap: var(--ams-space-m);
margin-block: var(--ams-space-m) !important;
}

._ams-space-between-finder__fields {
display: flex;
flex-wrap: wrap;
gap: var(--ams-space-m);
}

._ams-space-between-finder label {
color: var(--ams-color-text);
display: block;
font-size: 1.125rem;
font-weight: var(--ams-typography-body-text-bold-font-weight);
margin-block-end: var(--ams-space-xs);
}

._ams-space-between-finder select {
font-family: var(--ams-typography-font-family);
font-size: 1rem;
padding-block: var(--ams-space-xs);
padding-inline: var(--ams-space-xs);
}

._ams-space-between-finder__result {
margin-block: 0 !important;
}
5 changes: 5 additions & 0 deletions storybook/src/_styles/overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@
line-height: 1.5;
}

.sbdocs-content.sbdocs-content > table:not(.sb-unstyled) th,
.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > table:not(.sb-unstyled) th {
font-weight: var(--ams-typography-body-text-bold-font-weight);
}

.sbdocs-content.sbdocs-content > p code {
color: currentColor;
font-size: 1rem;
Expand Down
Loading
Loading