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 ( - + ); }; 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`] = `
-
- - - - +