Skip to content

Commit 3d41d1a

Browse files
Merge pull request #82 from jeffreylauwers/feature/details
feat(Details): uitvouwbare inhoudsaanwijzer
2 parents c8c3afd + 38a3270 commit 3d41d1a

File tree

11 files changed

+707
-3
lines changed

11 files changed

+707
-3
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Details Component
3+
* Expandable disclosure widget based on the native <details>/<summary> element.
4+
*
5+
* Usage:
6+
* <!-- Default (closed) -->
7+
* <details class="dsn-details">
8+
* <summary class="dsn-details__summary">
9+
* <svg class="dsn-icon dsn-details__icon" aria-hidden="true"><!-- chevron-down --></svg>
10+
* <span class="dsn-details__summary-label">Label</span>
11+
* </summary>
12+
* <div class="dsn-details__content">
13+
* <p class="dsn-paragraph">Aanvullende informatie.</p>
14+
* </div>
15+
* </details>
16+
*
17+
* <!-- Default open -->
18+
* <details class="dsn-details" open>
19+
* <summary class="dsn-details__summary">
20+
* <svg class="dsn-icon dsn-details__icon" aria-hidden="true"><!-- chevron-down --></svg>
21+
* <span class="dsn-details__summary-label">Label</span>
22+
* </summary>
23+
* <div class="dsn-details__content">
24+
* <p class="dsn-paragraph">Inhoud is standaard zichtbaar.</p>
25+
* </div>
26+
* </details>
27+
*/
28+
29+
/* ===========================
30+
Base layout
31+
=========================== */
32+
.dsn-details {
33+
display: flex;
34+
flex-direction: column;
35+
row-gap: var(--dsn-details-row-gap);
36+
}
37+
38+
/* ===========================
39+
Summary (toggle trigger)
40+
=========================== */
41+
.dsn-details__summary {
42+
display: flex;
43+
align-items: center;
44+
gap: var(--dsn-details-summary-gap);
45+
list-style: none;
46+
cursor: pointer;
47+
}
48+
49+
/* Hide native browser disclosure marker (WebKit) */
50+
.dsn-details__summary::-webkit-details-marker {
51+
display: none;
52+
}
53+
54+
/* ===========================
55+
Summary label
56+
=========================== */
57+
.dsn-details__summary-label {
58+
color: var(--dsn-details-summary-color);
59+
text-decoration-line: var(--dsn-details-summary-text-decoration-line);
60+
text-underline-offset: var(--dsn-details-summary-text-underline-offset);
61+
text-decoration-thickness: var(
62+
--dsn-details-summary-text-decoration-thickness
63+
);
64+
}
65+
66+
/* ===========================
67+
Summary hover state
68+
=========================== */
69+
.dsn-details__summary:hover .dsn-details__summary-label {
70+
color: var(--dsn-details-summary-hover-color);
71+
text-decoration-line: var(--dsn-details-summary-hover-text-decoration-line);
72+
}
73+
74+
/* ===========================
75+
Summary active state
76+
=========================== */
77+
.dsn-details__summary:active .dsn-details__summary-label {
78+
color: var(--dsn-details-summary-active-color);
79+
}
80+
81+
/* ===========================
82+
Summary focus state
83+
=========================== */
84+
.dsn-details__summary:focus-visible {
85+
outline: var(--dsn-focus-outline-width) solid var(--dsn-focus-outline-color);
86+
outline-offset: var(--dsn-focus-outline-offset);
87+
border-radius: var(--dsn-focus-border-radius);
88+
}
89+
90+
/* ===========================
91+
Icon — rotates 180° when open
92+
=========================== */
93+
.dsn-details__icon {
94+
flex-shrink: 0;
95+
width: var(--dsn-details-icon-size);
96+
height: var(--dsn-details-icon-size);
97+
color: var(--dsn-details-summary-color);
98+
transition: transform 0.2s ease;
99+
}
100+
101+
.dsn-details[open] .dsn-details__icon {
102+
transform: rotate(180deg);
103+
}
104+
105+
/* ===========================
106+
Content area
107+
=========================== */
108+
.dsn-details__content {
109+
background-color: var(--dsn-details-content-background-color);
110+
border-inline-start: var(--dsn-details-content-border-inline-start-width)
111+
solid var(--dsn-details-content-border-color);
112+
padding-inline-start: var(--dsn-details-content-padding-inline-start);
113+
padding-inline-end: var(--dsn-details-content-padding-inline-end);
114+
padding-block: var(--dsn-details-content-padding-block);
115+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Details component styles for React
3+
* Re-exports the base Details styles from components-html
4+
*/
5+
6+
@import '../../../components-html/src/details/details.css';
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { Details } from './Details';
5+
6+
describe('Details', () => {
7+
// ===========================
8+
// Rendering
9+
// ===========================
10+
11+
it('renders summary label', () => {
12+
render(<Details summary="Meer informatie">Inhoud</Details>);
13+
expect(screen.getByText('Meer informatie')).toBeInTheDocument();
14+
});
15+
16+
it('renders children content', () => {
17+
render(<Details summary="Label">Aanvullende informatie</Details>);
18+
expect(screen.getByText('Aanvullende informatie')).toBeInTheDocument();
19+
});
20+
21+
it('renders as a <details> element', () => {
22+
const { container } = render(<Details summary="Label">Inhoud</Details>);
23+
expect(container.firstChild?.nodeName).toBe('DETAILS');
24+
});
25+
26+
it('renders a <summary> element inside details', () => {
27+
const { container } = render(<Details summary="Label">Inhoud</Details>);
28+
expect(container.querySelector('summary')).toBeInTheDocument();
29+
});
30+
31+
it('wraps children in dsn-details__content div', () => {
32+
render(
33+
<Details summary="Label">
34+
<p>Inhoud</p>
35+
</Details>
36+
);
37+
const content = document.querySelector('.dsn-details__content');
38+
expect(content).toBeInTheDocument();
39+
expect(content?.querySelector('p')).toBeInTheDocument();
40+
});
41+
42+
// ===========================
43+
// Classes
44+
// ===========================
45+
46+
it('always has base dsn-details class', () => {
47+
const { container } = render(<Details summary="Label">Inhoud</Details>);
48+
expect(container.firstChild).toHaveClass('dsn-details');
49+
});
50+
51+
it('applies custom className', () => {
52+
const { container } = render(
53+
<Details summary="Label" className="custom-details">
54+
Inhoud
55+
</Details>
56+
);
57+
expect(container.firstChild).toHaveClass('dsn-details');
58+
expect(container.firstChild).toHaveClass('custom-details');
59+
});
60+
61+
it('applies dsn-details__summary class to summary element', () => {
62+
const { container } = render(<Details summary="Label">Inhoud</Details>);
63+
expect(container.querySelector('summary')).toHaveClass(
64+
'dsn-details__summary'
65+
);
66+
});
67+
68+
it('applies dsn-details__summary-label class to label span', () => {
69+
const { container } = render(<Details summary="Label">Inhoud</Details>);
70+
expect(
71+
container.querySelector('.dsn-details__summary-label')
72+
).toBeInTheDocument();
73+
});
74+
75+
it('applies dsn-details__icon class to chevron icon', () => {
76+
const { container } = render(<Details summary="Label">Inhoud</Details>);
77+
expect(container.querySelector('.dsn-details__icon')).toBeInTheDocument();
78+
});
79+
80+
// ===========================
81+
// Open state
82+
// ===========================
83+
84+
it('is closed by default', () => {
85+
const { container } = render(<Details summary="Label">Inhoud</Details>);
86+
expect(container.firstChild).not.toHaveAttribute('open');
87+
});
88+
89+
it('opens when defaultOpen is true', () => {
90+
const { container } = render(
91+
<Details summary="Label" defaultOpen>
92+
Inhoud
93+
</Details>
94+
);
95+
expect(container.firstChild).toHaveAttribute('open');
96+
});
97+
98+
// ===========================
99+
// onToggle callback
100+
// ===========================
101+
102+
it('calls onToggle when toggled', async () => {
103+
const onToggle = vi.fn();
104+
render(
105+
<Details summary="Label" onToggle={onToggle}>
106+
Inhoud
107+
</Details>
108+
);
109+
const summary = screen.getByText('Label').closest('summary')!;
110+
await userEvent.click(summary);
111+
expect(onToggle).toHaveBeenCalledWith(true);
112+
});
113+
114+
it('calls onToggle with false when closed after being open', async () => {
115+
const onToggle = vi.fn();
116+
render(
117+
<Details summary="Label" defaultOpen onToggle={onToggle}>
118+
Inhoud
119+
</Details>
120+
);
121+
const summary = screen.getByText('Label').closest('summary')!;
122+
await userEvent.click(summary);
123+
expect(onToggle).toHaveBeenCalledWith(false);
124+
});
125+
126+
it('does not throw when onToggle is not provided', async () => {
127+
render(<Details summary="Label">Inhoud</Details>);
128+
const summary = screen.getByText('Label').closest('summary')!;
129+
await expect(userEvent.click(summary)).resolves.not.toThrow();
130+
});
131+
132+
// ===========================
133+
// Ref + HTML attributes
134+
// ===========================
135+
136+
it('forwards ref to the details element', () => {
137+
const ref = { current: null as HTMLDetailsElement | null };
138+
render(
139+
<Details ref={ref} summary="Label">
140+
Inhoud
141+
</Details>
142+
);
143+
expect(ref.current).toBeInstanceOf(HTMLElement);
144+
expect(ref.current?.tagName).toBe('DETAILS');
145+
});
146+
147+
it('spreads additional HTML attributes', () => {
148+
render(
149+
<Details summary="Label" id="details-1" data-testid="my-details">
150+
Inhoud
151+
</Details>
152+
);
153+
const el = screen.getByTestId('my-details');
154+
expect(el).toHaveAttribute('id', 'details-1');
155+
});
156+
157+
// ===========================
158+
// Accessibility
159+
// ===========================
160+
161+
it('chevron icon has aria-hidden', () => {
162+
const { container } = render(<Details summary="Label">Inhoud</Details>);
163+
const icon = container.querySelector('.dsn-details__icon');
164+
expect(icon).toHaveAttribute('aria-hidden', 'true');
165+
});
166+
167+
it('summary label text is the accessible name', () => {
168+
render(<Details summary="Meer informatie">Inhoud</Details>);
169+
// The summary element should be discoverable by its visible text
170+
expect(
171+
screen.getByText('Meer informatie').closest('summary')
172+
).toBeInTheDocument();
173+
});
174+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React from 'react';
2+
import { classNames } from '@dsn/core';
3+
import { Icon } from '../Icon';
4+
import './Details.css';
5+
6+
export interface DetailsProps extends Omit<
7+
React.HTMLAttributes<HTMLDetailsElement>,
8+
'onToggle'
9+
> {
10+
/**
11+
* Zichtbare labeltekst in de `<summary>`
12+
*/
13+
summary: string;
14+
15+
/**
16+
* Standaard open bij eerste render (ongecontroleerde staat)
17+
* @default false
18+
*/
19+
defaultOpen?: boolean;
20+
21+
/**
22+
* Callback die wordt aangeroepen wanneer de open/dicht staat wijzigt
23+
*/
24+
onToggle?: (open: boolean) => void;
25+
26+
/**
27+
* De verborgen inhoud
28+
*/
29+
children?: React.ReactNode;
30+
}
31+
32+
/**
33+
* Details component
34+
* Uitvouwbare inhoudsaanwijzer voor aanvullende inhoud die niet iedereen nodig heeft.
35+
* Gebaseerd op het native `<details>`/`<summary>` element — CSS-only, geen JavaScript voor de toggle.
36+
*
37+
* @example
38+
* ```tsx
39+
* // Standaard gesloten
40+
* <Details summary="Meer informatie">
41+
* <Paragraph>Aanvullende informatie die beschikbaar is voor wie dit nodig heeft.</Paragraph>
42+
* </Details>
43+
*
44+
* // Standaard open
45+
* <Details summary="Meer informatie" defaultOpen>
46+
* <Paragraph>Inhoud is standaard zichtbaar.</Paragraph>
47+
* </Details>
48+
*
49+
* // Met toggle callback
50+
* <Details summary="Meer informatie" onToggle={(open) => console.log('open:', open)}>
51+
* <Paragraph>Inhoud.</Paragraph>
52+
* </Details>
53+
* ```
54+
*/
55+
export const Details = React.forwardRef<HTMLDetailsElement, DetailsProps>(
56+
(
57+
{ className, summary, defaultOpen = false, onToggle, children, ...props },
58+
ref
59+
) => {
60+
const classes = classNames('dsn-details', className);
61+
62+
const handleToggle = (event: React.SyntheticEvent<HTMLDetailsElement>) => {
63+
onToggle?.((event.currentTarget as HTMLDetailsElement).open);
64+
};
65+
66+
return (
67+
<details
68+
ref={ref}
69+
className={classes}
70+
open={defaultOpen || undefined}
71+
onToggle={handleToggle}
72+
{...props}
73+
>
74+
<summary className="dsn-details__summary">
75+
<Icon name="chevron-down" className="dsn-details__icon" aria-hidden />
76+
<span className="dsn-details__summary-label">{summary}</span>
77+
</summary>
78+
<div className="dsn-details__content">{children}</div>
79+
</details>
80+
);
81+
}
82+
);
83+
84+
Details.displayName = 'Details';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Details } from './Details';
2+
export type { DetailsProps } from './Details';

packages/components-react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export * from './StatusBadge';
5050
export * from './Alert';
5151
export * from './Note';
5252
export * from './Table';
53+
export * from './Details';
5354

5455
// Form Field Components
5556
export * from './FormField';

0 commit comments

Comments
 (0)