146 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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