Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@internxt/ui",
"version": "0.1.26",
"version": "0.1.27",
"description": "Library of Internxt components",
"repository": {
"type": "git",
Expand Down
65 changes: 58 additions & 7 deletions src/components/navigation/sidenav/SidenavItem.tsx
Original file line number Diff line number Diff line change
@@ -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<IconProps & React.RefAttributes<SVGSVGElement>>;
onClick?: () => void;
onRefresh?: () => void | Promise<void>;
iconDataCy?: string;
isActive?: boolean;
isCollapsed?: boolean;
Expand All @@ -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<ReturnType<typeof setTimeout>>();
const showRefresh = (isHovered || isSpinning) && !!onRefresh;

const onRefreshClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
onRefresh?.();

if (spinTimeoutRef.current) {
clearTimeout(spinTimeoutRef.current);
}
setIsSpinning(true);
spinTimeoutRef.current = setTimeout(() => setIsSpinning(false), SPIN_DURATION_MS);
};

return (
<button
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick?.();
}
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
data-cy={iconDataCy}
className={`flex w-full flex-col overflow-hidden focus-visible:bg-gray-10 rounded-lg ${
className={`flex w-full flex-col overflow-hidden focus-visible:bg-gray-10 rounded-lg cursor-pointer ${
isActive ? 'bg-primary/20' : 'hover:bg-gray-5'
} ${subsection ? 'pl-5' : ''}`}
title={isCollapsed ? label : undefined}
Expand All @@ -40,15 +72,34 @@ const SidenavItem = ({
</p>
</div>

{!!notifications && (
{showRefresh ? (
<div
className={`flex rounded-full px-2 py-1 ${isActive ? 'text-white bg-primary' : 'bg-gray-10 text-gray-60'} ${isCollapsed ? 'opacity-0 invisible delay-300' : 'opacity-100 delay-0'}`}
role="button"
tabIndex={0}
onClick={onRefreshClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onRefreshClick(event as unknown as React.MouseEvent<HTMLDivElement>);
}
}}
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'}`}
>
{<p className="text-xs font-medium">{notifications}</p>}
<ArrowsClockwiseIcon size={16} className={isSpinning ? 'animate-spin' : undefined} />
</div>
) : (
!!notifications && (
<div
className={`flex rounded-full px-2 py-1 ${isActive ? 'text-white bg-primary' : 'bg-gray-10 text-gray-60'} ${isCollapsed ? 'opacity-0 invisible delay-300' : 'opacity-100 delay-0'}`}
>
{<p className="text-xs font-medium">{notifications}</p>}
</div>
)
)}
</div>
</button>
</div>
);
};

Expand Down
2 changes: 2 additions & 0 deletions src/components/navigation/sidenav/SidenavOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface SidenavOption {
isActive?: boolean;
notifications?: number;
onClick?: () => void;
onRefresh?: () => void | Promise<void>;
subsection?: boolean;
}

Expand Down Expand Up @@ -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}
/>
Expand Down
8 changes: 4 additions & 4 deletions src/components/navigation/sidenav/__test__/Sidenav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -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);
});
});
Expand Down Expand Up @@ -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);
});
Expand Down
142 changes: 142 additions & 0 deletions src/components/navigation/sidenav/__test__/SidenavItem.test.tsx
Original file line number Diff line number Diff line change
@@ -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<SVGSVGElement, IconProps>(({ size = 20 }, ref) => (
<svg ref={ref} data-testid="mock-icon" width={size} height={size} />
));

const onClick = vi.fn();
const onRefresh = vi.fn();

const defaultProps = {
label: 'Inbox',
Icon: MockIcon,
iconDataCy: 'sideNavInboxIcon',
};

const renderSidenavItem = (props = {}) => render(<SidenavItem {...defaultProps} {...props} />);

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();
});
Loading
Loading