mirror of
https://github.com/mastodon/mastodon.git
synced 2026-08-28 18:05:10 -05:00
New NavigationFocusTarget component
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user