New NavigationFocusTarget component

This commit is contained in:
diondiondion
2026-06-09 19:45:59 +02:00
parent fc64804b4d
commit 261bce90de
4 changed files with 165 additions and 2 deletions

View File

@@ -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,
});
}
}

View File

@@ -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<React.MutableRefObject<FocusTarget> | 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<FocusTarget>(false);
const previousLocationRef = useRef<
| (Pick<MastodonLocation, 'pathname' | 'search'> & {
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 (
<FocusTargetContext.Provider value={focusTargetRef}>
{children}
</FocusTargetContext.Provider>
);
};
export function useFocusOnNavigation(targetName?: string) {
// const elementToFocusRef = useRef<HTMLHeadingElement>(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 (
<Component ref={focusOnNavigation} tabIndex={-1} {...otherProps}>
{children}
</Component>
);
});

View File

@@ -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;

View File

@@ -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 {
<ErrorBoundary>
<Router>
<ScrollContext>
<Route path='/' component={UI} />
<FocusTargetProvider>
<Route path='/' component={UI} />
</FocusTargetProvider>
</ScrollContext>
<BodyScrollLock />
</Router>