diff --git a/webapp/channels/src/components/channel_header/channel_header_title_favorite.test.tsx b/webapp/channels/src/components/channel_header/channel_header_title_favorite.test.tsx
new file mode 100644
index 0000000000..71c636e71a
--- /dev/null
+++ b/webapp/channels/src/components/channel_header/channel_header_title_favorite.test.tsx
@@ -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();
+ }
+
+ 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;
+ expect(event).toBeDefined();
+ expect(event.detail.target).toBe(button);
+ expect(event.detail.keyboardOnly).toBe(false);
+
+ dispatchEventSpy.mockRestore();
+
+ jest.useRealTimers();
+ });
+});
diff --git a/webapp/channels/src/components/channel_header/channel_header_title_favorite.tsx b/webapp/channels/src/components/channel_header/channel_header_title_favorite.tsx
index 17b220b4e0..528b6290e9 100644
--- a/webapp/channels/src/components/channel_header/channel_header_title_favorite.tsx
+++ b/webapp/channels/src/components/channel_header/channel_header_title_favorite.tsx
@@ -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(null);
const toggleFavoriteCallback = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -28,7 +32,19 @@ const ChannelHeaderTitleFavorite = () => {
} else {
dispatch(favoriteChannel(channel.id));
}
- }, [isFavorite, channel?.id]);
+ requestAnimationFrame(() => {
+ if (favIconRef.current) {
+ document.dispatchEvent(
+ new CustomEvent(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}
>