Skip to content

Commit b631b01

Browse files
committed
Critique NotificationBadge and NotificationPopup
1 parent 558a0ee commit b631b01

11 files changed

Lines changed: 403 additions & 43 deletions

src/components/NotificationBadge/NotificationBadge.module.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
line-height: 1;
2121
white-space: nowrap;
2222
pointer-events: none;
23+
box-shadow: 0 0 0 2px var(--notification-badge-ring-color, var(--color-surface));
24+
animation: badgePing 300ms ease-out;
25+
26+
@include reduced-motion {
27+
animation: none;
28+
}
2329
}
2430

2531
.count {
@@ -40,3 +46,8 @@
4046
@include sr-only;
4147
}
4248

49+
@keyframes badgePing {
50+
0% { transform: translate(50%, -50%) scale(1.5); }
51+
100% { transform: translate(50%, -50%) scale(1); }
52+
}
53+

src/components/NotificationBadge/NotificationBadge.stories.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { useState } from 'react';
23
import { NotificationBadge } from './NotificationBadge';
4+
import { Button } from '../Button';
35

46
const BellIcon = () => (
57
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -82,3 +84,19 @@ export const AllVariants: Story = {
8284
),
8385
};
8486

87+
/** Live count increment — watch the badge ping on each update. */
88+
export const LiveCount: Story = {
89+
render: () => {
90+
const [count, setCount] = useState(0);
91+
return (
92+
<div style={{ display: 'flex', gap: '2rem', alignItems: 'center' }}>
93+
<NotificationBadge count={count}>
94+
<BellIcon />
95+
</NotificationBadge>
96+
<Button onClick={() => setCount((c) => c + 1)}>Add notification</Button>
97+
<Button onClick={() => setCount(0)}>Clear</Button>
98+
</div>
99+
);
100+
},
101+
};
102+

src/components/NotificationBadge/NotificationBadge.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,14 @@ export function NotificationBadge({
5353
.filter(Boolean)
5454
.join(' ');
5555

56+
// Changing the key remounts the badge span, restarting the ping animation
57+
const badgeKey = dot ? 'dot' : count;
58+
5659
return (
5760
<span ref={ref} className={wrapperClasses} {...rest}>
5861
{children}
5962
{showBadge && (
60-
<span className={badgeClasses} aria-label={ariaLabel} role="status">
63+
<span key={badgeKey} className={badgeClasses} aria-label={ariaLabel} role="status">
6164
{!dot && displayText}
6265
{dot && <span className={styles.srOnly}>New notifications</span>}
6366
</span>

src/components/NotificationPopup/NotificationPopup.module.scss

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
@use '../../styles/mixins' as *;
22

33
.popup {
4-
position: fixed;
5-
bottom: var(--space-4);
6-
right: var(--space-4);
4+
position: relative;
75
display: flex;
86
align-items: flex-start;
97
gap: var(--space-3);
@@ -14,26 +12,38 @@
1412
box-shadow: var(--shadow-lg);
1513
font-family: var(--font-family);
1614
font-size: var(--font-size-sm);
17-
z-index: var(--z-index-notification);
18-
max-width: min(24rem, calc(100vw - var(--space-8)));
15+
overflow: hidden;
16+
max-height: 300px; /* upper-bound enables max-height transition in .leaving */
1917
animation: slideIn 250ms ease-out;
2018

2119
@include reduced-motion {
2220
animation: none;
2321
}
2422

2523
@media (max-width: 479px) {
26-
top: 0;
27-
bottom: auto;
28-
left: 0;
29-
right: 0;
30-
max-width: none;
3124
border-radius: 0;
3225
border-left: none;
3326
border-bottom: 4px solid transparent;
3427
}
3528
}
3629

30+
.leaving {
31+
animation: slideOut 250ms ease-in forwards;
32+
/* Collapse height so remaining toasts slide into place rather than snapping */
33+
max-height: 0;
34+
padding-top: 0;
35+
padding-bottom: 0;
36+
transition:
37+
max-height 250ms ease-in,
38+
padding 250ms ease-in;
39+
40+
@include reduced-motion {
41+
animation: none;
42+
opacity: 0;
43+
transition: opacity 150ms ease;
44+
}
45+
}
46+
3747
.icon {
3848
flex-shrink: 0;
3949
font-size: var(--font-size-md);
@@ -62,12 +72,27 @@
6272
margin-left: auto;
6373
}
6474

75+
.progress {
76+
position: absolute;
77+
bottom: 0;
78+
left: 0;
79+
right: 0;
80+
height: 2px;
81+
transform-origin: left;
82+
animation: progressDrain linear forwards;
83+
84+
@include reduced-motion {
85+
display: none;
86+
}
87+
}
88+
6589
/* Variants */
6690

6791
.info {
6892
border-left-color: var(--color-info);
6993

7094
.icon { color: var(--color-info); }
95+
.progress { background-color: var(--color-info); }
7196

7297
@media (max-width: 479px) {
7398
border-bottom-color: var(--color-info);
@@ -78,6 +103,7 @@
78103
border-left-color: var(--color-success);
79104

80105
.icon { color: var(--color-success); }
106+
.progress { background-color: var(--color-success); }
81107

82108
@media (max-width: 479px) {
83109
border-bottom-color: var(--color-success);
@@ -88,6 +114,7 @@
88114
border-left-color: var(--color-warning);
89115

90116
.icon { color: var(--color-warning); }
117+
.progress { background-color: var(--color-warning); }
91118

92119
@media (max-width: 479px) {
93120
border-bottom-color: var(--color-warning);
@@ -98,6 +125,7 @@
98125
border-left-color: var(--color-error);
99126

100127
.icon { color: var(--color-error); }
128+
.progress { background-color: var(--color-error); }
101129

102130
@media (max-width: 479px) {
103131
border-bottom-color: var(--color-error);
@@ -116,3 +144,20 @@
116144
}
117145
}
118146

147+
@keyframes slideOut {
148+
from {
149+
transform: translateX(0);
150+
opacity: 1;
151+
}
152+
153+
to {
154+
transform: translateX(100%);
155+
opacity: 0;
156+
}
157+
}
158+
159+
@keyframes progressDrain {
160+
from { transform: scaleX(1); }
161+
to { transform: scaleX(0); }
162+
}
163+

src/components/NotificationPopup/NotificationPopup.stories.tsx

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
22
import { useState } from 'react';
33
import { NotificationPopup } from './NotificationPopup';
4+
import { NotificationToastContainer } from './NotificationToastContainer';
45
import { Button } from '../Button';
56

67
const meta: Meta<typeof NotificationPopup> = {
@@ -17,36 +18,104 @@ const meta: Meta<typeof NotificationPopup> = {
1718
export default meta;
1819
type Story = StoryObj<typeof NotificationPopup>;
1920

21+
/** Interactive toast inside a NotificationToastContainer. */
2022
export const Interactive: Story = {
2123
render: () => {
2224
const [visible, setVisible] = useState(false);
2325
return (
2426
<>
2527
<Button onClick={() => setVisible(true)}>Show Notification</Button>
26-
<NotificationPopup
27-
variant="success"
28-
title="Changes saved"
29-
visible={visible}
30-
onDismiss={() => setVisible(false)}
31-
>
32-
Your changes have been saved successfully.
33-
</NotificationPopup>
28+
<NotificationToastContainer>
29+
<NotificationPopup
30+
variant="success"
31+
title="Changes saved"
32+
visible={visible}
33+
onDismiss={() => setVisible(false)}
34+
>
35+
Your changes have been saved successfully.
36+
</NotificationPopup>
37+
</NotificationToastContainer>
3438
</>
3539
);
3640
},
3741
};
3842

43+
/** Persistent notification — `autoDismiss={false}` keeps it until the user dismisses. */
44+
export const Persistent: Story = {
45+
render: () => {
46+
const [visible, setVisible] = useState(true);
47+
return (
48+
<>
49+
<Button onClick={() => setVisible(true)}>Show Notification</Button>
50+
<NotificationToastContainer>
51+
<NotificationPopup
52+
variant="warning"
53+
title="Session expiring"
54+
visible={visible}
55+
autoDismiss={false}
56+
onDismiss={() => setVisible(false)}
57+
>
58+
Your session will expire in 5 minutes.
59+
</NotificationPopup>
60+
</NotificationToastContainer>
61+
</>
62+
);
63+
},
64+
};
65+
66+
/** Multiple stacked toasts — each exits with a smooth animation before being removed from the list. */
67+
export const Stacked: Story = {
68+
render: () => {
69+
const [toasts, setToasts] = useState([
70+
{ id: 1, variant: 'success' as const, title: 'Saved', body: 'Draft saved successfully.', visible: true },
71+
{ id: 2, variant: 'error' as const, title: 'Upload failed', body: 'File exceeds 10 MB limit.', visible: true },
72+
{ id: 3, variant: 'info' as const, title: 'Update available', body: 'A new version is ready.', visible: true },
73+
]);
74+
75+
// Step 1: trigger exit animation by setting visible=false
76+
const dismiss = (id: number) =>
77+
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, visible: false } : t)));
78+
79+
// Step 2: remove from list only after the animation finishes (via onExited)
80+
const remove = (id: number) =>
81+
setToasts((prev) => prev.filter((t) => t.id !== id));
82+
83+
return (
84+
<NotificationToastContainer>
85+
{toasts.map((t, i) => (
86+
<NotificationPopup
87+
key={t.id}
88+
variant={t.variant}
89+
title={t.title}
90+
visible={t.visible}
91+
autoDismiss={false}
92+
onDismiss={() => dismiss(t.id)}
93+
onExited={() => remove(t.id)}
94+
style={{
95+
animationDelay: `${i * 80}ms`,
96+
animationFillMode: 'backwards',
97+
}}
98+
>
99+
{t.body}
100+
</NotificationPopup>
101+
))}
102+
</NotificationToastContainer>
103+
);
104+
},
105+
};
106+
107+
/** All four variants displayed inline (no container needed for static display). */
39108
export const AllVariants: Story = {
40109
render: () => (
41-
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', position: 'relative' }}>
110+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
42111
{(['info', 'success', 'warning', 'error'] as const).map((v) => (
43112
<NotificationPopup
44113
key={v}
45114
variant={v}
46115
title={`${v.charAt(0).toUpperCase() + v.slice(1)} notification`}
47116
visible
117+
autoDismiss={false}
48118
onDismiss={() => {}}
49-
style={{ position: 'static' }}
50119
>
51120
This is a {v} notification message.
52121
</NotificationPopup>

0 commit comments

Comments
 (0)