@solidrt/components 0.0.56 → 0.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -53,10 +53,13 @@ objects at that level - and the two compose freely in one tree.
53
53
  for one custom shape never means giving up `View`/`Text` elsewhere.
54
54
  - Focus navigation: `createFocusNav` moves real focus across `focusable`
55
55
  elements (Button is focusable by default; Pressable opt-in) - spatially on
56
- arrows/dpad, sequentially on Tab/Shift+Tab (reading order, wrapping).
57
- Attach its onKeyDown to the window; gamepad dpad/south wire automatically.
58
- A focused press control activates on Enter/Space/remote-select via
59
- createPress and draws Button's ring under `policy.focusRing` (true when a
56
+ `navigate`, sequentially on `cycle` (reading order, wrapping), activating
57
+ on `select`: three input-map actions (`uiActions`), never a device read.
58
+ Bare, it binds the standard set (`uiBindings`: arrows, Tab, Enter, Space,
59
+ remote center; dpad, left stick, south on every pad) on its own map,
60
+ `nav.input`; or pass `input`. Spread `nav.handlers` on the window. A
61
+ focused press control activates only through `select` (createPress reads
62
+ no key) and draws Button's ring under `policy.focusRing` (true when a
60
63
  keyboard OR gamepad/remote is present). An open Modal traps navigation
61
64
  inside itself automatically.
62
65
 
@@ -76,7 +79,7 @@ One bullet per module, generated from the first paragraph of its docs/ file.
76
79
  - `ScrollView` - A scrollable region; vertical by default, `horizontal` to flip. Both the wheel and dragging scroll the content: the drag activates after a small movement threshold along the scroll axis, also when it starts on a pressable (the press is cancelled and its feedback retracts), and keeps scrolling when the pointer leaves the box. Scrolling glides: the offset springs to each new target (250 ms, critically damped), so a wheel notch never jumps and a burst of notches reads as one motion; a dragging finger is tracked exactly, without the spring. No momentum/fling yet.
77
80
  - `Pressable` - A pressable box: `onPress` fires on a primary-button press released over the box; a drag out of the box (or a non-primary button) does not fire it, and a drag back in restores the pressed state. `children` and `style` may each be a function of the live `{ pressed, hovered, pending }` state, so the box restyles on press/hover without extra signals - read the state inside the prop or child expression, never eagerly into a local.
78
81
  - `Button` - A themed press target over `Pressable`: a padded, centered box with a label. A press shrinks it slightly on a quick spring and tints it with `overlayPressed`; hover tints with `overlayHover` (non-touch policies). `variant` picks the visual role - `primary` (accent fill, the default), `secondary`, `ghost` (no fill until hover), `danger` (destructive) - with fill, tints, and label color from the matching theme tokens; no variant draws a border. `size` (`sm`/`md`/`lg`) pins a minimum width so a row of buttons lines up (a longer label still expands past it); omitted, the button sizes to its content. A string or number child renders as the themed label; any other child renders as-is (an icon, a row, ...).
79
- - `createFocusNav` - Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving real focus across the elements declaring `focusable`. Two movement types over the same candidates: spatial (arrow keys, dpad) picks the nearest candidate in the pressed direction by on-screen boxes, and sequential (Tab / Shift+Tab) walks visual reading order - rows top to bottom, left to right - wrapping at the ends. Enter / remote center / gamepad south activates the focused control. Nothing is focused until the first navigation press; pointer input works unchanged throughout. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
82
+ - `createFocusNav` - Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving real focus across the elements declaring `focusable`. It consumes three UI actions from an input map and reads no device: `navigate` (vec2) moves spatially, picking the nearest candidate in the pressed direction by on-screen boxes; `cycle` (axis) walks visual reading order - rows top to bottom, left to right - wrapping at the ends; `select` (button) activates the focused control; a held direction repeats. Created bare, the nav binds the standard set itself (`uiBindings`: arrows, Tab and Shift+Tab, Enter, Space and the remote center key on the keyboard; dpad, left stick and south on every pad) on a map of its own, reachable as `nav.input` for rebinding; given `input`, it consumes the app's map instead. Spread `nav.handlers` on the window. Nothing is focused until the first navigation press; pointer input works unchanged throughout. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
80
83
  - `Switch` - An on/off toggle: the track fills with `primary` when on and `surfaceAlt` when off (a fade), and the thumb springs across - the `knob` transition entry retimes that travel. Controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Built on `Pressable`, so `disabled` takes no pointer events. `style` overrides the track colors and radius.
81
84
  - `Checkbox` - A checkbox: filled with `primary` and a drawn checkmark when checked, an empty bordered box otherwise - the fill fades and the mark pops in and out. Controlled via `checked`/`onChange`, or uncontrolled via `defaultChecked`. The mark is the `theme.icons.check` slot when a theme sets one. `style` overrides the box colors, border, and radius.
82
85
  - `RadioGroup / Radio` - A single-selection pair: `RadioGroup` owns the selected value (controlled via `value`/`onChange`, or uncontrolled via `defaultValue`) and shares it with its `Radio` children; each `Radio` is a ring with an inner dot when selected - the ring color fades and the dot pops in and out. A string/number child of `Radio` renders as a themed label beside the ring; anything else as-is. `disabled` on the group disables every option, on a `Radio` just that one.
package/README.md CHANGED
@@ -382,30 +382,43 @@ An `onPress` returning a promise makes the button an async action: while it is u
382
382
  <Button onPress={async () => { await save() }}>Save</Button>
383
383
  ```
384
384
 
385
- A focused Button (see `createFocusNav`) draws a ring in the theme `ring` color under the `focusRing` policy (text-colored by default so it stays visible on primary-filled buttons) and activates on Enter, Space, or a remote's center key. `focusable` (default true) opts out of focus-navigation candidacy; disabled buttons are never candidates.
385
+ A focused Button (see `createFocusNav`) draws a ring in the theme `ring` color under the `focusRing` policy (text-colored by default so it stays visible on primary-filled buttons) and activates on the navigation's `select` action (Enter, Space, a remote's center key or a pad's south button under the standard bindings). `focusable` (default true) opts out of focus-navigation candidacy; disabled buttons are never candidates.
386
386
 
387
387
  API: `Button`, `ButtonProps`, `ButtonVariant` - typed and commented in [src/button.tsx](./src/button.tsx).
388
388
 
389
389
  ### createFocusNav
390
390
 
391
- Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving real focus across the elements declaring `focusable`. Two movement types over the same candidates: spatial (arrow keys, dpad) picks the nearest candidate in the pressed direction by on-screen boxes, and sequential (Tab / Shift+Tab) walks visual reading order - rows top to bottom, left to right - wrapping at the ends. Enter / remote center / gamepad south activates the focused control. Nothing is focused until the first navigation press; pointer input works unchanged throughout. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
391
+ Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving real focus across the elements declaring `focusable`. It consumes three UI actions from an input map and reads no device: `navigate` (vec2) moves spatially, picking the nearest candidate in the pressed direction by on-screen boxes; `cycle` (axis) walks visual reading order - rows top to bottom, left to right - wrapping at the ends; `select` (button) activates the focused control; a held direction repeats. Created bare, the nav binds the standard set itself (`uiBindings`: arrows, Tab and Shift+Tab, Enter, Space and the remote center key on the keyboard; dpad, left stick and south on every pad) on a map of its own, reachable as `nav.input` for rebinding; given `input`, it consumes the app's map instead. Spread `nav.handlers` on the window. Nothing is focused until the first navigation press; pointer input works unchanged throughout. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
392
392
 
393
393
  ```jsx
394
394
  import { createFocusNav } from "@solidrt/components"
395
395
 
396
396
  function App() {
397
397
  let nav = createFocusNav()
398
- return <window onKeyDown={nav.onKeyDown}>...</window>
398
+ return <Window {...nav.handlers}>...</Window>
399
399
  }
400
400
  ```
401
401
 
402
- Attaching `nav.onKeyDown` on the window is what keeps it cooperative: key events bubble from the focused node, so a focused TextInput keeps its arrow keys and navigation only sees what nothing else consumed. Gamepad dpad/south are wired automatically.
402
+ Spreading the map's handlers on the window is what keeps it cooperative: key events bubble from the focused node, so a focused TextInput keeps its caret keys (and a typed Space) and navigation only sees what nothing else consumed. Pads need no handler; their sources poll.
403
+
404
+ Rebind on the nav's own map, or bring a map that also holds the app's actions:
405
+
406
+ ```jsx
407
+ let nav = createFocusNav()
408
+ nav.input.bind("select", gamepad().button("east"))
409
+
410
+ let input = createInputMap({ ...uiActions, pause: "button" })
411
+ input.bind(uiBindings({ keyboard, gamepad: gamepad() }))
412
+ input.bind("pause", keyboard.key("Escape"), gamepad().button("start"))
413
+ let nav = createFocusNav({ input })
414
+ return <Window {...input.handlers}>...</Window>
415
+ ```
403
416
 
404
417
  When the focused control disappears (an action replacing it, a screen change), focus lands on the nearest candidate to where it sat as soon as the successor is laid out - the ring follows a Disconnect button into the Connect button that replaces it. A deliberate blur (tapping outside, dismissing the keyboard) stays blurred; the next press resumes at the nearest candidate.
405
418
 
406
419
  An open `Modal` traps navigation inside itself with no extra wiring (topmost wins when stacked); pass `scope: () => nodeOrNull` to trap into some other subtree instead. `move`/`tab`/`activate` are exposed for custom triggers.
407
420
 
408
- API: `createFocusNav`, `FocusNavOptions` - typed and commented in [src/focus-nav.ts](./src/focus-nav.ts).
421
+ API: `createFocusNav`, `uiActions`, `uiBindings`, `FocusNavOptions`, `UiActions`, `UiDevices` - typed and commented in [src/focus-nav.ts](./src/focus-nav.ts).
409
422
 
410
423
  ### Switch
411
424
 
package/docs/button.md CHANGED
@@ -18,4 +18,4 @@ An `onPress` returning a promise makes the button an async action: while it is u
18
18
  <Button onPress={async () => { await save() }}>Save</Button>
19
19
  ```
20
20
 
21
- A focused Button (see `createFocusNav`) draws a ring in the theme `ring` color under the `focusRing` policy (text-colored by default so it stays visible on primary-filled buttons) and activates on Enter, Space, or a remote's center key. `focusable` (default true) opts out of focus-navigation candidacy; disabled buttons are never candidates.
21
+ A focused Button (see `createFocusNav`) draws a ring in the theme `ring` color under the `focusRing` policy (text-colored by default so it stays visible on primary-filled buttons) and activates on the navigation's `select` action (Enter, Space, a remote's center key or a pad's south button under the standard bindings). `focusable` (default true) opts out of focus-navigation candidacy; disabled buttons are never candidates.
package/docs/focus-nav.md CHANGED
@@ -1,17 +1,30 @@
1
1
  # createFocusNav
2
2
 
3
- Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving real focus across the elements declaring `focusable`. Two movement types over the same candidates: spatial (arrow keys, dpad) picks the nearest candidate in the pressed direction by on-screen boxes, and sequential (Tab / Shift+Tab) walks visual reading order - rows top to bottom, left to right - wrapping at the ends. Enter / remote center / gamepad south activates the focused control. Nothing is focused until the first navigation press; pointer input works unchanged throughout. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
3
+ Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving real focus across the elements declaring `focusable`. It consumes three UI actions from an input map and reads no device: `navigate` (vec2) moves spatially, picking the nearest candidate in the pressed direction by on-screen boxes; `cycle` (axis) walks visual reading order - rows top to bottom, left to right - wrapping at the ends; `select` (button) activates the focused control; a held direction repeats. Created bare, the nav binds the standard set itself (`uiBindings`: arrows, Tab and Shift+Tab, Enter, Space and the remote center key on the keyboard; dpad, left stick and south on every pad) on a map of its own, reachable as `nav.input` for rebinding; given `input`, it consumes the app's map instead. Spread `nav.handlers` on the window. Nothing is focused until the first navigation press; pointer input works unchanged throughout. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
4
4
 
5
5
  ```jsx
6
6
  import { createFocusNav } from "@solidrt/components"
7
7
 
8
8
  function App() {
9
9
  let nav = createFocusNav()
10
- return <window onKeyDown={nav.onKeyDown}>...</window>
10
+ return <Window {...nav.handlers}>...</Window>
11
11
  }
12
12
  ```
13
13
 
14
- Attaching `nav.onKeyDown` on the window is what keeps it cooperative: key events bubble from the focused node, so a focused TextInput keeps its arrow keys and navigation only sees what nothing else consumed. Gamepad dpad/south are wired automatically.
14
+ Spreading the map's handlers on the window is what keeps it cooperative: key events bubble from the focused node, so a focused TextInput keeps its caret keys (and a typed Space) and navigation only sees what nothing else consumed. Pads need no handler; their sources poll.
15
+
16
+ Rebind on the nav's own map, or bring a map that also holds the app's actions:
17
+
18
+ ```jsx
19
+ let nav = createFocusNav()
20
+ nav.input.bind("select", gamepad().button("east"))
21
+
22
+ let input = createInputMap({ ...uiActions, pause: "button" })
23
+ input.bind(uiBindings({ keyboard, gamepad: gamepad() }))
24
+ input.bind("pause", keyboard.key("Escape"), gamepad().button("start"))
25
+ let nav = createFocusNav({ input })
26
+ return <Window {...input.handlers}>...</Window>
27
+ ```
15
28
 
16
29
  When the focused control disappears (an action replacing it, a screen change), focus lands on the nearest candidate to where it sat as soon as the successor is laid out - the ring follows a Disconnect button into the Connect button that replaces it. A deliberate blur (tapping outside, dismissing the keyboard) stays blurred; the next press resumes at the nearest candidate.
17
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.56",
3
+ "version": "0.0.57",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -21,6 +21,6 @@
21
21
  },
22
22
  "peerDependencies": {
23
23
  "@solidjs/signals": "2.0.0-rc.6",
24
- "@solidrt/core": "0.0.56"
24
+ "@solidrt/core": "0.0.57"
25
25
  }
26
26
  }
@@ -269,6 +269,10 @@ export function EditorField(props: EditorFieldProps) {
269
269
  activateField()
270
270
  } else if (e.key === "Escape") {
271
271
  if (node) setFocus(null)
272
+ } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && textInputActive()) {
273
+ // A printable key is this field's text (it arrives as a text-input
274
+ // event); consumed here so a typed Space never reaches the window as
275
+ // the focus navigation's select.
272
276
  } else {
273
277
  consumed = false
274
278
  }
package/src/focus-nav.ts CHANGED
@@ -1,37 +1,132 @@
1
1
  import {
2
2
  createEffect,
3
+ createInputMap,
3
4
  createSignal,
4
- gamepads,
5
+ gamepad,
5
6
  getBoundingBoxViewport,
6
7
  getFocusables,
7
8
  focusedNode,
8
9
  getNodePath,
10
+ keyboard,
11
+ onCleanup,
9
12
  onLayout,
10
13
  setFocus,
14
+ untrack,
11
15
  } from "@solidrt/core"
12
- import type { KeyEvent } from "@solidrt/core"
16
+ import type { Binding, GamepadDevice, InputMap, KeyboardDevice, Vec2 } from "@solidrt/core"
13
17
 
14
18
  // Focus navigation: the pointer-free control path, so a TV remote, a
15
19
  // keyboard, or a gamepad can drive an app. Two movement types over the same
16
20
  // candidates, both steering real focus (setFocus) across the elements
17
21
  // declaring `focusable`:
18
22
  //
19
- // - Spatial (arrows, dpad): judged by on-screen boxes at press time - the
23
+ // - Spatial (`navigate`): judged by on-screen boxes at press time - the
20
24
  // nearest candidate with progress in the pressed direction wins, so
21
25
  // registration order never matters.
22
- // - Sequential (Tab / Shift+Tab): visual reading order - rows top to bottom,
23
- // left to right within a row, wrapping at the ends. Derived from the same
26
+ // - Sequential (`cycle`): visual reading order - rows top to bottom, left
27
+ // to right within a row, wrapping at the ends. Derived from the same
24
28
  // boxes rather than registration order, which reordering mounts (a <For>
25
29
  // shuffle) silently scrambles.
26
30
  //
27
- // Activation is split by source: Enter / the remote center key bubble to the
28
- // focused node itself (createPress consumes them there), while a controller's
29
- // south button goes through the action registry below. Purely additive to
31
+ // The nav reads no device (ARCHITECTURE.md: controls through an
32
+ // abstraction, never direct event handling). It consumes the three UI
33
+ // actions of an input map - `navigate` (vec2), `cycle` (axis), `select`
34
+ // (button) - the way Flutter's focus widgets consume intents and Godot's
35
+ // controls the ui_* actions. A component library must work with nothing
36
+ // wired, as those two do, so a nav created bare binds the standard set
37
+ // itself (uiBindings over the keyboard and every pad) on a map of its own,
38
+ // reachable as `nav.input` for rebinding; given `input`, it consumes the
39
+ // app's map, which may hold the app's other actions too. Either way the
40
+ // app spreads the map's handlers on the window: keys arrive there only when
41
+ // no focused component consumed them, so a focused TextInput keeps its
42
+ // caret keys and the Slider its arrows.
43
+ //
44
+ // A held direction repeats on the nav's own timing over the action's rate
45
+ // (a first step on the edge, the next after NAV_REPEAT_DELAY, then every
46
+ // NAV_REPEAT_INTERVAL), so pads and remotes walk the way keyboards used to
47
+ // through key repeat. Activation is one path: the `select` press runs the
48
+ // focused node's action from the registry below, for Enter, Space, the
49
+ // remote's center key and the pad's south button alike. Purely additive to
30
50
  // pointer input: nothing is focused until the first navigation press, and
31
51
  // pointer presses work unchanged throughout.
32
52
 
33
53
  type Direction = "up" | "down" | "left" | "right"
34
54
 
55
+ // A held direction walks: the first step on the edge, the next after this
56
+ // many ms, then one every NAV_REPEAT_INTERVAL ms while it holds.
57
+ const NAV_REPEAT_DELAY = 400
58
+ const NAV_REPEAT_INTERVAL = 100
59
+ // Deflection along the dominant axis of `navigate` (a stick) or on `cycle`
60
+ // below which the action reads as no direction; dpad and arrows read 1.
61
+ const NAV_THRESHOLD = 0.5
62
+
63
+ /** The UI actions the focus navigation consumes: `navigate` (vec2, the
64
+ * direction to move focus in the screen convention, x right and y down),
65
+ * `cycle` (axis: +1 steps to the next candidate in reading order, -1 to
66
+ * the previous) and `select` (button: activate the focused control). An
67
+ * app's own map declares them by spreading this object. */
68
+ export const uiActions = { navigate: "vec2", cycle: "axis", select: "button" } as const
69
+ export type UiActions = typeof uiActions
70
+
71
+ export type UiDevices = { keyboard?: KeyboardDevice; gamepad?: GamepadDevice }
72
+
73
+ /**
74
+ * The standard UI bindings: the arrows on `navigate`, Tab and Shift+Tab
75
+ * on `cycle`, Enter, Space and the remote's center key (code "Select") on
76
+ * `select` for the keyboard; the dpad and the left stick on `navigate` and
77
+ * the south button on `select` for a pad. What a bare createFocusNav
78
+ * binds; an app with its own map applies it with `input.bind(...)`.
79
+ */
80
+ export function uiBindings(devices: UiDevices): Binding[] {
81
+ let out: Binding[] = []
82
+ let kb = devices.keyboard
83
+ if (kb) {
84
+ out.push(
85
+ { action: "navigate", source: kb.arrows },
86
+ { action: "cycle", source: kb.axis("Shift+Tab", "Tab") },
87
+ { action: "select", source: kb.key("Enter") },
88
+ { action: "select", source: kb.key("Space") },
89
+ { action: "select", source: kb.key("Select") },
90
+ )
91
+ }
92
+ let pad = devices.gamepad
93
+ if (pad) {
94
+ out.push({ action: "navigate", source: pad.dpad }, { action: "navigate", source: pad.leftStick }, { action: "select", source: pad.button("south") })
95
+ }
96
+ return out
97
+ }
98
+
99
+ // Steps on a held value: once on the edge, again after NAV_REPEAT_DELAY,
100
+ // then every NAV_REPEAT_INTERVAL until it reads null or changes (a change
101
+ // restarts the delay). Runs under the calling scope: the effect and the
102
+ // timer die with it.
103
+ function repeating<T>(read: () => T | null, step: (value: T) => void): void {
104
+ let current: T | null = null
105
+ let timer: ReturnType<typeof setTimeout> | null = null
106
+ let stop = () => {
107
+ if (timer == null) return
108
+ clearTimeout(timer)
109
+ timer = null
110
+ }
111
+ let later = (value: T, delay: number) => {
112
+ timer = setTimeout(() => {
113
+ step(value)
114
+ later(value, NAV_REPEAT_INTERVAL)
115
+ }, delay)
116
+ }
117
+ // The step reads focus and the candidates for itself, a snapshot, not a
118
+ // dependency: untracked so the strict-mode read diagnostic stays quiet.
119
+ createEffect(read, (value) => {
120
+ if (value === current) return
121
+ current = value
122
+ stop()
123
+ if (value === null) return
124
+ untrack(() => step(value))
125
+ later(value, NAV_REPEAT_DELAY)
126
+ })
127
+ onCleanup(stop)
128
+ }
129
+
35
130
  // What "select" means per focusable node, registered by createPress's ref.
36
131
  // Only the controller path consults it - key activation reaches the focused
37
132
  // node by bubbling and never comes through here. Package-internal.
@@ -56,6 +151,15 @@ export function pushNavScope(node: { id: number }): () => void {
56
151
  }
57
152
 
58
153
  export interface FocusNavOptions {
154
+ /**
155
+ * The input map to consume the UI actions from: it must declare
156
+ * `navigate`, `cycle` and `select` with the kinds of `uiActions`, and the
157
+ * app binds it (uiBindings or its own) and spreads its handlers on the
158
+ * window. Without one the nav makes its own map bound to the standard set
159
+ * over the keyboard and every pad, which `nav.input` exposes for
160
+ * rebinding.
161
+ */
162
+ input?: InputMap<any>
59
163
  /**
60
164
  * Restricts reachable candidates to this node's subtree while it returns
61
165
  * one, overriding the default (the topmost open Modal, which traps
@@ -71,16 +175,23 @@ type Placed = { id: number; x: number; y: number }
71
175
 
72
176
  /**
73
177
  * Creates focus navigation over the `focusable` elements: spatial movement on
74
- * arrows/dpad, sequential on Tab/Shift+Tab. Attach the returned onKeyDown to
75
- * the window - keys arrive there only when no focused component consumed
76
- * them, so a focused TextInput keeps its caret keys:
178
+ * `navigate`, sequential on `cycle`, activation on `select`, consumed from an
179
+ * input map (see FocusNavOptions.input). Spread the map's handlers on the
180
+ * window - keys arrive there only when no focused component consumed them,
181
+ * so a focused TextInput keeps its caret keys:
77
182
  *
78
183
  * let nav = createFocusNav()
79
- * <window onKeyDown={nav.onKeyDown}>...
184
+ * <window {...nav.handlers}>...
80
185
  *
81
- * Gamepad dpad/south edges are wired automatically (call it inside a
82
- * component/root scope). move/tab/activate are exposed for custom triggers.
186
+ * Call it inside a component/root scope: its effects and repeat timers live
187
+ * there. move/tab/activate are the verbs, for custom triggers.
83
188
  */
189
+ let defaultMap = (): InputMap<UiActions> => {
190
+ let map = createInputMap(uiActions)
191
+ map.bind(uiBindings({ keyboard, gamepad: gamepad() }))
192
+ return map
193
+ }
194
+
84
195
  export function createFocusNav(options?: FocusNavOptions) {
85
196
  let currentScope = () => options?.scope?.() ?? scopeStack()[scopeStack().length - 1]
86
197
 
@@ -157,9 +268,9 @@ export function createFocusNav(options?: FocusNavOptions) {
157
268
  }
158
269
 
159
270
  // Sequential step in reading order, wrapping at the ends. With no
160
- // reachable focus it resumes near the last position; lacking one, Tab
161
- // enters at the first element and Shift+Tab at the last (the step "wraps
162
- // into" the set from either side).
271
+ // reachable focus it resumes near the last position; lacking one, a
272
+ // forward step enters at the first element and a backward one at the
273
+ // last (the step "wraps into" the set from either side).
163
274
  let tab = (delta: 1 | -1) => {
164
275
  let placed = reachable()
165
276
  if (placed.length === 0) return
@@ -173,9 +284,9 @@ export function createFocusNav(options?: FocusNavOptions) {
173
284
  focusCandidate(row[(i + delta + row.length) % row.length]!)
174
285
  }
175
286
 
176
- // Controller select, and Enter reaching the window (nothing focused, or the
177
- // focused node did not consume it): with no reachable focus the press lands
178
- // focus instead of acting.
287
+ // The `select` press (Enter, the remote's center key, the pad's south
288
+ // button, whatever the app bound): runs the focused node's registered
289
+ // action; with no reachable focus the press lands focus instead of acting.
179
290
  let activate = () => {
180
291
  let placed = reachable()
181
292
  if (placed.length === 0) return
@@ -188,18 +299,6 @@ export function createFocusNav(options?: FocusNavOptions) {
188
299
  navActions.get(hit.id)?.()
189
300
  }
190
301
 
191
- // Keyboard and TV remote. Arrows and Tab use key repeat (holding walks
192
- // through candidates); activation ignores it. The remote center key's `key`
193
- // is "Unidentified", so it is matched by code.
194
- let onKeyDown = (e: KeyEvent) => {
195
- if (e.key === "ArrowUp") move("up")
196
- else if (e.key === "ArrowDown") move("down")
197
- else if (e.key === "ArrowLeft") move("left")
198
- else if (e.key === "ArrowRight") move("right")
199
- else if (e.key === "Tab") tab(e.shiftKey ? -1 : 1)
200
- else if ((e.key === "Enter" || e.code === "Select") && !e.repeat) activate()
201
- }
202
-
203
302
  // The focused control vanishing (replaced by its own action, a screen
204
303
  // change) clears focus; hand it to the nearest successor so the ring
205
304
  // never disappears mid-navigation. Only when the node actually died: a
@@ -243,26 +342,26 @@ export function createFocusNav(options?: FocusNavOptions) {
243
342
  },
244
343
  )
245
344
 
246
- // Gamepads: edge-detect the dpad and south button on the union of all pads'
247
- // pressed buttons. The sticky replay on the first read seeds the baseline
248
- // (a button already held at creation fires its edge once).
249
- let prevButtons = new Set<string>()
250
- createEffect(
251
- () => gamepads(),
252
- (pads) => {
253
- let now = new Set<string>()
254
- for (let pad of pads) for (let b of pad?.buttons ?? []) now.add(b)
255
- for (let b of now) {
256
- if (prevButtons.has(b)) continue
257
- if (b === "dpadUp") move("up")
258
- else if (b === "dpadDown") move("down")
259
- else if (b === "dpadLeft") move("left")
260
- else if (b === "dpadRight") move("right")
261
- else if (b === "south") activate()
262
- }
263
- prevButtons = now
264
- },
265
- )
345
+ // The map: the app's, checked for the UI actions, or the platform default.
346
+ let input: InputMap<any> = options?.input ?? defaultMap()
347
+ for (let [name, kind] of Object.entries(uiActions)) {
348
+ if (input.actions[name] !== kind) throw new Error(`createFocusNav: the input map needs a ${kind} action "${name}" (declare it with uiActions)`)
349
+ }
350
+ // The direction an action reads now, or null: the dominant axis of
351
+ // `navigate` past the threshold, the sign of `cycle`.
352
+ let direction = (): Direction | null => {
353
+ let [x, y] = input.value("navigate") as Vec2
354
+ if (Math.max(Math.abs(x), Math.abs(y)) < NAV_THRESHOLD) return null
355
+ if (Math.abs(x) > Math.abs(y)) return x > 0 ? "right" : "left"
356
+ return y > 0 ? "down" : "up"
357
+ }
358
+ let cycleStep = (): 1 | -1 | null => {
359
+ let v = input.value("cycle") as number
360
+ return v >= NAV_THRESHOLD ? 1 : v <= -NAV_THRESHOLD ? -1 : null
361
+ }
362
+ repeating(direction, move)
363
+ repeating(cycleStep, tab)
364
+ onCleanup(input.onPress("select", activate))
266
365
 
267
- return { onKeyDown, move, tab, activate }
366
+ return { input, handlers: input.handlers, move, tab, activate }
268
367
  }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export { createDocumentBuffer, plainDocument, ATOM, type Document, type Document
9
9
  export { ScrollView, type ScrollViewProps } from "./scroll-view"
10
10
  export { Pressable, type PressableProps, type PressState } from "./pressable"
11
11
  export { Button, type ButtonProps, type ButtonVariant } from "./button"
12
- export { createFocusNav, type FocusNavOptions } from "./focus-nav"
12
+ export { createFocusNav, uiActions, uiBindings, type FocusNavOptions, type UiActions, type UiDevices } from "./focus-nav"
13
13
  export { Switch, type SwitchProps } from "./switch"
14
14
  export { Checkbox, type CheckboxProps } from "./checkbox"
15
15
  export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
package/src/press.ts CHANGED
@@ -45,12 +45,12 @@ export interface PressOptions {
45
45
  // changes live. The host view must attach `ref` for retention bounds; without
46
46
  // it every position counts as inside (the up always fires).
47
47
  //
48
- // Keyboard/remote activation: when the host node holds focus (spatial nav or
49
- // setFocus), Enter, Space, or the remote center key fires onPress and stops
50
- // propagating; `focused` mirrors the node's focus for styling (a ring). The
51
- // ref also registers onPress as the node's nav action, the path a
52
- // controller's south button activates through (see focus-nav.ts). Key
53
- // activation shows no pressed state - the focus ring is the feedback.
48
+ // Keyboard/remote/controller activation reads no key here: the ref registers
49
+ // onPress as the node's nav action, and the focus navigation's `select`
50
+ // action (Enter, Space, the remote center key, the pad's south button, or
51
+ // whatever the app bound) runs it while the node holds focus (see
52
+ // focus-nav.ts). `focused` mirrors the node's focus for styling (a ring).
53
+ // Key activation shows no pressed state - the focus ring is the feedback.
54
54
  // The arena and the movement recognizers (createPan, createTransform) live in
55
55
  // core; press stays here because it couples to this package's focus-nav
56
56
  // (registerNavAction) and nothing outside components needs it yet
@@ -187,11 +187,6 @@ export function createPress(options: PressOptions) {
187
187
  options.onPointerLeave?.(e)
188
188
  },
189
189
  onKeyDown: (e: KeyEvent) => {
190
- // The remote center key's `key` is "Unidentified"; match its code.
191
- if ((e.key === "Enter" || e.key === " " || e.code === "Select") && !e.repeat && !options.disabled) {
192
- e.stopPropagation()
193
- activate()
194
- }
195
190
  options.onKeyDown?.(e)
196
191
  },
197
192
  onFocus: () => {