Date: Mon, 11 May 2026 09:01:48 -0600
Subject: [PATCH 4/4] feat(sidenav): enhance notification handling and add
animation on collapse
---
src/components/navigation/sidenav/Sidenav.tsx | 51 ++++-
.../sidenav/__test__/Sidenav.test.tsx | 68 +++++-
.../__snapshots__/Sidenav.test.tsx.snap | 212 +++++++++++++++++-
3 files changed, 316 insertions(+), 15 deletions(-)
diff --git a/src/components/navigation/sidenav/Sidenav.tsx b/src/components/navigation/sidenav/Sidenav.tsx
index cdcc014..68f8c89 100644
--- a/src/components/navigation/sidenav/Sidenav.tsx
+++ b/src/components/navigation/sidenav/Sidenav.tsx
@@ -1,4 +1,5 @@
-import { ReactNode } from 'react';
+import { ReactNode, useEffect, useRef, useState } from 'react';
+import { WarningIcon } from '@phosphor-icons/react';
import SidenavOptions, { SidenavOption } from './SidenavOptions';
import SidenavHeader from './SidenavHeader';
import SidenavStorage from './SidenavStorage';
@@ -39,7 +40,12 @@ export interface SidenavProps {
showSubsections?: boolean;
isCollapsed?: boolean;
storage?: SidenavStorageProps;
- notification?: ReactNode;
+ notification?: {
+ message: string;
+ actionLabel: string;
+ onAction: () => void;
+ type?: 'warning';
+ };
onToggleCollapse?: () => void;
}
@@ -56,7 +62,7 @@ export interface SidenavProps {
* @property {boolean} showSubsections - Determines whether to display the subsections of the sidenav
* @property {boolean} isCollapsed - Determines whether the sidenav is collapsed or not
* @property {SidenavStorage} storage - The storage information displayed at the bottom of the sidenav
- * @property {ReactNode} notification - Optional notification node rendered above the storage section (hidden when collapsed)
+ * @property {object} notification - Optional structured notification rendered above the storage section (hidden when collapsed). Accepts message, actionLabel, onAction, and an optional type ('warning').
* @property {() => void} onToggleCollapse - A callback function triggered when the collapse button is clicked
*/
const Sidenav = ({
@@ -71,6 +77,21 @@ const Sidenav = ({
notification,
onToggleCollapse,
}: SidenavProps) => {
+ const [showContent, setShowContent] = useState(!isCollapsed);
+ const timerRef = useRef
| null>(null);
+
+ useEffect(() => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ if (isCollapsed) {
+ setShowContent(false);
+ } else {
+ timerRef.current = setTimeout(() => setShowContent(true), 300);
+ }
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ };
+ }, [isCollapsed]);
+
return (
- {(notification || storage) && (
-
- {notification &&
{notification}
}
+ {(notification || storage) && showContent && (
+
+ {notification && (
+
+
+
+
+
{notification.message}
+
+
+
+
+ )}
{storage && (
{
});
it('hides storage when collapsed', () => {
- const { getByText } = renderSidenav({
+ const { queryByText } = renderSidenav({
isCollapsed: true,
storage: {
usage: '2.8 GB',
@@ -228,10 +228,8 @@ describe('Sidenav Component', () => {
isLoading: false,
},
});
- const usageText = getByText('2.8 GB');
- const storageContainer = usageText.closest('div')?.parentElement?.parentElement?.parentElement;
- expect(storageContainer).toHaveClass('opacity-0');
- expect(storageContainer).toHaveClass('invisible');
+ expect(queryByText('2.8 GB')).not.toBeInTheDocument();
+ expect(queryByText('4 GB')).not.toBeInTheDocument();
});
it('hides subsections when collapsed even if showSubsections is true', () => {
@@ -260,6 +258,64 @@ describe('Sidenav Component', () => {
});
});
+ describe('Notification', () => {
+ const onAction = vi.fn();
+ const notification = {
+ message: 'Your account will be deleted in 14 days.',
+ actionLabel: 'Upgrade plan',
+ onAction,
+ };
+
+ it('should match snapshot with notification', () => {
+ const { container } = renderSidenav({ notification });
+ expect(container).toMatchSnapshot();
+ });
+
+ it('renders notification message and action label', () => {
+ const { getByText } = renderSidenav({ notification });
+ expect(getByText('Your account will be deleted in 14 days.')).toBeInTheDocument();
+ expect(getByText('Upgrade plan')).toBeInTheDocument();
+ });
+
+ it('calls onAction when action button is clicked', () => {
+ const { getByText } = renderSidenav({ notification });
+ fireEvent.click(getByText('Upgrade plan'));
+ expect(onAction).toHaveBeenCalledOnce();
+ });
+
+ it('does not render notification when isCollapsed is true', () => {
+ const { queryByText } = renderSidenav({ notification, isCollapsed: true });
+ expect(queryByText('Your account will be deleted in 14 days.')).not.toBeInTheDocument();
+ });
+
+ it('hides notification immediately when sidenav collapses', () => {
+ vi.useFakeTimers();
+ const { getByText, queryByText, rerender } = renderSidenav({ notification, isCollapsed: false });
+ expect(getByText('Your account will be deleted in 14 days.')).toBeInTheDocument();
+
+ rerender();
+ expect(queryByText('Your account will be deleted in 14 days.')).not.toBeInTheDocument();
+
+ vi.useRealTimers();
+ });
+
+ it('shows notification after 300ms when sidenav expands', () => {
+ vi.useFakeTimers();
+ const { getByText, queryByText, rerender } = renderSidenav({ notification, isCollapsed: true });
+ expect(queryByText('Your account will be deleted in 14 days.')).not.toBeInTheDocument();
+
+ rerender();
+ expect(queryByText('Your account will be deleted in 14 days.')).not.toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(getByText('Your account will be deleted in 14 days.')).toBeInTheDocument();
+
+ vi.useRealTimers();
+ });
+ });
+
describe('Collapse toggle button', () => {
it('does not render collapse button when onToggleCollapse is not provided', () => {
const { container } = renderSidenav();
diff --git a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap
index 4b12928..b944554 100644
--- a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap
+++ b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap
@@ -1,5 +1,215 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+exports[`Sidenav Component > Notification > should match snapshot with notification 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your account will be deleted in 14 days.
+
+
+
+
+
+
+
+
+`;
+
exports[`Sidenav Component > should match snapshot 1`] = `
should match snapshot with storage 1`] = `