@solidrt/components 0.0.56 → 0.0.58
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 +8 -5
- package/README.md +18 -5
- package/docs/button.md +1 -1
- package/docs/focus-nav.md +16 -3
- package/package.json +2 -2
- package/src/editor-field.tsx +4 -0
- package/src/focus-nav.ts +152 -53
- package/src/index.ts +1 -1
- package/src/press.ts +6 -11
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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`.
|
|
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,
|
|
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`.
|
|
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 <
|
|
398
|
+
return <Window {...nav.handlers}>...</Window>
|
|
399
399
|
}
|
|
400
400
|
```
|
|
401
401
|
|
|
402
|
-
|
|
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,
|
|
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`.
|
|
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 <
|
|
10
|
+
return <Window {...nav.handlers}>...</Window>
|
|
11
11
|
}
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.58",
|
|
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.
|
|
24
|
+
"@solidrt/core": "0.0.58"
|
|
25
25
|
}
|
|
26
26
|
}
|
package/src/editor-field.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 (
|
|
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 (
|
|
23
|
-
//
|
|
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
184
|
+
* <window {...nav.handlers}>...
|
|
80
185
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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,
|
|
161
|
-
// enters at the first element and
|
|
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
|
-
//
|
|
177
|
-
//
|
|
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
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 {
|
|
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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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: () => {
|