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
parent
4a3be6bd35
commit
6e0b80db0b
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@rocket.chat/meteor': minor |
||||
--- |
||||
|
||||
Introduces sidebar navigability, allowing users to navigate on sidebar channels through keyboard |
||||
@ -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 }; |
||||
}; |
||||
@ -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…
Reference in new issue