Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/focus-batch-initial-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': minor
---

`FocusManager` takes `batchInitialFocus` (on in `CanvasRoot`): the first focus pick and initial focus claims wait for the registrations of the current tick, a claim whose winner has no content yet holds the default pick for up to a second, and a focused item that goes away while its list re-renders is repicked once the new items are in. Entering a group at `first` picks the top-left item instead of the first registered, `last-focused` in `destinationKeys` skips a group that never had focus, an outer `last-focused` group returns to the exact remembered item through nested `first` groups, removal repick falls to the next sibling on ties, and a group that unmounts while focused sends no `onFocusLeave`. A reparented element that stays on the focus path is no longer blurred and refocused.
5 changes: 5 additions & 0 deletions .changeset/focus-entry-scopes-repick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': minor
---

The focus engine implements the native focus semantics itself: `focusEntry` (`first`, `last-focused`, `spatial`), `rememberAs`, scopes with `initialFocus` claims (`refocusInitial()`), nearest-by-geometry repick when the focused element is removed, and `onFocusEnter` / `onFocusLeave` per group. Groups that use none of them behave as before.
5 changes: 5 additions & 0 deletions .changeset/focus-keys-destination-keys-exits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': minor
---

Focus groups can be registered under a string `focusKey`, so other groups can name them without holding a ref. `destinationKeys` forwards arriving focus to the first mounted key (`first` and `last-focused` stand for the group's own first and remembered child), `exits` sends a move that finds nothing inside the group to a key per direction, and `FocusManager.focusByKey` focuses a keyed element.
5 changes: 5 additions & 0 deletions .changeset/native-id-focus-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-native-lightning': minor
---

A focusable View, Pressable or FocusGroup registers its `nativeID` as its focus key, so `destinationKeys`, `exits` and `focusByKey` on react-lightning's focus groups can name it.
5 changes: 5 additions & 0 deletions .changeset/pressable-focusable-false.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-native-lightning': patch
---

`Pressable` honours `focusable={false}`: it registers as an inactive focus target instead of a focusable one.
5 changes: 5 additions & 0 deletions .changeset/remember-as-nested-and-refocus-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

A group's `rememberAs` now swaps the whole remembered chain, not only its direct child: a value that was never visited enters at its first item, and a value that comes back returns to the item it remembered by focus key, which still names the right item after a recycling list reused its cells. `refocusInitial` also holds back the repick of the focused item while its target is still mounting, so focus doesn't land on a surviving neighbour on the way (bounded to one second).
5 changes: 5 additions & 0 deletions .changeset/use-focus-single-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

`useFocus` registers once with a single options object and applies later changes in one effect that compares them, instead of an effect per option. `FocusGroup` passes `focusEntry`, `rememberAs`, `scope`, `active`, `initialFocus`, `onFocusEnter` and `onFocusLeave` straight to it, and its traps no longer re-apply on every render. The focus key manager no longer allocates per directional key.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ type Props = Omit<CanvasProps, 'options'> & { width?: number; height?: number };

export const CanvasRoot: FC<Props> = ({ width, height, children, keyMap }) => {
const ref = useRef<LightningElement>(null);
const [focusManager] = useState(() => new FocusManager<LightningElement>());
const [focusManager] = useState(
() => new FocusManager<LightningElement>({ batchInitialFocus: true }),
);
const [focusKeyManager] = useState(() => new FocusKeyManager(focusManager));
const rootContext = useContext(LightningRootContext);

Expand Down
62 changes: 46 additions & 16 deletions packages/react-lightning/src/focus/FocusGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@ import { type ForwardRefExoticComponent, forwardRef, useEffect, useRef, useState
import { useCombinedRef } from '../hooks/useCombinedRef';
import type { KeyEvent, LightningElement, LightningViewElementProps } from '../types';
import { FocusGroupContext } from './FocusGroupContext';
import type { FocusEntry, FocusExits } from './FocusManager';
import { useFocus } from './useFocus';
import { useFocusKeyManager } from './useFocusKeyManager';
import { useFocusManager } from './useFocusManager';

export interface FocusGroupProps extends Omit<LightningViewElementProps, 'style'> {
autoFocus?: boolean;
disable?: boolean;
focusRedirect?: boolean;
destinations?: (LightningElement | null)[];
focusKey?: string;
destinationKeys?: readonly string[];
exits?: FocusExits;
focusEntry?: FocusEntry;
rememberAs?: string;
scope?: boolean;
active?: boolean;
initialFocus?: number;
onFocusEnter?: () => void;
onFocusLeave?: () => void;
trapFocusUp?: boolean;
trapFocusRight?: boolean;
trapFocusDown?: boolean;
Expand All @@ -24,6 +34,12 @@ export interface FocusGroupProps extends Omit<LightningViewElementProps, 'style'
onChildFocused?: (child: LightningElement) => void;
}

const markFocusGroup = (element: LightningElement | null) => {
if (element) {
element.isFocusGroup = true;
}
};

export const FocusGroup: ForwardRefExoticComponent<FocusGroupProps> = forwardRef<
LightningElement,
FocusGroupProps
Expand All @@ -34,6 +50,16 @@ export const FocusGroup: ForwardRefExoticComponent<FocusGroupProps> = forwardRef
disable,
focusRedirect,
destinations,
focusKey,
destinationKeys,
exits,
focusEntry,
rememberAs,
scope,
active,
initialFocus,
onFocusEnter,
onFocusLeave,
trapFocusUp,
trapFocusRight,
trapFocusDown,
Expand All @@ -46,26 +72,36 @@ export const FocusGroup: ForwardRefExoticComponent<FocusGroupProps> = forwardRef
},
ref,
) => {
const focusManager = useFocusManager();
const focusKeyManager = useFocusKeyManager();
const traps = {
up: trapFocusUp ?? false,
right: trapFocusRight ?? false,
down: trapFocusDown ?? false,
left: trapFocusLeft ?? false,
};

const { ref: focusRef, focused } = useFocus({
autoFocus,
active: !disable,
focusRedirect,
destinations,
onChildFocused,
allowOffscreen,
focusKey,
destinationKeys,
exits,
traps,
focusEntry,
rememberAs,
scope,
scopeActive: active,
initialFocus,
onFocusEnter,
onFocusLeave,
});
const [viewElement, setViewElement] = useState<LightningElement | null>(null);
const viewRef = useRef<LightningElement>(null);
const combinedRef = useCombinedRef(ref, focusRef, viewRef);

const traps = {
up: trapFocusUp ?? false,
right: trapFocusRight ?? false,
down: trapFocusDown ?? false,
left: trapFocusLeft ?? false,
};
const combinedRef = useCombinedRef(ref, focusRef, viewRef, markFocusGroup);

const handleFocusKeyDown = (event: KeyEvent) => {
if (!viewRef.current) {
Expand All @@ -79,12 +115,6 @@ export const FocusGroup: ForwardRefExoticComponent<FocusGroupProps> = forwardRef

const finalStyle = typeof style === 'function' ? style(focused) : style;

useEffect(() => {
if (viewElement) {
focusManager.setTraps(viewElement, traps);
}
}, [focusManager, viewElement, traps]);

useEffect(() => {
if (viewRef.current) {
viewRef.current.isFocusGroup = true;
Expand Down
64 changes: 55 additions & 9 deletions packages/react-lightning/src/focus/FocusKeyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Keys } from '../input/Keys';
import type { KeyEvent, LightningElement } from '../types';
import { findClosestElement, resolveDirectionalTarget } from '../utils/findClosestElement';
import { Direction } from './Direction';
import type { FocusManager, FocusNode } from './FocusManager';
import type { FocusExits, FocusManager, FocusNode } from './FocusManager';

/** Lazily maps FocusNode children to their elements without allocating an array */
function* childElements(children: FocusNode<LightningElement>[]): Iterable<LightningElement> {
Expand All @@ -12,14 +12,39 @@ function* childElements(children: FocusNode<LightningElement>[]): Iterable<Light

// A focus group with no focusable descendant only wraps non-interactive
// content (a list header); skip it so nav lands on a real target.
if (child.element.isFocusGroup && !child.hasFocusableChildren) {
// A redirect with destinations is a target even when empty, it forwards on arrival.
const redirectsSomewhere =
child.focusRedirect && !!(child.destinationKeys || child.destinations);

if (child.element.isFocusGroup && !child.hasFocusableChildren && !redirectsSomewhere) {
continue;
}

yield child.element;
}
}

const EXIT_NAMES: Record<number, keyof FocusExits> = {
[Direction.Up]: 'up',
[Direction.Right]: 'right',
[Direction.Down]: 'down',
[Direction.Left]: 'left',
};

function isInside(element: LightningElement, group: LightningElement): boolean {
for (
let current: LightningElement | null | undefined = element;
current;
current = current.parent
) {
if (current === group) {
return true;
}
}

return false;
}

export class FocusKeyManager<T extends LightningElement> {
private _focusManager: FocusManager<LightningElement>;

Expand All @@ -33,14 +58,22 @@ export class FocusKeyManager<T extends LightningElement> {
}

const key = event.remoteKey;
const direction = Array.isArray(key)
? key.map((k) => this._getKeyDirection(k)).find((dir) => dir != null)
: this._getKeyDirection(key);
let direction: Direction | null = null;

if (Array.isArray(key)) {
for (let i = 0; i < key.length && direction == null; i++) {
direction = this._getKeyDirection(key[i] as Keys);
}
} else {
direction = this._getKeyDirection(key);
}

if (direction == null) {
return true;
}

this._focusManager.endInitialClaims();

return this._tryFocusNext(element, event, direction);
};

Expand Down Expand Up @@ -107,16 +140,25 @@ export class FocusKeyManager<T extends LightningElement> {
closestElement,
focusNode.parent.element,
direction,
(child) => this._focusableChildElements(child),
(child) => this._isRedirect(child),
(child) => this._getAllowOffscreen(child),
this._focusableChildElements,
this._isRedirect,
this._getAllowOffscreen,
);

this._focusManager.focus(target);

return false;
}

const exitKey = focusNode.exits?.[EXIT_NAMES[direction] as keyof FocusExits];
const exitTarget = exitKey ? this._focusManager.getElementByKey(exitKey) : null;

if (exitTarget?.focusable && !isInside(exitTarget, element)) {
this._focusManager.focus(exitTarget);

return false;
}

const { traps } = focusNode;

if (
Expand Down Expand Up @@ -148,6 +190,10 @@ export class FocusKeyManager<T extends LightningElement> {
private _isRedirect = (element: LightningElement): boolean => {
const node = this._focusManager.getFocusNode(element);

return !!node?.focusRedirect;
if (!node || node.focusEntry === 'spatial') {
return false;
}

return node.focusRedirect || node.focusEntry === 'first' || node.focusEntry === 'last-focused';
};
}
Loading
Loading