From 3119a6c115bf81c7d27303e27522680e882ebfcc Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:35:45 +0200 Subject: [PATCH 1/3] feat(sidenav): add refresh option --- package.json | 2 +- .../navigation/sidenav/SidenavItem.tsx | 65 +++++++++++++++++-- .../navigation/sidenav/SidenavOptions.tsx | 2 + 3 files changed, 61 insertions(+), 8 deletions(-) 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} /> From 7f84e3a5bdd2391de5a4b0d1e281c56b4f902d0d Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:50:00 +0200 Subject: [PATCH 2/3] fix: tests and snapshots --- .../sidenav/__test__/Sidenav.test.tsx | 8 +- .../sidenav/__test__/SidenavItem.test.tsx | 144 ++++++++++++++++ .../__snapshots__/Sidenav.test.tsx.snap | 160 +++++++++++------- 3 files changed, 248 insertions(+), 64 deletions(-) create mode 100644 src/components/navigation/sidenav/__test__/SidenavItem.test.tsx 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..541b31a --- /dev/null +++ b/src/components/navigation/sidenav/__test__/SidenavItem.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { IconProps } from '@phosphor-icons/react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '@testing-library/jest-dom'; +import SidenavItem from '../SidenavItem'; + +const MockIcon = React.forwardRef(({ size = 20 }, ref) => ( + +)); + +describe('SidenavItem Component', () => { + 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(); + }); + + it('renders the label and icon', () => { + const { getByText, getByTestId } = renderSidenavItem(); + expect(getByText('Inbox')).toBeInTheDocument(); + expect(getByTestId('mock-icon')).toBeInTheDocument(); + }); + + it('calls onClick when the item is clicked', () => { + const { container } = renderSidenavItem({ onClick }); + fireEvent.click(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders the notifications badge when notifications is set', () => { + const { getByText } = renderSidenavItem({ notifications: 5 }); + expect(getByText('5')).toBeInTheDocument(); + }); + + it('does not render the notifications badge when notifications is 0', () => { + const { queryByText } = renderSidenavItem({ notifications: 0 }); + expect(queryByText('0')).not.toBeInTheDocument(); + }); + + it('does not show the refresh button when onRefresh is not provided', () => { + const container = renderSidenavItem({ notifications: 5 }).container; + fireEvent.mouseEnter(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element); + expect(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]')).not.toBeInTheDocument(); + }); + + it('hides notifications and shows the refresh button on hover when onRefresh is provided', () => { + 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(); + }); + + it('shows notifications again after the mouse leaves', () => { + 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(); + }); + + it('calls onRefresh and not onClick when the refresh button is clicked', () => { + 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(); + }); + + it('spins the refresh icon for exactly one second after clicking', () => { + 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'); + }); + + it('keeps the refresh button visible while spinning even after the mouse leaves', () => { + 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); + }); + }); + + it('activates onClick when Enter is pressed on the item', () => { + const { container } = renderSidenavItem({ onClick }); + fireEvent.keyDown(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('activates onRefresh when Enter is pressed on the refresh button without triggering onClick', () => { + 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`] = `
-
- - - - + From 04dc87fb85e45efcb9659d2ea1f096520d43704d Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:03:10 +0200 Subject: [PATCH 3/3] fix: use test pattern instead of it for tests --- .../sidenav/__test__/SidenavItem.test.tsx | 200 +++++++++--------- 1 file changed, 99 insertions(+), 101 deletions(-) diff --git a/src/components/navigation/sidenav/__test__/SidenavItem.test.tsx b/src/components/navigation/sidenav/__test__/SidenavItem.test.tsx index 541b31a..1880d32 100644 --- a/src/components/navigation/sidenav/__test__/SidenavItem.test.tsx +++ b/src/components/navigation/sidenav/__test__/SidenavItem.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { IconProps } from '@phosphor-icons/react'; import { render, fireEvent, act } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import '@testing-library/jest-dom'; import SidenavItem from '../SidenavItem'; @@ -9,136 +9,134 @@ const MockIcon = React.forwardRef(({ size = 20 }, ref) )); -describe('SidenavItem Component', () => { - const onClick = vi.fn(); - const onRefresh = vi.fn(); +const onClick = vi.fn(); +const onRefresh = vi.fn(); - const defaultProps = { - label: 'Inbox', - Icon: MockIcon, - iconDataCy: 'sideNavInboxIcon', - }; +const defaultProps = { + label: 'Inbox', + Icon: MockIcon, + iconDataCy: 'sideNavInboxIcon', +}; - const renderSidenavItem = (props = {}) => render(); +const renderSidenavItem = (props = {}) => render(); - afterEach(() => { - vi.clearAllMocks(); - vi.useRealTimers(); - }); +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); - it('renders the label and icon', () => { - const { getByText, getByTestId } = renderSidenavItem(); - expect(getByText('Inbox')).toBeInTheDocument(); - expect(getByTestId('mock-icon')).toBeInTheDocument(); - }); +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(); +}); - it('calls onClick when the item is clicked', () => { - const { container } = renderSidenavItem({ onClick }); - fireEvent.click(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element); - expect(onClick).toHaveBeenCalledTimes(1); - }); +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); +}); - it('renders the notifications badge when notifications is set', () => { - const { getByText } = renderSidenavItem({ notifications: 5 }); - expect(getByText('5')).toBeInTheDocument(); - }); +test('When notifications is set, then the notifications badge is rendered', () => { + const { getByText } = renderSidenavItem({ notifications: 5 }); + expect(getByText('5')).toBeInTheDocument(); +}); - it('does not render the notifications badge when notifications is 0', () => { - const { queryByText } = renderSidenavItem({ notifications: 0 }); - expect(queryByText('0')).not.toBeInTheDocument(); - }); +test('When notifications is 0, then the notifications badge is not rendered', () => { + const { queryByText } = renderSidenavItem({ notifications: 0 }); + expect(queryByText('0')).not.toBeInTheDocument(); +}); - it('does not show the refresh button when onRefresh is not provided', () => { - const container = renderSidenavItem({ notifications: 5 }).container; - fireEvent.mouseEnter(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element); - expect(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]')).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(); +}); - it('hides notifications and shows the refresh button on hover when onRefresh is provided', () => { - const { container, getByText, queryByText } = renderSidenavItem({ notifications: 5, onRefresh }); - expect(getByText('5')).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); + fireEvent.mouseEnter(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element); - expect(queryByText('5')).not.toBeInTheDocument(); - expect(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]')).toBeInTheDocument(); - }); + expect(queryByText('5')).not.toBeInTheDocument(); + expect(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]')).toBeInTheDocument(); +}); - it('shows notifications again after the mouse leaves', () => { - const { container, getByText } = renderSidenavItem({ notifications: 5, onRefresh }); - const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element; +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); + fireEvent.mouseEnter(item); + fireEvent.mouseLeave(item); - expect(getByText('5')).toBeInTheDocument(); - }); + expect(getByText('5')).toBeInTheDocument(); +}); - it('calls onRefresh and not onClick when the refresh button is clicked', () => { - const { container } = renderSidenavItem({ notifications: 5, onClick, onRefresh }); - const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element; +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); + fireEvent.mouseEnter(item); + fireEvent.click(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]') as Element); - expect(onRefresh).toHaveBeenCalledTimes(1); - expect(onClick).not.toHaveBeenCalled(); - }); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); +}); - it('spins the refresh icon for exactly one second after clicking', () => { - vi.useFakeTimers(); - const { container } = renderSidenavItem({ onRefresh }); - const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element; +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); + 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'); + const icon = refreshButton.querySelector('svg') as Element; + expect(icon).toHaveClass('animate-spin'); - act(() => { - vi.advanceTimersByTime(999); - }); - expect(icon).toHaveClass('animate-spin'); + act(() => { + vi.advanceTimersByTime(999); + }); + expect(icon).toHaveClass('animate-spin'); - act(() => { - vi.advanceTimersByTime(1); - }); - expect(icon).not.toHaveClass('animate-spin'); + act(() => { + vi.advanceTimersByTime(1); }); + expect(icon).not.toHaveClass('animate-spin'); +}); - it('keeps the refresh button visible while spinning even after the mouse leaves', () => { - vi.useFakeTimers(); - const { container, queryByText } = renderSidenavItem({ notifications: 5, onRefresh }); - const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element; +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); + 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(); + expect(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]')).toBeInTheDocument(); + expect(queryByText('5')).not.toBeInTheDocument(); - act(() => { - vi.advanceTimersByTime(1000); - }); + act(() => { + vi.advanceTimersByTime(1000); }); +}); - it('activates onClick when Enter is pressed on the item', () => { - 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 item, then onClick is activated', () => { + const { container } = renderSidenavItem({ onClick }); + fireEvent.keyDown(container.querySelector('[data-cy="sideNavInboxIcon"]') as Element, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(1); +}); - it('activates onRefresh when Enter is pressed on the refresh button without triggering onClick', () => { - const { container } = renderSidenavItem({ notifications: 5, onClick, onRefresh }); - const item = container.querySelector('[data-cy="sideNavInboxIcon"]') as Element; +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' }); + fireEvent.mouseEnter(item); + fireEvent.keyDown(container.querySelector('[data-cy="sideNavInboxIcon-refresh"]') as Element, { key: 'Enter' }); - expect(onRefresh).toHaveBeenCalledTimes(1); - expect(onClick).not.toHaveBeenCalled(); - }); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); });