diff --git a/packages/css/src/components/description-list/README.md b/packages/css/src/components/description-list/README.md new file mode 100644 index 0000000000..04853b4d66 --- /dev/null +++ b/packages/css/src/components/description-list/README.md @@ -0,0 +1,18 @@ + + +# Description List + +A collection of terms and their details. + +## Design + +On a narrow screen, details appear indented below their term. +From the medium breakpoint, terms and details appear next to each other. +The column for the details is twice as wide as the one for the term. + +Details are set in bold text. + +## References + +- [MDN: `
`: The Description List element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dl) +- [WCAG: Using description lists](https://www.w3.org/WAI/WCAG22/Techniques/html/H40) diff --git a/packages/css/src/components/description-list/description-list.scss b/packages/css/src/components/description-list/description-list.scss new file mode 100644 index 0000000000..9f73928c20 --- /dev/null +++ b/packages/css/src/components/description-list/description-list.scss @@ -0,0 +1,55 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@import "../../common/breakpoint"; +@import "../../common/text-rendering"; + +@mixin reset { + box-sizing: border-box; + margin-block: 0; +} + +.ams-description-list { + color: var(--ams-description-list-color); + display: grid; + font-family: var(--ams-description-list-font-family); + font-size: var(--ams-description-list-font-size); + font-weight: var(--ams-description-list-font-weight); + gap: var(--ams-description-list-gap); + line-height: var(--ams-description-list-line-height); + + @media screen and (min-width: $ams-breakpoint-medium) { + grid-template-columns: 1fr 2fr; + } + + @include reset; + @include text-rendering; +} + +.ams-description-list--inverse-color { + color: var(--ams-description-list-inverse-color); +} + +.ams-description-list__term { + @media screen and (min-width: $ams-breakpoint-medium) { + grid-column-start: 1; + } +} + +@mixin reset-details { + margin-inline: 0; +} + +.ams-description-list__details { + font-weight: var(--ams-description-list-details-font-weight); + padding-inline-start: var(--ams-description-list-details-padding-inline-start); + + @media screen and (min-width: $ams-breakpoint-medium) { + grid-column-start: 2; + padding-inline-start: 0; + } + + @include reset-details; +} diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index eebfc194b6..800d1ae507 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -7,6 +7,7 @@ @import "./document/document"; @import "./avatar/avatar"; @import "./form-field-character-counter/form-field-character-counter"; +@import "./description-list/description-list"; @import "./row/row"; @import "./radio/radio"; @import "./tabs/tabs"; diff --git a/packages/react/src/DescriptionList/DescriptionList.test.tsx b/packages/react/src/DescriptionList/DescriptionList.test.tsx new file mode 100644 index 0000000000..d5ea286553 --- /dev/null +++ b/packages/react/src/DescriptionList/DescriptionList.test.tsx @@ -0,0 +1,49 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { DescriptionList } from './DescriptionList' +import '@testing-library/jest-dom' + +describe('Description list', () => { + 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-description-list') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-description-list 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 the right inverse color class', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-description-list--inverse-color') + }) +}) diff --git a/packages/react/src/DescriptionList/DescriptionList.tsx b/packages/react/src/DescriptionList/DescriptionList.tsx new file mode 100644 index 0000000000..c94f3f9191 --- /dev/null +++ b/packages/react/src/DescriptionList/DescriptionList.tsx @@ -0,0 +1,33 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' +import { DescriptionListDetails } from './DescriptionListDetails' +import { DescriptionListTerm } from './DescriptionListTerm' + +export type DescriptionListProps = { + inverseColor?: boolean +} & PropsWithChildren> + +const DescriptionListRoot = forwardRef( + ({ children, className, inverseColor, ...restProps }: DescriptionListProps, ref: ForwardedRef) => ( +
+ {children} +
+ ), +) + +DescriptionListRoot.displayName = 'DescriptionList' + +export const DescriptionList = Object.assign(DescriptionListRoot, { + Term: DescriptionListTerm, + Details: DescriptionListDetails, +}) diff --git a/packages/react/src/DescriptionList/DescriptionListDetails.test.tsx b/packages/react/src/DescriptionList/DescriptionListDetails.test.tsx new file mode 100644 index 0000000000..3237521eee --- /dev/null +++ b/packages/react/src/DescriptionList/DescriptionListDetails.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { DescriptionList } from './DescriptionList' +import '@testing-library/jest-dom' + +describe('Description list details', () => { + it('renders', () => { + render(Test) + + const component = screen.getByRole('definition') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + render(Test) + + const component = screen.getByRole('definition') + + expect(component).toHaveClass('ams-description-list__details') + }) + + it('renders an additional class name', () => { + render(Test) + + const component = screen.getByRole('definition') + + expect(component).toHaveClass('ams-description-list__details extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render(Test) + + const component = screen.getByRole('definition') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/DescriptionList/DescriptionListDetails.tsx b/packages/react/src/DescriptionList/DescriptionListDetails.tsx new file mode 100644 index 0000000000..79a800891a --- /dev/null +++ b/packages/react/src/DescriptionList/DescriptionListDetails.tsx @@ -0,0 +1,20 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' + +export type DescriptionListDetailsProps = PropsWithChildren> + +export const DescriptionListDetails = forwardRef( + ({ children, className, ...restProps }: DescriptionListDetailsProps, ref: ForwardedRef) => ( +
+ {children} +
+ ), +) + +DescriptionListDetails.displayName = 'DescriptionList.Details' diff --git a/packages/react/src/DescriptionList/DescriptionListTerm.test.tsx b/packages/react/src/DescriptionList/DescriptionListTerm.test.tsx new file mode 100644 index 0000000000..fff06b9099 --- /dev/null +++ b/packages/react/src/DescriptionList/DescriptionListTerm.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { DescriptionList } from './DescriptionList' +import '@testing-library/jest-dom' + +describe('Description list term', () => { + it('renders', () => { + render(Test) + + const component = screen.getByRole('term') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + render(Test) + + const component = screen.getByRole('term') + + expect(component).toHaveClass('ams-description-list__term') + }) + + it('renders an additional class name', () => { + render(Test) + + const component = screen.getByRole('term') + + expect(component).toHaveClass('ams-description-list__term extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render(Test) + + const component = screen.getByRole('term') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/DescriptionList/DescriptionListTerm.tsx b/packages/react/src/DescriptionList/DescriptionListTerm.tsx new file mode 100644 index 0000000000..0326783e25 --- /dev/null +++ b/packages/react/src/DescriptionList/DescriptionListTerm.tsx @@ -0,0 +1,20 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' + +export type DescriptionListTermProps = PropsWithChildren> + +export const DescriptionListTerm = forwardRef( + ({ children, className, ...restProps }: DescriptionListTermProps, ref: ForwardedRef) => ( +
+ {children} +
+ ), +) + +DescriptionListTerm.displayName = 'DescriptionList.Term' diff --git a/packages/react/src/DescriptionList/README.md b/packages/react/src/DescriptionList/README.md new file mode 100644 index 0000000000..f88e8f6f60 --- /dev/null +++ b/packages/react/src/DescriptionList/README.md @@ -0,0 +1,5 @@ + + +# React Description List component + +[Description List documentation](../../../css/src/components/description-list/README.md) diff --git a/packages/react/src/DescriptionList/index.ts b/packages/react/src/DescriptionList/index.ts new file mode 100644 index 0000000000..fa57776c93 --- /dev/null +++ b/packages/react/src/DescriptionList/index.ts @@ -0,0 +1,4 @@ +export { DescriptionList } from './DescriptionList' +export type { DescriptionListProps } from './DescriptionList' +export type { DescriptionListTermProps } from './DescriptionListTerm' +export type { DescriptionListDetailsProps } from './DescriptionListDetails' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 57cd966e21..a71afe61bc 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,6 +6,7 @@ /* Append here */ export * from './Avatar' export * from './FormFieldCharacterCounter' +export * from './DescriptionList' export * from './Row' export * from './Radio' export * from './Tabs' diff --git a/proprietary/tokens/src/components/ams/description-list.tokens.json b/proprietary/tokens/src/components/ams/description-list.tokens.json new file mode 100644 index 0000000000..9248ca9d8a --- /dev/null +++ b/proprietary/tokens/src/components/ams/description-list.tokens.json @@ -0,0 +1,20 @@ +{ + "ams": { + "description-list": { + "color": { "value": "{ams.color.primary-black}" }, + "font-family": { "value": "{ams.text.font-family}" }, + "font-size": { "value": "{ams.text.level.5.font-size}" }, + "font-weight": { "value": "{ams.text.font-weight.normal}" }, + "gap": { "value": "{ams.space.stack.md}" }, + "inverse-color": { "value": "{ams.color.primary-white}" }, + "line-height": { "value": "{ams.text.level.5.line-height}" }, + "row": { + "gap": { "value": "{ams.space.stack.md}" } + }, + "details": { + "font-weight": { "value": "{ams.text.font-weight.bold}" }, + "padding-inline-start": { "value": "{ams.space.inside.xl}" } + } + } + } +} diff --git a/storybook/src/components/DescriptionList/DescriptionList.docs.mdx b/storybook/src/components/DescriptionList/DescriptionList.docs.mdx new file mode 100644 index 0000000000..1326476a3f --- /dev/null +++ b/storybook/src/components/DescriptionList/DescriptionList.docs.mdx @@ -0,0 +1,24 @@ +import { Canvas, Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as DescriptionListStories from "./DescriptionList.stories.tsx"; +import README from "../../../../packages/css/src/components/description-list/README.md?raw"; + + + +{README} + + + + + +## Multiple details + +A term may have multiple details. + + + +### Inverse colour + +Set the `inverseColor` prop if the Description List sits on a dark background. +This ensures the colour of the text provides enough contrast. + + diff --git a/storybook/src/components/DescriptionList/DescriptionList.stories.tsx b/storybook/src/components/DescriptionList/DescriptionList.stories.tsx new file mode 100644 index 0000000000..31af9ae4dd --- /dev/null +++ b/storybook/src/components/DescriptionList/DescriptionList.stories.tsx @@ -0,0 +1,59 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { DescriptionList } from '@amsterdam/design-system-react' +import { Meta, StoryObj } from '@storybook/react' +import { exampleParagraph } from '../shared/exampleContent' + +const paragraph = exampleParagraph() + +const meta = { + title: 'Components/Text/Description List', + component: DescriptionList, + decorators: [ + (Story, context) => ( +
+ +
+ ), + ], + args: { + children: [ + Gebied, + Gemeente Amsterdam, + Stadsdeel, + West, + Opmerkingen, + {paragraph}, + ], + inverseColor: false, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const MultipleDetails: Story = { + args: { + children: [ + Gebied, + Gemeente Amsterdam, + Stadsdeel, + Noord, + Oost, + Zuid, + West, + ], + }, +} + +export const InvertedColor: Story = { + args: { + inverseColor: true, + }, +}