Skip to content

Commit 5340eaa

Browse files
committed
Accessibility labels
1 parent 43a477a commit 5340eaa

3 files changed

Lines changed: 119 additions & 14 deletions

File tree

src/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ const logo = require('../logo.png').default;
2323
init({
2424
debug: false,
2525
visualDebug: false,
26-
distanceCalculationMethod: 'center'
26+
distanceCalculationMethod: 'center',
27+
onUtterText: (text: string) => {
28+
console.log('onUtterText', text);
29+
}
2730
});
2831

2932
const rows = shuffle([
@@ -232,6 +235,7 @@ function Asset({
232235
index
233236
}: AssetProps) {
234237
const { ref, focused } = useFocusable<object, HTMLDivElement>({
238+
accessibilityLabel: title,
235239
onEnterPress,
236240
onFocus,
237241
extraProps: {
@@ -297,6 +301,7 @@ function ContentRow({
297301
isShuffleSize
298302
}: ContentRowProps) {
299303
const { ref, focusKey } = useFocusable<object, HTMLDivElement>({
304+
accessibilityLabel: rowTitle,
300305
onFocus
301306
});
302307

src/SpatialNavigation.ts

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ interface FocusableComponent {
9696
lastFocusedChildKey?: string;
9797
layout?: FocusableComponentLayout;
9898
layoutUpdated?: boolean;
99+
accessibilityLabel?: string;
99100
}
100101

101102
interface FocusableComponentUpdatePayload {
@@ -110,6 +111,7 @@ interface FocusableComponentUpdatePayload {
110111
onArrowRelease: (direction: string) => void;
111112
onFocus: (layout: FocusableComponentLayout, details: FocusDetails) => void;
112113
onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void;
114+
accessibilityLabel?: string;
113115
}
114116

115117
interface FocusableComponentRemovePayload {
@@ -261,6 +263,12 @@ class SpatialNavigationService {
261263

262264
private customDistanceCalculationFunction?: DistanceCalculationFunction;
263265

266+
/**
267+
* Callback invoked with concatenated accessibility labels when a focusable component receives focus.
268+
* Parent region labels are included only when entering a new region for the first time.
269+
*/
270+
private onUtterText: ((text: string) => void) | null;
271+
264272
/**
265273
* Used to determine the coordinate that will be used to filter items that are over the "edge"
266274
*/
@@ -650,6 +658,8 @@ class SpatialNavigationService {
650658
trailing: true
651659
});
652660

661+
this.onUtterText = null;
662+
653663
this.debug = false;
654664
this.visualDebugger = null;
655665

@@ -670,7 +680,8 @@ class SpatialNavigationService {
670680
shouldUseNativeEvents = false,
671681
rtl = false,
672682
distanceCalculationMethod = 'corners' as DistanceCalculationMethod,
673-
customDistanceCalculationFunction = undefined as DistanceCalculationFunction
683+
customDistanceCalculationFunction = undefined as DistanceCalculationFunction,
684+
onUtterText = undefined as ((text: string) => void) | undefined
674685
} = {}) {
675686
if (!this.enabled) {
676687
this.domNodeFocusOptions = domNodeFocusOptions;
@@ -684,6 +695,7 @@ class SpatialNavigationService {
684695
this.distanceCalculationMethod = distanceCalculationMethod;
685696
this.customDistanceCalculationFunction =
686697
customDistanceCalculationFunction;
698+
this.onUtterText = onUtterText || null;
687699

688700
this.debug = debug;
689701

@@ -740,6 +752,7 @@ class SpatialNavigationService {
740752
this.focusableComponents = {};
741753
this.paused = false;
742754
this.keyMap = DEFAULT_KEY_MAP;
755+
this.onUtterText = null;
743756

744757
this.unbindEventHandlers();
745758
}
@@ -833,12 +846,14 @@ class SpatialNavigationService {
833846
this.onEnterRelease();
834847
}
835848

836-
if (this.focusKey && (
837-
eventType === DIRECTION_LEFT ||
838-
eventType === DIRECTION_RIGHT ||
839-
eventType === DIRECTION_UP ||
840-
eventType === DIRECTION_DOWN)) {
841-
this.onArrowRelease(eventType)
849+
if (
850+
this.focusKey &&
851+
(eventType === DIRECTION_LEFT ||
852+
eventType === DIRECTION_RIGHT ||
853+
eventType === DIRECTION_UP ||
854+
eventType === DIRECTION_DOWN)
855+
) {
856+
this.onArrowRelease(eventType);
842857
}
843858
};
844859

@@ -1319,7 +1334,8 @@ class SpatialNavigationService {
13191334
forceFocus,
13201335
focusable,
13211336
isFocusBoundary,
1322-
focusBoundaryDirections
1337+
focusBoundaryDirections,
1338+
accessibilityLabel
13231339
}: FocusableComponent) {
13241340
this.focusableComponents[focusKey] = {
13251341
focusKey,
@@ -1341,6 +1357,7 @@ class SpatialNavigationService {
13411357
focusBoundaryDirections,
13421358
autoRestoreFocus,
13431359
forceFocus,
1360+
accessibilityLabel,
13441361
lastFocusedChildKey: null,
13451362
layout: {
13461363
x: 0,
@@ -1643,6 +1660,73 @@ class SpatialNavigationService {
16431660
this.paused = false;
16441661
}
16451662

1663+
/**
1664+
* Builds and utters accessibility labels when focus changes.
1665+
* Parent region labels are spoken only when entering a new region (similar to aria-label on role=region).
1666+
* When navigating within the same parent, only the leaf node's label is spoken.
1667+
*
1668+
* Must be called BEFORE updateParentsHasFocusedChild so we can compare
1669+
* the new parent chain against the current one to detect newly entered regions.
1670+
*/
1671+
private utterAccessibilityLabels(newFocusKey: string) {
1672+
if (!this.onUtterText) {
1673+
return;
1674+
}
1675+
1676+
// Don't utter if focus hasn't actually changed
1677+
if (newFocusKey === this.focusKey) {
1678+
return;
1679+
}
1680+
1681+
const newComponent = this.focusableComponents[newFocusKey];
1682+
if (!newComponent) {
1683+
return;
1684+
}
1685+
1686+
// Walk up the tree to collect all parents of the new focus key (bottom-up)
1687+
const parentChain: string[] = [];
1688+
let current = this.focusableComponents[newFocusKey];
1689+
1690+
while (current) {
1691+
const { parentFocusKey } = current;
1692+
const parentComponent = this.focusableComponents[parentFocusKey];
1693+
1694+
if (parentComponent) {
1695+
parentChain.push(parentFocusKey);
1696+
}
1697+
1698+
current = parentComponent;
1699+
}
1700+
1701+
// Find newly entered parent regions (parents not in the current focused parent chain).
1702+
// These are regions whose labels should be spoken because focus is entering them for the first time.
1703+
const newlyEnteredParents = parentChain.filter(
1704+
(key) => !this.parentsHavingFocusedChild.includes(key)
1705+
);
1706+
1707+
// Reverse to get top-down order (root → leaf) for natural reading order
1708+
newlyEnteredParents.reverse();
1709+
1710+
// Collect labels from newly entered parent regions and the leaf node
1711+
const labels: string[] = [];
1712+
1713+
newlyEnteredParents.forEach((parentKey) => {
1714+
const parent = this.focusableComponents[parentKey];
1715+
1716+
if (parent?.accessibilityLabel) {
1717+
labels.push(parent.accessibilityLabel);
1718+
}
1719+
});
1720+
1721+
if (newComponent.accessibilityLabel) {
1722+
labels.push(newComponent.accessibilityLabel);
1723+
}
1724+
1725+
if (labels.length > 0) {
1726+
this.onUtterText(labels.join(', '));
1727+
}
1728+
}
1729+
16461730
setFocus(focusKey: string, focusDetails: FocusDetails = {}) {
16471731
// Cancel any pending auto-restore focus calls if we are setting focus manually
16481732
this.setFocusDebounced.cancel();
@@ -1667,6 +1751,9 @@ class SpatialNavigationService {
16671751

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

1754+
// Utter accessibility labels BEFORE updating parents so we can detect newly entered regions
1755+
this.utterAccessibilityLabels(newFocusKey);
1756+
16701757
this.setCurrentFocusedKey(newFocusKey, focusDetails);
16711758
this.updateParentsHasFocusedChild(newFocusKey, focusDetails);
16721759
this.updateParentsLastFocusedChild(newFocusKey);
@@ -1713,7 +1800,8 @@ class SpatialNavigationService {
17131800
onEnterRelease,
17141801
onArrowPress,
17151802
onFocus,
1716-
onBlur
1803+
onBlur,
1804+
accessibilityLabel
17171805
}: FocusableComponentUpdatePayload
17181806
) {
17191807
if (this.nativeMode) {
@@ -1732,6 +1820,7 @@ class SpatialNavigationService {
17321820
component.onArrowPress = onArrowPress;
17331821
component.onFocus = onFocus;
17341822
component.onBlur = onBlur;
1823+
component.accessibilityLabel = accessibilityLabel;
17351824

17361825
if (node) {
17371826
component.node = node;

src/useFocusable.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ export interface UseFocusableConfig<P = object> {
6464
onFocus?: FocusHandler<P>;
6565
onBlur?: BlurHandler<P>;
6666
extraProps?: P;
67+
/**
68+
* Accessibility label for this focusable component.
69+
* When focus lands on a leaf node, the labels of all newly-entered parent
70+
* regions (in tree order) are concatenated with the leaf's own label and
71+
* passed to the `onUtterText` callback provided during `init()`.
72+
*/
73+
accessibilityLabel?: string;
6774
}
6875

6976
export interface UseFocusableResult<E = any> {
@@ -90,7 +97,8 @@ const useFocusableHook = <P, E = any>({
9097
onArrowRelease = noop,
9198
onFocus = noop,
9299
onBlur = noop,
93-
extraProps
100+
extraProps,
101+
accessibilityLabel
94102
}: UseFocusableConfig<P> = {}): UseFocusableResult<E> => {
95103
const onEnterPressHandler = useCallback(
96104
(details: KeyPressDetails) => {
@@ -172,7 +180,8 @@ const useFocusableHook = <P, E = any>({
172180
focusBoundaryDirections,
173181
autoRestoreFocus,
174182
forceFocus,
175-
focusable
183+
focusable,
184+
accessibilityLabel
176185
});
177186

178187
return () => {
@@ -196,7 +205,8 @@ const useFocusableHook = <P, E = any>({
196205
onArrowPress: onArrowPressHandler,
197206
onArrowRelease: onArrowReleaseHandler,
198207
onFocus: onFocusHandler,
199-
onBlur: onBlurHandler
208+
onBlur: onBlurHandler,
209+
accessibilityLabel
200210
});
201211
}, [
202212
focusKey,
@@ -209,7 +219,8 @@ const useFocusableHook = <P, E = any>({
209219
onArrowPressHandler,
210220
onArrowReleaseHandler,
211221
onFocusHandler,
212-
onBlurHandler
222+
onBlurHandler,
223+
accessibilityLabel
213224
]);
214225

215226
return {

0 commit comments

Comments
 (0)