@@ -96,6 +96,7 @@ interface FocusableComponent {
9696 lastFocusedChildKey ?: string ;
9797 layout ?: FocusableComponentLayout ;
9898 layoutUpdated ?: boolean ;
99+ accessibilityLabel ?: string ;
99100}
100101
101102interface 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
115117interface 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 ;
0 commit comments