feat: Improve sidebar keyboard navigation (#32115)

Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
pull/32186/head^2
Douglas Fabris 2 years ago committed by GitHub
parent 4a3be6bd35
commit 6e0b80db0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/good-ducks-vanish.md
  2. 6
      apps/meteor/client/sidebar/RoomList/RoomList.tsx
  3. 8
      apps/meteor/client/sidebar/RoomList/RoomListRowWrapper.tsx
  4. 16
      apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx
  5. 99
      apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts
  6. 1
      apps/meteor/client/sidebar/RoomMenu.tsx
  7. 41
      apps/meteor/client/sidebar/Sidebar.tsx
  8. 23
      apps/meteor/client/sidebar/SidebarRegion.tsx
  9. 6
      apps/meteor/client/sidebar/header/UserAvatarWithStatus.tsx
  10. 2
      apps/meteor/tests/e2e/create-channel.spec.ts
  11. 2
      apps/meteor/tests/e2e/federation/page-objects/fragments/home-sidenav.ts
  12. 16
      apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts
  13. 7
      apps/meteor/tests/e2e/saml.spec.ts
  14. 30
      apps/meteor/tests/e2e/sidebar-administration-menu.spec.ts
  15. 35
      apps/meteor/tests/e2e/sidebar.spec.ts

@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': minor
---
Introduces sidebar navigability, allowing users to navigate on sidebar channels through keyboard

@ -15,6 +15,8 @@ import { useRoomList } from '../hooks/useRoomList';
import { useShortcutOpenMenu } from '../hooks/useShortcutOpenMenu';
import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode';
import RoomListRow from './RoomListRow';
import RoomListRowWrapper from './RoomListRowWrapper';
import RoomListWrapper from './RoomListWrapper';
const computeItemKey = (index: number, room: IRoom): IRoom['_id'] | number => room._id || index;
@ -116,12 +118,12 @@ const RoomList = (): ReactElement => {
`;
return (
<Box className={[roomsListStyle, 'sidebar--custom-colors'].filter(Boolean)} aria-label={t('Channels')} role='navigation'>
<Box className={[roomsListStyle, 'sidebar--custom-colors'].filter(Boolean)}>
<Box h='full' w='full' ref={ref}>
<Virtuoso
totalCount={roomsList.length}
data={roomsList}
components={{ Scroller: VirtuosoScrollbars }}
components={{ Item: RoomListRowWrapper, List: RoomListWrapper, Scroller: VirtuosoScrollbars }}
computeItemKey={computeItemKey}
itemContent={(_, data): ReactElement => <RoomListRow data={itemData} item={data} />}
/>

@ -0,0 +1,8 @@
import type { HTMLAttributes, Ref } from 'react';
import React, { forwardRef } from 'react';
const RoomListRoomWrapper = forwardRef(function RoomListRoomWrapper(props: HTMLAttributes<HTMLDivElement>, ref: Ref<HTMLDivElement>) {
return <div role='listitem' ref={ref} {...props} />;
});
export default RoomListRoomWrapper;

@ -0,0 +1,16 @@
import { useMergedRefs } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { HTMLAttributes, Ref } from 'react';
import React, { forwardRef } from 'react';
import { useSidebarListNavigation } from './useSidebarListNavigation';
const RoomListWrapper = forwardRef(function RoomListWrapper(props: HTMLAttributes<HTMLDivElement>, ref: Ref<HTMLDivElement>) {
const t = useTranslation();
const { sidebarListRef } = useSidebarListNavigation();
const mergedRefs = useMergedRefs(ref, sidebarListRef);
return <div role='list' aria-label={t('Channels')} ref={mergedRefs} {...props} />;
});
export default RoomListWrapper;

@ -0,0 +1,99 @@
import { useFocusManager } from '@react-aria/focus';
import { useCallback } from 'react';
const isListItem = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-item');
const isListItemMenu = (node: EventTarget) => (node as HTMLElement).classList.contains('rcx-sidebar-item__menu');
/**
* Custom hook to provide the sidebar navigation by keyboard.
* @param ref - A ref to the message list DOM element.
*/
export const useSidebarListNavigation = () => {
const sidebarListFocusManager = useFocusManager();
const sidebarListRef = useCallback(
(node: HTMLElement | null) => {
let lastItemFocused: HTMLElement | null = null;
if (!node) {
return;
}
node.addEventListener('keydown', (e) => {
if (!e.target) {
return;
}
if (!isListItem(e.target)) {
return;
}
if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
sidebarListFocusManager.focusPrevious({
accept: (node) => !isListItem(node) && !isListItemMenu(node),
});
} else if (isListItemMenu(e.target)) {
sidebarListFocusManager.focusNext({
accept: (node) => !isListItem(node) && !isListItemMenu(node),
});
} else {
sidebarListFocusManager.focusNext({
accept: (node) => !isListItem(node),
});
}
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (e.key === 'ArrowUp') {
sidebarListFocusManager.focusPrevious({ accept: (node) => isListItem(node) });
}
if (e.key === 'ArrowDown') {
sidebarListFocusManager.focusNext({ accept: (node) => isListItem(node) });
}
lastItemFocused = document.activeElement as HTMLElement;
}
});
node.addEventListener(
'blur',
(e) => {
if (
!(e.relatedTarget as HTMLElement)?.classList.contains('focus-visible') ||
!(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement)
) {
return;
}
if (!e.currentTarget.contains(e.relatedTarget) && !lastItemFocused) {
lastItemFocused = e.target as HTMLElement;
}
},
{ capture: true },
);
node.addEventListener(
'focus',
(e) => {
const triggeredByKeyboard = (e.target as HTMLElement)?.classList.contains('focus-visible');
if (!triggeredByKeyboard || !(e.currentTarget instanceof HTMLElement && e.relatedTarget instanceof HTMLElement)) {
return;
}
if (lastItemFocused && !e.currentTarget.contains(e.relatedTarget) && node.contains(e.target as HTMLElement)) {
lastItemFocused?.focus();
}
},
{ capture: true },
);
},
[sidebarListFocusManager],
);
return { sidebarListRef };
};

@ -251,7 +251,6 @@ const RoomMenu = ({
title={t('Options')}
mini
aria-keyshortcuts='alt'
tabIndex={-1}
options={menuOptions}
maxHeight={300}
renderItem={({ label: { label, icon }, ...props }): JSX.Element => <Option label={label} icon={icon} {...props} />}

@ -27,28 +27,25 @@ const Sidebar = () => {
`;
return (
<>
<Box
display='flex'
flexDirection='column'
height='100%'
is='nav'
className={[
'rcx-sidebar--main',
`rcx-sidebar rcx-sidebar--${sidebarViewMode}`,
sidebarHideAvatar && 'rcx-sidebar--hide-avatar',
sidebarLink,
].filter(Boolean)}
role='navigation'
data-qa-opened={sidebar.isCollapsed ? 'false' : 'true'}
>
<SidebarHeader />
{presenceDisabled && !bannerDismissed && <StatusDisabledSection onDismiss={() => setBannerDismissed(true)} />}
{showOmnichannel && <OmnichannelSection />}
<SidebarRoomList />
<SidebarFooter />
</Box>
</>
<Box
display='flex'
flexDirection='column'
height='100%'
is='nav'
className={[
'rcx-sidebar--main',
`rcx-sidebar rcx-sidebar--${sidebarViewMode}`,
sidebarHideAvatar && 'rcx-sidebar--hide-avatar',
sidebarLink,
].filter(Boolean)}
data-qa-opened={sidebar.isCollapsed ? 'false' : 'true'}
>
<SidebarHeader />
{presenceDisabled && !bannerDismissed && <StatusDisabledSection onDismiss={() => setBannerDismissed(true)} />}
{showOmnichannel && <OmnichannelSection />}
<SidebarRoomList />
<SidebarFooter />
</Box>
);
};

@ -3,6 +3,7 @@ import { Box } from '@rocket.chat/fuselage';
import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client';
import { useLayout } from '@rocket.chat/ui-contexts';
import React, { lazy, memo } from 'react';
import { FocusScope } from 'react-aria';
import Sidebar from './Sidebar';
@ -101,17 +102,19 @@ const SidebarRegion = () => {
<></>
</FeaturePreviewOff>
</FeaturePreview>
<Box
id='sidebar-region'
className={['rcx-sidebar', !sidebar.isCollapsed && isMobile && 'opened', sideBarStyle, isMobile && sidebarMobileClass].filter(
Boolean,
<FocusScope>
<Box
id='sidebar-region'
className={['rcx-sidebar', !sidebar.isCollapsed && isMobile && 'opened', sideBarStyle, isMobile && sidebarMobileClass].filter(
Boolean,
)}
>
<Sidebar />
</Box>
{isMobile && (
<Box className={[sidebarWrapStyle, !sidebar.isCollapsed && 'opened'].filter(Boolean)} onClick={() => sidebar.toggle()}></Box>
)}
>
<Sidebar />
</Box>
{isMobile && (
<Box className={[sidebarWrapStyle, !sidebar.isCollapsed && 'opened'].filter(Boolean)} onClick={() => sidebar.toggle()}></Box>
)}
</FocusScope>
</>
);
};

@ -1,7 +1,7 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useSetting, useUser, useTranslation } from '@rocket.chat/ui-contexts';
import { useSetting, useUser } from '@rocket.chat/ui-contexts';
import React from 'react';
import { UserStatus } from '../../components/UserStatus';
@ -20,7 +20,6 @@ const anon = {
*/
const UserAvatarWithStatus = () => {
const t = useTranslation();
const user = useUser();
const presenceDisabled = useSetting<boolean>('Presence_broadcast_disabled');
@ -32,9 +31,6 @@ const UserAvatarWithStatus = () => {
className={css`
cursor: pointer;
`}
aria-label={t('User_menu')}
role='button'
data-qa='sidebar-avatar-button'
>
{username && <UserAvatar size='x24' username={username} etag={avatarETag} />}
<Box

@ -6,7 +6,7 @@ import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('channel-management', () => {
test.describe.serial('create-channel', () => {
let poHomeChannel: HomeChannel;
test.beforeEach(async ({ page }) => {

@ -36,7 +36,7 @@ export class FederationSidenav {
}
async logout(): Promise<void> {
await this.page.locator('[data-qa="sidebar-avatar-button"]').click();
await this.page.getByRole('button', { name: 'User menu' }).click();
await this.page.locator('//*[contains(@class, "rcx-option__content") and contains(text(), "Logout")]').click();
}

@ -37,6 +37,18 @@ export class HomeSidenav {
return this.page.locator('[placeholder="Search (Ctrl+K)"]').first();
}
get userProfileMenu(): Locator {
return this.page.getByRole('button', { name: 'User menu' });
}
get sidebarChannelsList(): Locator {
return this.page.getByRole('list', { name: 'Channels' });
}
get sidebarToolbar(): Locator {
return this.page.getByRole('toolbar', { name: 'Sidebar actions' });
}
getSidebarItemByName(name: string): Locator {
return this.page.locator(`[data-qa="sidebar-item"][aria-label="${name}"]`);
}
@ -68,12 +80,12 @@ export class HomeSidenav {
}
async logout(): Promise<void> {
await this.page.locator('[data-qa="sidebar-avatar-button"]').click();
await this.userProfileMenu.click();
await this.page.locator('//*[contains(@class, "rcx-option__content") and contains(text(), "Logout")]').click();
}
async switchStatus(status: 'offline' | 'online'): Promise<void> {
await this.page.locator('[data-qa="sidebar-avatar-button"]').click();
await this.userProfileMenu.click();
await this.page.locator(`role=menuitemcheckbox[name="${status}"]`).click();
}

@ -90,6 +90,7 @@ const setupCustomRole = async (api: BaseTest['api']) => {
}
test.describe('SAML', () => {
let poRegistration: Registration;
let samlRoleId: string;
@ -188,17 +189,17 @@ test.describe('SAML', () => {
// Redirect back to rocket.chat
await expect(page).toHaveURL('/home');
await expect(page.getByLabel('User Menu')).toBeVisible();
await expect(page.getByRole('button', { name: 'User menu' })).toBeVisible();
});
};
const doLogoutStep = async (page: Page) => {
await test.step('logout', async () => {
await page.getByLabel('User Menu').click();
await page.getByRole('button', { name: 'User menu' }).click();
await page.locator('//*[contains(@class, "rcx-option__content") and contains(text(), "Logout")]').click();
await expect(page).toHaveURL('/home');
await expect(page.getByLabel('User Menu')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'User menu' })).not.toBeVisible();
});
};

@ -5,7 +5,7 @@ import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('administration-menu', () => {
test.describe.serial('sidebar-administration-menu', () => {
let poHomeDiscussion: HomeDiscussion;
test.beforeEach(async ({ page }) => {
@ -14,20 +14,22 @@ test.describe.serial('administration-menu', () => {
await page.goto('/home');
});
test('expect open Workspace page', async ({ page }) => {
test.skip(!IS_EE, 'Enterprise only');
await poHomeDiscussion.sidenav.openAdministrationByLabel('Workspace');
await expect(page).toHaveURL('admin/info');
});
test('expect open omnichannel page', async ({ page }) => {
await poHomeDiscussion.sidenav.openAdministrationByLabel('Omnichannel');
await expect(page).toHaveURL('omnichannel/current');
test.describe('admin user', () => {
test('should open workspace page', async ({ page }) => {
test.skip(!IS_EE, 'Enterprise only');
await poHomeDiscussion.sidenav.openAdministrationByLabel('Workspace');
await expect(page).toHaveURL('admin/info');
});
test('should open omnichannel page', async ({ page }) => {
await poHomeDiscussion.sidenav.openAdministrationByLabel('Omnichannel');
await expect(page).toHaveURL('omnichannel/current');
});
});
test.describe('user', () => {
test.describe('regular user', () => {
test.use({ storageState: Users.user1.state });
test('expect to not render administration menu when no permission', async ({ page }) => {

@ -0,0 +1,35 @@
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { test, expect } from './utils/test';
test.use({ storageState: Users.admin.state });
test.describe.serial('sidebar', () => {
let poHomeDiscussion: HomeChannel;
test.beforeEach(async ({ page }) => {
poHomeDiscussion = new HomeChannel(page);
await page.goto('/home');
});
test('should navigate on sidebar toolbar using arrow keys', async ({ page }) => {
await poHomeDiscussion.sidenav.userProfileMenu.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowRight');
await expect(poHomeDiscussion.sidenav.sidebarToolbar.getByRole('button', { name: 'Search' })).toBeFocused();
});
test('should navigate on sidebar items using arrow keys and restore focus', async ({ page }) => {
// focus should be on the next item
await poHomeDiscussion.sidenav.sidebarChannelsList.getByRole('link').first().focus();
await page.keyboard.press('ArrowDown');
await expect(poHomeDiscussion.sidenav.sidebarChannelsList.getByRole('link').first()).not.toBeFocused();
// shouldn't focus the first item
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Tab');
await expect(poHomeDiscussion.sidenav.sidebarChannelsList.getByRole('link').first()).not.toBeFocused();
});
});
Loading…
Cancel
Save