Skip to content
Closed
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
7 changes: 6 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ const logo = require('../logo.png').default;
init({
debug: false,
visualDebug: false,
distanceCalculationMethod: 'center'
distanceCalculationMethod: 'center',
onUtterText: (text: string) => {
console.log('onUtterText', text);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will go into the platforms' TTS method.

}
});

const rows = shuffle([
Expand Down Expand Up @@ -232,6 +235,7 @@ function Asset({
index
}: AssetProps) {
const { ref, focused } = useFocusable<object, HTMLDivElement>({
accessibilityLabel: title,
onEnterPress,
onFocus,
extraProps: {
Expand Down Expand Up @@ -297,6 +301,7 @@ function ContentRow({
isShuffleSize
}: ContentRowProps) {
const { ref, focusKey } = useFocusable<object, HTMLDivElement>({
accessibilityLabel: rowTitle,
onFocus
});

Expand Down
107 changes: 98 additions & 9 deletions src/SpatialNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ interface FocusableComponent {
lastFocusedChildKey?: string;
layout?: FocusableComponentLayout;
layoutUpdated?: boolean;
accessibilityLabel?: string;
}

interface FocusableComponentUpdatePayload {
Expand All @@ -110,6 +111,7 @@ interface FocusableComponentUpdatePayload {
onArrowRelease: (direction: string) => void;
onFocus: (layout: FocusableComponentLayout, details: FocusDetails) => void;
onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void;
accessibilityLabel?: string;
}

interface FocusableComponentRemovePayload {
Expand Down Expand Up @@ -261,6 +263,12 @@ class SpatialNavigationService {

private customDistanceCalculationFunction?: DistanceCalculationFunction;

/**
* Callback invoked with concatenated accessibility labels when a focusable component receives focus.
* Parent region labels are included only when entering a new region for the first time.
*/
private onUtterText: ((text: string) => void) | null;

/**
* Used to determine the coordinate that will be used to filter items that are over the "edge"
*/
Expand Down Expand Up @@ -650,6 +658,8 @@ class SpatialNavigationService {
trailing: true
});

this.onUtterText = null;

this.debug = false;
this.visualDebugger = null;

Expand All @@ -670,7 +680,8 @@ class SpatialNavigationService {
shouldUseNativeEvents = false,
rtl = false,
distanceCalculationMethod = 'corners' as DistanceCalculationMethod,
customDistanceCalculationFunction = undefined as DistanceCalculationFunction
customDistanceCalculationFunction = undefined as DistanceCalculationFunction,
onUtterText = undefined as ((text: string) => void) | undefined
} = {}) {
if (!this.enabled) {
this.domNodeFocusOptions = domNodeFocusOptions;
Expand All @@ -684,6 +695,7 @@ class SpatialNavigationService {
this.distanceCalculationMethod = distanceCalculationMethod;
this.customDistanceCalculationFunction =
customDistanceCalculationFunction;
this.onUtterText = onUtterText || null;

this.debug = debug;

Expand Down Expand Up @@ -740,6 +752,7 @@ class SpatialNavigationService {
this.focusableComponents = {};
this.paused = false;
this.keyMap = DEFAULT_KEY_MAP;
this.onUtterText = null;

this.unbindEventHandlers();
}
Expand Down Expand Up @@ -833,12 +846,14 @@ class SpatialNavigationService {
this.onEnterRelease();
}

if (this.focusKey && (
eventType === DIRECTION_LEFT ||
eventType === DIRECTION_RIGHT ||
eventType === DIRECTION_UP ||
eventType === DIRECTION_DOWN)) {
this.onArrowRelease(eventType)
if (
this.focusKey &&
(eventType === DIRECTION_LEFT ||
eventType === DIRECTION_RIGHT ||
eventType === DIRECTION_UP ||
eventType === DIRECTION_DOWN)
) {
this.onArrowRelease(eventType);
Comment on lines +849 to +856
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prettier

}
};

Expand Down Expand Up @@ -1319,7 +1334,8 @@ class SpatialNavigationService {
forceFocus,
focusable,
isFocusBoundary,
focusBoundaryDirections
focusBoundaryDirections,
accessibilityLabel
}: FocusableComponent) {
this.focusableComponents[focusKey] = {
focusKey,
Expand All @@ -1341,6 +1357,7 @@ class SpatialNavigationService {
focusBoundaryDirections,
autoRestoreFocus,
forceFocus,
accessibilityLabel,
lastFocusedChildKey: null,
layout: {
x: 0,
Expand Down Expand Up @@ -1643,6 +1660,73 @@ class SpatialNavigationService {
this.paused = false;
}

/**
* Builds and utters accessibility labels when focus changes.
* Parent region labels are spoken only when entering a new region (similar to aria-label on role=region).
* When navigating within the same parent, only the leaf node's label is spoken.
*
* Must be called BEFORE updateParentsHasFocusedChild so we can compare
* the new parent chain against the current one to detect newly entered regions.
Comment on lines +1668 to +1669
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation makes it fully "stateless" in a sense that we don't need to remember which parents were entered before and which ones are entered for the first time. It just checks the parent tree before updating the focus, so the parents not having focused children NOW are considered as "entering for the first time" regions.

*/
private utterAccessibilityLabels(newFocusKey: string) {
if (!this.onUtterText) {
return;
}

// Don't utter if focus hasn't actually changed
if (newFocusKey === this.focusKey) {
return;
}

const newComponent = this.focusableComponents[newFocusKey];
if (!newComponent) {
return;
}

// Walk up the tree to collect all parents of the new focus key (bottom-up)
const parentChain: string[] = [];
let current = this.focusableComponents[newFocusKey];

while (current) {
const { parentFocusKey } = current;
const parentComponent = this.focusableComponents[parentFocusKey];

if (parentComponent) {
parentChain.push(parentFocusKey);
}

current = parentComponent;
}

// Find newly entered parent regions (parents not in the current focused parent chain).
// These are regions whose labels should be spoken because focus is entering them for the first time.
const newlyEnteredParents = parentChain.filter(
(key) => !this.parentsHavingFocusedChild.includes(key)
);

// Reverse to get top-down order (root → leaf) for natural reading order
newlyEnteredParents.reverse();

// Collect labels from newly entered parent regions and the leaf node
const labels: string[] = [];

newlyEnteredParents.forEach((parentKey) => {
const parent = this.focusableComponents[parentKey];

if (parent?.accessibilityLabel) {
labels.push(parent.accessibilityLabel);
}
});

if (newComponent.accessibilityLabel) {
labels.push(newComponent.accessibilityLabel);
}

if (labels.length > 0) {
this.onUtterText(labels.join(', '));
}
}

setFocus(focusKey: string, focusDetails: FocusDetails = {}) {
// Cancel any pending auto-restore focus calls if we are setting focus manually
this.setFocusDebounced.cancel();
Expand All @@ -1667,6 +1751,9 @@ class SpatialNavigationService {

this.log('setFocus', 'newFocusKey', newFocusKey);

// Utter accessibility labels BEFORE updating parents so we can detect newly entered regions
this.utterAccessibilityLabels(newFocusKey);

this.setCurrentFocusedKey(newFocusKey, focusDetails);
this.updateParentsHasFocusedChild(newFocusKey, focusDetails);
this.updateParentsLastFocusedChild(newFocusKey);
Expand Down Expand Up @@ -1713,7 +1800,8 @@ class SpatialNavigationService {
onEnterRelease,
onArrowPress,
onFocus,
onBlur
onBlur,
accessibilityLabel
}: FocusableComponentUpdatePayload
) {
if (this.nativeMode) {
Expand All @@ -1732,6 +1820,7 @@ class SpatialNavigationService {
component.onArrowPress = onArrowPress;
component.onFocus = onFocus;
component.onBlur = onBlur;
component.accessibilityLabel = accessibilityLabel;

if (node) {
component.node = node;
Expand Down
19 changes: 15 additions & 4 deletions src/useFocusable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ export interface UseFocusableConfig<P = object> {
onFocus?: FocusHandler<P>;
onBlur?: BlurHandler<P>;
extraProps?: P;
/**
* Accessibility label for this focusable component.
* When focus lands on a leaf node, the labels of all newly-entered parent
* regions (in tree order) are concatenated with the leaf's own label and
* passed to the `onUtterText` callback provided during `init()`.
*/
accessibilityLabel?: string;
}

export interface UseFocusableResult<E = any> {
Expand All @@ -90,7 +97,8 @@ const useFocusableHook = <P, E = any>({
onArrowRelease = noop,
onFocus = noop,
onBlur = noop,
extraProps
extraProps,
accessibilityLabel
}: UseFocusableConfig<P> = {}): UseFocusableResult<E> => {
const onEnterPressHandler = useCallback(
(details: KeyPressDetails) => {
Expand Down Expand Up @@ -172,7 +180,8 @@ const useFocusableHook = <P, E = any>({
focusBoundaryDirections,
autoRestoreFocus,
forceFocus,
focusable
focusable,
accessibilityLabel
});

return () => {
Expand All @@ -196,7 +205,8 @@ const useFocusableHook = <P, E = any>({
onArrowPress: onArrowPressHandler,
onArrowRelease: onArrowReleaseHandler,
onFocus: onFocusHandler,
onBlur: onBlurHandler
onBlur: onBlurHandler,
accessibilityLabel
});
}, [
focusKey,
Expand All @@ -209,7 +219,8 @@ const useFocusableHook = <P, E = any>({
onArrowPressHandler,
onArrowReleaseHandler,
onFocusHandler,
onBlurHandler
onBlurHandler,
accessibilityLabel
]);

return {
Expand Down