From 62396c085c595f856f3c5aa735eb68c5838f9cee Mon Sep 17 00:00:00 2001 From: Dmitriy Bryokhin Date: Thu, 18 Jun 2026 14:03:49 +0200 Subject: [PATCH] feat: make focus-on-preset-key behavior configurable; add CLAUDE.md Add a `focusOnPresetKey` init option (default `true`) gating the implicit refocus that fires in addFocusable when a component's focus key was already set as the current focus key before it mounted. Enabled by default to preserve backward compatibility. Also add a CLAUDE.md derived from CONTRIBUTING.md, docs for the new option, and a changeset. Map lodash-es -> lodash in the core jest config so the previously-unrunnable test suite executes (build still bundles lodash-es). --- .changeset/focus-on-preset-key.md | 7 ++ CLAUDE.md | 90 +++++++++++++++++++ docs/api-reference/SpatialNavigation.md | 2 + package-lock.json | 1 + packages/core/jest.config.js | 6 ++ packages/core/package.json | 1 + packages/core/src/SpatialNavigation.ts | 18 +++- .../src/__tests__/SpatialNavigation.test.ts | 81 ++++++++++++++++- 8 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 .changeset/focus-on-preset-key.md create mode 100644 CLAUDE.md diff --git a/.changeset/focus-on-preset-key.md b/.changeset/focus-on-preset-key.md new file mode 100644 index 0000000..e341567 --- /dev/null +++ b/.changeset/focus-on-preset-key.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..acf63ee --- /dev/null +++ b/CLAUDE.md @@ -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/`. diff --git a/docs/api-reference/SpatialNavigation.md b/docs/api-reference/SpatialNavigation.md index 5b0fbee..566dec3 100644 --- a/docs/api-reference/SpatialNavigation.md +++ b/docs/api-reference/SpatialNavigation.md @@ -54,6 +54,7 @@ init(config?: { distanceCalculationMethod: string ) => number; onUtterText?: (text: string) => void; + focusOnPresetKey?: boolean; }): void ``` @@ -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 diff --git a/package-lock.json b/package-lock.json index f16989b..56c278f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9387,6 +9387,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", diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index 8333e7e..7fceb1d 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -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__/**', diff --git a/packages/core/package.json b/packages/core/package.json index fdb77ac..d17ba66 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index 07d11ab..fcdd03c 100644 --- a/packages/core/src/SpatialNavigation.ts +++ b/packages/core/src/SpatialNavigation.ts @@ -48,6 +48,7 @@ export interface SpatialNavigationServiceInit { distanceCalculationMethod?: DistanceCalculationMethod; customDistanceCalculationFunction?: DistanceCalculationFunction; onUtterText?: (text: string) => void; + focusOnPresetKey?: boolean; } const DEFAULT_KEY_MAP = { @@ -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" */ @@ -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; @@ -721,6 +730,7 @@ class SpatialNavigationService { this.customDistanceCalculationFunction = customDistanceCalculationFunction; this.onUtterText = onUtterText ?? undefined; + this.focusOnPresetKey = focusOnPresetKey; this.debug = debug; @@ -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); } diff --git a/packages/core/src/__tests__/SpatialNavigation.test.ts b/packages/core/src/__tests__/SpatialNavigation.test.ts index 317cf14..324ae51 100644 --- a/packages/core/src/__tests__/SpatialNavigation.test.ts +++ b/packages/core/src/__tests__/SpatialNavigation.test.ts @@ -4,7 +4,11 @@ import { destroy, init } from '../SpatialNavigation'; -import { createHorizontalLayout, createVerticalLayout } from './domNodes'; +import { + createHorizontalLayout, + createRootNode, + createVerticalLayout +} from './domNodes'; describe('SpatialNavigation', () => { beforeEach(() => { @@ -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(); + }); + }); });