diff --git a/package.json b/package.json
index b16fa93..b92c49f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@internxt/ui",
- "version": "0.1.26",
+ "version": "0.1.27",
"description": "Library of Internxt components",
"repository": {
"type": "git",
diff --git a/src/components/navigation/sidenav/SidenavItem.tsx b/src/components/navigation/sidenav/SidenavItem.tsx
index b795e73..7edea70 100644
--- a/src/components/navigation/sidenav/SidenavItem.tsx
+++ b/src/components/navigation/sidenav/SidenavItem.tsx
@@ -1,10 +1,15 @@
-import { IconProps } from '@phosphor-icons/react';
+import { useRef, useState } from 'react';
+import { ArrowsClockwiseIcon, IconProps } from '@phosphor-icons/react';
+
+// Match Tailwind duration (1s per rotation)
+const SPIN_DURATION_MS = 1000;
interface SidenavItemProps {
label: string;
notifications?: number;
Icon: React.ForwardRefExoticComponent>;
onClick?: () => void;
+ onRefresh?: () => void | Promise;
iconDataCy?: string;
isActive?: boolean;
isCollapsed?: boolean;
@@ -15,17 +20,44 @@ const SidenavItem = ({
label,
Icon,
onClick,
+ onRefresh,
notifications,
iconDataCy,
isActive = false,
isCollapsed = false,
subsection = false,
}: SidenavItemProps): JSX.Element => {
+ const [isHovered, setIsHovered] = useState(false);
+ const [isSpinning, setIsSpinning] = useState(false);
+ const spinTimeoutRef = useRef>();
+ const showRefresh = (isHovered || isSpinning) && !!onRefresh;
+
+ const onRefreshClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ onRefresh?.();
+
+ if (spinTimeoutRef.current) {
+ clearTimeout(spinTimeoutRef.current);
+ }
+ setIsSpinning(true);
+ spinTimeoutRef.current = setTimeout(() => setIsSpinning(false), SPIN_DURATION_MS);
+ };
+
return (
-
- {!!notifications && (
+ {showRefresh ? (
{
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onRefreshClick(event as unknown as React.MouseEvent
);
+ }
+ }}
+ data-cy={iconDataCy ? `${iconDataCy}-refresh` : undefined}
+ title="Refresh"
+ className={`flex rounded-full p-1 hover:bg-gray-10 ${isActive ? 'text-primary' : 'text-gray-60'} ${isCollapsed ? 'opacity-0 invisible' : 'opacity-100'}`}
>
- {{notifications}
}
+
+ ) : (
+ !!notifications && (
+
+ )
)}
-
+
);
};
diff --git a/src/components/navigation/sidenav/SidenavOptions.tsx b/src/components/navigation/sidenav/SidenavOptions.tsx
index 5296487..d91440b 100644
--- a/src/components/navigation/sidenav/SidenavOptions.tsx
+++ b/src/components/navigation/sidenav/SidenavOptions.tsx
@@ -9,6 +9,7 @@ export interface SidenavOption {
isActive?: boolean;
notifications?: number;
onClick?: () => void;
+ onRefresh?: () => void | Promise;
subsection?: boolean;
}
@@ -41,6 +42,7 @@ const SidenavOptions = ({ options, isCollapsed, showSubsections }: SidenavOption
isActive={option.isActive}
notifications={option.notifications}
onClick={option.onClick}
+ onRefresh={option.onRefresh}
isCollapsed={isCollapsed}
subsection={option.subsection}
/>
diff --git a/src/components/navigation/sidenav/__test__/Sidenav.test.tsx b/src/components/navigation/sidenav/__test__/Sidenav.test.tsx
index 142a7e1..66ed636 100644
--- a/src/components/navigation/sidenav/__test__/Sidenav.test.tsx
+++ b/src/components/navigation/sidenav/__test__/Sidenav.test.tsx
@@ -172,7 +172,7 @@ describe('Sidenav Component', () => {
it('applies active styles to selected option', () => {
const activeOptions = mockOptions.map((opt, idx) => ({ ...opt, isActive: idx === 0 }));
const { getByText } = renderSidenav({ options: activeOptions });
- const inboxButton = getByText('Inbox').closest('button');
+ const inboxButton = getByText('Inbox').closest('[role="button"]');
expect(inboxButton).toHaveClass('bg-primary/20');
});
@@ -253,7 +253,7 @@ describe('Sidenav Component', () => {
it('adds title attribute to options when collapsed for tooltip', () => {
const { container } = renderSidenav({ isCollapsed: true });
- const optionButtons = container.querySelectorAll('button[title="Inbox"]');
+ const optionButtons = container.querySelectorAll('[role="button"][title="Inbox"]');
expect(optionButtons.length).toBeGreaterThan(0);
});
});
@@ -320,14 +320,14 @@ describe('Sidenav Component', () => {
it('does not render collapse button when onToggleCollapse is not provided', () => {
const { container } = renderSidenav();
- const buttons = container.querySelectorAll('button');
+ const buttons = container.querySelectorAll('[role="button"], button');
expect(buttons.length).toBe(5);
});
it('renders collapse button when onToggleCollapse is provided', () => {
const { container } = renderSidenav({ onToggleCollapse });
- const buttons = container.querySelectorAll('button');
+ const buttons = container.querySelectorAll('[role="button"], button');
expect(buttons.length).toBeGreaterThan(5);
});
diff --git a/src/components/navigation/sidenav/__test__/SidenavItem.test.tsx b/src/components/navigation/sidenav/__test__/SidenavItem.test.tsx
new file mode 100644
index 0000000..1880d32
--- /dev/null
+++ b/src/components/navigation/sidenav/__test__/SidenavItem.test.tsx
@@ -0,0 +1,142 @@
+import React from 'react';
+import { IconProps } from '@phosphor-icons/react';
+import { render, fireEvent, act } from '@testing-library/react';
+import { afterEach, expect, test, vi } from 'vitest';
+import '@testing-library/jest-dom';
+import SidenavItem from '../SidenavItem';
+
+const MockIcon = React.forwardRef(({ size = 20 }, ref) => (
+
+));
+
+const onClick = vi.fn();
+const onRefresh = vi.fn();
+
+const defaultProps = {
+ label: 'Inbox',
+ Icon: MockIcon,
+ iconDataCy: 'sideNavInboxIcon',
+};
+
+const renderSidenavItem = (props = {}) => render();
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+});
+
+test('When the item is rendered, then it shows the label and icon', () => {
+ const { getByText, getByTestId } = renderSidenavItem();
+ expect(getByText('Inbox')).toBeInTheDocument();
+ expect(getByTestId('mock-icon')).toBeInTheDocument();
+});
+
+test('When the item is clicked, then onClick is called', () => {
+ const { container } = renderSidenavItem({ onClick });
+ fireEvent.click(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element);
+ expect(onClick).toHaveBeenCalledTimes(1);
+});
+
+test('When notifications is set, then the notifications badge is rendered', () => {
+ const { getByText } = renderSidenavItem({ notifications: 5 });
+ expect(getByText('5')).toBeInTheDocument();
+});
+
+test('When notifications is 0, then the notifications badge is not rendered', () => {
+ const { queryByText } = renderSidenavItem({ notifications: 0 });
+ expect(queryByText('0')).not.toBeInTheDocument();
+});
+
+test('When onRefresh is not provided and the item is hovered, then the refresh button is not shown', () => {
+ const { container } = renderSidenavItem({ notifications: 5 });
+ fireEvent.mouseEnter(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element);
+ expect(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]')).not.toBeInTheDocument();
+});
+
+test('When onRefresh is provided and the item is hovered, then notifications hide and the refresh button shows', () => {
+ const { container, getByText, queryByText } = renderSidenavItem({ notifications: 5, onRefresh });
+ expect(getByText('5')).toBeInTheDocument();
+
+ fireEvent.mouseEnter(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element);
+
+ expect(queryByText('5')).not.toBeInTheDocument();
+ expect(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]')).toBeInTheDocument();
+});
+
+test('When the mouse leaves after hovering, then notifications are shown again', () => {
+ const { container, getByText } = renderSidenavItem({ notifications: 5, onRefresh });
+ const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element;
+
+ fireEvent.mouseEnter(item);
+ fireEvent.mouseLeave(item);
+
+ expect(getByText('5')).toBeInTheDocument();
+});
+
+test('When the refresh button is clicked, then onRefresh is called and onClick is not', () => {
+ const { container } = renderSidenavItem({ notifications: 5, onClick, onRefresh });
+ const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element;
+
+ fireEvent.mouseEnter(item);
+ fireEvent.click(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]') as Element);
+
+ expect(onRefresh).toHaveBeenCalledTimes(1);
+ expect(onClick).not.toHaveBeenCalled();
+});
+
+test('When the refresh button is clicked, then the icon spins for exactly one second', () => {
+ vi.useFakeTimers();
+ const { container } = renderSidenavItem({ onRefresh });
+ const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element;
+
+ fireEvent.mouseEnter(item);
+ const refreshButton = container.querySelector('[data-cy="sideNavInboxIcon-refresh"]') as Element;
+ fireEvent.click(refreshButton);
+
+ const icon = refreshButton.querySelector('svg') as Element;
+ expect(icon).toHaveClass('animate-spin');
+
+ act(() => {
+ vi.advanceTimersByTime(999);
+ });
+ expect(icon).toHaveClass('animate-spin');
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(icon).not.toHaveClass('animate-spin');
+});
+
+test('When the mouse leaves while the icon is spinning, then the refresh button stays visible', () => {
+ vi.useFakeTimers();
+ const { container, queryByText } = renderSidenavItem({ notifications: 5, onRefresh });
+ const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element;
+
+ fireEvent.mouseEnter(item);
+ fireEvent.click(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]') as Element);
+ fireEvent.mouseLeave(item);
+
+ expect(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]')).toBeInTheDocument();
+ expect(queryByText('5')).not.toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+});
+
+test('When Enter is pressed on the item, then onClick is activated', () => {
+ const { container } = renderSidenavItem({ onClick });
+ fireEvent.keyDown(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element, { key: 'Enter' });
+ expect(onClick).toHaveBeenCalledTimes(1);
+});
+
+test('When Enter is pressed on the refresh button, then onRefresh is activated and onClick is not', () => {
+ const { container } = renderSidenavItem({ notifications: 5, onClick, onRefresh });
+ const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element;
+
+ fireEvent.mouseEnter(item);
+ fireEvent.keyDown(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]') as Element, { key: 'Enter' });
+
+ expect(onRefresh).toHaveBeenCalledTimes(1);
+ expect(onClick).not.toHaveBeenCalled();
+});
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 183ec5c..8c0933f 100644
--- a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap
+++ b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap
@@ -48,9 +48,11 @@ exports[`Sidenav Component > Notification > should match snapshot with notificat
-
-
-
-
-
+
@@ -252,9 +260,11 @@ exports[`Sidenav Component > should match snapshot 1`] = `
-
-
-
-
-
+
@@ -443,9 +459,11 @@ exports[`Sidenav Component > should match snapshot when collapsed 1`] = `
-
-
-
-
-
+
@@ -618,9 +642,11 @@ exports[`Sidenav Component > should match snapshot with primary action 1`] = `
-
-
-
-
-
+
@@ -783,9 +815,11 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = `
-
-
-
-
-
+