diff --git a/src/components/ChannelListItem/ChannelListItemActionButtons.tsx b/src/components/ChannelListItem/ChannelListItemActionButtons.tsx index cb8fabcac..0e2f9769a 100644 --- a/src/components/ChannelListItem/ChannelListItemActionButtons.tsx +++ b/src/components/ChannelListItem/ChannelListItemActionButtons.tsx @@ -24,6 +24,7 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface const { channel } = useChannelListItemContext(); const [referenceElement, setReferenceElement] = React.useState(null); + const [isRestoringFocus, setIsRestoringFocus] = React.useState(false); const dialogId = ChannelListItemActionButtons.getDialogId({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion channelId: channel.id!, @@ -31,6 +32,26 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId }); const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id); + const closeContextMenu = React.useCallback(() => { + setIsRestoringFocus(true); + dialog?.close(); + + requestAnimationFrame(() => { + if (!referenceElement?.isConnected) { + setIsRestoringFocus(false); + return; + } + + referenceElement.focus(); + + requestAnimationFrame(() => { + if (document.activeElement !== referenceElement) { + setIsRestoringFocus(false); + } + }); + }); + }, [dialog, referenceElement]); + const filteredActionSet = useBaseChannelActionSetFilter(defaultChannelActionSet); const { dropdownActionSet, quickActionSet, quickDropdownToggleAction } = useSplitActionSet(filteredActionSet); @@ -43,9 +64,13 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface return (
{ + setIsRestoringFocus(false); + }} > {quickDropdownToggleAction && dropdownActionSet.length > 0 && ( @@ -59,7 +84,7 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface data-testid='channel-list-item-context-menu' dialogManagerId={dialogManager?.id} id={dialog.id} - onClose={dialog?.close} + onClose={closeContextMenu} placement='bottom-start' referenceElement={referenceElement} tabIndex={-1} diff --git a/src/components/ChannelListItem/ChannelListItemUI.tsx b/src/components/ChannelListItem/ChannelListItemUI.tsx index e98ad486e..f8d86ef88 100644 --- a/src/components/ChannelListItem/ChannelListItemUI.tsx +++ b/src/components/ChannelListItem/ChannelListItemUI.tsx @@ -53,7 +53,6 @@ const UnMemoizedChannelListItemUI = (props: ChannelListItemUIProps) => { return (
-
+
); }; diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx index 48506f1b7..1afb3dbc2 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx @@ -814,5 +814,38 @@ describe('ChannelListItemActionButtons defaults', () => { expect(toggle).toHaveAttribute('aria-expanded', 'false'); }); }); + + it('restores focus to the dropdown toggle on Escape', async () => { + const { channel, client } = await setupTwoMemberGroupChannel(); + + act(() => { + render( + + + , + ); + }); + + const toggle = screen.getByTestId('channel-list-item-dropdown-toggle'); + + act(() => { + fireEvent.click(toggle); + }); + + const firstItem = await screen.findByRole('menuitem', { name: 'Archive' }); + firstItem.focus(); + + act(() => { + fireEvent.keyDown(firstItem, { key: 'Escape' }); + }); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(toggle).toHaveFocus(); + }); + }); }); }); diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx index 21adc8b57..ce8159a8c 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx @@ -13,8 +13,10 @@ import { import type { Channel, StreamChat } from 'stream-chat'; import { ChannelListItemUI } from '../ChannelListItemUI'; +import type { ChannelListItemUIProps } from '../ChannelListItem'; import { ChatProvider, + type ComponentContextValue, ComponentProvider, DialogManagerProvider, TranslationProvider, @@ -27,6 +29,16 @@ const NoopActionButtons = () => null; NoopActionButtons.getDialogId = () => ''; NoopActionButtons.displayName = 'ChannelListItemActionButtons'; +const FocusableActionButtons = () => ( +
+ +
+); +FocusableActionButtons.getDialogId = () => ''; +FocusableActionButtons.displayName = 'ChannelListItemActionButtons'; + const mockTranslation = (key: string, options?: Record) => { const interpolated = Object.entries(options || {}).reduce( (value, [name, arg]) => value.replace(`{{ ${name} }}`, String(arg)), @@ -43,7 +55,10 @@ describe('ChannelPreviewMessenger', () => { let chatClient: StreamChat; let channel: Channel; - const renderComponent = (props?: any, componentOverrides = {}) => ( + const renderComponent = ( + props?: Partial, + componentOverrides: Partial = {}, + ) => ( @@ -128,6 +143,21 @@ describe('ChannelPreviewMessenger', () => { expect(onSelect).toHaveBeenCalledTimes(1); }); + it('renders channel actions after the channel item so keyboard users can tab into them', () => { + render( + renderComponent(undefined, { + ChannelListItemActionButtons: FocusableActionButtons, + }), + ); + + const previewButton = screen.getByTestId(PREVIEW_TEST_ID); + const actionButton = screen.getByTestId('channel-options-button'); + expect( + previewButton.compareDocumentPosition(actionButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + describe('pinned', () => { it('should not add pinned class or render pin icon when not pinned', () => { const { container } = render(renderComponent({ pinned: false })); diff --git a/src/components/ChannelListItem/styling/ChannelListItem.scss b/src/components/ChannelListItem/styling/ChannelListItem.scss index 10d61e8c9..8dccfe55e 100644 --- a/src/components/ChannelListItem/styling/ChannelListItem.scss +++ b/src/components/ChannelListItem/styling/ChannelListItem.scss @@ -3,6 +3,7 @@ position: relative; &:has(.str-chat__channel-list-item__action-buttons--active), + &:focus-within, &:hover { .str-chat__channel-list-item__action-buttons { display: flex;