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
7 changes: 7 additions & 0 deletions .changeset/focus-on-preset-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@noriginmedia/norigin-spatial-navigation-core': minor
'@noriginmedia/norigin-spatial-navigation-react': minor
'@noriginmedia/norigin-spatial-navigation': minor
---

Add `focusOnPresetKey` init option (default `true`) to control whether a component is automatically focused when it is added and its focus key was already set as the current focus key (e.g. `setFocus` was called before the component mounted). Set it to `false` to disable this implicit refocus on add.
90 changes: 90 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# CLAUDE.md

Guidance for AI agents working in this repository. [CONTRIBUTING.md](CONTRIBUTING.md) is the
authoritative contributor doc — this file is a quick-reference summary of it.

## What this is

Norigin Spatial Navigation — a library for directional (D-pad / remote control) focus management
on TVs and web apps. The core engine is framework-agnostic; a React binding wraps it.

## Repository layout

[Turborepo](https://turbo.build/) monorepo using [npm workspaces](https://docs.npmjs.com/cli/v10/using-npm/workspaces):

```
apps/
react-demo/ # Example React app for trying changes end-to-end
packages/
core/ # @noriginmedia/norigin-spatial-navigation-core (the engine)
react/ # @noriginmedia/norigin-spatial-navigation-react (useFocusable hook)
legacy/ # @noriginmedia/norigin-spatial-navigation (re-exports core + react)
```

The bulk of the logic lives in `packages/core/src/SpatialNavigation.ts`. `legacy` is a thin
`export *` of core + react, so additive changes to those packages flow through automatically.

## Commands

Requires Node.js 20+ and npm 10+. Run everything **from the repository root** — Turbo and npm
workspaces handle cross-package symlinking, so there is no need to `npm install` inside packages.

| Command | What it does |
| ---------------------- | ----------------------------------------------------- |
| `npm install` | Install all workspaces; also sets up the Husky hooks. |
| `npm run start` | Build packages and run the demo app via Turbo. |
| `npm run build` | Build every package via Turbo. |
| `npm run test` | Run the test suite for every package that has tests. |
| `npm run lint` | ESLint check across all workspaces. |
| `npm run prettier` | Prettier format check across the repo. |
| `npm run prettier:fix` | Auto-format files with Prettier. |
| `npm run changeset` | Generate a changeset for user-visible changes. |

## Code style

- **ESLint** — configured at the root (`.eslintrc.json`), inherited by each package.
- **Prettier** — configured at the root (`.prettierrc`).

Both run automatically on commit via a [Husky](https://typicode.github.io/husky/) `pre-commit`
hook (installed by `npm install`) and again in CI on every pull request. If a commit is rejected,
run `npm run lint` and `npm run prettier` to see what needs fixing.

## Testing

Jest + ts-jest, running in jsdom. Tests live in `**/__tests__/**/*.test.[jt]s?(x)` alongside the
source they cover (e.g. `packages/core/src/__tests__/SpatialNavigation.test.ts`). `domNodes.ts`
provides helpers for building focusable layouts in tests.

## Changesets

Any **user-visible** change needs a changeset. Run `npm run changeset` (interactive) or add a
markdown file under `.changeset/` directly. The format is YAML frontmatter mapping each affected
package name to its semver bump (`patch` / `minor` / `major`), followed by a one-paragraph summary
written for end users:

```markdown
---
'@noriginmedia/norigin-spatial-navigation-core': minor
'@noriginmedia/norigin-spatial-navigation-react': minor
'@noriginmedia/norigin-spatial-navigation': minor
---

Short, user-facing description of the change.
```

Versioning and publishing are handled by CI after merge. Changes to internal tooling, docs, tests,
CI, or the demo app do **not** need a changeset.

## Pull request process

1. Branch from `main`.
2. Make the change. If it touches the public API, update the docs under `docs/` (and the demo app
in `apps/react-demo/` if applicable).
3. Run `npm run lint`, `npm run prettier`, and `npm run test` locally.
4. Generate a changeset (unless the change is tooling-only).
5. Open the PR; once approved, use "Squash and Merge" and delete the branch.

## Library usage reference

For how to _use_ the library (the `useFocusable` hook, `init` options, common patterns), see
`skills/norigin-spatial-navigation-react/SKILL.md` and the full docs under `docs/`.
2 changes: 2 additions & 0 deletions docs/api-reference/SpatialNavigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ init(config?: {
distanceCalculationMethod: string
) => number;
onUtterText?: (text: string) => void;
focusOnPresetKey?: boolean;
}): void
```

Expand All @@ -74,6 +75,7 @@ init(config?: {
| `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. |
| `focusOnPresetKey` | `boolean` | `true` | When a component is added whose focus key was already set as the current focus key (via `setFocus` before it mounted), automatically focus it. Set to `false` to disable this implicit refocus on add. |

### Example

Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/core/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
// lodash-es ships ESM only, which Jest cannot transform out of node_modules.
// Map it to the CJS lodash build (identical API) for tests; the rollup build
// still bundles lodash-es for the published packages.
moduleNameMapper: {
'^lodash-es$': 'lodash'
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/__tests__/**',
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@types/lodash-es": "^4.17.12",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lodash": "^4.17.21",
"prettier": "^2.5.1",
"rollup": "^4.28.1",
"ts-jest": "^29.1.4",
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/SpatialNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface SpatialNavigationServiceInit {
distanceCalculationMethod?: DistanceCalculationMethod;
customDistanceCalculationFunction?: DistanceCalculationFunction;
onUtterText?: (text: string) => void;
focusOnPresetKey?: boolean;
}

const DEFAULT_KEY_MAP = {
Expand Down Expand Up @@ -290,6 +291,13 @@ class SpatialNavigationService {
*/
private onUtterText: ((text: string) => void) | undefined;

/**
* When a focusable is added whose focus key was already set as the current focus key
* (e.g. setFocus was called before the component mounted), automatically focus it.
* Enabled by default for backward compatibility.
*/
private focusOnPresetKey: boolean;

/**
* Used to determine the coordinate that will be used to filter items that are over the "edge"
*/
Expand Down Expand Up @@ -706,7 +714,8 @@ class SpatialNavigationService {
rtl = false,
distanceCalculationMethod = 'corners' as DistanceCalculationMethod,
customDistanceCalculationFunction = undefined as DistanceCalculationFunction,
onUtterText
onUtterText,
focusOnPresetKey = true
}: SpatialNavigationServiceInit = {}) {
if (!this.enabled) {
this.domNodeFocusOptions = domNodeFocusOptions;
Expand All @@ -721,6 +730,7 @@ class SpatialNavigationService {
this.customDistanceCalculationFunction =
customDistanceCalculationFunction;
this.onUtterText = onUtterText ?? undefined;
this.focusOnPresetKey = focusOnPresetKey;

this.debug = debug;

Expand Down Expand Up @@ -1438,9 +1448,11 @@ class SpatialNavigationService {
);

/**
* If for some reason this component was already focused before it was added, call the update
* If for some reason this component was already focused before it was added, call the update.
* Gated behind the focusOnPresetKey option (enabled by default) so this implicit refocus can
* be opted out of.
*/
if (focusKey === this.focusKey) {
if (this.focusOnPresetKey && focusKey === this.focusKey) {
this.setFocus(preferredChildFocusKey || focusKey);
}

Expand Down
81 changes: 80 additions & 1 deletion packages/core/src/__tests__/SpatialNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
destroy,
init
} from '../SpatialNavigation';
import { createHorizontalLayout, createVerticalLayout } from './domNodes';
import {
createHorizontalLayout,
createRootNode,
createVerticalLayout
} from './domNodes';

describe('SpatialNavigation', () => {
beforeEach(() => {
Expand Down Expand Up @@ -161,4 +165,79 @@ describe('SpatialNavigation', () => {

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-3');
});

describe('focusOnPresetKey', () => {
const addPresetChild = (onUpdateFocus: (focused: boolean) => void) => {
SpatialNavigation.addFocusable({
focusKey: 'preset-child',
node: {
offsetLeft: 100,
offsetTop: 100,
offsetWidth: 400,
offsetHeight: 200,
parentElement: {
offsetLeft: 0,
offsetTop: 0,
offsetWidth: 1920,
offsetHeight: 1280
} as HTMLElement,
offsetParent: {
offsetLeft: 0,
offsetTop: 0,
scrollLeft: 0,
scrollTop: 0,
offsetWidth: 1920,
offsetHeight: 1280,
nodeType: Node.ELEMENT_NODE
} as HTMLElement
} as unknown as HTMLElement,
isFocusBoundary: false,
parentFocusKey: ROOT_FOCUS_KEY,
focusable: true,
trackChildren: false,
forceFocus: false,
autoRestoreFocus: true,
saveLastFocusedChild: false,
onEnterPress: () => {},
onEnterRelease: () => {},
onFocus: () => {},
onBlur: () => {},
onArrowPress: () => true,
onArrowRelease: () => {},
onUpdateFocus,
onUpdateHasFocusedChild: () => {}
});
};

it('focuses a component on add when its key was pre-set as current focus (enabled by default)', () => {
createRootNode();

// Pre-set focus to a component that has not mounted yet
SpatialNavigation.setFocus('preset-child');
expect(SpatialNavigation.getCurrentFocusKey()).toBe('preset-child');

const onUpdateFocus = jest.fn();
addPresetChild(onUpdateFocus);

// The component is auto-focused on add, so its focus callback fires
expect(onUpdateFocus).toHaveBeenCalledWith(true);
expect(SpatialNavigation.getCurrentFocusKey()).toBe('preset-child');
});

it('does not focus a component on add when focusOnPresetKey is false', () => {
destroy();
init({ focusOnPresetKey: false });
createRootNode();

// Pre-set focus to a component that has not mounted yet
SpatialNavigation.setFocus('preset-child');
expect(SpatialNavigation.getCurrentFocusKey()).toBe('preset-child');

const onUpdateFocus = jest.fn();
addPresetChild(onUpdateFocus);

// The implicit refocus is disabled, so the focus callback does not fire on add
expect(onUpdateFocus).not.toHaveBeenCalled();
});
});
});
Loading