MM 61586 - fix fav icon accessibility focus problem (#29329)

* MM-61586 - fix fav icon accesibility focus issue

* add unit test to the component
Этот коммит содержится в:
Pablo Vélez
2024-11-22 13:31:43 +01:00
коммит произвёл GitHub
родитель 3add525ad1
Коммит dc5e395543
2 изменённых файлов: 253 добавлений и 2 удалений

Просмотреть файл

@@ -0,0 +1,234 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen, fireEvent, act} from '@testing-library/react';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import {favoriteChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels';
import * as channelsSelectors from 'mattermost-redux/selectors/entities/channels';
import {renderWithContext} from 'tests/react_testing_utils';
import type {A11yFocusEventDetail} from 'utils/constants';
import {A11yCustomEventTypes} from 'utils/constants';
import ChannelHeaderTitleFavorite from './channel_header_title_favorite';
jest.mock('mattermost-redux/actions/channels', () => ({
favoriteChannel: jest.fn(),
unfavoriteChannel: jest.fn(),
}));
describe('ChannelHeaderTitleFavorite Component', () => {
let isCurrentChannelFavoriteMock: jest.SpyInstance;
let getCurrentChannelMock: jest.SpyInstance;
let dispatchMock: jest.Mock;
const ADD_TO_FAVORITES_REGEX = /add to favorites/i;
const REMOVE_FROM_FAVORITES_REGEX = /remove from favorites/i;
const ADD_TO_FAVORITES_LABEL = 'add to favorites';
const REMOVE_FROM_FAVORITES_LABEL = 'remove from favorites';
const activeChannel: Channel = {
id: 'channel_id',
name: 'Active Channel',
type: 'O',
display_name: 'Active Channel',
delete_at: 0,
} as Channel;
const archivedChannel: Channel = {
id: 'channel_id',
name: 'Archived Channel',
type: 'O',
display_name: 'Archived Channel',
delete_at: 1,
} as Channel;
beforeEach(() => {
jest.clearAllMocks();
// Spy on selectors
isCurrentChannelFavoriteMock = jest.spyOn(channelsSelectors, 'isCurrentChannelFavorite');
getCurrentChannelMock = jest.spyOn(channelsSelectors, 'getCurrentChannel');
// Mock useDispatch to capture dispatch calls
dispatchMock = jest.fn();
jest.spyOn(require('react-redux'), 'useDispatch').mockReturnValue(dispatchMock);
});
afterEach(() => {
jest.restoreAllMocks();
});
function renderComponent() {
return renderWithContext(<ChannelHeaderTitleFavorite/>);
}
it('should dispatch favoriteChannel when "Add to Favorites" button is clicked', () => {
isCurrentChannelFavoriteMock.mockReturnValue(false);
getCurrentChannelMock.mockReturnValue(activeChannel);
// Mock favoriteChannel
(favoriteChannel as jest.Mock).mockReturnValue({
type: 'FAVORITE_CHANNEL',
data: activeChannel.id,
});
renderComponent();
const button = screen.getByRole('button', {name: ADD_TO_FAVORITES_REGEX});
fireEvent.click(button);
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(dispatchMock).toHaveBeenCalledWith({
type: 'FAVORITE_CHANNEL',
data: activeChannel.id,
});
});
it('should dispatch unfavoriteChannel when "Remove from Favorites" button is clicked', () => {
isCurrentChannelFavoriteMock.mockReturnValue(true);
getCurrentChannelMock.mockReturnValue(activeChannel);
// Mock unfavoriteChannel
(unfavoriteChannel as jest.Mock).mockReturnValue({
type: 'UNFAVORITE_CHANNEL',
data: activeChannel.id,
});
renderComponent();
const button = screen.getByRole('button', {name: REMOVE_FROM_FAVORITES_REGEX});
fireEvent.click(button);
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(dispatchMock).toHaveBeenCalledWith({
type: 'UNFAVORITE_CHANNEL',
data: activeChannel.id,
});
});
it('should not render anything when channel is null', () => {
isCurrentChannelFavoriteMock.mockReturnValue(false);
getCurrentChannelMock.mockReturnValue(null);
renderComponent();
const button = screen.queryByRole('button', {name: ADD_TO_FAVORITES_REGEX});
expect(button).toBeNull();
});
it('should not render anything when channel is archived', () => {
isCurrentChannelFavoriteMock.mockReturnValue(false);
getCurrentChannelMock.mockReturnValue(archivedChannel);
renderComponent();
const button = screen.queryByRole('button', {name: ADD_TO_FAVORITES_REGEX});
expect(button).toBeNull();
});
it('should render "Add to Favorites" button when not favorite', () => {
isCurrentChannelFavoriteMock.mockReturnValue(false);
getCurrentChannelMock.mockReturnValue(activeChannel);
renderComponent();
const button = screen.getByRole('button', {name: ADD_TO_FAVORITES_REGEX});
expect(button).toBeInTheDocument();
expect(button).toHaveClass('inactive');
expect(button).toHaveAttribute('aria-label', ADD_TO_FAVORITES_LABEL);
const icon = button.querySelector('i');
expect(icon).toHaveClass('icon-star-outline');
});
it('should render "Remove from Favorites" button when favorite', () => {
isCurrentChannelFavoriteMock.mockReturnValue(true);
getCurrentChannelMock.mockReturnValue(activeChannel);
renderComponent();
const button = screen.getByRole('button', {name: REMOVE_FROM_FAVORITES_REGEX});
expect(button).toBeInTheDocument();
expect(button).toHaveClass('active');
expect(button).toHaveAttribute('aria-label', REMOVE_FROM_FAVORITES_LABEL);
const icon = button.querySelector('i');
expect(icon).toHaveClass('icon-star');
});
it('should have correct aria-label and icon based on isFavorite', () => {
// Start with Not favorite channel
isCurrentChannelFavoriteMock.mockReturnValue(false);
getCurrentChannelMock.mockReturnValue(activeChannel);
renderComponent();
let button = screen.getByRole('button', {name: ADD_TO_FAVORITES_REGEX});
expect(button).toHaveAttribute('aria-label', ADD_TO_FAVORITES_LABEL);
let icon = button.querySelector('i');
expect(icon).toHaveClass('icon-star-outline');
// Reset mocks to simulate favorite state
jest.clearAllMocks();
isCurrentChannelFavoriteMock = jest.spyOn(channelsSelectors, 'isCurrentChannelFavorite');
getCurrentChannelMock = jest.spyOn(channelsSelectors, 'getCurrentChannel');
isCurrentChannelFavoriteMock.mockReturnValue(true);
getCurrentChannelMock.mockReturnValue(activeChannel);
// Re-render component with updated state, now is Favorite
renderComponent();
button = screen.getByRole('button', {name: REMOVE_FROM_FAVORITES_REGEX});
expect(button).toHaveAttribute('aria-label', REMOVE_FROM_FAVORITES_LABEL);
icon = button.querySelector('i');
expect(icon).toHaveClass('icon-star');
});
it('should dispatch A11yFocusEvent after toggling favorite', () => {
// Use fake timers to handle requestAnimationFrame
jest.useFakeTimers();
isCurrentChannelFavoriteMock.mockReturnValue(false);
getCurrentChannelMock.mockReturnValue(activeChannel);
(favoriteChannel as jest.Mock).mockReturnValue({
type: 'FAVORITE_CHANNEL',
data: activeChannel.id,
});
// Spy on document.dispatchEvent
const dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
renderComponent();
const button = screen.getByRole('button', {name: ADD_TO_FAVORITES_REGEX});
fireEvent.click(button);
expect(dispatchMock).toHaveBeenCalledWith({
type: 'FAVORITE_CHANNEL',
data: activeChannel.id,
});
// Execute the requestAnimationFrame callback
act(() => {
jest.runAllTimers();
});
expect(dispatchEventSpy).toHaveBeenCalled();
// Verify the details of the dispatched event
const event = dispatchEventSpy.mock.calls.find((call) => call[0].type === A11yCustomEventTypes.FOCUS)?.[0] as CustomEvent<A11yFocusEventDetail>;
expect(event).toBeDefined();
expect(event.detail.target).toBe(button);
expect(event.detail.keyboardOnly).toBe(false);
dispatchEventSpy.mockRestore();
jest.useRealTimers();
});
});

Просмотреть файл

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {memo, useCallback} from 'react';
import React, {memo, useCallback, useRef} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
@@ -11,12 +11,16 @@ import {getCurrentChannel, isCurrentChannelFavorite} from 'mattermost-redux/sele
import WithTooltip from 'components/with_tooltip';
import type {A11yFocusEventDetail} from 'utils/constants';
import {A11yCustomEventTypes} from 'utils/constants';
const ChannelHeaderTitleFavorite = () => {
const intl = useIntl();
const dispatch = useDispatch();
const isFavorite = useSelector(isCurrentChannelFavorite);
const channel = useSelector(getCurrentChannel);
const channelIsArchived = (channel?.delete_at ?? 0) > 0;
const favIconRef = useRef<HTMLButtonElement>(null);
const toggleFavoriteCallback = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@@ -28,7 +32,19 @@ const ChannelHeaderTitleFavorite = () => {
} else {
dispatch(favoriteChannel(channel.id));
}
}, [isFavorite, channel?.id]);
requestAnimationFrame(() => {
if (favIconRef.current) {
document.dispatchEvent(
new CustomEvent<A11yFocusEventDetail>(A11yCustomEventTypes.FOCUS, {
detail: {
target: favIconRef.current,
keyboardOnly: false,
},
}),
);
}
});
}, [isFavorite, channel, dispatch]);
if (!channel || channelIsArchived) {
return null;
@@ -67,6 +83,7 @@ const ChannelHeaderTitleFavorite = () => {
onClick={toggleFavoriteCallback}
className={classNames('channel-header__favorites btn btn-icon btn-xs', {active: isFavorite, inactive: !isFavorite})}
aria-label={ariaLabel}
ref={favIconRef}
>
<i className={classNames('icon', {'icon-star': isFavorite, 'icon-star-outline': !isFavorite})}/>
</button>