From 261bce90de4f3ff84535781510b37fe8df6bb0a3 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 9 Jun 2026 19:45:59 +0200 Subject: [PATCH] New NavigationFocusTarget component --- .../mastodon/components/modal_root.jsx | 6 +- .../navigation_focus_target/index.tsx | 151 ++++++++++++++++++ app/javascript/mastodon/components/router.tsx | 5 + .../mastodon/containers/mastodon.jsx | 5 +- 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 app/javascript/mastodon/components/navigation_focus_target/index.tsx diff --git a/app/javascript/mastodon/components/modal_root.jsx b/app/javascript/mastodon/components/modal_root.jsx index 61ff19256f8..538a20a301a 100644 --- a/app/javascript/mastodon/components/modal_root.jsx +++ b/app/javascript/mastodon/components/modal_root.jsx @@ -118,7 +118,11 @@ class ModalRoot extends PureComponent { _ensureHistoryBuffer () { const { pathname, search, hash, state } = this.history.location; if (!state || state.mastodonModalKey !== this._modalHistoryKey) { - this.history.push({ pathname, search, hash }, { ...state, mastodonModalKey: this._modalHistoryKey }); + this.history.push({ pathname, search, hash }, { + ...state, + focusTarget: true, + mastodonModalKey: this._modalHistoryKey, + }); } } diff --git a/app/javascript/mastodon/components/navigation_focus_target/index.tsx b/app/javascript/mastodon/components/navigation_focus_target/index.tsx new file mode 100644 index 00000000000..c28cf10404e --- /dev/null +++ b/app/javascript/mastodon/components/navigation_focus_target/index.tsx @@ -0,0 +1,151 @@ +import { + createContext, + useContext, + useRef, + useLayoutEffect, + useCallback, +} from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import type { MastodonLocation } from '../router'; + +export const FOCUS_TARGET = { + POST: 'detailed-status', +} as const; + +export type FocusTarget = + | boolean + | (typeof FOCUS_TARGET)[keyof typeof FOCUS_TARGET]; + +const FocusTargetContext = + createContext | null>(null); + +/** + * `FocusTargetProvider` keeps track of whether focus should be + * set after a navigation. By default, any navigation will set the + * current value of `focusTargetRef` to `true`, which will cause + * the `NavigationFocusTarget` component to focus itself when it mounts. + * + * To disable this behaviour for a navigation, the focus target can be + * set to `false` using location state, for example: + * ``` + * location.push(url, { focusTarget: false }); + * ``` + * + * If the target page contains multiple `NavigationFocusTarget` components + * (e.g. a main heading and a post that should be focused), give the more + * specific `NavigationFocusTarget` instance a name, and pass the same name + * via location state: + * ``` + * location.push(url, { focusTarget: 'detailed-status' }); + * ``` + */ + +export const FocusTargetProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const focusTargetRef = useRef(false); + const previousLocationRef = useRef< + | (Pick & { + focusTarget?: FocusTarget; + }) + | null + >(null); + + const { + pathname, + search, + state = {}, + } = useLocation<{ focusTarget?: FocusTarget } | undefined>(); + + const { focusTarget } = state; + + useLayoutEffect(() => { + // We never want to set focus on page load, so we keep + // track of whether a manual navigation has occurred by comparing + // our current with the previous location: + const previous = previousLocationRef.current; + + // Bail out on the first render, populate previousLocationRef + if (previous === null) { + previousLocationRef.current = { pathname, search, focusTarget }; + return; + } + + // Bail out if location hasn't changed + if ( + previous.pathname === pathname && + previous.search === search && + previous.focusTarget === focusTarget + ) { + return; + } + + // Location has changed: + // - Set focusTarget + // – Store current location as previous + // (We store `focusTarget` as `false` to allow overriding it.) + previousLocationRef.current = { pathname, search, focusTarget: false }; + focusTargetRef.current = focusTarget ?? true; + }, [pathname, search, focusTarget]); + + return ( + + {children} + + ); +}; + +export function useFocusOnNavigation(targetName?: string) { + // const elementToFocusRef = useRef(null); + const focusTargetRef = useContext(FocusTargetContext); + + if (focusTargetRef === null) { + throw Error( + 'useFocusTargetContext must be used inside of a FocusTargetProvider', + ); + } + + return useCallback( + (element: HTMLElement | null) => { + const focusTarget = focusTargetRef.current; + + // Bail out if focusTarget was set to `false` + if (!element || !focusTarget) { + return; + } + + if ( + focusTarget === true || + (targetName && + typeof focusTarget === 'string' && + focusTarget === targetName) + ) { + setTimeout(() => { + element.focus({ preventScroll: true }); + }, 0); + } + }, + [focusTargetRef, targetName], + ); +} + +interface FocusTargetElementProps extends React.ComponentPropsWithoutRef<'h1'> { + focusTargetName?: string; +} + +export const NavigationFocusTarget = polymorphicForwardRef< + 'h1', + FocusTargetElementProps +>(({ as: Component = 'h1', focusTargetName, children, ...otherProps }) => { + const focusOnNavigation = useFocusOnNavigation(focusTargetName); + + return ( + + {children} + + ); +}); diff --git a/app/javascript/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx index bd6e4b568f0..2a53d6dff43 100644 --- a/app/javascript/mastodon/components/router.tsx +++ b/app/javascript/mastodon/components/router.tsx @@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history'; import { layoutFromWindow } from 'mastodon/is_mobile'; import { isDevelopment } from 'mastodon/utils/environment'; +import type { FocusTarget } from './navigation_focus_target'; + interface MastodonLocationState { fromMastodon?: boolean; mastodonModalKey?: string; + // Controls which element is focused after a navigation. + // Set to `false` to prevent navigation focus. + focusTarget?: FocusTarget; // Prevent the rightmost column in advanced UI from scrolling // into view on location changes preventMultiColumnAutoScroll?: string; diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index d6df09db49e..4f87a1b823c 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux'; import { hydrateStore } from 'mastodon/actions/store'; import { connectUserStream } from 'mastodon/actions/streaming'; import ErrorBoundary from 'mastodon/components/error_boundary'; +import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target'; import { Router } from 'mastodon/components/router'; import UI from 'mastodon/features/ui'; import { IdentityContext, createIdentityContext } from 'mastodon/identity_context'; @@ -49,7 +50,9 @@ export default class Mastodon extends PureComponent { - + + +