Skip to content

Commit 8937a3f

Browse files
Merge pull request #131 from jeffreylauwers/feature/number-badge
feat(NumberBadge): compact inline getal-badge voor Button en Menu-item
2 parents 6fa63bf + 9b6b0d4 commit 8937a3f

11 files changed

Lines changed: 741 additions & 3 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* NumberBadge Component
3+
* Compact inline-element dat een getal toont (bijv. ongelezen berichten of openstaande taken).
4+
* Geplaatst binnen een Button of Menu-item, naast het label, via de iconEnd-positie.
5+
*
6+
* Altijd aria-hidden="true" — context via dsn-visually-hidden in de parent.
7+
*
8+
* Usage:
9+
* <!-- Standalone -->
10+
* <span class="dsn-number-badge dsn-number-badge--negative" aria-hidden="true">5</span>
11+
*
12+
* <!-- Inline in een Button (buiten het label, als iconEnd) -->
13+
* <button type="button" class="dsn-button dsn-button--subtle">
14+
* <svg class="dsn-icon" aria-hidden="true"><!-- inbox --></svg>
15+
* <span class="dsn-button__label">Inbox</span>
16+
* <span class="dsn-number-badge dsn-number-badge--negative" aria-hidden="true">5</span>
17+
* </button>
18+
*
19+
* <!-- Afgekapt getal (99+) — screenreader-context in dsn-button__label -->
20+
* <button type="button" class="dsn-button dsn-button--subtle">
21+
* <svg class="dsn-icon" aria-hidden="true"><!-- inbox --></svg>
22+
* <span class="dsn-button__label">
23+
* Inbox
24+
* <span class="dsn-visually-hidden">, 128 ongelezen berichten</span>
25+
* </span>
26+
* <span class="dsn-number-badge dsn-number-badge--negative" aria-hidden="true">99+</span>
27+
* </button>
28+
*
29+
* <!-- Standalone varianten -->
30+
* <span class="dsn-number-badge dsn-number-badge--positive" aria-hidden="true">5</span>
31+
* <span class="dsn-number-badge dsn-number-badge--warning" aria-hidden="true">5</span>
32+
* <span class="dsn-number-badge dsn-number-badge--info" aria-hidden="true">5</span>
33+
* <span class="dsn-number-badge dsn-number-badge--neutral" aria-hidden="true">5</span>
34+
*/
35+
36+
.dsn-number-badge {
37+
display: inline-flex;
38+
align-items: center;
39+
justify-content: center;
40+
/*
41+
* min-inline-size = hoogte van de badge: 1lh (regel) + 2× padding-block
42+
* De 1lh-unit schaalt mee met de fluid type scale — bij een ééncijferig
43+
* getal blijft de badge altijd een perfecte cirkel ongeacht de font-size.
44+
*/
45+
min-inline-size: calc(1lh + 2 * var(--dsn-number-badge-padding-block));
46+
padding-block: var(--dsn-number-badge-padding-block);
47+
padding-inline: var(--dsn-number-badge-padding-inline);
48+
border-radius: var(--dsn-number-badge-border-radius);
49+
border: var(--dsn-number-badge-border-width) solid
50+
var(--dsn-number-badge-border-color);
51+
font-family: inherit;
52+
font-size: var(--dsn-number-badge-font-size);
53+
line-height: var(--dsn-number-badge-line-height);
54+
font-weight: var(--dsn-number-badge-font-weight);
55+
white-space: nowrap;
56+
}
57+
58+
/* Variant kleuren */
59+
.dsn-number-badge--negative {
60+
color: var(--dsn-number-badge-negative-color);
61+
background-color: var(--dsn-number-badge-negative-background-color);
62+
}
63+
64+
.dsn-number-badge--positive {
65+
color: var(--dsn-number-badge-positive-color);
66+
background-color: var(--dsn-number-badge-positive-background-color);
67+
}
68+
69+
.dsn-number-badge--warning {
70+
color: var(--dsn-number-badge-warning-color);
71+
background-color: var(--dsn-number-badge-warning-background-color);
72+
}
73+
74+
.dsn-number-badge--info {
75+
color: var(--dsn-number-badge-info-color);
76+
background-color: var(--dsn-number-badge-info-background-color);
77+
}
78+
79+
.dsn-number-badge--neutral {
80+
color: var(--dsn-number-badge-neutral-color);
81+
background-color: var(--dsn-number-badge-neutral-background-color);
82+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@import '../../../components-html/src/number-badge/number-badge.css';
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import { NumberBadge } from './NumberBadge';
4+
5+
describe('NumberBadge', () => {
6+
it('renders as a <span> element', () => {
7+
const { container } = render(<NumberBadge>5</NumberBadge>);
8+
expect(container.firstChild?.nodeName).toBe('SPAN');
9+
});
10+
11+
it('always has base dsn-number-badge class', () => {
12+
const { container } = render(<NumberBadge>5</NumberBadge>);
13+
expect(container.firstChild).toHaveClass('dsn-number-badge');
14+
});
15+
16+
it('always has aria-hidden="true"', () => {
17+
const { container } = render(<NumberBadge>5</NumberBadge>);
18+
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
19+
});
20+
21+
it('applies negative variant class by default', () => {
22+
const { container } = render(<NumberBadge>5</NumberBadge>);
23+
expect(container.firstChild).toHaveClass('dsn-number-badge--negative');
24+
});
25+
26+
it.each(['positive', 'negative', 'warning', 'info', 'neutral'] as const)(
27+
'applies variant modifier class for %s variant',
28+
(variant) => {
29+
const { container } = render(
30+
<NumberBadge variant={variant}>5</NumberBadge>
31+
);
32+
expect(container.firstChild).toHaveClass(`dsn-number-badge--${variant}`);
33+
}
34+
);
35+
36+
it('renders children as display value', () => {
37+
const { getByText } = render(<NumberBadge>42</NumberBadge>);
38+
expect(getByText('42')).toBeTruthy();
39+
});
40+
41+
it('renders string children unchanged', () => {
42+
const { getByText } = render(<NumberBadge>99+</NumberBadge>);
43+
expect(getByText('99+')).toBeTruthy();
44+
});
45+
46+
it('shows maxCount+ when children number exceeds maxCount', () => {
47+
const { getByText } = render(<NumberBadge maxCount={99}>128</NumberBadge>);
48+
expect(getByText('99+')).toBeTruthy();
49+
});
50+
51+
it('shows actual count when children number equals maxCount', () => {
52+
const { getByText } = render(<NumberBadge maxCount={99}>99</NumberBadge>);
53+
expect(getByText('99')).toBeTruthy();
54+
});
55+
56+
it('shows actual count when children number is below maxCount', () => {
57+
const { getByText } = render(<NumberBadge maxCount={99}>5</NumberBadge>);
58+
expect(getByText('5')).toBeTruthy();
59+
});
60+
61+
it('does not apply maxCount truncation to non-numeric string children', () => {
62+
const { getByText } = render(<NumberBadge maxCount={99}>99+</NumberBadge>);
63+
expect(getByText('99+')).toBeTruthy();
64+
});
65+
66+
it('applies custom className', () => {
67+
const { container } = render(
68+
<NumberBadge className="custom">5</NumberBadge>
69+
);
70+
expect(container.firstChild).toHaveClass('dsn-number-badge');
71+
expect(container.firstChild).toHaveClass('custom');
72+
});
73+
74+
it('forwards ref', () => {
75+
const ref = { current: null as HTMLSpanElement | null };
76+
render(<NumberBadge ref={ref}>5</NumberBadge>);
77+
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
78+
});
79+
80+
it('spreads additional HTML attributes', () => {
81+
const { container } = render(
82+
<NumberBadge data-testid="badge">5</NumberBadge>
83+
);
84+
expect(container.firstChild).toHaveAttribute('data-testid', 'badge');
85+
});
86+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React from 'react';
2+
import { classNames } from '@dsn/core';
3+
import './NumberBadge.css';
4+
5+
export type NumberBadgeVariant =
6+
| 'negative'
7+
| 'positive'
8+
| 'warning'
9+
| 'info'
10+
| 'neutral';
11+
12+
export interface NumberBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
13+
/**
14+
* Signaalkleur van de badge
15+
* @default 'negative'
16+
*/
17+
variant?: NumberBadgeVariant;
18+
19+
/**
20+
* Als `children` als getal groter is dan `maxCount`, wordt `{maxCount}+` getoond.
21+
* Heeft geen effect als `children` geen getal is.
22+
*/
23+
maxCount?: number;
24+
}
25+
26+
/**
27+
* NumberBadge component
28+
* Compact inline-element dat een getal toont — zoals het aantal ongelezen berichten
29+
* of openstaande taken. Geplaatst binnen een Button of Menu-item, naast het label.
30+
*
31+
* Altijd aria-hidden="true" — context via dsn-visually-hidden in de parent.
32+
*
33+
* @example
34+
* ```tsx
35+
* // Basis gebruik — inline in een Button
36+
* <Button variant="subtle" iconStart={<Icon name="inbox" aria-hidden />}>
37+
* Inbox
38+
* <NumberBadge variant="negative">5</NumberBadge>
39+
* </Button>
40+
*
41+
* // Afgekapt getal — toont "99+" als count > 99
42+
* <Button variant="subtle" iconStart={<Icon name="inbox" aria-hidden />}>
43+
* <span>
44+
* Inbox
45+
* <span className="dsn-visually-hidden">, 128 ongelezen berichten</span>
46+
* </span>
47+
* <NumberBadge variant="negative" maxCount={99}>128</NumberBadge>
48+
* </Button>
49+
* ```
50+
*/
51+
export const NumberBadge = React.forwardRef<HTMLSpanElement, NumberBadgeProps>(
52+
({ className, variant = 'negative', maxCount, children, ...props }, ref) => {
53+
const classes = classNames(
54+
'dsn-number-badge',
55+
`dsn-number-badge--${variant}`,
56+
className
57+
);
58+
59+
let displayValue: React.ReactNode = children;
60+
if (maxCount !== undefined) {
61+
const numeric =
62+
typeof children === 'number'
63+
? children
64+
: typeof children === 'string' && /^\d+$/.test(children)
65+
? parseInt(children, 10)
66+
: null;
67+
if (numeric !== null && numeric > maxCount) {
68+
displayValue = `${maxCount}+`;
69+
}
70+
}
71+
72+
return (
73+
<span ref={ref} className={classes} aria-hidden="true" {...props}>
74+
{displayValue}
75+
</span>
76+
);
77+
}
78+
);
79+
80+
NumberBadge.displayName = 'NumberBadge';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './NumberBadge';

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 './OptionLabel';
5050
// Display & Feedback Components
5151
export * from './Backdrop';
5252
export * from './DotBadge';
53+
export * from './NumberBadge';
5354
export * from './StatusBadge';
5455
export * from './Alert';
5556
export * from './Note';
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
{
2+
"dsn": {
3+
"number-badge": {
4+
"font-size": {
5+
"value": "{dsn.text.font-size.sm}",
6+
"type": "fontSize",
7+
"comment": "NumberBadge font size — zelfde schaal als StatusBadge (kleinst beschikbare stap)"
8+
},
9+
"line-height": {
10+
"value": "{dsn.text.line-height.sm}",
11+
"type": "lineHeight",
12+
"comment": "NumberBadge line height"
13+
},
14+
"font-weight": {
15+
"value": "{dsn.text.font-weight.bold}",
16+
"comment": "NumberBadge font weight — bold voor leesbaarheid op kleine afmeting"
17+
},
18+
"padding-block": {
19+
"value": "0px",
20+
"type": "dimension",
21+
"comment": "Verticale padding"
22+
},
23+
"padding-inline": {
24+
"value": "{dsn.space.inline.xs}",
25+
"type": "dimension",
26+
"comment": "Horizontale padding"
27+
},
28+
"border-radius": {
29+
"value": "{dsn.border.radius.round}",
30+
"type": "dimension",
31+
"comment": "Pill-vorm via volledig ronde border-radius"
32+
},
33+
"border-width": {
34+
"value": "{dsn.border.width.thin}",
35+
"type": "dimension",
36+
"comment": "Transparante border voor forced-colors / High Contrast mode ondersteuning"
37+
},
38+
"border-color": {
39+
"value": "{dsn.color.transparent}",
40+
"type": "color",
41+
"comment": "Transparant by default; zichtbaar in forced-colors / High Contrast mode"
42+
},
43+
"negative": {
44+
"color": {
45+
"value": "{dsn.color.negative-inverse.color-default}",
46+
"type": "color",
47+
"comment": "Tekstkleur voor negative variant (wit op donkere achtergrond)"
48+
},
49+
"background-color": {
50+
"value": "{dsn.color.negative-inverse.bg-default}",
51+
"type": "color",
52+
"comment": "Achtergrondkleur voor negative variant"
53+
}
54+
},
55+
"positive": {
56+
"color": {
57+
"value": "{dsn.color.positive-inverse.color-default}",
58+
"type": "color",
59+
"comment": "Tekstkleur voor positive variant (wit op donkere achtergrond)"
60+
},
61+
"background-color": {
62+
"value": "{dsn.color.positive-inverse.bg-default}",
63+
"type": "color",
64+
"comment": "Achtergrondkleur voor positive variant"
65+
}
66+
},
67+
"warning": {
68+
"color": {
69+
"value": "{dsn.color.warning-inverse.color-default}",
70+
"type": "color",
71+
"comment": "Tekstkleur voor warning variant (wit op donkere achtergrond)"
72+
},
73+
"background-color": {
74+
"value": "{dsn.color.warning-inverse.bg-default}",
75+
"type": "color",
76+
"comment": "Achtergrondkleur voor warning variant"
77+
}
78+
},
79+
"info": {
80+
"color": {
81+
"value": "{dsn.color.info-inverse.color-default}",
82+
"type": "color",
83+
"comment": "Tekstkleur voor info variant (wit op donkere achtergrond)"
84+
},
85+
"background-color": {
86+
"value": "{dsn.color.info-inverse.bg-default}",
87+
"type": "color",
88+
"comment": "Achtergrondkleur voor info variant"
89+
}
90+
},
91+
"neutral": {
92+
"color": {
93+
"value": "{dsn.color.neutral-inverse.color-default}",
94+
"type": "color",
95+
"comment": "Tekstkleur voor neutral variant (wit op donkere achtergrond)"
96+
},
97+
"background-color": {
98+
"value": "{dsn.color.neutral-inverse.bg-default}",
99+
"type": "color",
100+
"comment": "Achtergrondkleur voor neutral variant"
101+
}
102+
}
103+
}
104+
}
105+
}

packages/storybook/src/Introduction.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ function App() {
6161

6262
## Componenten overzicht
6363

64-
**46 componenten totaal** — alle beschikbaar als HTML/CSS én React.
64+
**47 componenten totaal** — alle beschikbaar als HTML/CSS én React.
6565

6666
### Layout Components (5)
6767

@@ -82,10 +82,11 @@ function App() {
8282
- **Link** — Hyperlinks met icon ondersteuning en externe link handling
8383
- **Lists** — OrderedList en UnorderedList
8484

85-
### Display & Feedback Components (10)
85+
### Display & Feedback Components (11)
8686

8787
- **Backdrop** — Vaste, volledig-scherm overlay die de achtergrondinhoud verhult achter een Modal Dialog of Drawer — puur decoratief (`aria-hidden="true"`)
8888
- **DotBadge** — Kleine gekleurde stip bij een Button of Link die zonder label de aandacht trekt bij een statuswijziging (met optioneel pulse-effect)
89+
- **NumberBadge** — Compact inline-element dat een getal toont (bijv. ongelezen berichten of openstaande taken) binnen een Button of Menu-item
8990
- **StatusBadge** — Compact label dat een status communiceert met een signaalkleur (neutral, info, positive, negative, warning)
9091
- **Alert** — Belangrijk bericht dat de gebruiker informeert over de huidige activiteit (info, positive, negative, warning)
9192
- **Note** — Visueel uitgelicht bericht voor aanvullende informatie, passief (geen live region)
@@ -156,4 +157,4 @@ MIT License — zie LICENSE bestand voor details.
156157

157158
---
158159

159-
**Versie:** 5.14.0 | **Laatste update:** 25 maart 2026 | **Auteur:** Jeffrey Lauwers
160+
**Versie:** 5.17.0 | **Laatste update:** 3 april 2026 | **Auteur:** Jeffrey Lauwers

0 commit comments

Comments
 (0)