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
154 changes: 154 additions & 0 deletions packages/components-html/src/page-header/page-header.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* PageHeader Component
* Primaire navigatieheader voor een pagina. Mobile-first: menuknop (inline-start),
* gecentreerd logo, zoekknop (inline-end). Navigatie via Drawer; zoekpaneel inline.
*
* Structuur:
* <header class="dsn-page-header">
* <div class="dsn-page-header__inner">
* <div class="dsn-page-header__start">
* <!-- Menuknop -->
* </div>
* <div class="dsn-page-header__logo">
* <!-- Logo (svg, img, of <a> wrapper) -->
* </div>
* <div class="dsn-page-header__end">
* <!-- Zoekknop -->
* </div>
* </div>
* <div class="dsn-page-header__search-panel" id="..." hidden>
* <div class="dsn-page-header__search-inner">
* <!-- SearchInput + zoekknop -->
* </div>
* </div>
* </header>
*/

/* =============================================================================
Base
============================================================================= */

.dsn-page-header {
background-color: var(--dsn-page-header-background-color);
border-block-end: var(--dsn-page-header-border-block-end-width) solid
var(--dsn-page-header-border-block-end-color);
}

/* =============================================================================
Sticky gedrag
============================================================================= */

.dsn-page-header--sticky {
position: sticky;
inset-block-start: 0;
z-index: var(--dsn-page-header-z-index);
}

/* =============================================================================
Auto-hide (sticky + CSS-transitie)
JS toggle via data-hidden attribuut: scroll-down → "true", scroll-up → "false"
============================================================================= */

.dsn-page-header--auto-hide {
position: sticky;
inset-block-start: 0;
z-index: var(--dsn-page-header-z-index);
transition: transform var(--dsn-transition-duration-normal)
var(--dsn-transition-easing-default);
}

.dsn-page-header--auto-hide[data-hidden='true'] {
transform: translateY(-100%);
}

/* =============================================================================
Inner — CSS-grid centreert logo onafhankelijk van knopbreedte
============================================================================= */

.dsn-page-header__inner {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding-block: var(--dsn-page-header-padding-block);
padding-inline: var(--dsn-page-header-padding-inline);
}

/* =============================================================================
Start-slot (inline-start — menuknop)
============================================================================= */

.dsn-page-header__start {
display: flex;
align-items: center;
}

/* =============================================================================
Logo-slot (gecentreerd via middelste grid-kolom)
De SVG/img zelf krijgt de max-block-size — niet de wrapper.
SVG-attributen width/height worden overschreven door CSS block-size + inline-size: auto.
============================================================================= */

.dsn-page-header__logo {
display: flex;
justify-content: center;
align-items: center;
}

.dsn-page-header__logo svg,
.dsn-page-header__logo img {
display: block;
block-size: var(--dsn-page-header-logo-max-block-size);
inline-size: auto;
}

/* =============================================================================
Knoppen in de header krijgen compacte inline padding
============================================================================= */

.dsn-page-header__inner .dsn-button {
padding-inline: var(--dsn-space-row-md);
}

/* =============================================================================
End-slot (inline-end — zoekknop)
============================================================================= */

.dsn-page-header__end {
display: flex;
justify-content: flex-end;
align-items: center;
}

/* =============================================================================
Zoekpaneel (standaard verborgen via [hidden])
============================================================================= */

.dsn-page-header__search-panel {
background-color: var(--dsn-page-header-search-panel-background-color);
padding-block: var(--dsn-page-header-search-panel-padding-block);
padding-inline: var(--dsn-page-header-search-panel-padding-inline);
}

.dsn-page-header__search-inner {
display: flex;
gap: var(--dsn-space-inline-md);
align-items: flex-start;
}

/* SearchInput wrapper vult beschikbare ruimte volledig — overschrijft de standaard
form-control max-inline-size zodat het veld de volledige breedte pakt */
.dsn-page-header__search-inner .dsn-search-input-wrapper {
flex: 1;
min-inline-size: 0;
max-inline-size: none;
}

/* Input op gelijke hoogte als de Zoeken-knop: padding-block terugbrengen naar
8px zodat padding + line-height < min-block-size (48px) — en min-block-size
de hoogte bepaalt, net als bij de button */
.dsn-page-header__search-inner .dsn-text-input {
max-inline-size: none;
inline-size: 100%;
--dsn-text-input-padding-block-start: var(--dsn-space-block-md);
--dsn-text-input-padding-block-end: var(--dsn-space-block-md);
}
1 change: 1 addition & 0 deletions packages/components-react/src/PageHeader/PageHeader.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import '../../../components-html/src/page-header/page-header.css';
228 changes: 228 additions & 0 deletions packages/components-react/src/PageHeader/PageHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { PageHeader } from './PageHeader';

const defaultLogo = (
<a href="/">
<span>Logo</span>
</a>
);

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

it('rendert een <header>-element', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
expect(container.querySelector('header')).toBeTruthy();
});

it('heeft de basis dsn-page-header klasse', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
expect(container.querySelector('header')).toHaveClass('dsn-page-header');
});

it('rendert de inner-wrapper met dsn-page-header__inner', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
expect(container.querySelector('.dsn-page-header__inner')).toBeTruthy();
});

it('rendert het logo in dsn-page-header__logo', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
const logoSlot = container.querySelector('.dsn-page-header__logo');
expect(logoSlot).toBeTruthy();
expect(logoSlot?.querySelector('a')).toBeTruthy();
});

it('rendert een menuknop in dsn-page-header__start', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
const start = container.querySelector('.dsn-page-header__start');
expect(start?.querySelector('button')).toBeTruthy();
});

it('rendert een zoekknop in dsn-page-header__end', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
const end = container.querySelector('.dsn-page-header__end');
expect(end?.querySelector('button')).toBeTruthy();
});

// ---------------------------------------------------------------------------
// Zoekpaneel
// ---------------------------------------------------------------------------

it('zoekpaneel is standaard verborgen', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
const panel = container.querySelector('.dsn-page-header__search-panel');
expect(panel).toHaveAttribute('hidden');
});

it('zoekknop heeft aria-expanded="false" bij gesloten paneel', () => {
render(<PageHeader logoSlot={defaultLogo} />);
const searchButton = screen.getByRole('button', { name: /zoeken/i });
expect(searchButton).toHaveAttribute('aria-expanded', 'false');
});

it('zoekpaneel opent bij klik op zoekknop', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
const searchButton = screen.getByRole('button', { name: /zoeken/i });
fireEvent.click(searchButton);
const panel = container.querySelector('.dsn-page-header__search-panel');
expect(panel).not.toHaveAttribute('hidden');
});

it('zoekknop heeft aria-expanded="true" bij geopend paneel', () => {
render(<PageHeader logoSlot={defaultLogo} />);
const searchButton = screen.getByRole('button', { name: /zoeken/i });
fireEvent.click(searchButton);
expect(searchButton).toHaveAttribute('aria-expanded', 'true');
});

it('zoekknop toont "Sluiten" tekst bij geopend paneel', () => {
render(<PageHeader logoSlot={defaultLogo} />);
const searchButton = screen.getByRole('button', { name: /zoeken/i });
fireEvent.click(searchButton);
expect(screen.getByRole('button', { name: /sluiten/i })).toBeTruthy();
});

it('zoekpaneel sluit bij klik op sluitknop', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
const searchButton = screen.getByRole('button', { name: /zoeken/i });
fireEvent.click(searchButton);
const closeButton = screen.getByRole('button', { name: /sluiten/i });
fireEvent.click(closeButton);
const panel = container.querySelector('.dsn-page-header__search-panel');
expect(panel).toHaveAttribute('hidden');
});

it('zoekpaneel heeft aria-controls dat verwijst naar het zoekpaneel-id', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
const searchButton = screen.getByRole('button', { name: /zoeken/i });
const panelId = searchButton.getAttribute('aria-controls');
expect(panelId).toBeTruthy();
expect(container.querySelector(`#${CSS.escape(panelId!)}`)).toBeTruthy();
});

// ---------------------------------------------------------------------------
// Drawer
// ---------------------------------------------------------------------------

it('drawer is standaard gesloten', () => {
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
const drawer = container.querySelector('.dsn-drawer');
expect(drawer).toBeTruthy();
expect(drawer).not.toHaveAttribute('open');
});

it('rendert primaire navigatie in de drawer', () => {
render(
<PageHeader
logoSlot={defaultLogo}
primaryNavigation={
<ul>
<li>Home</li>
</ul>
}
/>
);
expect(screen.getByText('Home')).toBeTruthy();
});

it('rendert service-navigatie in de drawer', () => {
render(
<PageHeader
logoSlot={defaultLogo}
secondaryNavigation={
<ul>
<li>Contact</li>
</ul>
}
/>
);
expect(screen.getByText('Contact')).toBeTruthy();
});

// ---------------------------------------------------------------------------
// Sticky modifiers
// ---------------------------------------------------------------------------

it('heeft geen sticky modifier bij sticky="none"', () => {
const { container } = render(
<PageHeader logoSlot={defaultLogo} sticky="none" />
);
const header = container.querySelector('header');
expect(header).not.toHaveClass('dsn-page-header--sticky');
expect(header).not.toHaveClass('dsn-page-header--auto-hide');
});

it('heeft dsn-page-header--sticky bij sticky="sticky"', () => {
const { container } = render(
<PageHeader logoSlot={defaultLogo} sticky="sticky" />
);
expect(container.querySelector('header')).toHaveClass(
'dsn-page-header--sticky'
);
});

it('heeft dsn-page-header--auto-hide bij sticky="auto-hide"', () => {
const { container } = render(
<PageHeader logoSlot={defaultLogo} sticky="auto-hide" />
);
expect(container.querySelector('header')).toHaveClass(
'dsn-page-header--auto-hide'
);
});

it('heeft data-hidden="false" bij sticky="auto-hide"', () => {
const { container } = render(
<PageHeader logoSlot={defaultLogo} sticky="auto-hide" />
);
expect(container.querySelector('header')).toHaveAttribute(
'data-hidden',
'false'
);
});

// ---------------------------------------------------------------------------
// Callbacks
// ---------------------------------------------------------------------------

it('roept onSearchOpen aan bij openen zoekpaneel', () => {
const onSearchOpen = vi.fn();
render(<PageHeader logoSlot={defaultLogo} onSearchOpen={onSearchOpen} />);
fireEvent.click(screen.getByRole('button', { name: /zoeken/i }));
expect(onSearchOpen).toHaveBeenCalledOnce();
});

it('roept onSearchClose aan bij sluiten zoekpaneel', () => {
const onSearchClose = vi.fn();
render(<PageHeader logoSlot={defaultLogo} onSearchClose={onSearchClose} />);
fireEvent.click(screen.getByRole('button', { name: /zoeken/i }));
fireEvent.click(screen.getByRole('button', { name: /sluiten/i }));
expect(onSearchClose).toHaveBeenCalledOnce();
});

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

it('accepteert extra className', () => {
const { container } = render(
<PageHeader logoSlot={defaultLogo} className="custom" />
);
expect(container.querySelector('header')).toHaveClass(
'dsn-page-header',
'custom'
);
});

it('stuurt HTML-attributen door', () => {
const { container } = render(
<PageHeader logoSlot={defaultLogo} data-testid="ph" />
);
expect(container.querySelector('header')).toHaveAttribute(
'data-testid',
'ph'
);
});
});
Loading
Loading