Skip to content

Commit 33b6854

Browse files
Jeffrey Lauwersclaude
andcommitted
feat(PageHeader): navigatieheader met menu-drawer, zoekpaneel en sticky-gedrag
Mobile-first PageHeader met CSS-grid 1fr/auto/1fr layout die het logo centreert onafhankelijk van de knopbreedte. Menuknop opent een Drawer met primaire en service-navigatie (Stack space 5xl). Zoekknop toggelt een inline zoekpaneel met SearchInput (volledig uitvullend) en Zoeken-knop, gelijk qua hoogte via padding-block override. Sticky en auto-hide varianten via --auto-hide modifier met scroll-detectie in useEffect. Sluit issue #138. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6368f7d commit 33b6854

11 files changed

Lines changed: 1206 additions & 3 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* PageHeader Component
3+
* Primaire navigatieheader voor een pagina. Mobile-first: menuknop (inline-start),
4+
* gecentreerd logo, zoekknop (inline-end). Navigatie via Drawer; zoekpaneel inline.
5+
*
6+
* Structuur:
7+
* <header class="dsn-page-header">
8+
* <div class="dsn-page-header__inner">
9+
* <div class="dsn-page-header__start">
10+
* <!-- Menuknop -->
11+
* </div>
12+
* <div class="dsn-page-header__logo">
13+
* <!-- Logo (svg, img, of <a> wrapper) -->
14+
* </div>
15+
* <div class="dsn-page-header__end">
16+
* <!-- Zoekknop -->
17+
* </div>
18+
* </div>
19+
* <div class="dsn-page-header__search-panel" id="..." hidden>
20+
* <div class="dsn-page-header__search-inner">
21+
* <!-- SearchInput + zoekknop -->
22+
* </div>
23+
* </div>
24+
* </header>
25+
*/
26+
27+
/* =============================================================================
28+
Base
29+
============================================================================= */
30+
31+
.dsn-page-header {
32+
background-color: var(--dsn-page-header-background-color);
33+
border-block-end: var(--dsn-page-header-border-block-end-width) solid
34+
var(--dsn-page-header-border-block-end-color);
35+
}
36+
37+
/* =============================================================================
38+
Sticky gedrag
39+
============================================================================= */
40+
41+
.dsn-page-header--sticky {
42+
position: sticky;
43+
inset-block-start: 0;
44+
z-index: var(--dsn-page-header-z-index);
45+
}
46+
47+
/* =============================================================================
48+
Auto-hide (sticky + CSS-transitie)
49+
JS toggle via data-hidden attribuut: scroll-down → "true", scroll-up → "false"
50+
============================================================================= */
51+
52+
.dsn-page-header--auto-hide {
53+
position: sticky;
54+
inset-block-start: 0;
55+
z-index: var(--dsn-page-header-z-index);
56+
transition: transform var(--dsn-transition-duration-normal)
57+
var(--dsn-transition-easing-default);
58+
}
59+
60+
.dsn-page-header--auto-hide[data-hidden='true'] {
61+
transform: translateY(-100%);
62+
}
63+
64+
/* =============================================================================
65+
Inner — CSS-grid centreert logo onafhankelijk van knopbreedte
66+
============================================================================= */
67+
68+
.dsn-page-header__inner {
69+
display: grid;
70+
grid-template-columns: 1fr auto 1fr;
71+
align-items: center;
72+
padding-block: var(--dsn-page-header-padding-block);
73+
padding-inline: var(--dsn-page-header-padding-inline);
74+
}
75+
76+
/* =============================================================================
77+
Start-slot (inline-start — menuknop)
78+
============================================================================= */
79+
80+
.dsn-page-header__start {
81+
display: flex;
82+
align-items: center;
83+
}
84+
85+
/* =============================================================================
86+
Logo-slot (gecentreerd via middelste grid-kolom)
87+
De SVG/img zelf krijgt de max-block-size — niet de wrapper.
88+
SVG-attributen width/height worden overschreven door CSS block-size + inline-size: auto.
89+
============================================================================= */
90+
91+
.dsn-page-header__logo {
92+
display: flex;
93+
justify-content: center;
94+
align-items: center;
95+
}
96+
97+
.dsn-page-header__logo svg,
98+
.dsn-page-header__logo img {
99+
display: block;
100+
block-size: var(--dsn-page-header-logo-max-block-size);
101+
inline-size: auto;
102+
}
103+
104+
/* =============================================================================
105+
Knoppen in de header krijgen compacte inline padding
106+
============================================================================= */
107+
108+
.dsn-page-header__inner .dsn-button {
109+
padding-inline: var(--dsn-space-row-md);
110+
}
111+
112+
/* =============================================================================
113+
End-slot (inline-end — zoekknop)
114+
============================================================================= */
115+
116+
.dsn-page-header__end {
117+
display: flex;
118+
justify-content: flex-end;
119+
align-items: center;
120+
}
121+
122+
/* =============================================================================
123+
Zoekpaneel (standaard verborgen via [hidden])
124+
============================================================================= */
125+
126+
.dsn-page-header__search-panel {
127+
background-color: var(--dsn-page-header-search-panel-background-color);
128+
padding-block: var(--dsn-page-header-search-panel-padding-block);
129+
padding-inline: var(--dsn-page-header-search-panel-padding-inline);
130+
}
131+
132+
.dsn-page-header__search-inner {
133+
display: flex;
134+
gap: var(--dsn-space-inline-md);
135+
align-items: flex-start;
136+
}
137+
138+
/* SearchInput wrapper vult beschikbare ruimte volledig — overschrijft de standaard
139+
form-control max-inline-size zodat het veld de volledige breedte pakt */
140+
.dsn-page-header__search-inner .dsn-search-input-wrapper {
141+
flex: 1;
142+
min-inline-size: 0;
143+
max-inline-size: none;
144+
}
145+
146+
/* Input op gelijke hoogte als de Zoeken-knop: padding-block terugbrengen naar
147+
8px zodat padding + line-height < min-block-size (48px) — en min-block-size
148+
de hoogte bepaalt, net als bij de button */
149+
.dsn-page-header__search-inner .dsn-text-input {
150+
max-inline-size: none;
151+
inline-size: 100%;
152+
--dsn-text-input-padding-block-start: var(--dsn-space-block-md);
153+
--dsn-text-input-padding-block-end: var(--dsn-space-block-md);
154+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@import '../../../components-html/src/page-header/page-header.css';
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { PageHeader } from './PageHeader';
4+
5+
const defaultLogo = (
6+
<a href="/">
7+
<span>Logo</span>
8+
</a>
9+
);
10+
11+
describe('PageHeader', () => {
12+
// ---------------------------------------------------------------------------
13+
// Structuur
14+
// ---------------------------------------------------------------------------
15+
16+
it('rendert een <header>-element', () => {
17+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
18+
expect(container.querySelector('header')).toBeTruthy();
19+
});
20+
21+
it('heeft de basis dsn-page-header klasse', () => {
22+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
23+
expect(container.querySelector('header')).toHaveClass('dsn-page-header');
24+
});
25+
26+
it('rendert de inner-wrapper met dsn-page-header__inner', () => {
27+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
28+
expect(container.querySelector('.dsn-page-header__inner')).toBeTruthy();
29+
});
30+
31+
it('rendert het logo in dsn-page-header__logo', () => {
32+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
33+
const logoSlot = container.querySelector('.dsn-page-header__logo');
34+
expect(logoSlot).toBeTruthy();
35+
expect(logoSlot?.querySelector('a')).toBeTruthy();
36+
});
37+
38+
it('rendert een menuknop in dsn-page-header__start', () => {
39+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
40+
const start = container.querySelector('.dsn-page-header__start');
41+
expect(start?.querySelector('button')).toBeTruthy();
42+
});
43+
44+
it('rendert een zoekknop in dsn-page-header__end', () => {
45+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
46+
const end = container.querySelector('.dsn-page-header__end');
47+
expect(end?.querySelector('button')).toBeTruthy();
48+
});
49+
50+
// ---------------------------------------------------------------------------
51+
// Zoekpaneel
52+
// ---------------------------------------------------------------------------
53+
54+
it('zoekpaneel is standaard verborgen', () => {
55+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
56+
const panel = container.querySelector('.dsn-page-header__search-panel');
57+
expect(panel).toHaveAttribute('hidden');
58+
});
59+
60+
it('zoekknop heeft aria-expanded="false" bij gesloten paneel', () => {
61+
render(<PageHeader logoSlot={defaultLogo} />);
62+
const searchButton = screen.getByRole('button', { name: /zoeken/i });
63+
expect(searchButton).toHaveAttribute('aria-expanded', 'false');
64+
});
65+
66+
it('zoekpaneel opent bij klik op zoekknop', () => {
67+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
68+
const searchButton = screen.getByRole('button', { name: /zoeken/i });
69+
fireEvent.click(searchButton);
70+
const panel = container.querySelector('.dsn-page-header__search-panel');
71+
expect(panel).not.toHaveAttribute('hidden');
72+
});
73+
74+
it('zoekknop heeft aria-expanded="true" bij geopend paneel', () => {
75+
render(<PageHeader logoSlot={defaultLogo} />);
76+
const searchButton = screen.getByRole('button', { name: /zoeken/i });
77+
fireEvent.click(searchButton);
78+
expect(searchButton).toHaveAttribute('aria-expanded', 'true');
79+
});
80+
81+
it('zoekknop toont "Sluiten" tekst bij geopend paneel', () => {
82+
render(<PageHeader logoSlot={defaultLogo} />);
83+
const searchButton = screen.getByRole('button', { name: /zoeken/i });
84+
fireEvent.click(searchButton);
85+
expect(screen.getByRole('button', { name: /sluiten/i })).toBeTruthy();
86+
});
87+
88+
it('zoekpaneel sluit bij klik op sluitknop', () => {
89+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
90+
const searchButton = screen.getByRole('button', { name: /zoeken/i });
91+
fireEvent.click(searchButton);
92+
const closeButton = screen.getByRole('button', { name: /sluiten/i });
93+
fireEvent.click(closeButton);
94+
const panel = container.querySelector('.dsn-page-header__search-panel');
95+
expect(panel).toHaveAttribute('hidden');
96+
});
97+
98+
it('zoekpaneel heeft aria-controls dat verwijst naar het zoekpaneel-id', () => {
99+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
100+
const searchButton = screen.getByRole('button', { name: /zoeken/i });
101+
const panelId = searchButton.getAttribute('aria-controls');
102+
expect(panelId).toBeTruthy();
103+
expect(container.querySelector(`#${CSS.escape(panelId!)}`)).toBeTruthy();
104+
});
105+
106+
// ---------------------------------------------------------------------------
107+
// Drawer
108+
// ---------------------------------------------------------------------------
109+
110+
it('drawer is standaard gesloten', () => {
111+
const { container } = render(<PageHeader logoSlot={defaultLogo} />);
112+
const drawer = container.querySelector('.dsn-drawer');
113+
expect(drawer).toBeTruthy();
114+
expect(drawer).not.toHaveAttribute('open');
115+
});
116+
117+
it('rendert primaire navigatie in de drawer', () => {
118+
render(
119+
<PageHeader
120+
logoSlot={defaultLogo}
121+
primaryNavigation={
122+
<ul>
123+
<li>Home</li>
124+
</ul>
125+
}
126+
/>
127+
);
128+
expect(screen.getByText('Home')).toBeTruthy();
129+
});
130+
131+
it('rendert service-navigatie in de drawer', () => {
132+
render(
133+
<PageHeader
134+
logoSlot={defaultLogo}
135+
secondaryNavigation={
136+
<ul>
137+
<li>Contact</li>
138+
</ul>
139+
}
140+
/>
141+
);
142+
expect(screen.getByText('Contact')).toBeTruthy();
143+
});
144+
145+
// ---------------------------------------------------------------------------
146+
// Sticky modifiers
147+
// ---------------------------------------------------------------------------
148+
149+
it('heeft geen sticky modifier bij sticky="none"', () => {
150+
const { container } = render(
151+
<PageHeader logoSlot={defaultLogo} sticky="none" />
152+
);
153+
const header = container.querySelector('header');
154+
expect(header).not.toHaveClass('dsn-page-header--sticky');
155+
expect(header).not.toHaveClass('dsn-page-header--auto-hide');
156+
});
157+
158+
it('heeft dsn-page-header--sticky bij sticky="sticky"', () => {
159+
const { container } = render(
160+
<PageHeader logoSlot={defaultLogo} sticky="sticky" />
161+
);
162+
expect(container.querySelector('header')).toHaveClass(
163+
'dsn-page-header--sticky'
164+
);
165+
});
166+
167+
it('heeft dsn-page-header--auto-hide bij sticky="auto-hide"', () => {
168+
const { container } = render(
169+
<PageHeader logoSlot={defaultLogo} sticky="auto-hide" />
170+
);
171+
expect(container.querySelector('header')).toHaveClass(
172+
'dsn-page-header--auto-hide'
173+
);
174+
});
175+
176+
it('heeft data-hidden="false" bij sticky="auto-hide"', () => {
177+
const { container } = render(
178+
<PageHeader logoSlot={defaultLogo} sticky="auto-hide" />
179+
);
180+
expect(container.querySelector('header')).toHaveAttribute(
181+
'data-hidden',
182+
'false'
183+
);
184+
});
185+
186+
// ---------------------------------------------------------------------------
187+
// Callbacks
188+
// ---------------------------------------------------------------------------
189+
190+
it('roept onSearchOpen aan bij openen zoekpaneel', () => {
191+
const onSearchOpen = vi.fn();
192+
render(<PageHeader logoSlot={defaultLogo} onSearchOpen={onSearchOpen} />);
193+
fireEvent.click(screen.getByRole('button', { name: /zoeken/i }));
194+
expect(onSearchOpen).toHaveBeenCalledOnce();
195+
});
196+
197+
it('roept onSearchClose aan bij sluiten zoekpaneel', () => {
198+
const onSearchClose = vi.fn();
199+
render(<PageHeader logoSlot={defaultLogo} onSearchClose={onSearchClose} />);
200+
fireEvent.click(screen.getByRole('button', { name: /zoeken/i }));
201+
fireEvent.click(screen.getByRole('button', { name: /sluiten/i }));
202+
expect(onSearchClose).toHaveBeenCalledOnce();
203+
});
204+
205+
// ---------------------------------------------------------------------------
206+
// className en ref
207+
// ---------------------------------------------------------------------------
208+
209+
it('accepteert extra className', () => {
210+
const { container } = render(
211+
<PageHeader logoSlot={defaultLogo} className="custom" />
212+
);
213+
expect(container.querySelector('header')).toHaveClass(
214+
'dsn-page-header',
215+
'custom'
216+
);
217+
});
218+
219+
it('stuurt HTML-attributen door', () => {
220+
const { container } = render(
221+
<PageHeader logoSlot={defaultLogo} data-testid="ph" />
222+
);
223+
expect(container.querySelector('header')).toHaveAttribute(
224+
'data-testid',
225+
'ph'
226+
);
227+
});
228+
});

0 commit comments

Comments
 (0)