diff --git a/.changeset/good-ducks-vanish.md b/.changeset/good-ducks-vanish.md new file mode 100644 index 00000000000..3edfc6baca4 --- /dev/null +++ b/.changeset/good-ducks-vanish.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Introduces sidebar navigability, allowing users to navigate on sidebar channels through keyboard diff --git a/apps/meteor/client/sidebar/RoomList/RoomList.tsx b/apps/meteor/client/sidebar/RoomList/RoomList.tsx index 8dc44dcb73a..4bd7338f916 100644 --- a/apps/meteor/client/sidebar/RoomList/RoomList.tsx +++ b/apps/meteor/client/sidebar/RoomList/RoomList.tsx @@ -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 ( - + } /> diff --git a/apps/meteor/client/sidebar/RoomList/RoomListRowWrapper.tsx b/apps/meteor/client/sidebar/RoomList/RoomListRowWrapper.tsx new file mode 100644 index 00000000000..e2c8f90b695 --- /dev/null +++ b/apps/meteor/client/sidebar/RoomList/RoomListRowWrapper.tsx @@ -0,0 +1,8 @@ +import type { HTMLAttributes, Ref } from 'react'; +import React, { forwardRef } from 'react'; + +const RoomListRoomWrapper = forwardRef(function RoomListRoomWrapper(props: HTMLAttributes, ref: Ref) { + return
; +}); + +export default RoomListRoomWrapper; diff --git a/apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx b/apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx new file mode 100644 index 00000000000..6779fb8d6c8 --- /dev/null +++ b/apps/meteor/client/sidebar/RoomList/RoomListWrapper.tsx @@ -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, ref: Ref) { + const t = useTranslation(); + const { sidebarListRef } = useSidebarListNavigation(); + const mergedRefs = useMergedRefs(ref, sidebarListRef); + + return
; +}); + +export default RoomListWrapper; diff --git a/apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts b/apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts new file mode 100644 index 00000000000..f5c2d00d4b2 --- /dev/null +++ b/apps/meteor/client/sidebar/RoomList/useSidebarListNavigation.ts @@ -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 }; +}; diff --git a/apps/meteor/client/sidebar/RoomMenu.tsx b/apps/meteor/client/sidebar/RoomMenu.tsx index be8d889f306..a1fd7400e88 100644 --- a/apps/meteor/client/sidebar/RoomMenu.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -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 =>