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.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, {memo, useCallback} from 'react';
|
import React, {memo, useCallback, useRef} from 'react';
|
||||||
import {FormattedMessage, useIntl} from 'react-intl';
|
import {FormattedMessage, useIntl} from 'react-intl';
|
||||||
import {useSelector, useDispatch} from 'react-redux';
|
import {useSelector, useDispatch} from 'react-redux';
|
||||||
|
|
||||||
@@ -11,12 +11,16 @@ import {getCurrentChannel, isCurrentChannelFavorite} from 'mattermost-redux/sele
|
|||||||
|
|
||||||
import WithTooltip from 'components/with_tooltip';
|
import WithTooltip from 'components/with_tooltip';
|
||||||
|
|
||||||
|
import type {A11yFocusEventDetail} from 'utils/constants';
|
||||||
|
import {A11yCustomEventTypes} from 'utils/constants';
|
||||||
|
|
||||||
const ChannelHeaderTitleFavorite = () => {
|
const ChannelHeaderTitleFavorite = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const isFavorite = useSelector(isCurrentChannelFavorite);
|
const isFavorite = useSelector(isCurrentChannelFavorite);
|
||||||
const channel = useSelector(getCurrentChannel);
|
const channel = useSelector(getCurrentChannel);
|
||||||
const channelIsArchived = (channel?.delete_at ?? 0) > 0;
|
const channelIsArchived = (channel?.delete_at ?? 0) > 0;
|
||||||
|
const favIconRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const toggleFavoriteCallback = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
const toggleFavoriteCallback = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -28,7 +32,19 @@ const ChannelHeaderTitleFavorite = () => {
|
|||||||
} else {
|
} else {
|
||||||
dispatch(favoriteChannel(channel.id));
|
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) {
|
if (!channel || channelIsArchived) {
|
||||||
return null;
|
return null;
|
||||||
@@ -67,6 +83,7 @@ const ChannelHeaderTitleFavorite = () => {
|
|||||||
onClick={toggleFavoriteCallback}
|
onClick={toggleFavoriteCallback}
|
||||||
className={classNames('channel-header__favorites btn btn-icon btn-xs', {active: isFavorite, inactive: !isFavorite})}
|
className={classNames('channel-header__favorites btn btn-icon btn-xs', {active: isFavorite, inactive: !isFavorite})}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
ref={favIconRef}
|
||||||
>
|
>
|
||||||
<i className={classNames('icon', {'icon-star': isFavorite, 'icon-star-outline': !isFavorite})}/>
|
<i className={classNames('icon', {'icon-star': isFavorite, 'icon-star-outline': !isFavorite})}/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user