Files
2026-10-14 04:20:24 +02:00

69 lines
2.6 KiB
JavaScript

// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
import {createElement, useContext, useLayoutEffect, useRef, useState} from 'react';
import {manager, navigationListeners, ScopeContext} from './runtime.js';
/**
* Groups focusable elements: navigation searches the innermost scope first, then its parents.
* @param {import('./manager.js').ScopeProps & {children?: import('react').ReactNode}} props
* `restoreFocus` defaults to true.
*/
export function FocusScope(props) {
const parent = useContext(ScopeContext);
const [scope] = useState(() => manager.createScope(parent, props));
useLayoutEffect(() => { manager.setProps(scope, props); });
useLayoutEffect(() => {
manager.mountScope(scope);
return () => manager.unmountScope(scope);
}, []);
return createElement(ScopeContext.Provider, {value: scope}, props.children);
}
const focusedKey = () => manager.focused?.props.focusKey ?? null;
/**
* The focused element's key, and imperative focus by `focusKey` (an element or a FocusScope).
* Re-renders the caller when the focused key changes.
* @returns {{focusedKey: string | null, focus: (key: string) => boolean, blur: () => void}}
*/
export function useFocus() {
const [key, setKey] = useState(focusedKey);
useLayoutEffect(() => {
setKey(focusedKey());
return manager.subscribe(() => setKey(focusedKey()));
}, []);
return {focusedKey: key, ...actions};
}
const actions = {
focus: target => manager.focus(target),
blur: () => manager.blur(),
focusedRect: () => manager.focusedRect(),
};
/**
* `useFocus()` without `focusedKey`: imperative focus that never re-renders the caller, for components
* that move focus but do not draw it (an app root, a page).
* @returns {{focus: (key: string) => boolean, blur: () => void, focusedRect: () => object | null}}
*/
export function useFocusActions() {
return actions;
}
/**
* Calls `listener` after the focus manager handles each input: a move or a blocked direction, Cross,
* Circle and the other buttons, with whether something handled them. For feedback such as sounds;
* it does not re-render the caller.
* @param {(event: import('./runtime.js').NavigationEvent) => void} listener
*/
export function useNavigationEvents(listener) {
const current = useRef(listener);
useLayoutEffect(() => { current.current = listener; });
useLayoutEffect(() => {
const forward = event => current.current(event);
navigationListeners.add(forward);
return () => { navigationListeners.delete(forward); };
}, []);
}