diff --git a/webapp/channels/src/components/new_search/new_search.tsx b/webapp/channels/src/components/new_search/new_search.tsx index dfaaa57fad..34ad61e8e9 100644 --- a/webapp/channels/src/components/new_search/new_search.tsx +++ b/webapp/channels/src/components/new_search/new_search.tsx @@ -14,7 +14,9 @@ import {getSearchTerms, getSearchType} from 'selectors/rhs'; import Popover from 'components/widgets/popover'; -import Constants from 'utils/constants'; +import a11yController from 'utils/a11y_controller_instance'; +import type {A11yFocusEventDetail} from 'utils/constants'; +import Constants, {A11yCustomEventTypes} from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent'; @@ -102,6 +104,7 @@ const NewSearch = (): JSX.Element => { const [focused, setFocused] = useState(false); const [currentChannel, setCurrentChannel] = useState(''); const searchBoxRef = useRef(null); + const searchButtonRef = useRef(null); useEffect(() => { const isDesktop = isDesktopApp() && isServerVersionGreaterThanOrEqualTo(getDesktopVersion(), '4.7.0'); @@ -160,10 +163,24 @@ const NewSearch = (): JSX.Element => { const closeSearchBox = useCallback(() => { setFocused(false); setCurrentChannel(''); + if (searchButtonRef.current) { + document.dispatchEvent( + new CustomEvent(A11yCustomEventTypes.FOCUS, { + detail: { + target: searchButtonRef.current, + keyboardOnly: false, + }, + }), + ); + a11yController.resetOriginElement(); + } }, []); const openSearchBox = useCallback(() => { setFocused(true); + if (searchButtonRef.current) { + a11yController.storeOriginElement(searchButtonRef.current); + } }, []); const openSearchBoxOnKeyPress = useCallback( @@ -220,6 +237,7 @@ const NewSearch = (): JSX.Element => { id='searchFormContainer' role='button' className='a11y__region' + ref={searchButtonRef} > {(searchType === 'messages' || searchType === 'files') && ( diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index 5c914320ce..7147e1226b 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -31,7 +31,7 @@ import SidebarMobileRightMenu from 'components/sidebar_mobile_right_menu'; import webSocketClient from 'client/web_websocket_client'; import {initializePlugins} from 'plugins'; -import A11yController from 'utils/a11y_controller'; +import 'utils/a11y_controller_instance'; import {PageLoadContext, SCHEDULED_POST_URL_SUFFIX} from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; import {EmojiIndicesByAlias} from 'utils/emoji'; @@ -92,8 +92,6 @@ interface State { export default class Root extends React.PureComponent { // The constructor adds a bunch of event listeners, // so we do need this. - private a11yController: A11yController; - constructor(props: Props) { super(props); @@ -107,8 +105,6 @@ export default class Root extends React.PureComponent { this.state = { shouldMountAppRoutes: false, }; - - this.a11yController = new A11yController(); } setRudderConfig = () => { diff --git a/webapp/channels/src/components/sidebar_right/sidebar_right.tsx b/webapp/channels/src/components/sidebar_right/sidebar_right.tsx index cdfe0677c8..cdd3d88eeb 100644 --- a/webapp/channels/src/components/sidebar_right/sidebar_right.tsx +++ b/webapp/channels/src/components/sidebar_right/sidebar_right.tsx @@ -21,6 +21,7 @@ import RhsThread from 'components/rhs_thread'; import Search from 'components/search/index'; import RhsPlugin from 'plugins/rhs_plugin'; +import a11yController from 'utils/a11y_controller_instance'; import type {A11yFocusEventDetail} from 'utils/constants'; import Constants, {A11yCustomEventTypes} from 'utils/constants'; import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard'; @@ -170,19 +171,23 @@ export default class SidebarRight extends React.PureComponent { } else if (!this.props.isOpen && wasOpen) { // RHS just was closed, restore focus to the previous element had it // this will have to change for upcoming work specially for search and probalby plugins - requestAnimationFrame(() => { - if (this.previousActiveElement) { - document.dispatchEvent( - new CustomEvent(A11yCustomEventTypes.FOCUS, { - detail: { - target: this.previousActiveElement, - keyboardOnly: false, - }, - }), - ); - this.previousActiveElement = null; - } - }); + if (a11yController.originElement) { + a11yController.restoreOriginFocus(); + } else { + requestAnimationFrame(() => { + if (this.previousActiveElement) { + document.dispatchEvent( + new CustomEvent(A11yCustomEventTypes.FOCUS, { + detail: { + target: this.previousActiveElement, + keyboardOnly: false, + }, + }), + ); + this.previousActiveElement = null; + } + }); + } } } diff --git a/webapp/channels/src/utils/a11y_controller.ts b/webapp/channels/src/utils/a11y_controller.ts index 96b655df72..9323af82d3 100644 --- a/webapp/channels/src/utils/a11y_controller.ts +++ b/webapp/channels/src/utils/a11y_controller.ts @@ -42,6 +42,7 @@ export default class A11yController { lKeyIsPressed = false; escKeyIsPressed = false; windowIsFocused = true; + originElement?: HTMLElement | null = null; // used to reset navigation whenever navigation within a region occurs (section or element) resetNavigation = false; @@ -71,6 +72,27 @@ export default class A11yController { window.removeEventListener(EventTypes.BLUR, this.handleWindowBlur, listenerOptions); } + /** + * Stores a reference to the provided DOM element as the "origin element". + * The origin element is the element that triggered or last caused a certain + * navigation or focus event. This allows restoring focus back to this element + * once certain UI components (like RHS, panels, or menus) are closed. + * + * @param {HTMLElement} element - The DOM element to store as the origin element. + */ + storeOriginElement(element: HTMLElement) { + this.originElement = element; + } + + /** + * Resets the stored origin element reference back to null. + * This is typically called after focus has been restored to the origin element, + * indicating that the controller no longer needs to keep track of it. + */ + resetOriginElement() { + this.originElement = null; + } + // convenience getter/setters /** @@ -370,6 +392,23 @@ export default class A11yController { this.resetNavigation = true; } + restoreOriginFocus() { + if (this.originElement && this.isElementValid(this.originElement)) { + // Dispatch a focus event to manually focus this element + document.dispatchEvent( + new CustomEvent(A11yCustomEventTypes.FOCUS, { + detail: { + target: this.originElement, + keyboardOnly: false, + }, + }), + ); + setTimeout(() => { + this.originElement = null; + }, 0); + } + } + /** * Resets the a11y navigation controller, active region/section/element, clears focus and resets user interraction states */ diff --git a/webapp/channels/src/utils/a11y_controller_instance.ts b/webapp/channels/src/utils/a11y_controller_instance.ts new file mode 100644 index 0000000000..379fe3f025 --- /dev/null +++ b/webapp/channels/src/utils/a11y_controller_instance.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import A11yController from 'utils/a11y_controller'; + +const a11yController = new A11yController(); +export default a11yController;