Skip to content

Commit

Permalink
feat: app drawer should close on click
Browse files Browse the repository at this point in the history
Should not reopen until mouse leaves and re-enters
  • Loading branch information
colbr committed Feb 27, 2025
1 parent 83545e4 commit 23199ef
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 7 deletions.
88 changes: 82 additions & 6 deletions src/components/app-bar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,59 @@ interface State {

export class AppBar extends React.Component<Properties, State> {
state = { isModalOpen: false };
containerRef: HTMLDivElement | null = null;
appBarRef = React.createRef<HTMLDivElement>();
mouseLeaveHandler: ((event: MouseEvent) => void) | null = null;

componentDidMount() {
if (this.appBarRef.current) {
this.containerRef = this.appBarRef.current.querySelector(`.${cn('container').className}`);
}
}

componentWillUnmount() {
this.removeMouseLeaveListener();
}

openModal = () => this.setState({ isModalOpen: true });
closeModal = () => this.setState({ isModalOpen: false });

/**
* Removes the mouseleave listener from the container.
* @dev mouse listener needs to be removed so we don't add multiple listeners.
*/
removeMouseLeaveListener = () => {
if (this.containerRef && this.mouseLeaveHandler) {
this.containerRef.removeEventListener('mouseleave', this.mouseLeaveHandler);
this.mouseLeaveHandler = null;
}
};

/**
* Unhovers the container, and prevents another hover occuring until the mouse
* leaves the container.
*/
unhoverContainer = () => {
if (this.containerRef) {
this.containerRef.classList.add('no-hover');

// Force a reflow to ensure the width change happens immediately
this.containerRef.getBoundingClientRect();

// Ensure we aren't adding multiple listeners
this.removeMouseLeaveListener();

this.mouseLeaveHandler = (_event: MouseEvent) => {
if (this.containerRef) {
this.containerRef.classList.remove('no-hover');
this.removeMouseLeaveListener();
}
};

this.containerRef.addEventListener('mouseleave', this.mouseLeaveHandler);
}
};

renderNotificationIcon = () => {
const { hasUnreadNotifications, hasUnreadHighlights } = this.props;

Expand All @@ -52,27 +101,47 @@ export class AppBar extends React.Component<Properties, State> {

return (
<>
<div {...cn('')}>
<div {...cn('')} ref={this.appBarRef}>
<LegacyPanel {...cn('container')}>
<AppLink Icon={IconHome} isActive={isActive('home')} label='Home' to='/home' />
<AppLink
Icon={IconHome}
isActive={isActive('home')}
label='Home'
to='/home'
onLinkClick={this.unhoverContainer}
/>
<AppLink
Icon={IconMessageSquare2}
isActive={isActive('conversation')}
label='Messenger'
to='/conversation'
onLinkClick={this.unhoverContainer}
/>
{featureFlags.enableFeedApp && (
<AppLink Icon={IconSlashes} isActive={isActive('feed')} label='Channels' to='/feed' />
<AppLink
Icon={IconSlashes}
isActive={isActive('feed')}
label='Channels'
to='/feed'
onLinkClick={this.unhoverContainer}
/>
)}
{featureFlags.enableNotificationsApp && (
<AppLink
Icon={this.renderNotificationIcon}
isActive={isActive('notifications')}
label='Notifications'
to='/notifications'
onLinkClick={this.unhoverContainer}
/>
)}
<AppLink Icon={IconGlobe3} isActive={isActive('explorer')} label='Explorer' to='/explorer' />
<AppLink
Icon={IconGlobe3}
isActive={isActive('explorer')}
label='Explorer'
to='/explorer'
onLinkClick={this.unhoverContainer}
/>
<div {...cn('link')} title='More Apps'>
<WorldPanelItem Icon={IconDotsGrid} label='More Apps' isActive={false} onClick={this.openModal} />
<span>More Apps</span>
Expand All @@ -90,11 +159,18 @@ interface AppLinkProps {
isActive: boolean;
label: string;
to: string;
onLinkClick?: () => void;
}

const AppLink = ({ Icon, isActive, to, label }: AppLinkProps) => {
const AppLink = ({ Icon, isActive, to, label, onLinkClick }: AppLinkProps) => {
const handleClick = () => {
if (!isActive && onLinkClick) {
onLinkClick();
}
};

return (
<Link title={label} {...cn('link')} to={!isActive && to}>
<Link title={label} {...cn('link')} to={!isActive && to} onClick={handleClick}>
<WorldPanelItem Icon={Icon} label={label} isActive={isActive} />
<span data-active={isActive ? '' : null}>{label}</span>
</Link>
Expand Down
70 changes: 69 additions & 1 deletion src/components/app-bar/index.vitest.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { vi } from 'vitest';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

import { AppBar, Properties } from '.';

vi.mock('react-router-dom', () => ({
Link: ({ children, onClick, ...props }: any) => (
<a onClick={onClick} {...props}>
{children}
</a>
),
MemoryRouter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

vi.mock('@zero-tech/zui/icons', () => ({
IconHome: 'IconHome',
IconSlashes: 'IconSlashes',
Expand All @@ -13,6 +22,7 @@ vi.mock('@zero-tech/zui/icons', () => ({
IconList: 'IconList',
IconBell1: 'IconBell1',
}));

vi.mock('./more-apps-modal', () => ({
MoreAppsModal: () => <div data-testid='more-apps-modal' />,
}));
Expand Down Expand Up @@ -56,4 +66,62 @@ describe(AppBar, () => {
expect(mockWorldPanelItem).toHaveBeenCalledWith(expect.objectContaining({ label: 'Messenger', isActive: false }));
});
});

describe('unhover functionality', () => {
let container: HTMLElement;

it('should add no-hover class when AppLink is clicked', () => {
const { getByText, container: renderedContainer } = renderComponent({});
container = renderedContainer.querySelector('.app-bar__container') as HTMLElement;

const link = getByText('Home');
fireEvent.click(link);

expect(container.classList.contains('no-hover')).toBe(true);
});

it('should remove no-hover class when mouse leaves container', () => {
const { getByText, container: renderedContainer } = renderComponent({});
container = renderedContainer.querySelector('.app-bar__container') as HTMLElement;

const link = getByText('Home');
fireEvent.click(link);

expect(container.classList.contains('no-hover')).toBe(true);

fireEvent.mouseLeave(container);

expect(container.classList.contains('no-hover')).toBe(false);
});

it('should keep no-hover class when mouse moves within container after click', () => {
const { getByText, container: renderedContainer } = renderComponent({});
container = renderedContainer.querySelector('.app-bar__container') as HTMLElement;

const link = getByText('Home');
fireEvent.click(link);

expect(container.classList.contains('no-hover')).toBe(true);

fireEvent.mouseMove(container);

expect(container.classList.contains('no-hover')).toBe(true);
});

it('should allow hover again after mouse leaves and re-enters', () => {
const { getByText, container: renderedContainer } = renderComponent({});
container = renderedContainer.querySelector('.app-bar__container') as HTMLElement;

const link = getByText('Home');
fireEvent.click(link);

fireEvent.mouseLeave(container);

expect(container.classList.contains('no-hover')).toBe(false);

fireEvent.mouseEnter(container);

expect(container.classList.contains('no-hover')).toBe(false);
});
});
});
9 changes: 9 additions & 0 deletions src/components/app-bar/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ $width: 46px;
&:hover {
width: calc(200px);
}

&.no-hover {
width: $width !important;
transition: width 0.15s ease-in-out;

&:hover {
width: $width !important;
}
}
}

&__notification-icon-wrapper {
Expand Down

0 comments on commit 23199ef

Please sign in to comment.