Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/components/ChannelListItem/ChannelListItemActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,34 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface
const { channel } = useChannelListItemContext();
const [referenceElement, setReferenceElement] =
React.useState<HTMLButtonElement | null>(null);
const [isRestoringFocus, setIsRestoringFocus] = React.useState(false);
const dialogId = ChannelListItemActionButtons.getDialogId({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
channelId: channel.id!,
});
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);
Expand All @@ -43,9 +64,13 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface
return (
<div
className={clsx('str-chat__channel-list-item__action-buttons', {
'str-chat__channel-list-item__action-buttons--active': dialogIsOpen,
'str-chat__channel-list-item__action-buttons--active':
dialogIsOpen || isRestoringFocus,
})}
data-testid='channel-list-item-action-buttons'
onFocusCapture={() => {
setIsRestoringFocus(false);
}}
>
{quickDropdownToggleAction && dropdownActionSet.length > 0 && (
<quickDropdownToggleAction.Component ref={setReferenceElement} />
Expand All @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ChannelListItem/ChannelListItemUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ const UnMemoizedChannelListItemUI = (props: ChannelListItemUIProps) => {

return (
<div className='str-chat__channel-list-item-container'>
<ChannelListItemActionButtons />
<button
aria-label={t('aria/Select Channel: {{ channelName }}', {
channelName: displayTitle || '',
Expand Down Expand Up @@ -103,6 +102,7 @@ const UnMemoizedChannelListItemUI = (props: ChannelListItemUIProps) => {
/>
</div>
</button>
<ChannelListItemActionButtons />
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Chat client={client}>
<ChannelListItem channel={channel} />
</Chat>,
);
});

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();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +29,16 @@ const NoopActionButtons = () => null;
NoopActionButtons.getDialogId = () => '';
NoopActionButtons.displayName = 'ChannelListItemActionButtons';

const FocusableActionButtons = () => (
<div data-testid='channel-list-item-action-buttons'>
<button data-testid='channel-options-button' type='button'>
Channel actions
</button>
</div>
);
FocusableActionButtons.getDialogId = () => '';
FocusableActionButtons.displayName = 'ChannelListItemActionButtons';

const mockTranslation = (key: string, options?: Record<string, unknown>) => {
const interpolated = Object.entries(options || {}).reduce(
(value, [name, arg]) => value.replace(`{{ ${name} }}`, String(arg)),
Expand All @@ -43,7 +55,10 @@ describe('ChannelPreviewMessenger', () => {

let chatClient: StreamChat;
let channel: Channel;
const renderComponent = (props?: any, componentOverrides = {}) => (
const renderComponent = (
props?: Partial<ChannelListItemUIProps>,
componentOverrides: Partial<ComponentContextValue> = {},
) => (
<TranslationProvider value={mockTranslationContextValue({ t: mockTranslation })}>
<ChatProvider value={mockChatContext({ client: chatClient })}>
<DialogManagerProvider>
Expand Down Expand Up @@ -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 }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down