Skip to content
Merged
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
3 changes: 3 additions & 0 deletions packages/components-html/src/page-body/page-body.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.dsn-page-body {
flex: 1;
}
5 changes: 5 additions & 0 deletions packages/components-html/src/page-layout/page-layout.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.dsn-page-layout {
display: flex;
flex-direction: column;
min-block-size: 100dvh;
}
1 change: 1 addition & 0 deletions packages/components-react/src/PageBody/PageBody.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import '../../../components-html/src/page-body/page-body.css';
72 changes: 72 additions & 0 deletions packages/components-react/src/PageBody/PageBody.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { PageBody } from './PageBody';

describe('PageBody', () => {
// ---------------------------------------------------------------------------
// Structuur
// ---------------------------------------------------------------------------

it('rendert een <div>-element', () => {
const { container } = render(
<PageBody>
<main>inhoud</main>
</PageBody>
);
expect(container.querySelector('div')).toBeTruthy();
});

it('heeft de basis dsn-page-body klasse', () => {
const { container } = render(
<PageBody>
<main>inhoud</main>
</PageBody>
);
expect(container.querySelector('div')).toHaveClass('dsn-page-body');
});

it('rendert children', () => {
const { container } = render(
<PageBody>
<main id="main-content">Hoofdinhoud</main>
</PageBody>
);
expect(container.querySelector('main#main-content')).toBeTruthy();
});

// ---------------------------------------------------------------------------
// className en ref
// ---------------------------------------------------------------------------

it('voegt extra className toe aan het root element', () => {
const { container } = render(
<PageBody className="extra-class">
<main>inhoud</main>
</PageBody>
);
expect(container.querySelector('div')).toHaveClass(
'dsn-page-body',
'extra-class'
);
});

it('stuurt HTML-attributen door naar het <div>-element', () => {
const { container } = render(
<PageBody data-testid="page-body">
<main>inhoud</main>
</PageBody>
);
expect(container.querySelector('[data-testid="page-body"]')).toBeTruthy();
});

it('geeft ref door naar het <div>-element', () => {
const ref = { current: null as HTMLDivElement | null };
render(
<PageBody ref={ref}>
<main>inhoud</main>
</PageBody>
);
expect(ref.current).not.toBeNull();
expect(ref.current?.tagName.toLowerCase()).toBe('div');
});
});
50 changes: 50 additions & 0 deletions packages/components-react/src/PageBody/PageBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { classNames } from '@dsn/core';
import './PageBody.css';

export interface PageBodyProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* De paginainhoud: doorgaans een `<main>` element met `id="main-content"`.
*/
children: React.ReactNode;

/**
* Extra CSS-klassen
*/
className?: string;
}

/**
* PageBody component
* Structurele wrapper voor de hoofdinhoud van een pagina. Vult de beschikbare
* verticale ruimte op binnen `PageLayout` zodat `PageFooter` altijd onderaan
* de viewport staat (sticky footer patroon via `flex: 1`).
*
* @example
* ```tsx
* <PageLayout>
* <PageHeader logoSlot={<Logo />} />
* <PageBody>
* <main id="main-content" tabIndex={-1}>
* <Container>...</Container>
* </main>
* </PageBody>
* <PageFooter slot1={<Logo />} />
* </PageLayout>
* ```
*/
export const PageBody = React.forwardRef<HTMLDivElement, PageBodyProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={classNames('dsn-page-body', className)}
{...props}
>
{children}
</div>
);
}
);

PageBody.displayName = 'PageBody';
1 change: 1 addition & 0 deletions packages/components-react/src/PageBody/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './PageBody';
1 change: 1 addition & 0 deletions packages/components-react/src/PageLayout/PageLayout.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import '../../../components-html/src/page-layout/page-layout.css';
76 changes: 76 additions & 0 deletions packages/components-react/src/PageLayout/PageLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { PageLayout } from './PageLayout';

describe('PageLayout', () => {
// ---------------------------------------------------------------------------
// Structuur
// ---------------------------------------------------------------------------

it('rendert een <div>-element', () => {
const { container } = render(
<PageLayout>
<div>inhoud</div>
</PageLayout>
);
expect(container.querySelector('div.dsn-page-layout')).toBeTruthy();
});

it('heeft de basis dsn-page-layout klasse', () => {
const { container } = render(
<PageLayout>
<div>inhoud</div>
</PageLayout>
);
expect(container.querySelector('div')).toHaveClass('dsn-page-layout');
});

it('rendert children', () => {
const { container } = render(
<PageLayout>
<header>header</header>
<main>main</main>
<footer>footer</footer>
</PageLayout>
);
expect(container.querySelector('header')).toBeTruthy();
expect(container.querySelector('main')).toBeTruthy();
expect(container.querySelector('footer')).toBeTruthy();
});

// ---------------------------------------------------------------------------
// className en ref
// ---------------------------------------------------------------------------

it('voegt extra className toe aan het root element', () => {
const { container } = render(
<PageLayout className="extra-class">
<div>inhoud</div>
</PageLayout>
);
expect(container.querySelector('div')).toHaveClass(
'dsn-page-layout',
'extra-class'
);
});

it('stuurt HTML-attributen door naar het <div>-element', () => {
const { container } = render(
<PageLayout data-testid="page-layout">
<div>inhoud</div>
</PageLayout>
);
expect(container.querySelector('[data-testid="page-layout"]')).toBeTruthy();
});

it('geeft ref door naar het <div>-element', () => {
const ref = { current: null as HTMLDivElement | null };
render(
<PageLayout ref={ref}>
<div>inhoud</div>
</PageLayout>
);
expect(ref.current).not.toBeNull();
expect(ref.current?.tagName.toLowerCase()).toBe('div');
});
});
55 changes: 55 additions & 0 deletions packages/components-react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';
import { classNames } from '@dsn/core';
import './PageLayout.css';

export interface PageLayoutProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Paginainhoud: doorgaans `PageHeader`, `PageBody` en `PageFooter`.
*/
children: React.ReactNode;

/**
* Extra CSS-klassen
*/
className?: string;
}

/**
* PageLayout component
* Structurele wrapper die `PageHeader`, `PageBody` en `PageFooter` verticaal
* stapelt via flexbox. Garandeert dat de pagina altijd de volledige viewport
* vult (`min-block-size: 100dvh`) en dat `PageFooter` altijd onderaan staat
* zodra `PageBody` `flex: 1` heeft.
*
* `PageLayout` is een neutrale `<div>` zonder semantische rol. De semantische
* landmarks komen van de children (`<header>`, `<main>`, `<footer>`).
*
* @example
* ```tsx
* <SkipLink href="#main-content" />
* <PageLayout>
* <PageHeader logoSlot={<Logo />} />
* <PageBody>
* <main id="main-content" tabIndex={-1}>
* <Container>...</Container>
* </main>
* </PageBody>
* <PageFooter slot1={<Logo />} />
* </PageLayout>
* ```
*/
export const PageLayout = React.forwardRef<HTMLDivElement, PageLayoutProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={classNames('dsn-page-layout', className)}
{...props}
>
{children}
</div>
);
}
);

PageLayout.displayName = 'PageLayout';
1 change: 1 addition & 0 deletions packages/components-react/src/PageLayout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './PageLayout';
2 changes: 2 additions & 0 deletions packages/components-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ export * from './BreadcrumbNavigation';
export * from './Menu';
export * from './MenuButton';
export * from './MenuLink';
export * from './PageBody';
export * from './PageFooter';
export * from './PageHeader';
export * from './PageLayout';

// Form Field Components
export * from './FormField';
Expand Down
6 changes: 4 additions & 2 deletions packages/storybook/src/Introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,16 @@ function App() {

- **SkipLink**: Eerste focusbaar element op de pagina: verborgen totdat de gebruiker er met Tab op focust, zodat toetsenbordgebruikers herhalende navigatie kunnen overslaan (WCAG 2.4.1)

### Navigation Components (6)
### Navigation Components (8)

- **BreadcrumbNavigation**: Hiërarchisch navigatiepad met compacte variant via container query
- **Menu**: Containercomponent voor MenuLink- en MenuButton-items in verticale of horizontale navigatielijst
- **MenuButton**: Navigatieknop voor JavaScript-acties (uitloggen, modal openen): semantisch `<button>`, visueel consistent met MenuLink
- **MenuLink**: Navigatielink met niveau-hiërarchie (level 1–4), actieve pagina-staat en uitklapbare subnavigatie
- **PageBody**: Structurele wrapper voor hoofdinhoud: vult beschikbare verticale ruimte zodat `PageFooter` altijd onderaan de viewport staat
- **PageFooter**: Paginavoettekst met accent-1 achtergrond, dikke topborder en 4-koloms grid: logo, tussenslot, content en footerlinks
- **PageHeader**: Paginabrede koptekstbalk met logo-slot, navigatie-slot en acties-slot: theme-aware achtergrond via design tokens
- **PageLayout**: Structurele full-page wrapper die `PageHeader`, `PageBody` en `PageFooter` verticaal stapelt met `min-block-size: 100dvh`

### Form Components (25)

Expand Down Expand Up @@ -171,4 +173,4 @@ MIT License: zie LICENSE bestand voor details.

---

**Versie:** 5.25.0 | **Laatste update:** 14 april 2026 | **Auteur:** Jeffrey Lauwers
**Versie:** 5.26.0 | **Laatste update:** 17 april 2026 | **Auteur:** Jeffrey Lauwers
83 changes: 83 additions & 0 deletions packages/storybook/src/PageLayout.docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# PageLayout

Structurele full-page layout container die `PageHeader`, `PageBody` en `PageFooter` verticaal stapelt.

## Doel

`PageLayout` is de buitenste wrapper voor elke pagina in de applicatie. Het component zorgt via `display: flex; flex-direction: column` en `min-block-size: 100dvh` dat de pagina altijd de volledige viewport vult. In combinatie met `PageBody` (dat `flex: 1` heeft) staat `PageFooter` altijd onderaan de viewport, ongeacht de hoeveelheid content.

`PageLayout` bevat geen eigen visuele stijl: geen kleur, padding of border. Het is een transparante structuurlaag.

<!-- VOORBEELD -->

## Use when

- Je de basisstructuur van een pagina opzet met `PageHeader`, `PageBody` en `PageFooter`.
- De footer altijd onderaan de viewport moet staan, ook als de pagina weinig content heeft.
- Je de drie pagina-onderdelen in één consistente wrapper wilt samenbrengen.

## Don't use when

- Je een gedeelte van een pagina opmaakt, zoals een kaart of sectie: gebruik dan `Stack`, `Grid` of `Container`.
- Je geen volledige viewport-hoogte nodig hebt.

## Best practices

### Skip-link (aanbevolen)

Templates die `PageLayout` gebruiken moeten een skip-link als **eerste focusbaar element** bevatten, vóór `PageLayout`. Dit voldoet aan WCAG 2.4.1 (Bypass Blocks). De `SkipLink`-component staat buiten `PageLayout`:

```html
<a href="#main-content" class="dsn-skip-link">Ga direct naar de hoofdinhoud</a>
<div class="dsn-page-layout">
<header class="dsn-page-header">...</header>
<div class="dsn-page-body">
<main id="main-content" tabindex="-1">...</main>
</div>
<footer class="dsn-page-footer">...</footer>
</div>
```

```tsx
<SkipLink href="#main-content" />
<PageLayout>
<PageHeader logoSlot={<Logo />} />
<PageBody>
<main id="main-content" tabIndex={-1}>
<Container>...</Container>
</main>
</PageBody>
<PageFooter slot1={<Logo />} />
</PageLayout>
```

### `main`-element en `id="main-content"`

Geef de `<main>` altijd een `id="main-content"` zodat de skip-link er naartoe kan springen. Voeg `tabIndex={-1}` toe zodat programmatische focus werkt ook als `<main>` niet natively focusbaar is.

### Semantische landmarks

`PageLayout` zelf is een neutrale `<div>` zonder semantische rol. De landmarks komen van de children:

- `PageHeader` rendert een `<header>` (impliciet `role="banner"`)
- `PageBody` rendert een `<div>` zonder rol — de semantische `<main>` ligt binnenin als child
- `PageFooter` rendert een `<footer>` (impliciet `role="contentinfo"`)

## Design tokens

`PageLayout` heeft geen component-specifieke tokens. De `min-block-size: 100dvh` is een structurele constante, geen designbeslissing.

| Keuze | Reden |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `min-block-size: 100dvh` i.p.v. `100vh` | `dvh` (dynamic viewport height) compenseert op mobiel voor de adresbalk van de browser. `100vh` is te groot op iOS Safari. |
| `flex-direction: column` | Meest directe aanpak voor sticky footer: children stapelen verticaal, `PageBody` vult de resterende ruimte. |

## Accessibility

### Transparante structuurlaag

`PageLayout` heeft geen `role`, `aria-label` of andere ARIA-attributen. Screenreaders navigeren via de landmarks in de children: `<header>`, `<main>` en `<footer>`.

### Skip-link vereiste

Elke template die `PageLayout` gebruikt moet een `<SkipLink href="#main-content">` als eerste focusbaar element bevatten (vóór `PageLayout`). Dit is een template-vereiste, niet ingebouwd in `PageLayout` zelf, zodat de skip-link flexibel blijft voor meerdere talen en bestemmingen.
Loading
Loading