Skip to content

Commit a18ed66

Browse files
authored
feat: configurable focus-on-preset-key behavior + CLAUDE.md (#236)
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).
1 parent 02f652b commit a18ed66

8 files changed

Lines changed: 202 additions & 4 deletions

File tree

.changeset/focus-on-preset-key.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@noriginmedia/norigin-spatial-navigation-core': minor
3+
'@noriginmedia/norigin-spatial-navigation-react': minor
4+
'@noriginmedia/norigin-spatial-navigation': minor
5+
---
6+
7+
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.

CLAUDE.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# CLAUDE.md
2+
3+
Guidance for AI agents working in this repository. [CONTRIBUTING.md](CONTRIBUTING.md) is the
4+
authoritative contributor doc — this file is a quick-reference summary of it.
5+
6+
## What this is
7+
8+
Norigin Spatial Navigation — a library for directional (D-pad / remote control) focus management
9+
on TVs and web apps. The core engine is framework-agnostic; a React binding wraps it.
10+
11+
## Repository layout
12+
13+
[Turborepo](https://turbo.build/) monorepo using [npm workspaces](https://docs.npmjs.com/cli/v10/using-npm/workspaces):
14+
15+
```
16+
apps/
17+
react-demo/ # Example React app for trying changes end-to-end
18+
packages/
19+
core/ # @noriginmedia/norigin-spatial-navigation-core (the engine)
20+
react/ # @noriginmedia/norigin-spatial-navigation-react (useFocusable hook)
21+
legacy/ # @noriginmedia/norigin-spatial-navigation (re-exports core + react)
22+
```
23+
24+
The bulk of the logic lives in `packages/core/src/SpatialNavigation.ts`. `legacy` is a thin
25+
`export *` of core + react, so additive changes to those packages flow through automatically.
26+
27+
## Commands
28+
29+
Requires Node.js 20+ and npm 10+. Run everything **from the repository root** — Turbo and npm
30+
workspaces handle cross-package symlinking, so there is no need to `npm install` inside packages.
31+
32+
| Command | What it does |
33+
| ---------------------- | ----------------------------------------------------- |
34+
| `npm install` | Install all workspaces; also sets up the Husky hooks. |
35+
| `npm run start` | Build packages and run the demo app via Turbo. |
36+
| `npm run build` | Build every package via Turbo. |
37+
| `npm run test` | Run the test suite for every package that has tests. |
38+
| `npm run lint` | ESLint check across all workspaces. |
39+
| `npm run prettier` | Prettier format check across the repo. |
40+
| `npm run prettier:fix` | Auto-format files with Prettier. |
41+
| `npm run changeset` | Generate a changeset for user-visible changes. |
42+
43+
## Code style
44+
45+
- **ESLint** — configured at the root (`.eslintrc.json`), inherited by each package.
46+
- **Prettier** — configured at the root (`.prettierrc`).
47+
48+
Both run automatically on commit via a [Husky](https://typicode.github.io/husky/) `pre-commit`
49+
hook (installed by `npm install`) and again in CI on every pull request. If a commit is rejected,
50+
run `npm run lint` and `npm run prettier` to see what needs fixing.
51+
52+
## Testing
53+
54+
Jest + ts-jest, running in jsdom. Tests live in `**/__tests__/**/*.test.[jt]s?(x)` alongside the
55+
source they cover (e.g. `packages/core/src/__tests__/SpatialNavigation.test.ts`). `domNodes.ts`
56+
provides helpers for building focusable layouts in tests.
57+
58+
## Changesets
59+
60+
Any **user-visible** change needs a changeset. Run `npm run changeset` (interactive) or add a
61+
markdown file under `.changeset/` directly. The format is YAML frontmatter mapping each affected
62+
package name to its semver bump (`patch` / `minor` / `major`), followed by a one-paragraph summary
63+
written for end users:
64+
65+
```markdown
66+
---
67+
'@noriginmedia/norigin-spatial-navigation-core': minor
68+
'@noriginmedia/norigin-spatial-navigation-react': minor
69+
'@noriginmedia/norigin-spatial-navigation': minor
70+
---
71+
72+
Short, user-facing description of the change.
73+
```
74+
75+
Versioning and publishing are handled by CI after merge. Changes to internal tooling, docs, tests,
76+
CI, or the demo app do **not** need a changeset.
77+
78+
## Pull request process
79+
80+
1. Branch from `main`.
81+
2. Make the change. If it touches the public API, update the docs under `docs/` (and the demo app
82+
in `apps/react-demo/` if applicable).
83+
3. Run `npm run lint`, `npm run prettier`, and `npm run test` locally.
84+
4. Generate a changeset (unless the change is tooling-only).
85+
5. Open the PR; once approved, use "Squash and Merge" and delete the branch.
86+
87+
## Library usage reference
88+
89+
For how to _use_ the library (the `useFocusable` hook, `init` options, common patterns), see
90+
`skills/norigin-spatial-navigation-react/SKILL.md` and the full docs under `docs/`.

docs/api-reference/SpatialNavigation.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ init(config?: {
5454
distanceCalculationMethod: string
5555
) => number;
5656
onUtterText?: (text: string) => void;
57+
focusOnPresetKey?: boolean;
5758
}): void
5859
```
5960

@@ -74,6 +75,7 @@ init(config?: {
7475
| `distanceCalculationMethod` | `'center' \| 'edges' \| 'corners'` | `'corners'` | Algorithm used to calculate distance between components. See [Distance Calculation](../guides/distance-calculation.md). |
7576
| `customDistanceCalculationFunction` | `function` | `undefined` | Override the secondary-axis distance calculation. See [Distance Calculation](../guides/distance-calculation.md). |
7677
| `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. |
78+
| `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. |
7779

7880
### Example
7981

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/jest.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ module.exports = {
33
preset: 'ts-jest',
44
testEnvironment: 'jsdom',
55
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
6+
// lodash-es ships ESM only, which Jest cannot transform out of node_modules.
7+
// Map it to the CJS lodash build (identical API) for tests; the rollup build
8+
// still bundles lodash-es for the published packages.
9+
moduleNameMapper: {
10+
'^lodash-es$': 'lodash'
11+
},
612
collectCoverageFrom: [
713
'src/**/*.ts',
814
'!src/__tests__/**',

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@types/lodash-es": "^4.17.12",
4747
"jest": "^29.7.0",
4848
"jest-environment-jsdom": "^29.7.0",
49+
"lodash": "^4.17.21",
4950
"prettier": "^2.5.1",
5051
"rollup": "^4.28.1",
5152
"ts-jest": "^29.1.4",

packages/core/src/SpatialNavigation.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface SpatialNavigationServiceInit {
4848
distanceCalculationMethod?: DistanceCalculationMethod;
4949
customDistanceCalculationFunction?: DistanceCalculationFunction;
5050
onUtterText?: (text: string) => void;
51+
focusOnPresetKey?: boolean;
5152
}
5253

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

294+
/**
295+
* When a focusable is added whose focus key was already set as the current focus key
296+
* (e.g. setFocus was called before the component mounted), automatically focus it.
297+
* Enabled by default for backward compatibility.
298+
*/
299+
private focusOnPresetKey: boolean;
300+
293301
/**
294302
* Used to determine the coordinate that will be used to filter items that are over the "edge"
295303
*/
@@ -706,7 +714,8 @@ class SpatialNavigationService {
706714
rtl = false,
707715
distanceCalculationMethod = 'corners' as DistanceCalculationMethod,
708716
customDistanceCalculationFunction = undefined as DistanceCalculationFunction,
709-
onUtterText
717+
onUtterText,
718+
focusOnPresetKey = true
710719
}: SpatialNavigationServiceInit = {}) {
711720
if (!this.enabled) {
712721
this.domNodeFocusOptions = domNodeFocusOptions;
@@ -721,6 +730,7 @@ class SpatialNavigationService {
721730
this.customDistanceCalculationFunction =
722731
customDistanceCalculationFunction;
723732
this.onUtterText = onUtterText ?? undefined;
733+
this.focusOnPresetKey = focusOnPresetKey;
724734

725735
this.debug = debug;
726736

@@ -1438,9 +1448,11 @@ class SpatialNavigationService {
14381448
);
14391449

14401450
/**
1441-
* If for some reason this component was already focused before it was added, call the update
1451+
* If for some reason this component was already focused before it was added, call the update.
1452+
* Gated behind the focusOnPresetKey option (enabled by default) so this implicit refocus can
1453+
* be opted out of.
14421454
*/
1443-
if (focusKey === this.focusKey) {
1455+
if (this.focusOnPresetKey && focusKey === this.focusKey) {
14441456
this.setFocus(preferredChildFocusKey || focusKey);
14451457
}
14461458

packages/core/src/__tests__/SpatialNavigation.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
destroy,
55
init
66
} from '../SpatialNavigation';
7-
import { createHorizontalLayout, createVerticalLayout } from './domNodes';
7+
import {
8+
createHorizontalLayout,
9+
createRootNode,
10+
createVerticalLayout
11+
} from './domNodes';
812

913
describe('SpatialNavigation', () => {
1014
beforeEach(() => {
@@ -161,4 +165,79 @@ describe('SpatialNavigation', () => {
161165

162166
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-3');
163167
});
168+
169+
describe('focusOnPresetKey', () => {
170+
const addPresetChild = (onUpdateFocus: (focused: boolean) => void) => {
171+
SpatialNavigation.addFocusable({
172+
focusKey: 'preset-child',
173+
node: {
174+
offsetLeft: 100,
175+
offsetTop: 100,
176+
offsetWidth: 400,
177+
offsetHeight: 200,
178+
parentElement: {
179+
offsetLeft: 0,
180+
offsetTop: 0,
181+
offsetWidth: 1920,
182+
offsetHeight: 1280
183+
} as HTMLElement,
184+
offsetParent: {
185+
offsetLeft: 0,
186+
offsetTop: 0,
187+
scrollLeft: 0,
188+
scrollTop: 0,
189+
offsetWidth: 1920,
190+
offsetHeight: 1280,
191+
nodeType: Node.ELEMENT_NODE
192+
} as HTMLElement
193+
} as unknown as HTMLElement,
194+
isFocusBoundary: false,
195+
parentFocusKey: ROOT_FOCUS_KEY,
196+
focusable: true,
197+
trackChildren: false,
198+
forceFocus: false,
199+
autoRestoreFocus: true,
200+
saveLastFocusedChild: false,
201+
onEnterPress: () => {},
202+
onEnterRelease: () => {},
203+
onFocus: () => {},
204+
onBlur: () => {},
205+
onArrowPress: () => true,
206+
onArrowRelease: () => {},
207+
onUpdateFocus,
208+
onUpdateHasFocusedChild: () => {}
209+
});
210+
};
211+
212+
it('focuses a component on add when its key was pre-set as current focus (enabled by default)', () => {
213+
createRootNode();
214+
215+
// Pre-set focus to a component that has not mounted yet
216+
SpatialNavigation.setFocus('preset-child');
217+
expect(SpatialNavigation.getCurrentFocusKey()).toBe('preset-child');
218+
219+
const onUpdateFocus = jest.fn();
220+
addPresetChild(onUpdateFocus);
221+
222+
// The component is auto-focused on add, so its focus callback fires
223+
expect(onUpdateFocus).toHaveBeenCalledWith(true);
224+
expect(SpatialNavigation.getCurrentFocusKey()).toBe('preset-child');
225+
});
226+
227+
it('does not focus a component on add when focusOnPresetKey is false', () => {
228+
destroy();
229+
init({ focusOnPresetKey: false });
230+
createRootNode();
231+
232+
// Pre-set focus to a component that has not mounted yet
233+
SpatialNavigation.setFocus('preset-child');
234+
expect(SpatialNavigation.getCurrentFocusKey()).toBe('preset-child');
235+
236+
const onUpdateFocus = jest.fn();
237+
addPresetChild(onUpdateFocus);
238+
239+
// The implicit refocus is disabled, so the focus callback does not fire on add
240+
expect(onUpdateFocus).not.toHaveBeenCalled();
241+
});
242+
});
164243
});

0 commit comments

Comments
 (0)