Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
144 changes: 144 additions & 0 deletions src/components/navigation/sidenav/__test__/SidenavItem.test.tsx
Original file line number Diff line number Diff line change
@@ -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<SVGSVGElement, IconProps>(({ size = 20 }, ref) => (
<svg ref={ref} data-testid="mock-icon" width={size} height={size} />
));

describe('SidenavItem Component', () => {
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();
});

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