MM 61586 - fix fav icon accessibility focus problem (#29329)
* MM-61586 - fix fav icon accesibility focus issue * add unit test to the component
Этот коммит содержится в:
@@ -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>
|
||||
|
||||
Ссылка в новой задаче
Block a user