@solidrt/components 0.0.41 → 0.0.43

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
@@ -23,6 +23,14 @@ Most components group props into two objects, plus top-level event handlers:
23
23
  transform `x`/`y`/`rotate`/`scale`.
24
24
  - Event handlers (`onPointerDown`, `onKeyDown`, ...) are top-level props, NOT
25
25
  inside `layout`/`style`.
26
+ - Focus navigation: `createFocusNav` moves real focus across `focusable`
27
+ elements (Button is focusable by default; Pressable opt-in) - spatially on
28
+ arrows/dpad, sequentially on Tab/Shift+Tab (reading order, wrapping).
29
+ Attach its onKeyDown to the window; gamepad dpad/south wire automatically.
30
+ A focused press control activates on Enter/Space/remote-select via
31
+ createPress and draws Button's ring under `policy.focusRing` (true when a
32
+ keyboard OR gamepad/remote is present). An open Modal traps navigation
33
+ inside itself automatically.
26
34
 
27
35
  ## Exports
28
36
 
package/README.md CHANGED
@@ -246,7 +246,8 @@ function NameField() {
246
246
  | `placeholder` | `string` | - | Shown when value is empty and the field is not focused. |
247
247
  | `maxLength` | `number` | - | Truncates input to this length. |
248
248
  | `disabled` | `boolean` | `false` | Ignores pointer and key events when true. |
249
- | `autoFocus` | `boolean` | `false` | Focuses on mount. |
249
+ | `autoFocus` | `boolean` | `false` | Focuses on mount (the on-screen keyboard waits for a tap). |
250
+ | `hints` | `TextInputHints` | - | IME behavior: keyboard type, capitalization, autocorrect. |
250
251
  | `layout` | `LayoutProps` | - | Layout properties (e.g. `width`). |
251
252
  | `style` | `StyleProps` | - | Overrides theme colors, border, and radius. |
252
253
 
@@ -359,13 +360,31 @@ import { Button } from "@solidrt/components"
359
360
 
360
361
  **Props**
361
362
 
362
- | Prop | Type | Description |
363
- | ---------- | ------------- | ---------------------------------------------- |
364
- | `onPress` | `() => void` | Fires on a completed press. |
365
- | `disabled` | `boolean` | Mutes colors and ignores presses. |
366
- | `layout` | `LayoutProps` | Overrides padding/sizing. |
367
- | `style` | `StyleProps` | Overrides background, radius, etc. |
368
- | `children` | `any` | Label text, or custom content. |
363
+ | Prop | Type | Description |
364
+ | ----------- | ------------- | -------------------------------------------------------- |
365
+ | `onPress` | `() => void` | Fires on a completed press. |
366
+ | `disabled` | `boolean` | Mutes colors and ignores presses. |
367
+ | `focusable` | `boolean` | Focus-navigation candidacy; defaults to `true`. |
368
+ | `layout` | `LayoutProps` | Overrides padding/sizing. |
369
+ | `style` | `StyleProps` | Overrides background, radius, etc. |
370
+ | `children` | `any` | Label text, or custom content. |
371
+
372
+ A focused Button (see `createFocusNav`) wears a focus ring under the `focusRing` policy and activates on Enter, Space, or a remote's center key.
373
+
374
+ ### createFocusNav
375
+
376
+ Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving 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. 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.
377
+
378
+ ```jsx
379
+ import { createFocusNav } from "@solidrt/components"
380
+
381
+ function App() {
382
+ let nav = createFocusNav()
383
+ return <window onKeyDown={nav.onKeyDown}>...</window>
384
+ }
385
+ ```
386
+
387
+ 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. 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.
369
388
 
370
389
  ### Switch
371
390
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -18,6 +18,6 @@
18
18
  },
19
19
  "peerDependencies": {
20
20
  "@solidjs/signals": "2.0.0-beta.26",
21
- "@solidrt/core": "0.0.41"
21
+ "@solidrt/core": "0.0.43"
22
22
  }
23
23
  }
package/src/button.tsx CHANGED
@@ -24,6 +24,9 @@ export interface ButtonProps {
24
24
  size?: ButtonSize
25
25
  onPress?: () => void
26
26
  disabled?: boolean
27
+ // Focus-navigation candidacy (spatial nav, TV remotes); on by default.
28
+ // Disabled buttons are never candidates.
29
+ focusable?: boolean
27
30
  ref?: (node: { id: number }) => void
28
31
  layout?: LayoutProps
29
32
  style?: StyleProps
@@ -39,6 +42,9 @@ const SIZE_WIDTH: Record<ButtonSize, number> = { sm: 88, md: 120, lg: 160 }
39
42
  // nodes are recreated. Override the box via style and the padding/sizing via
40
43
  // layout. A caller-set backgroundColor disables the hover tint: we cannot know
41
44
  // its hover variant. When disabled, it takes no pointer events at all.
45
+ // Focus (spatial nav) draws a ring under the focusRing policy, text-colored
46
+ // rather than primary so it stays visible on primary-filled buttons; Enter/
47
+ // Space/remote-select activates (handled by createPress).
42
48
  export function Button(props: ButtonProps) {
43
49
  // Fill, hover fill, and label color per variant, read reactively from the
44
50
  // theme. No variant draws a border.
@@ -84,6 +90,7 @@ export function Button(props: ButtonProps) {
84
90
  let press = createPress(props)
85
91
  let style = () => ({
86
92
  ...props.style,
93
+ ...(press.focused() && policy.focusRing ? { borderWidth: 2, borderColor: theme.color.text } : {}),
87
94
  backgroundColor: bg(press.state()),
88
95
  borderRadius: radius(),
89
96
  // Always a number: a scale that flips from a number back to undefined
@@ -114,6 +121,7 @@ export function Button(props: ButtonProps) {
114
121
  rotate={style().rotate}
115
122
  opacity={style().opacity}
116
123
  {...press.handlers}
124
+ focusable={(props.focusable ?? true) && props.disabled !== true}
117
125
  pointerEvents={props.disabled ? "none" : undefined}
118
126
  >
119
127
  <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
@@ -0,0 +1,268 @@
1
+ import {
2
+ createEffect,
3
+ createSignal,
4
+ gamepads,
5
+ getBoundingBoxViewport,
6
+ getFocusables,
7
+ focusedNode,
8
+ getNodePath,
9
+ onLayout,
10
+ setFocus,
11
+ } from "@solidrt/core"
12
+ import type { KeyEvent } from "@solidrt/core"
13
+
14
+ // Focus navigation: the pointer-free control path, so a TV remote, a
15
+ // keyboard, or a gamepad can drive an app. Two movement types over the same
16
+ // candidates, both steering real focus (setFocus) across the elements
17
+ // declaring `focusable`:
18
+ //
19
+ // - Spatial (arrows, dpad): judged by on-screen boxes at press time - the
20
+ // nearest candidate with progress in the pressed direction wins, so
21
+ // 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
24
+ // boxes rather than registration order, which reordering mounts (a <For>
25
+ // shuffle) silently scrambles.
26
+ //
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
30
+ // pointer input: nothing is focused until the first navigation press, and
31
+ // pointer presses work unchanged throughout.
32
+
33
+ type Direction = "up" | "down" | "left" | "right"
34
+
35
+ // What "select" means per focusable node, registered by createPress's ref.
36
+ // Only the controller path consults it - key activation reaches the focused
37
+ // node by bubbling and never comes through here. Package-internal.
38
+ let navActions = new Map<number, () => void>()
39
+
40
+ export function registerNavAction(nodeId: number, action: () => void): () => void {
41
+ navActions.set(nodeId, action)
42
+ return () => {
43
+ if (navActions.get(nodeId) === action) navActions.delete(nodeId)
44
+ }
45
+ }
46
+
47
+ // Open modals register their container here, most recent on top; the top of
48
+ // the stack is every nav's default scope, so modals trap navigation with no
49
+ // per-app wiring (see Modal). An explicit `scope` option overrides the stack.
50
+ // ownedWrite: the pop runs from onCleanup, inside disposal.
51
+ let [scopeStack, setScopeStack] = createSignal<{ id: number }[]>([], { ownedWrite: true })
52
+
53
+ export function pushNavScope(node: { id: number }): () => void {
54
+ setScopeStack((s) => [...s, node])
55
+ return () => setScopeStack((s) => s.filter((n) => n !== node))
56
+ }
57
+
58
+ export interface FocusNavOptions {
59
+ /**
60
+ * Restricts reachable candidates to this node's subtree while it returns
61
+ * one, overriding the default (the topmost open Modal, which traps
62
+ * navigation automatically); null/undefined falls back to that default.
63
+ * When a scope appears while focus sits outside it, focus is pulled inside
64
+ * (or cleared until the scope has been laid out), so a bubbled Enter cannot
65
+ * reach a control behind the modal.
66
+ */
67
+ scope?: () => { id: number } | null | undefined
68
+ }
69
+
70
+ type Placed = { id: number; x: number; y: number }
71
+
72
+ /**
73
+ * 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:
77
+ *
78
+ * let nav = createFocusNav()
79
+ * <window onKeyDown={nav.onKeyDown}>...
80
+ *
81
+ * Gamepad dpad/south edges are wired automatically (call it inside a
82
+ * component/root scope). move/tab/activate are exposed for custom triggers.
83
+ */
84
+ export function createFocusNav(options?: FocusNavOptions) {
85
+ let currentScope = () => options?.scope?.() ?? scopeStack()[scopeStack().length - 1]
86
+
87
+ // The currently reachable candidates with their centers: declared, laid
88
+ // out, and inside the scope's subtree while one is set.
89
+ let reachable = (): Placed[] => {
90
+ let scopeNode = currentScope()
91
+ let placed: Placed[] = []
92
+ for (let id of getFocusables()) {
93
+ if (scopeNode && !getNodePath(id).includes(scopeNode.id)) continue
94
+ let b = getBoundingBoxViewport({ id })
95
+ if (b) placed.push({ id, x: b.x + b.width / 2, y: b.y + b.height / 2 })
96
+ }
97
+ return placed
98
+ }
99
+
100
+ // Reading order: rows top to bottom (1px tie tolerance), left to right
101
+ // within a row.
102
+ let ordered = (placed: Placed[]): Placed[] =>
103
+ [...placed].sort((a, b) => (Math.abs(a.y - b.y) <= 1 ? a.x - b.x : a.y - b.y))
104
+
105
+ // Where focus last sat (a candidate's center). Navigation that finds
106
+ // nothing focused resumes at the nearest candidate instead of restarting
107
+ // in reading order: activating a control that is then replaced in place
108
+ // (the dev card's Disconnect swapping to Connect) destroys the focused
109
+ // node and clears focus, and the next press should land on the successor,
110
+ // not the top-left of the screen.
111
+ let lastPos: { x: number; y: number } | null = null
112
+
113
+ let focusCandidate = (p: Placed) => {
114
+ lastPos = { x: p.x, y: p.y }
115
+ setFocus(p.id)
116
+ }
117
+
118
+ // Entry focus with no history (very first press) or where predictability
119
+ // beats continuity (a modal opening): the first element in reading order.
120
+ let focusFirst = (placed: Placed[]) => {
121
+ focusCandidate(ordered(placed)[0]!)
122
+ }
123
+
124
+ let focusEntry = (placed: Placed[]) => {
125
+ if (!lastPos) return focusFirst(placed)
126
+ let { x, y } = lastPos
127
+ let best = placed.reduce((a, b) =>
128
+ (b.x - x) ** 2 + (b.y - y) ** 2 < (a.x - x) ** 2 + (a.y - y) ** 2 ? b : a,
129
+ )
130
+ focusCandidate(best)
131
+ }
132
+
133
+ let move = (dir: Direction) => {
134
+ let placed = reachable()
135
+ if (placed.length === 0) return
136
+ let focused = focusedNode()
137
+ let from = focused != null ? placed.find((p) => p.id === focused) : undefined
138
+ if (!from) return focusEntry(placed)
139
+ let best: Placed | null = null
140
+ let bestScore = Infinity
141
+ for (let p of placed) {
142
+ if (p === from) continue
143
+ let dx = p.x - from.x
144
+ let dy = p.y - from.y
145
+ // Progress along the pressed direction is required; among candidates the
146
+ // nearest mostly-aligned one wins (cross-axis distance weighs double).
147
+ let ahead = dir === "up" ? -dy : dir === "down" ? dy : dir === "left" ? -dx : dx
148
+ if (ahead <= 1) continue
149
+ let across = Math.abs(dir === "up" || dir === "down" ? dx : dy)
150
+ let score = ahead + 2 * across
151
+ if (score < bestScore) {
152
+ bestScore = score
153
+ best = p
154
+ }
155
+ }
156
+ if (best) focusCandidate(best)
157
+ }
158
+
159
+ // 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).
163
+ let tab = (delta: 1 | -1) => {
164
+ let placed = reachable()
165
+ if (placed.length === 0) return
166
+ let row = ordered(placed)
167
+ let focused = focusedNode()
168
+ let i = focused != null ? row.findIndex((p) => p.id === focused) : -1
169
+ if (i < 0) {
170
+ if (lastPos) return focusEntry(placed)
171
+ return focusCandidate(row[delta === 1 ? 0 : row.length - 1]!)
172
+ }
173
+ focusCandidate(row[(i + delta + row.length) % row.length]!)
174
+ }
175
+
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.
179
+ let activate = () => {
180
+ let placed = reachable()
181
+ if (placed.length === 0) return
182
+ let focused = focusedNode()
183
+ let hit = focused != null ? placed.find((p) => p.id === focused) : undefined
184
+ if (!hit) return focusEntry(placed)
185
+ // Refresh the resume position before acting: the action may replace the
186
+ // control (and take the focus) with it.
187
+ lastPos = { x: hit.x, y: hit.y }
188
+ navActions.get(hit.id)?.()
189
+ }
190
+
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
+ // The focused control vanishing (replaced by its own action, a screen
204
+ // change) clears focus; hand it to the nearest successor so the ring
205
+ // never disappears mid-navigation. Only when the node actually died: a
206
+ // deliberate blur (outside tap, keyboard dismissal) leaves focus empty,
207
+ // and the two are told apart by whether the previous node still resolves
208
+ // (a destroyed node is gone from the tree by effect time - empty path).
209
+ // The landing waits for the next layout: the successor was mounted this
210
+ // very tick, so it has no box until the frame the swap itself scheduled.
211
+ let prevFocused: number | null = null
212
+ let refocusPending = false
213
+ createEffect(
214
+ () => focusedNode(),
215
+ (id) => {
216
+ let prev = prevFocused
217
+ prevFocused = id
218
+ if (id != null || prev == null) return
219
+ refocusPending = getNodePath(prev).length === 0
220
+ },
221
+ )
222
+ onLayout(() => {
223
+ if (!refocusPending) return
224
+ refocusPending = false
225
+ if (focusedNode() != null) return
226
+ let placed = reachable()
227
+ if (placed.length > 0) focusEntry(placed)
228
+ })
229
+
230
+ // A scope arriving (modal opening) pulls focus inside it: focus left on an
231
+ // outside control would still receive Enter directly (bubbling), bypassing
232
+ // the trap. A scope mounted this very tick has no boxes yet - then focus
233
+ // just clears, and the first navigation press lands inside.
234
+ createEffect(
235
+ () => currentScope(),
236
+ (scopeNode) => {
237
+ if (!scopeNode) return
238
+ let focused = focusedNode()
239
+ if (focused != null && getNodePath(focused).includes(scopeNode.id)) return
240
+ let placed = reachable()
241
+ if (placed.length > 0) focusFirst(placed)
242
+ else if (focused != null) setFocus(null)
243
+ },
244
+ )
245
+
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
+ )
266
+
267
+ return { onKeyDown, move, tab, activate }
268
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export { TextInput, type TextInputProps } from "./text-input"
7
7
  export { ScrollView, type ScrollViewProps } from "./scroll-view"
8
8
  export { Pressable, type PressableProps, type PressState } from "./pressable"
9
9
  export { Button, type ButtonProps, type ButtonVariant } from "./button"
10
+ export { createFocusNav, type FocusNavOptions } from "./focus-nav"
10
11
  export { Switch, type SwitchProps } from "./switch"
11
12
  export { Checkbox, type CheckboxProps } from "./checkbox"
12
13
  export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
package/src/modal.tsx CHANGED
@@ -1,6 +1,7 @@
1
- import { createPortal } from "@solidrt/core"
1
+ import { createPortal, onCleanup } from "@solidrt/core"
2
2
  import type { Color, PointerEvent } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
+ import { pushNavScope } from "./focus-nav"
4
5
 
5
6
  export interface ModalProps {
6
7
  // Called when the backdrop (the area around the content) is pressed, unless
@@ -34,8 +35,16 @@ export function Modal(props: ModalProps) {
34
35
  if (props.dismissable !== false) props.onClose?.()
35
36
  }
36
37
 
38
+ // While mounted, the modal is a focus-navigation trap: its container tops
39
+ // the nav scope stack, so createFocusNav only reaches controls inside it.
40
+ let popNavScope: (() => void) | null = null
41
+ onCleanup(() => popNavScope?.())
42
+
37
43
  return createPortal(
38
44
  <view
45
+ ref={(n: { id: number }) => {
46
+ popNavScope = pushNavScope(n)
47
+ }}
39
48
  position="absolute"
40
49
  top={0}
41
50
  left={0}
package/src/policy.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { capabilities, env, createSignal } from "@solidrt/core"
1
+ import { capabilities, env, createSignal, gamepads } from "@solidrt/core"
2
2
  import type { Capabilities } from "@solidrt/core"
3
3
 
4
4
  // Policies: how components should behave. Derived from capabilities by a
@@ -56,7 +56,10 @@ export function defaultPolicyResolver(caps: Capabilities): Policies {
56
56
  interaction,
57
57
  density: interaction === "desktop" ? "compact" : "comfortable",
58
58
  motion: "normal",
59
- focusRing: caps.keyboardNav,
59
+ // Any focus-driving input warrants the ring: a keyboard, or a gamepad -
60
+ // which includes TV remotes, which register as gamepads and would
61
+ // otherwise navigate ringless on keyboard-free TVs.
62
+ focusRing: caps.keyboardNav || gamepads().some((p) => p != null),
60
63
  textScale: env.textScale,
61
64
  textWeightDelta: env.displayScale < 1.5 ? 100 : 0,
62
65
  navigation:
package/src/press.ts CHANGED
@@ -1,20 +1,25 @@
1
- import { createSignal, onSettled, getBoundingBoxViewport } from "@solidrt/core"
2
- import type { PointerEvent } from "@solidrt/core"
1
+ import { createSignal, createMemo, onSettled, focusedNode, getBoundingBoxViewport } from "@solidrt/core"
2
+ import type { PointerEvent, KeyEvent } from "@solidrt/core"
3
3
  import { claim, release } from "./arena"
4
+ import { registerNavAction } from "./focus-nav"
4
5
 
5
- // A live view of a recognizer's state, not a snapshot: both fields are getters,
6
+ // A live view of a recognizer's state, not a snapshot: the fields are getters,
6
7
  // so a consumer that reads one inside a JSX prop or child expression tracks that
7
8
  // signal there and nothing else re-runs. Read them in those positions, not
8
9
  // eagerly into a local, or the read lands in whatever scope destructured it.
9
- export type PressState = { pressed: boolean; hovered: boolean }
10
+ export type PressState = { pressed: boolean; hovered: boolean; focused: boolean }
10
11
 
11
12
  export interface PressOptions {
12
13
  onPress?: () => void
14
+ disabled?: boolean
13
15
  onPointerDown?: (e: PointerEvent) => void
14
16
  onPointerUp?: (e: PointerEvent) => void
15
17
  onPointerMove?: (e: PointerEvent) => void
16
18
  onPointerEnter?: (e: PointerEvent) => void
17
19
  onPointerLeave?: (e: PointerEvent) => void
20
+ onKeyDown?: (e: KeyEvent) => void
21
+ onFocus?: () => void
22
+ onBlur?: () => void
18
23
  }
19
24
 
20
25
  // The press state machine shared by the pressable components. onPress fires on
@@ -34,6 +39,13 @@ export interface PressOptions {
34
39
  // at event time, so passing a component's reactive props object keeps handler
35
40
  // changes live. The host view must attach `ref` for retention bounds; without
36
41
  // it every position counts as inside (the up always fires).
42
+ //
43
+ // Keyboard/remote activation: when the host node holds focus (spatial nav or
44
+ // setFocus), Enter, Space, or the remote center key fires onPress and stops
45
+ // propagating; `focused` mirrors the node's focus for styling (a ring). The
46
+ // ref also registers onPress as the node's nav action, the path a
47
+ // controller's south button activates through (see focus-nav.ts). Key
48
+ // activation shows no pressed state - the focus ring is the feedback.
37
49
  // Deliberately framework-agnostic (no theme, no styling): a candidate for
38
50
  // promotion into core once the recognizer family grows
39
51
  // (okf/plans/component-gestures.md).
@@ -41,6 +53,18 @@ export function createPress(options: PressOptions) {
41
53
  let [pressed, setPressed] = createSignal(false)
42
54
  let [hovered, setHovered] = createSignal(false)
43
55
  let node: { id: number } | null = null
56
+ let unregisterNav: (() => void) | null = null
57
+
58
+ // Focus is derived from core's reactive focus rather than tracked through
59
+ // the onFocus/onBlur handlers - one source of truth. Memoized so a focus
60
+ // move propagates into styling only for the two controls whose value flips.
61
+ // focusedNode() is read FIRST, unconditionally: the memo may first compute
62
+ // before the ref has set `node`, and short-circuiting past the read would
63
+ // leave the memo with no dependency, frozen false forever.
64
+ let focused = createMemo(() => {
65
+ let id = focusedNode()
66
+ return id != null && id === node?.id
67
+ })
44
68
  // The pointer this recognizer is tracking while a press is in flight, and
45
69
  // the retention state at the last move (read on up; the signal itself is
46
70
  // not readable same-dispatch because writes flush on the microtask).
@@ -62,10 +86,17 @@ export function createPress(options: PressOptions) {
62
86
  get hovered() {
63
87
  return hovered()
64
88
  },
89
+ get focused() {
90
+ return focused()
91
+ },
65
92
  }
66
93
  let state = (): PressState => live
67
94
  let ref = (n: { id: number }) => {
68
95
  node = n
96
+ unregisterNav?.()
97
+ unregisterNav = registerNavAction(n.id, () => {
98
+ if (!options.disabled) options.onPress?.()
99
+ })
69
100
  }
70
101
 
71
102
  let within = (e: PointerEvent) => {
@@ -88,7 +119,10 @@ export function createPress(options: PressOptions) {
88
119
 
89
120
  // A press abandoned mid-flight (unmount during a drag) must not leave its
90
121
  // claim behind, or that pointer id could never press anything again.
91
- onSettled(() => disengage)
122
+ onSettled(() => () => {
123
+ disengage()
124
+ unregisterNav?.()
125
+ })
92
126
 
93
127
  let handlers = {
94
128
  onPointerDown: (e: PointerEvent) => {
@@ -123,7 +157,21 @@ export function createPress(options: PressOptions) {
123
157
  setHovered(false)
124
158
  options.onPointerLeave?.(e)
125
159
  },
160
+ onKeyDown: (e: KeyEvent) => {
161
+ // The remote center key's `key` is "Unidentified"; match its code.
162
+ if ((e.key === "Enter" || e.key === " " || e.code === "Select") && !e.repeat && !options.disabled) {
163
+ e.stopPropagation()
164
+ options.onPress?.()
165
+ }
166
+ options.onKeyDown?.(e)
167
+ },
168
+ onFocus: () => {
169
+ options.onFocus?.()
170
+ },
171
+ onBlur: () => {
172
+ options.onBlur?.()
173
+ },
126
174
  }
127
175
 
128
- return { pressed, hovered, state, ref, handlers, cancel }
176
+ return { pressed, hovered, focused, state, ref, handlers, cancel }
129
177
  }
package/src/pressable.tsx CHANGED
@@ -19,7 +19,10 @@ export interface PressableProps extends PointerProps {
19
19
  }
20
20
 
21
21
  // A pressable box: the createPress semantics (see press.ts) on a styled view.
22
- // When disabled, it takes no pointer events at all.
22
+ // When disabled, it takes no pointer events at all. Focus navigation is
23
+ // opt-in via `focusable` (Button turns it on by default): a focused Pressable
24
+ // activates on Enter/Space/remote-select and exposes `focused` through the
25
+ // press state for the caller's ring styling.
23
26
  export function Pressable(props: PressableProps) {
24
27
  let press = createPress(props)
25
28
 
@@ -63,11 +66,12 @@ export function Pressable(props: PressableProps) {
63
66
  onPointerUp={press.handlers.onPointerUp}
64
67
  onPointerMove={press.handlers.onPointerMove}
65
68
  onWheel={props.onWheel}
66
- onFocus={props.onFocus}
67
- onBlur={props.onBlur}
68
- onKeyDown={props.onKeyDown}
69
+ onFocus={press.handlers.onFocus}
70
+ onBlur={press.handlers.onBlur}
71
+ onKeyDown={press.handlers.onKeyDown}
69
72
  onKeyUp={props.onKeyUp}
70
73
  onTextInput={props.onTextInput}
74
+ focusable={props.focusable === true && props.disabled !== true}
71
75
  pointerEvents={props.disabled ? "none" : props.pointerEvents}
72
76
  >
73
77
  {hasBackground() ? (
package/src/radio.tsx CHANGED
@@ -103,7 +103,7 @@ export function Radio(props: RadioProps) {
103
103
  <d-rect color={props.style?.backgroundColor ?? "transparent"} radius={props.style?.borderRadius} />
104
104
  </Show>
105
105
  <view width={ring()} height={ring()}>
106
- <d-oval x={1} y={1} w={ring() - 2} h={ring() - 2} drawStyle="stroke" color={ringColor()} strokeWidth={2} />
106
+ <d-oval drawStyle="stroke" color={ringColor()} strokeWidth={2} />
107
107
  <Show when={selected()}>
108
108
  <d-oval x={inset()} y={inset()} w={ring() - inset() * 2} h={ring() - inset() * 2} color={theme.color.primary} />
109
109
  </Show>
@@ -1,6 +1,17 @@
1
- import { createEffect, createSignal, onCleanup, measureText, setFocus } from "@solidrt/core"
1
+ import {
2
+ createEffect,
3
+ createMemo,
4
+ createSignal,
5
+ onCleanup,
6
+ focusedNode,
7
+ measureText,
8
+ setFocus,
9
+ startTextInput,
10
+ textInputActive,
11
+ } from "@solidrt/core"
2
12
  import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
3
- import type { Color, Gradient, LayoutProps } from "@solidrt/core"
13
+ import type { Color, Gradient, KeyEvent, LayoutProps, TextInputHints } from "@solidrt/core"
14
+ import { registerNavAction } from "./focus-nav"
4
15
  import type { StyleProps } from "./types"
5
16
  import { theme } from "./theme"
6
17
  import { policy } from "./policy"
@@ -27,7 +38,14 @@ export interface TextInputProps {
27
38
  maxLength?: number
28
39
  disabled?: boolean
29
40
  autoFocus?: boolean
41
+ /**
42
+ * IME behavior for the field's text sessions (keyboard type,
43
+ * capitalization, autocorrect). Identifier-like fields want
44
+ * `{ capitalize: "none", autocorrect: false }`.
45
+ */
46
+ hints?: TextInputHints
30
47
 
48
+ ref?: (node: { id: number }) => void
31
49
  layout?: LayoutProps
32
50
  style?: StyleProps
33
51
  }
@@ -35,17 +53,30 @@ export interface TextInputProps {
35
53
  // Single-line. The caret moves through the text (Left/Right/Home/End), edits
36
54
  // happen at the caret, and the inner box scrolls to keep it in view. Printable
37
55
  // text arrives via onTextInput (post-IME commit). onKeyDown handles caret
38
- // movement, Backspace/Delete, Enter, Escape. Range selection (shift-movement,
56
+ // movement, Backspace/Delete, Enter/select, Escape - and stops those keys
57
+ // from bubbling further. Focused and editing are distinct (see
58
+ // activateField): navigation focuses, select begins editing, Enter while
59
+ // editing submits. Range selection (shift-movement,
39
60
  // highlight, click-to-position) is not wired yet. Outside-click-to-blur is the
40
61
  // caller's job.
41
62
  export function TextInput(props: TextInputProps) {
42
- let [focused, setFocused] = createSignal(false)
43
63
  let [caretOn, setCaretOn] = createSignal(true)
44
64
 
45
65
  let node: { id: number } | undefined
46
66
  let viewport: { id: number } | undefined
47
67
  let blinkId: any = null
48
68
 
69
+ // Derived from core's reactive focus (setFocus is the only writer); the
70
+ // onFocus/onBlur handlers below keep only their side effects (blink timer,
71
+ // caller callbacks). focusedNode() is read FIRST, unconditionally: the
72
+ // memo may first compute before the ref has set `node`, and
73
+ // short-circuiting past the read would leave it dependency-free, frozen
74
+ // false forever.
75
+ let focused = createMemo(() => {
76
+ let id = focusedNode()
77
+ return id != null && id === node?.id
78
+ })
79
+
49
80
  let buffer = createTextBuffer({
50
81
  value: () => props.value,
51
82
  defaultValue: props.defaultValue,
@@ -71,7 +102,6 @@ export function TextInput(props: TextInputProps) {
71
102
  }
72
103
 
73
104
  let handleFocus = () => {
74
- setFocused(true)
75
105
  setCaretOn(true)
76
106
  if (blinkId == null) {
77
107
  blinkId = setInterval(() => setCaretOn((v) => !v), 500)
@@ -80,7 +110,6 @@ export function TextInput(props: TextInputProps) {
80
110
  }
81
111
 
82
112
  let handleBlur = () => {
83
- setFocused(false)
84
113
  if (blinkId != null) {
85
114
  clearInterval(blinkId)
86
115
  blinkId = null
@@ -88,8 +117,12 @@ export function TextInput(props: TextInputProps) {
88
117
  props.onBlur?.()
89
118
  }
90
119
 
91
- let handleKeyDown = (e: any) => {
120
+ // Keys the input consumes stop propagating: an ancestor (or an app-global
121
+ // shortcut on the window) must not also act on an ArrowLeft that moved the
122
+ // caret. Anything else (e.g. ctrl+s) bubbles on.
123
+ let handleKeyDown = (e: KeyEvent) => {
92
124
  if (props.disabled) return
125
+ let consumed = true
93
126
  if (e.key === "Backspace") {
94
127
  buffer.deleteBackward()
95
128
  setCaretOn(true)
@@ -108,12 +141,15 @@ export function TextInput(props: TextInputProps) {
108
141
  } else if (e.key === "End") {
109
142
  buffer.move("end")
110
143
  setCaretOn(true)
111
- } else if (e.key === "Enter") {
112
- props.onSubmit?.(value())
113
- setFocus(null)
144
+ } else if (e.key === "Enter" || e.code === "Select") {
145
+ // The remote center key's `key` is "Unidentified"; match its code.
146
+ activateField()
114
147
  } else if (e.key === "Escape") {
115
148
  if (node) setFocus(null)
149
+ } else {
150
+ consumed = false
116
151
  }
152
+ if (consumed) e.stopPropagation()
117
153
  }
118
154
 
119
155
  let handleTextInput = (e: any) => {
@@ -122,8 +158,28 @@ export function TextInput(props: TextInputProps) {
122
158
  setCaretOn(true)
123
159
  }
124
160
 
161
+ // Select on the focused field: focused and editing are distinct states. A
162
+ // field reached by navigation is focused but has no text session yet -
163
+ // select begins one (raising the on-screen keyboard where used, e.g. a TV
164
+ // with no keyboard attached); while editing, it submits. On platforms
165
+ // where the session starts invisibly at focus (desktop, physical
166
+ // keyboard) the first branch never runs and Enter submits as always.
167
+ // Registered as the nav action too, for a controller's south button.
168
+ let activateField = () => {
169
+ if (props.disabled) return
170
+ if (!textInputActive()) {
171
+ startTextInput()
172
+ } else {
173
+ props.onSubmit?.(value())
174
+ setFocus(null)
175
+ }
176
+ }
177
+
178
+ let unregisterNav: (() => void) | null = null
179
+
125
180
  onCleanup(() => {
126
181
  if (blinkId != null) clearInterval(blinkId)
182
+ unregisterNav?.()
127
183
  })
128
184
 
129
185
  // Style overrides fall back to theme defaults. The border doubles as the
@@ -175,7 +231,14 @@ export function TextInput(props: TextInputProps) {
175
231
 
176
232
  return (
177
233
  <view
178
- ref={(n: { id: number }) => (node = n)}
234
+ ref={(n: { id: number }) => {
235
+ node = n
236
+ unregisterNav?.()
237
+ unregisterNav = registerNavAction(n.id, activateField)
238
+ props.ref?.(n)
239
+ }}
240
+ textInputHints={props.hints}
241
+ focusable
179
242
  flexDirection="row"
180
243
  alignItems="center"
181
244
  paddingLeft={space("md")}