diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index b95b00fbdd..3ce99af8f0 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -4,6 +4,7 @@ */ /* Append here */ +@import "./table-of-contents/table-of-contents"; @import "./field/field"; @import "./select/select"; @import "./time-input/time-input"; diff --git a/packages/css/src/components/table-of-contents/README.md b/packages/css/src/components/table-of-contents/README.md new file mode 100644 index 0000000000..4541a1701b --- /dev/null +++ b/packages/css/src/components/table-of-contents/README.md @@ -0,0 +1,3 @@ + + +# Table of Contents diff --git a/packages/css/src/components/table-of-contents/table-of-contents.scss b/packages/css/src/components/table-of-contents/table-of-contents.scss new file mode 100644 index 0000000000..21c2d3fba3 --- /dev/null +++ b/packages/css/src/components/table-of-contents/table-of-contents.scss @@ -0,0 +1,78 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@import "../../common/text-rendering"; + +@mixin reset { + box-sizing: border-box; + margin-block: 0; + padding-inline: 0; +} + +.ams-table-of-contents { + display: flex; + flex-direction: column; + font-family: var(--ams-table-of-contents-font-family, inherit); + font-size: var(--ams-table-of-contents-font-size); + font-weight: var(--ams-table-of-contents-font-weight); + gap: var(--ams-table-of-contents-gap); + line-height: var(--ams-table-of-contents-line-height); +} + +.ams-table-of-contents__list { + display: flex; + flex-direction: column; + gap: var(--ams-table-of-contents-list-gap); + list-style: none; + + .ams-table-of-contents__list { + display: none; + padding-block-start: var(--ams-table-of-contents-list-gap); + padding-inline-start: var(--ams-table-of-contents-list-list-padding-inline-start); + } + + @include text-rendering; + @include reset; +} + +.ams-table-of-contents__title { + font-size: var(--ams-table-of-contents-title-font-size); + font-weight: var(--ams-table-of-contents-title-font-weight); + line-height: var(--ams-table-of-contents-title-line-height); +} + +.ams-table-of-contents__item:has(.ams-table-of-contents) { + background-image: var(--ams-table-of-contents-list-item-background-image); + background-position-x: right; + background-position-y: 0.3em; + background-repeat: no-repeat; + background-size: var(--ams-table-of-contents-font-size); + + &:has(.ams-table-of-contents__link--active), + .ams-table-of-contents__item:has(.ams-table-of-contents) { + background-image: var(--ams-table-of-contents-list-item-active-background-image); + + .ams-table-of-contents__list { + display: flex; + } + } +} + +.ams-table-of-contents__link { + color: var(--ams-table-of-contents-link-color); + outline-offset: var(--ams-table-of-contents-link-outline-offset); + text-decoration-line: var(--ams-table-of-contents-link-text-decoration-line); + text-decoration-thickness: var(--ams-table-of-contents-link-text-decoration-thickness); + text-underline-offset: var(--ams-table-of-contents-link-text-underline-offset); + + &--active { + font-weight: 700; + } + + &:hover { + color: var(--ams-table-of-contents-link-hover-color); + text-decoration-line: var(--ams-table-of-contents-link-hover-text-decoration-line); + } +} diff --git a/packages/react/src/TableOfContents/README.md b/packages/react/src/TableOfContents/README.md new file mode 100644 index 0000000000..6edaf03ab9 --- /dev/null +++ b/packages/react/src/TableOfContents/README.md @@ -0,0 +1,5 @@ + + +# React Table of Contents component + +[Table of Contents documentation](../../../css/src/components/table-of-contents/README.md) diff --git a/packages/react/src/TableOfContents/TableOfContents.test.tsx b/packages/react/src/TableOfContents/TableOfContents.test.tsx new file mode 100644 index 0000000000..ca180df95d --- /dev/null +++ b/packages/react/src/TableOfContents/TableOfContents.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { TableOfContents } from './TableOfContents' +import '@testing-library/jest-dom' + +describe('Table of contents', () => { + 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-table-of-contents') + }) + + it('renders an additional class name', () => { + const { container } = render() + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-table-of-contents extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/TableOfContents/TableOfContents.tsx b/packages/react/src/TableOfContents/TableOfContents.tsx new file mode 100644 index 0000000000..22d2398640 --- /dev/null +++ b/packages/react/src/TableOfContents/TableOfContents.tsx @@ -0,0 +1,26 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' +import { TableOfContentsLink } from './TableOfContentsLink' + +export type TableOfContentsProps = { + title?: string +} & PropsWithChildren> + +const TableOfContentsRoot = forwardRef( + ({ children, className, title, ...restProps }: TableOfContentsProps, ref: ForwardedRef) => ( + + ), +) + +TableOfContentsRoot.displayName = 'TableOfContents' + +export const TableOfContents = Object.assign(TableOfContentsRoot, { Link: TableOfContentsLink }) diff --git a/packages/react/src/TableOfContents/TableOfContentsLink.tsx b/packages/react/src/TableOfContents/TableOfContentsLink.tsx new file mode 100644 index 0000000000..7c3e88a2fd --- /dev/null +++ b/packages/react/src/TableOfContents/TableOfContentsLink.tsx @@ -0,0 +1,33 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { clsx } from 'clsx' +import { forwardRef } from 'react' +import type { AnchorHTMLAttributes, ForwardedRef } from 'react' + +export type TableOfContentsLinkProps = { + label: string + active?: boolean +} & AnchorHTMLAttributes + +export const TableOfContentsLink = forwardRef( + ( + { children, className, label, active, ...restProps }: TableOfContentsLinkProps, + ref: ForwardedRef, + ) => ( +
  • + + {label} + + {children} +
  • + ), +) + +TableOfContentsLink.displayName = 'TableOfContents.Link' diff --git a/packages/react/src/TableOfContents/index.ts b/packages/react/src/TableOfContents/index.ts new file mode 100644 index 0000000000..44552e9ae3 --- /dev/null +++ b/packages/react/src/TableOfContents/index.ts @@ -0,0 +1,2 @@ +export { TableOfContents } from './TableOfContents' +export type { TableOfContentsProps } from './TableOfContents' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d6b0c721f6..5b86f95173 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './TableOfContents' export * from './Field' export * from './Select' export * from './TimeInput' diff --git a/proprietary/tokens/src/components/ams/table-of-contents.tokens.json b/proprietary/tokens/src/components/ams/table-of-contents.tokens.json new file mode 100644 index 0000000000..4b562d6594 --- /dev/null +++ b/proprietary/tokens/src/components/ams/table-of-contents.tokens.json @@ -0,0 +1,40 @@ +{ + "ams": { + "table-of-contents": { + "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.inside.lg}" }, + "line-height": { "value": "{ams.text.level.5.line-height}" }, + "link": { + "color": { "value": "{ams.color.primary-blue}" }, + "inverse-color": { "value": "{ams.color.primary-white}" }, + "hover": { + "color": { "value": "{ams.color.dark-blue}" } + } + }, + "list": { + "gap": { "value": "{ams.space.inside.lg}" }, + "item": { + "background-image": { + "value": "url(\"data:image/svg+xml;utf8,\")" + }, + "active": { + "background-image": { + "value": "url(\"data:image/svg+xml;utf8,\")" + } + } + }, + "list": { + "padding-inline-start": { "value": "{ams.space.inside.lg}" } + } + }, + "title": { + "font-weight": { "value": "{ams.text.font-weight.bold}" }, + "font-size": { "value": "{ams.text.level.4.font-size}" }, + "line-height": { "value": "{ams.text.level.4.line-height}" } + } + } + } +} diff --git a/storybook/src/components/TableOfContents/TableOfContents.docs.mdx b/storybook/src/components/TableOfContents/TableOfContents.docs.mdx new file mode 100644 index 0000000000..88eecc44d6 --- /dev/null +++ b/storybook/src/components/TableOfContents/TableOfContents.docs.mdx @@ -0,0 +1,11 @@ +import { Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as TableOfContentsStories from "./TableOfContents.stories.tsx"; +import README from "../../../../packages/css/src/components/table-of-contents/README.md?raw"; + + + +{README} + + + + diff --git a/storybook/src/components/TableOfContents/TableOfContents.stories.tsx b/storybook/src/components/TableOfContents/TableOfContents.stories.tsx new file mode 100644 index 0000000000..ab6c9c61b7 --- /dev/null +++ b/storybook/src/components/TableOfContents/TableOfContents.stories.tsx @@ -0,0 +1,62 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { TableOfContents } from '@amsterdam/design-system-react/src' +import { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: 'Components/Navigation/Table of Contents', + component: TableOfContents, + args: { + title: 'Op deze pagina', + children: [ + <> + + + + + , + ], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const MultiLevel: Story = { + args: { + title: 'Inhoudsopgave', + children: [ + <> + + + + + + + + + + + + + + + + + + + + + + + + , + ], + }, +}