From e4cf3f63506b41d9b8abbfde0a9daac5bbfd4825 Mon Sep 17 00:00:00 2001 From: Dmitriy Bryokhin Date: Thu, 23 Apr 2026 12:38:05 +0200 Subject: [PATCH 1/4] Accessibility labels --- .changeset/accessibility-labels.md | 11 ++ apps/react-demo/src/App.tsx | 8 +- docs/api-reference/SpatialNavigation.md | 36 +++-- docs/api-reference/useFocusable.md | 82 +++++++--- docs/guides/accessibility-labels.md | 196 ++++++++++++++++++++++++ packages/core/src/SpatialNavigation.ts | 93 ++++++++++- packages/react/src/useFocusable.ts | 19 ++- 7 files changed, 403 insertions(+), 42 deletions(-) create mode 100644 .changeset/accessibility-labels.md create mode 100644 docs/guides/accessibility-labels.md diff --git a/.changeset/accessibility-labels.md b/.changeset/accessibility-labels.md new file mode 100644 index 0000000..32d42e3 --- /dev/null +++ b/.changeset/accessibility-labels.md @@ -0,0 +1,11 @@ +--- +'@noriginmedia/norigin-spatial-navigation-core': minor +'@noriginmedia/norigin-spatial-navigation-react': minor +'@noriginmedia/norigin-spatial-navigation': minor +--- + +Add accessibility labels on focusable components. + +Introduces a new optional `onUtterText` callback on `init()` and an `accessibilityLabel` prop on `useFocusable()` (and the underlying `addFocusable` / `updateFocusable` payloads). When focus lands on a focusable component, the library concatenates the labels of all newly-entered parent regions with the leaf node's own label and passes the resulting string to `onUtterText`. Parent region labels are only included when focus enters a region for the first time (similar to how `aria-label` on `role="region"` behaves), so subsequent focus moves within the same parent only utter the leaf label. + +This library does not implement Text-To-Speech itself — it only provides a unified way to declare accessibility labels and wire the callback to the platform's TTS engine, which is particularly useful for cross-platform TV apps where native `aria-*` support is fragmented. diff --git a/apps/react-demo/src/App.tsx b/apps/react-demo/src/App.tsx index e494016..5200fd5 100644 --- a/apps/react-demo/src/App.tsx +++ b/apps/react-demo/src/App.tsx @@ -24,7 +24,11 @@ import logo from './logo.png'; init({ debug: false, visualDebug: false, - distanceCalculationMethod: 'center' + distanceCalculationMethod: 'center', + onUtterText: (text: string) => { + // eslint-disable-next-line no-console + console.log('onUtterText', text); + } }); const rows = shuffle([ @@ -233,6 +237,7 @@ function Asset({ index }: AssetProps) { const { ref, focused } = useFocusable({ + accessibilityLabel: title, onEnterPress, onFocus, extraProps: { @@ -298,6 +303,7 @@ function ContentRow({ isShuffleSize }: ContentRowProps) { const { ref, focusKey } = useFocusable({ + accessibilityLabel: rowTitle, onFocus }); diff --git a/docs/api-reference/SpatialNavigation.md b/docs/api-reference/SpatialNavigation.md index 885415a..f3111dc 100644 --- a/docs/api-reference/SpatialNavigation.md +++ b/docs/api-reference/SpatialNavigation.md @@ -53,25 +53,27 @@ init(config?: { isVerticalDirection: boolean, distanceCalculationMethod: string ) => number; + onUtterText?: (text: string) => void; }): void ``` ### Config options -| Option | Type | Default | Description | -| ----------------------------------- | ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `debug` | `boolean` | `false` | Log navigation decisions to the browser console. | -| `visualDebug` | `boolean` | `false` | Draw a canvas overlay showing component bounding boxes and navigation paths. | -| `nativeMode` | `boolean` | `false` | **Deprecated.** Disable DOM key event listeners (for React Native). You must drive navigation manually. | -| `throttle` | `number` | `0` | Milliseconds to wait between processing repeated key presses. `0` means no throttle. | -| `throttleKeypresses` | `boolean` | `false` | When `true` and `throttle > 0`, throttle key repeat events while a key is held down. | -| `useGetBoundingClientRect` | `boolean` | `false` | Use `getBoundingClientRect()` instead of `offsetLeft/Top` for layout measurement. Use this when elements are CSS-transformed or scaled. | -| `shouldFocusDOMNode` | `boolean` | `false` | Call `HTMLElement.focus()` on the focused component's DOM node, enabling native browser focus behavior and accessibility. | -| `domNodeFocusOptions` | `FocusOptions` | `undefined` | Options passed to `HTMLElement.focus()` when `shouldFocusDOMNode` is `true`. | -| `shouldUseNativeEvents` | `boolean` | `false` | Do not call `preventDefault()` on key events, allowing the browser to handle them natively as well. | -| `rtl` | `boolean` | `false` | Enable right-to-left layout mode. Left and right navigation directions are swapped. | -| `distanceCalculationMethod` | `'center' \| 'edges' \| 'corners'` | `'corners'` | Algorithm used to calculate distance between components. See [Distance Calculation](../guides/distance-calculation.md). | -| `customDistanceCalculationFunction` | `function` | `undefined` | Override the secondary-axis distance calculation. See [Distance Calculation](../guides/distance-calculation.md). | +| Option | Type | Default | Description | +| ----------------------------------- | ---------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `debug` | `boolean` | `false` | Log navigation decisions to the browser console. | +| `visualDebug` | `boolean` | `false` | Draw a canvas overlay showing component bounding boxes and navigation paths. | +| `nativeMode` | `boolean` | `false` | **Deprecated.** Disable DOM key event listeners (for React Native). You must drive navigation manually. | +| `throttle` | `number` | `0` | Milliseconds to wait between processing repeated key presses. `0` means no throttle. | +| `throttleKeypresses` | `boolean` | `false` | When `true` and `throttle > 0`, throttle key repeat events while a key is held down. | +| `useGetBoundingClientRect` | `boolean` | `false` | Use `getBoundingClientRect()` instead of `offsetLeft/Top` for layout measurement. Use this when elements are CSS-transformed or scaled. | +| `shouldFocusDOMNode` | `boolean` | `false` | Call `HTMLElement.focus()` on the focused component's DOM node, enabling native browser focus behavior and accessibility. | +| `domNodeFocusOptions` | `FocusOptions` | `undefined` | Options passed to `HTMLElement.focus()` when `shouldFocusDOMNode` is `true`. | +| `shouldUseNativeEvents` | `boolean` | `false` | Do not call `preventDefault()` on key events, allowing the browser to handle them natively as well. | +| `rtl` | `boolean` | `false` | Enable right-to-left layout mode. Left and right navigation directions are swapped. | +| `distanceCalculationMethod` | `'center' \| 'edges' \| 'corners'` | `'corners'` | Algorithm used to calculate distance between components. See [Distance Calculation](../guides/distance-calculation.md). | +| `customDistanceCalculationFunction` | `function` | `undefined` | Override the secondary-axis distance calculation. See [Distance Calculation](../guides/distance-calculation.md). | +| `onUtterText` | `(text: string) => void` | `undefined` | Global callback invoked with a concatenated accessibility label string whenever focus changes. Wire this to your platform's Text-To-Speech engine. See the [Accessibility Labels](../guides/accessibility-labels.md) guide. | ### Example @@ -83,7 +85,11 @@ init({ visualDebug: false, distanceCalculationMethod: 'center', throttle: 150, - throttleKeypresses: true + throttleKeypresses: true, + onUtterText: (text) => { + // Hand the string off to the platform's Text-To-Speech engine + platformTTS.speak(text); + } }); ``` diff --git a/docs/api-reference/useFocusable.md b/docs/api-reference/useFocusable.md index 7545ef6..61e4e19 100644 --- a/docs/api-reference/useFocusable.md +++ b/docs/api-reference/useFocusable.md @@ -22,24 +22,25 @@ The generic parameter `P` is the type of `extraProps`. The parameter `E` is the All options are optional. -| Option | Type | Default | Description | -| ------------------------- | ------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `focusable` | `boolean` | `true` | Whether this component can receive focus. Set to `false` to temporarily disable a component without unmounting it. | -| `saveLastFocusedChild` | `boolean` | `true` | When focus returns to this container, restore focus to the last focused child instead of the first. | -| `trackChildren` | `boolean` | `false` | Update `hasFocusedChild` when any descendant gains or loses focus. Must be `true` to use `hasFocusedChild` for styling. | -| `autoRestoreFocus` | `boolean` | `true` | If this component is focused when it unmounts, automatically restore focus to the nearest other component. | -| `forceFocus` | `boolean` | `false` | Mark this component as the preferred fallback target when focus is lost and no other candidate exists. | -| `isFocusBoundary` | `boolean` | `false` | Prevent focus from leaving this container in any direction. See [Focus Boundaries](../guides/focus-boundaries.md). | -| `focusBoundaryDirections` | `Direction[]` | `undefined` | Limit boundary behavior to specific directions only (e.g., `['up', 'left']`). Only used when `isFocusBoundary` is `true`. | -| `focusKey` | `string` | auto-generated | A stable, unique identifier for this component. Required for programmatic focus via `setFocus`. | -| `preferredChildFocusKey` | `string` | `undefined` | Focus key of the child that should receive focus when this container is first entered. | -| `onEnterPress` | `EnterPressHandler

` | no-op | Called when the Enter key is pressed while this component is focused. | -| `onEnterRelease` | `EnterReleaseHandler

` | no-op | Called when the Enter key is released. | -| `onArrowPress` | `ArrowPressHandler

` | `() => true` | Called when an arrow key is pressed. Return `true` to allow default navigation, `false` to prevent it. | -| `onArrowRelease` | `ArrowReleaseHandler

` | no-op | Called when an arrow key is released. | -| `onFocus` | `FocusHandler

` | no-op | Called when this component gains focus. | -| `onBlur` | `BlurHandler

` | no-op | Called when this component loses focus. | -| `extraProps` | `P` | `undefined` | Arbitrary data passed as the first argument to all event callbacks. Use this to avoid closure stale-state issues. | +| Option | Type | Default | Description | +| ------------------------- | ------------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `focusable` | `boolean` | `true` | Whether this component can receive focus. Set to `false` to temporarily disable a component without unmounting it. | +| `saveLastFocusedChild` | `boolean` | `true` | When focus returns to this container, restore focus to the last focused child instead of the first. | +| `trackChildren` | `boolean` | `false` | Update `hasFocusedChild` when any descendant gains or loses focus. Must be `true` to use `hasFocusedChild` for styling. | +| `autoRestoreFocus` | `boolean` | `true` | If this component is focused when it unmounts, automatically restore focus to the nearest other component. | +| `forceFocus` | `boolean` | `false` | Mark this component as the preferred fallback target when focus is lost and no other candidate exists. | +| `isFocusBoundary` | `boolean` | `false` | Prevent focus from leaving this container in any direction. See [Focus Boundaries](../guides/focus-boundaries.md). | +| `focusBoundaryDirections` | `Direction[]` | `undefined` | Limit boundary behavior to specific directions only (e.g., `['up', 'left']`). Only used when `isFocusBoundary` is `true`. | +| `focusKey` | `string` | auto-generated | A stable, unique identifier for this component. Required for programmatic focus via `setFocus`. | +| `preferredChildFocusKey` | `string` | `undefined` | Focus key of the child that should receive focus when this container is first entered. | +| `onEnterPress` | `EnterPressHandler

` | no-op | Called when the Enter key is pressed while this component is focused. | +| `onEnterRelease` | `EnterReleaseHandler

` | no-op | Called when the Enter key is released. | +| `onArrowPress` | `ArrowPressHandler

` | `() => true` | Called when an arrow key is pressed. Return `true` to allow default navigation, `false` to prevent it. | +| `onArrowRelease` | `ArrowReleaseHandler

` | no-op | Called when an arrow key is released. | +| `onFocus` | `FocusHandler

` | no-op | Called when this component gains focus. | +| `onBlur` | `BlurHandler

` | no-op | Called when this component loses focus. | +| `extraProps` | `P` | `undefined` | Arbitrary data passed as the first argument to all event callbacks. Use this to avoid closure stale-state issues. | +| `accessibilityLabel` | `string` | `undefined` | Text uttered by the global `onUtterText` callback when this component is focused. See [accessibilityLabel](#accessibilitylabel) below. | --- @@ -243,10 +244,53 @@ function Modal() { } ``` +### `accessibilityLabel` + +When the global `onUtterText` callback is configured on [`init()`](./SpatialNavigation.md#init-config), each focus change builds a string from the labels of any parent regions that focus has newly entered plus the label of the focused component itself. That string is then passed to your `onUtterText` callback, which typically hands it off to the platform's Text-To-Speech engine. See the [Accessibility Labels](../guides/accessibility-labels.md) guide for a full walkthrough. + +Label on both leaf items and the containers that group them. Sibling focus moves inside the same parent only utter the leaf label; entering a new parent region prepends the parent's label. + +```typescript +import { + useFocusable, + FocusContext +} from '@noriginmedia/norigin-spatial-navigation-react'; + +function Asset({ title }: { title: string }) { + const { ref, focused } = useFocusable({ + accessibilityLabel: title + }); + + return ( +

+ {title} +
+ ); +} + +function Row({ title }: { title: string }) { + const { ref, focusKey } = useFocusable({ + accessibilityLabel: title + }); + + return ( + +
+

{title}

+ + +
+
+ ); +} +``` + +With the tree above, landing focus on `Inception` utters `"Movies, Inception"`. Moving focus to `Interstellar` only utters `"Interstellar"` (the `Row` region has not changed). Unlike most other options on this hook, `accessibilityLabel` is reactive — changing its value after mount is propagated to the navigation service and reflected on the next focus change. + --- ## Notes - The `ref` must be attached to a DOM element that has non-zero width and height when the component mounts. If the element is zero-sized, the library cannot measure its position and navigation to/from it will not work correctly. -- Config options other than `focusKey`, `focusable`, `isFocusBoundary`, `focusBoundaryDirections`, and `preferredChildFocusKey` are **not** reactive after mount. Callbacks are updated via a separate effect, but structural options like `saveLastFocusedChild`, `trackChildren`, `autoRestoreFocus`, and `forceFocus` are only read at registration time. +- Config options other than `focusKey`, `focusable`, `isFocusBoundary`, `focusBoundaryDirections`, `preferredChildFocusKey`, and `accessibilityLabel` are **not** reactive after mount. Callbacks are updated via a separate effect, but structural options like `saveLastFocusedChild`, `trackChildren`, `autoRestoreFocus`, and `forceFocus` are only read at registration time. - Use `extraProps` to pass data to callbacks instead of relying on closure variables. This avoids stale closure issues with callbacks that reference component props. diff --git a/docs/guides/accessibility-labels.md b/docs/guides/accessibility-labels.md new file mode 100644 index 0000000..3fed5e1 --- /dev/null +++ b/docs/guides/accessibility-labels.md @@ -0,0 +1,196 @@ +--- +sidebar_position: 14 +--- + +# Accessibility Labels + +Modern TV platforms have their own screen reader / Text-To-Speech (TTS) engines, and native `aria-*` support varies widely between them. This library provides a platform-agnostic way to declare accessibility labels on focusable components and receive a single callback every time focus moves, so you can route the resulting string to whichever TTS engine your target platform exposes. + +The library does not implement Text-To-Speech itself. It only tells you _what_ to speak; _how_ to speak it is up to you. + +## Overview + +Two pieces work together: + +1. A global [`onUtterText`](../api-reference/SpatialNavigation.md#init-config) callback passed to `init()`. The library invokes it with a `string` every time focus lands on a component whose own or ancestor labels need to be uttered. +2. A per-component [`accessibilityLabel`](../api-reference/useFocusable.md#accessibilitylabel) string passed to `useFocusable()`. Apply it to both leaf items (buttons, cards) and container components (rows, grids, menus). + +When focus moves, the library walks up the focus tree from the newly focused component, collects the labels of any ancestor regions that are being entered for the first time, appends the leaf component's own label, and joins the list with `', '`. The resulting string is passed to `onUtterText`. + +## Setup + +### 1. Wire the callback + +Connect `onUtterText` to your platform's TTS entry point at `init()` time: + +```typescript +import { init } from '@noriginmedia/norigin-spatial-navigation-core'; + +init({ + debug: false, + visualDebug: false, + onUtterText: (text) => { + // Replace with your platform's TTS API + platformTTS.speak(text); + } +}); +``` + +A few examples of what `platformTTS.speak` might look like in practice: + +| Platform | Typical entry point | +| ------------ | ----------------------------------------------------------------------------------------------- | +| Tizen (Samsung TV) | `tizen.tvinputdevice` + custom TTS bridge, or `window.webapis.tts.speak(text)` on some models | +| webOS (LG TV) | `webOS.service.request('luna://com.webos.service.tts', { method: 'speak', ... })` | +| Android TV | Web app running inside a native shell that forwards to `TextToSpeech.speak()` | +| Browser dev env | `window.speechSynthesis.speak(new SpeechSynthesisUtterance(text))` | + +During development, logging to the console is usually enough: + +```typescript +init({ + onUtterText: (text) => { + // eslint-disable-next-line no-console + console.log('onUtterText', text); + } +}); +``` + +### 2. Label your components + +Add `accessibilityLabel` to `useFocusable()` on both leaves and containers: + +```typescript +import { + useFocusable, + FocusContext +} from '@noriginmedia/norigin-spatial-navigation-react'; + +function Asset({ title }: { title: string }) { + const { ref, focused } = useFocusable({ + accessibilityLabel: title + }); + + return ( +
+ {title} +
+ ); +} + +function ContentRow({ title }: { title: string }) { + const { ref, focusKey } = useFocusable({ + accessibilityLabel: title + }); + + return ( + +
+

{title}

+ + +
+
+ ); +} +``` + +### 3. Disable native `aria-*` if needed + +If your app previously used `aria-label`, `role="region"`, or similar attributes on focusable elements, the browser (and any native screen reader running over it) may also try to announce them. When routing through `onUtterText`, strip or disable those native attributes on the same elements to avoid double-speaking. + +## How labels are combined + +The library keeps track of which parent containers already "contain" the currently focused component (`parentsHavingFocusedChild`). When focus changes: + +1. Walk up the focus tree from the new leaf, collecting every ancestor focus key. +2. Filter that chain down to just the ancestors that were **not** already parents of the previous focus — these are the "newly entered regions". +3. Order them top-down (root → leaf) so the utterance reads in natural order. +4. For each newly entered ancestor, push its `accessibilityLabel` if set. +5. Push the leaf component's `accessibilityLabel` if set. +6. If anything was collected, join with `', '` and call `onUtterText(text)`. + +This matches how a screen reader treats `aria-label` on `role="region"`: the region name is announced when focus enters the region for the first time, and subsequent movements inside the same region only announce the leaf. + +### Worked example + +Given this focus tree: + +``` +Menu (label="Main Menu") + ├── Home (label="Home") + └── Library (label="Library") +Content (label="Recommended") + ├── Row 1 (label="Movies") + │ ├── Asset 1 (label="Inception") + │ └── Asset 2 (label="Interstellar") + └── Row 2 (label="Series") + ├── Asset 3 (label="Breaking Bad") + └── Asset 4 (label="The Wire") +``` + +Navigating `Home` → `Inception` → `Interstellar` → `Breaking Bad` produces: + +| Step | `onUtterText` argument | Why | +| ---------------------- | -------------------------------------- | ------------------------------------------------------------------------- | +| `Home` → `Inception` | `"Recommended, Movies, Inception"` | Entered `Content` and `Row 1` for the first time, plus the leaf label | +| `Inception` → `Interstellar` | `"Interstellar"` | Still inside `Row 1`; no new parent region entered — only the leaf label | +| `Interstellar` → `Breaking Bad` | `"Series, Breaking Bad"` | Left `Row 1` and entered `Row 2`; `Content` is unchanged, so it's skipped | + +### When nothing is uttered + +`onUtterText` is called only when at least one label is collected. Specifically, nothing is spoken when: + +- The focus key didn't actually change (e.g. `setFocus()` re-focused the same component). +- The newly focused component has no `accessibilityLabel` and no newly-entered ancestor has one either. +- `onUtterText` was not passed to `init()`. + +Leaves with no label that live inside a region that _does_ have a label will still utter just the region label when focus first enters the region, which is usually what you want for container-level announcements. + +## Patterns and tips + +### Make labels descriptive, not generic + +Prefer the concrete item over the element type. `"Play"` is more useful than `"Button"`; `"Settings"` is more useful than `"Menu item 3"`. + +### Update labels reactively + +`accessibilityLabel` is one of the few `useFocusable` options that _is_ reactive after mount. Passing a new value triggers an update; the next focus change on that component will use the new label. This is useful for toggles and counters: + +```typescript +function Favorite({ title, isFavorited }: { title: string; isFavorited: boolean }) { + const { ref, focused } = useFocusable({ + accessibilityLabel: isFavorited + ? `Remove ${title} from favorites` + : `Add ${title} to favorites` + }); + + return ; +} +``` + +### Consider cancelling in-flight speech + +Most TTS engines queue utterances by default. During fast scrolling, that queue can grow long and lag behind the focus state. A common mitigation is to cancel the current utterance before starting the new one: + +```typescript +init({ + onUtterText: (text) => { + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(new SpeechSynthesisUtterance(text)); + } +}); +``` + +### Throttling + +If you expect fast, repeated key presses (e.g. holding the arrow key), consider using the existing [`throttle`](../api-reference/SpatialNavigation.md#init-config) init option. Throttling focus changes also throttles TTS calls, which keeps the spoken feed in sync with what the user can actually process. + +## API summary + +| Location | Purpose | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [`init({ onUtterText })`](../api-reference/SpatialNavigation.md#init-config) | Global callback fired with the text to be uttered when focus changes. | +| [`useFocusable({ accessibilityLabel })`](../api-reference/useFocusable.md#accessibilitylabel) | Per-component label. Set on both leaf items and containers. | + +See also: [`useFocusable`](../api-reference/useFocusable.md) and [`SpatialNavigation`](../api-reference/SpatialNavigation.md). diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index 23f1a8f..d601d01 100644 --- a/packages/core/src/SpatialNavigation.ts +++ b/packages/core/src/SpatialNavigation.ts @@ -98,6 +98,7 @@ interface FocusableComponent { lastFocusedChildKey?: string; layout?: FocusableComponentLayout; layoutUpdated?: boolean; + accessibilityLabel?: string; } interface FocusableComponentUpdatePayload { @@ -112,6 +113,7 @@ interface FocusableComponentUpdatePayload { onArrowRelease: (direction: string) => void; onFocus: (layout: FocusableComponentLayout, details: FocusDetails) => void; onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void; + accessibilityLabel?: string; } interface FocusableComponentRemovePayload { @@ -263,6 +265,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" */ @@ -652,6 +660,8 @@ class SpatialNavigationService { trailing: true }); + this.onUtterText = null; + this.debug = false; this.visualDebugger = null; @@ -676,7 +686,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; @@ -690,6 +701,7 @@ class SpatialNavigationService { this.distanceCalculationMethod = distanceCalculationMethod; this.customDistanceCalculationFunction = customDistanceCalculationFunction; + this.onUtterText = onUtterText || null; this.debug = debug; @@ -750,6 +762,7 @@ class SpatialNavigationService { this.focusableComponents = {}; this.paused = false; this.keyMap = DEFAULT_KEY_MAP; + this.onUtterText = null; this.unbindEventHandlers(); } @@ -1342,7 +1355,8 @@ class SpatialNavigationService { forceFocus, focusable, isFocusBoundary, - focusBoundaryDirections + focusBoundaryDirections, + accessibilityLabel }: FocusableComponent) { this.focusableComponents[focusKey] = { focusKey, @@ -1364,6 +1378,7 @@ class SpatialNavigationService { focusBoundaryDirections, autoRestoreFocus, forceFocus, + accessibilityLabel, lastFocusedChildKey: null, layout: { x: 0, @@ -1666,6 +1681,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. + */ + 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(); @@ -1701,6 +1783,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); @@ -1747,7 +1832,8 @@ class SpatialNavigationService { onEnterRelease, onArrowPress, onFocus, - onBlur + onBlur, + accessibilityLabel }: FocusableComponentUpdatePayload ) { if (this.nativeMode) { @@ -1766,6 +1852,7 @@ class SpatialNavigationService { component.onArrowPress = onArrowPress; component.onFocus = onFocus; component.onBlur = onBlur; + component.accessibilityLabel = accessibilityLabel; if (node) { component.node = node; diff --git a/packages/react/src/useFocusable.ts b/packages/react/src/useFocusable.ts index 407887f..658c6d6 100644 --- a/packages/react/src/useFocusable.ts +++ b/packages/react/src/useFocusable.ts @@ -63,6 +63,13 @@ export interface UseFocusableConfig

{ onFocus?: FocusHandler

; onBlur?: BlurHandler

; 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 { @@ -89,7 +96,8 @@ const useFocusableHook = ({ onArrowRelease = noop, onFocus = noop, onBlur = noop, - extraProps + extraProps, + accessibilityLabel }: UseFocusableConfig

= {}): UseFocusableResult => { const onEnterPressHandler = useCallback( (details: KeyPressDetails) => { @@ -174,7 +182,8 @@ const useFocusableHook = ({ focusBoundaryDirections, autoRestoreFocus, forceFocus, - focusable + focusable, + accessibilityLabel }); return () => { @@ -198,7 +207,8 @@ const useFocusableHook = ({ onArrowPress: onArrowPressHandler, onArrowRelease: onArrowReleaseHandler, onFocus: onFocusHandler, - onBlur: onBlurHandler + onBlur: onBlurHandler, + accessibilityLabel }); }, [ focusKey, @@ -211,7 +221,8 @@ const useFocusableHook = ({ onArrowPressHandler, onArrowReleaseHandler, onFocusHandler, - onBlurHandler + onBlurHandler, + accessibilityLabel ]); return { From bd467076c9f47cc3f4eb260a3f353654b195044c Mon Sep 17 00:00:00 2001 From: Dmitriy Bryokhin Date: Thu, 23 Apr 2026 12:39:24 +0200 Subject: [PATCH 2/4] Removed AndroidTV example --- docs/guides/accessibility-labels.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/guides/accessibility-labels.md b/docs/guides/accessibility-labels.md index 3fed5e1..166958d 100644 --- a/docs/guides/accessibility-labels.md +++ b/docs/guides/accessibility-labels.md @@ -42,7 +42,6 @@ A few examples of what `platformTTS.speak` might look like in practice: | ------------ | ----------------------------------------------------------------------------------------------- | | Tizen (Samsung TV) | `tizen.tvinputdevice` + custom TTS bridge, or `window.webapis.tts.speak(text)` on some models | | webOS (LG TV) | `webOS.service.request('luna://com.webos.service.tts', { method: 'speak', ... })` | -| Android TV | Web app running inside a native shell that forwards to `TextToSpeech.speak()` | | Browser dev env | `window.speechSynthesis.speak(new SpeechSynthesisUtterance(text))` | During development, logging to the console is usually enough: From ba839518bcf5fac9bb476af3cd43e31d77ac51f0 Mon Sep 17 00:00:00 2001 From: Dmitriy Bryokhin Date: Thu, 23 Apr 2026 13:39:38 +0200 Subject: [PATCH 3/4] Prettier changes --- docs/api-reference/SpatialNavigation.md | 28 ++++++++++---------- docs/guides/accessibility-labels.md | 34 +++++++++++++++---------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/docs/api-reference/SpatialNavigation.md b/docs/api-reference/SpatialNavigation.md index f3111dc..5b0fbee 100644 --- a/docs/api-reference/SpatialNavigation.md +++ b/docs/api-reference/SpatialNavigation.md @@ -59,20 +59,20 @@ init(config?: { ### Config options -| Option | Type | Default | Description | -| ----------------------------------- | ---------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `debug` | `boolean` | `false` | Log navigation decisions to the browser console. | -| `visualDebug` | `boolean` | `false` | Draw a canvas overlay showing component bounding boxes and navigation paths. | -| `nativeMode` | `boolean` | `false` | **Deprecated.** Disable DOM key event listeners (for React Native). You must drive navigation manually. | -| `throttle` | `number` | `0` | Milliseconds to wait between processing repeated key presses. `0` means no throttle. | -| `throttleKeypresses` | `boolean` | `false` | When `true` and `throttle > 0`, throttle key repeat events while a key is held down. | -| `useGetBoundingClientRect` | `boolean` | `false` | Use `getBoundingClientRect()` instead of `offsetLeft/Top` for layout measurement. Use this when elements are CSS-transformed or scaled. | -| `shouldFocusDOMNode` | `boolean` | `false` | Call `HTMLElement.focus()` on the focused component's DOM node, enabling native browser focus behavior and accessibility. | -| `domNodeFocusOptions` | `FocusOptions` | `undefined` | Options passed to `HTMLElement.focus()` when `shouldFocusDOMNode` is `true`. | -| `shouldUseNativeEvents` | `boolean` | `false` | Do not call `preventDefault()` on key events, allowing the browser to handle them natively as well. | -| `rtl` | `boolean` | `false` | Enable right-to-left layout mode. Left and right navigation directions are swapped. | -| `distanceCalculationMethod` | `'center' \| 'edges' \| 'corners'` | `'corners'` | Algorithm used to calculate distance between components. See [Distance Calculation](../guides/distance-calculation.md). | -| `customDistanceCalculationFunction` | `function` | `undefined` | Override the secondary-axis distance calculation. See [Distance Calculation](../guides/distance-calculation.md). | +| Option | Type | Default | Description | +| ----------------------------------- | ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `debug` | `boolean` | `false` | Log navigation decisions to the browser console. | +| `visualDebug` | `boolean` | `false` | Draw a canvas overlay showing component bounding boxes and navigation paths. | +| `nativeMode` | `boolean` | `false` | **Deprecated.** Disable DOM key event listeners (for React Native). You must drive navigation manually. | +| `throttle` | `number` | `0` | Milliseconds to wait between processing repeated key presses. `0` means no throttle. | +| `throttleKeypresses` | `boolean` | `false` | When `true` and `throttle > 0`, throttle key repeat events while a key is held down. | +| `useGetBoundingClientRect` | `boolean` | `false` | Use `getBoundingClientRect()` instead of `offsetLeft/Top` for layout measurement. Use this when elements are CSS-transformed or scaled. | +| `shouldFocusDOMNode` | `boolean` | `false` | Call `HTMLElement.focus()` on the focused component's DOM node, enabling native browser focus behavior and accessibility. | +| `domNodeFocusOptions` | `FocusOptions` | `undefined` | Options passed to `HTMLElement.focus()` when `shouldFocusDOMNode` is `true`. | +| `shouldUseNativeEvents` | `boolean` | `false` | Do not call `preventDefault()` on key events, allowing the browser to handle them natively as well. | +| `rtl` | `boolean` | `false` | Enable right-to-left layout mode. Left and right navigation directions are swapped. | +| `distanceCalculationMethod` | `'center' \| 'edges' \| 'corners'` | `'corners'` | Algorithm used to calculate distance between components. See [Distance Calculation](../guides/distance-calculation.md). | +| `customDistanceCalculationFunction` | `function` | `undefined` | Override the secondary-axis distance calculation. See [Distance Calculation](../guides/distance-calculation.md). | | `onUtterText` | `(text: string) => void` | `undefined` | Global callback invoked with a concatenated accessibility label string whenever focus changes. Wire this to your platform's Text-To-Speech engine. See the [Accessibility Labels](../guides/accessibility-labels.md) guide. | ### Example diff --git a/docs/guides/accessibility-labels.md b/docs/guides/accessibility-labels.md index 166958d..a43971e 100644 --- a/docs/guides/accessibility-labels.md +++ b/docs/guides/accessibility-labels.md @@ -38,11 +38,11 @@ init({ A few examples of what `platformTTS.speak` might look like in practice: -| Platform | Typical entry point | -| ------------ | ----------------------------------------------------------------------------------------------- | +| Platform | Typical entry point | +| ------------------ | --------------------------------------------------------------------------------------------- | | Tizen (Samsung TV) | `tizen.tvinputdevice` + custom TTS bridge, or `window.webapis.tts.speak(text)` on some models | -| webOS (LG TV) | `webOS.service.request('luna://com.webos.service.tts', { method: 'speak', ... })` | -| Browser dev env | `window.speechSynthesis.speak(new SpeechSynthesisUtterance(text))` | +| webOS (LG TV) | `webOS.service.request('luna://com.webos.service.tts', { method: 'speak', ... })` | +| Browser dev env | `window.speechSynthesis.speak(new SpeechSynthesisUtterance(text))` | During development, logging to the console is usually enough: @@ -130,11 +130,11 @@ Content (label="Recommended") Navigating `Home` → `Inception` → `Interstellar` → `Breaking Bad` produces: -| Step | `onUtterText` argument | Why | -| ---------------------- | -------------------------------------- | ------------------------------------------------------------------------- | -| `Home` → `Inception` | `"Recommended, Movies, Inception"` | Entered `Content` and `Row 1` for the first time, plus the leaf label | -| `Inception` → `Interstellar` | `"Interstellar"` | Still inside `Row 1`; no new parent region entered — only the leaf label | -| `Interstellar` → `Breaking Bad` | `"Series, Breaking Bad"` | Left `Row 1` and entered `Row 2`; `Content` is unchanged, so it's skipped | +| Step | `onUtterText` argument | Why | +| ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------- | +| `Home` → `Inception` | `"Recommended, Movies, Inception"` | Entered `Content` and `Row 1` for the first time, plus the leaf label | +| `Inception` → `Interstellar` | `"Interstellar"` | Still inside `Row 1`; no new parent region entered — only the leaf label | +| `Interstellar` → `Breaking Bad` | `"Series, Breaking Bad"` | Left `Row 1` and entered `Row 2`; `Content` is unchanged, so it's skipped | ### When nothing is uttered @@ -157,7 +157,13 @@ Prefer the concrete item over the element type. `"Play"` is more useful than `"B `accessibilityLabel` is one of the few `useFocusable` options that _is_ reactive after mount. Passing a new value triggers an update; the next focus change on that component will use the new label. This is useful for toggles and counters: ```typescript -function Favorite({ title, isFavorited }: { title: string; isFavorited: boolean }) { +function Favorite({ + title, + isFavorited +}: { + title: string; + isFavorited: boolean; +}) { const { ref, focused } = useFocusable({ accessibilityLabel: isFavorited ? `Remove ${title} from favorites` @@ -187,9 +193,9 @@ If you expect fast, repeated key presses (e.g. holding the arrow key), consider ## API summary -| Location | Purpose | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| [`init({ onUtterText })`](../api-reference/SpatialNavigation.md#init-config) | Global callback fired with the text to be uttered when focus changes. | -| [`useFocusable({ accessibilityLabel })`](../api-reference/useFocusable.md#accessibilitylabel) | Per-component label. Set on both leaf items and containers. | +| Location | Purpose | +| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| [`init({ onUtterText })`](../api-reference/SpatialNavigation.md#init-config) | Global callback fired with the text to be uttered when focus changes. | +| [`useFocusable({ accessibilityLabel })`](../api-reference/useFocusable.md#accessibilitylabel) | Per-component label. Set on both leaf items and containers. | See also: [`useFocusable`](../api-reference/useFocusable.md) and [`SpatialNavigation`](../api-reference/SpatialNavigation.md). From ed7292c9810dd6b7a2c02b1a259f20b4eee5f9a5 Mon Sep 17 00:00:00 2001 From: dmytrobrokhin Date: Fri, 24 Apr 2026 15:17:22 +0200 Subject: [PATCH 4/4] Changed onUtterText to undefined default, added type for init options --- packages/core/src/SpatialNavigation.ts | 31 +++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index d601d01..07d11ab 100644 --- a/packages/core/src/SpatialNavigation.ts +++ b/packages/core/src/SpatialNavigation.ts @@ -31,6 +31,25 @@ type DistanceCalculationFunction = ( distanceCalculationMethod: DistanceCalculationMethod ) => number; +export interface SpatialNavigationServiceInit { + debug?: boolean; + visualDebug?: boolean; + /** + * @deprecated Native mode will be removed in the next version + */ + nativeMode?: boolean; + throttle?: number; + throttleKeypresses?: boolean; + useGetBoundingClientRect?: boolean; + shouldFocusDOMNode?: boolean; + domNodeFocusOptions?: FocusOptions; + shouldUseNativeEvents?: boolean; + rtl?: boolean; + distanceCalculationMethod?: DistanceCalculationMethod; + customDistanceCalculationFunction?: DistanceCalculationFunction; + onUtterText?: (text: string) => void; +} + const DEFAULT_KEY_MAP = { [DIRECTION_LEFT]: [37, 'ArrowLeft'], [DIRECTION_UP]: [38, 'ArrowUp'], @@ -269,7 +288,7 @@ class SpatialNavigationService { * 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; + private onUtterText: ((text: string) => void) | undefined; /** * Used to determine the coordinate that will be used to filter items that are over the "edge" @@ -660,7 +679,7 @@ class SpatialNavigationService { trailing: true }); - this.onUtterText = null; + this.onUtterText = undefined; this.debug = false; this.visualDebugger = null; @@ -687,8 +706,8 @@ class SpatialNavigationService { rtl = false, distanceCalculationMethod = 'corners' as DistanceCalculationMethod, customDistanceCalculationFunction = undefined as DistanceCalculationFunction, - onUtterText = undefined as ((text: string) => void) | undefined - } = {}) { + onUtterText + }: SpatialNavigationServiceInit = {}) { if (!this.enabled) { this.domNodeFocusOptions = domNodeFocusOptions; this.enabled = true; @@ -701,7 +720,7 @@ class SpatialNavigationService { this.distanceCalculationMethod = distanceCalculationMethod; this.customDistanceCalculationFunction = customDistanceCalculationFunction; - this.onUtterText = onUtterText || null; + this.onUtterText = onUtterText ?? undefined; this.debug = debug; @@ -762,7 +781,7 @@ class SpatialNavigationService { this.focusableComponents = {}; this.paused = false; this.keyMap = DEFAULT_KEY_MAP; - this.onUtterText = null; + this.onUtterText = undefined; this.unbindEventHandlers(); }