@solidrt/components 0.0.40 → 0.0.42
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 +16 -7
- package/README.md +28 -9
- package/package.json +2 -2
- package/src/button.tsx +8 -0
- package/src/focus-nav.ts +268 -0
- package/src/icon.tsx +19 -12
- package/src/index.ts +1 -0
- package/src/modal.tsx +10 -1
- package/src/policy.ts +5 -2
- package/src/press.ts +54 -6
- package/src/pressable.tsx +8 -4
- package/src/radio.tsx +1 -1
- package/src/text-input.tsx +74 -11
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
|
|
|
@@ -75,13 +83,14 @@ Most components group props into two objects, plus top-level event handlers:
|
|
|
75
83
|
`moduleSize`/`margin`/`radius`/`level` (L/M/Q/H). Paints black-on-white by
|
|
76
84
|
default (NOT the theme) to stay scannable; override `color`/`background` only
|
|
77
85
|
if contrast holds. Deps on `qrcode-generator`.
|
|
78
|
-
- `Icon` - thin themed wrapper over the core
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
no icon
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
- `Icon` - thin themed wrapper over the core `parseSvg` primitive (draws mapped
|
|
87
|
+
to `<d-path>` in a `viewBox`-fitted box). `src` is an SVG string (an imported
|
|
88
|
+
`.svg` asset, a `lucide-static` string export, or an inline literal); `size`
|
|
89
|
+
sets a square box (default 24); `color` drives `currentColor` (default
|
|
90
|
+
`theme.color.text`). Carries no icon set of its own and no icon-name registry:
|
|
91
|
+
pass the SVG string in, so any currentColor set (Lucide, Feather, Heroicons)
|
|
92
|
+
works and only used icons are bundled. Multi-color documents keep their own
|
|
93
|
+
fills. Reach for `parseSvg` directly for a non-square box.
|
|
85
94
|
- `SafeArea` - pads children clear of system UI (notches, status bars); top and
|
|
86
95
|
bottom on by default, pass `false`/a number per edge.
|
|
87
96
|
- `theme` / `setTheme` / `darkTheme` / `lightTheme` - shared REACTIVE appearance
|
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
|
|
363
|
-
|
|
|
364
|
-
| `onPress`
|
|
365
|
-
| `disabled`
|
|
366
|
-
| `
|
|
367
|
-
| `
|
|
368
|
-
| `
|
|
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
|
|
|
@@ -598,7 +617,7 @@ import { QrCode } from "@solidrt/components"
|
|
|
598
617
|
|
|
599
618
|
### Icon
|
|
600
619
|
|
|
601
|
-
A thin themed wrapper over the core
|
|
620
|
+
A thin themed wrapper over the core `parseSvg` primitive. `src` is a whole SVG document as a string; the component parses it once (memoized), maps the draws to `<d-path>` in a square `viewBox`-fitted box and, for monochrome icons that stroke/fill with `currentColor`, recolors it from the theme. It carries no icon set and no name registry, so any `currentColor` SVG works (Lucide, Feather, Heroicons) and only the icons you import are bundled. Multi-color documents keep their own fills. For a non-square box, use `parseSvg` directly.
|
|
602
621
|
|
|
603
622
|
Icons are just SVG strings. Import them as assets (`import House from "lucide-static/icons/house.svg"`, resolved to a string), pull them from a string export, or inline a literal:
|
|
604
623
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/components",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.42",
|
|
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.
|
|
21
|
+
"@solidrt/core": "0.0.42"
|
|
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} />
|
package/src/focus-nav.ts
ADDED
|
@@ -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/icon.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createMemo, parseSvg, For } from "@solidrt/core"
|
|
1
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
2
3
|
import { theme } from "./theme"
|
|
3
4
|
|
|
@@ -16,22 +17,28 @@ export interface IconProps {
|
|
|
16
17
|
|
|
17
18
|
const SIZE = 24
|
|
18
19
|
|
|
19
|
-
// A themed wrapper over the
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// non-square box or
|
|
20
|
+
// A themed wrapper over parseSvg: the document parsed once per src/color (a
|
|
21
|
+
// memo), its draws mapped to <d-path> in a square viewBox-fitted box colored
|
|
22
|
+
// from the theme by default. That is the only value it adds, so reach for
|
|
23
|
+
// parseSvg plus your own <view viewBox> when you need a non-square box or
|
|
24
|
+
// want no theme coupling. The plain repaintBoundary keeps the static subtree
|
|
25
|
+
// from re-recording alongside animating siblings; pointerEvents="all" makes
|
|
26
|
+
// the box one hit unit (and skips per-path outline tests) - an icon's shapes
|
|
27
|
+
// are never individual targets.
|
|
23
28
|
export function Icon(props: IconProps) {
|
|
24
29
|
let size = () => props.size ?? SIZE
|
|
30
|
+
let doc = createMemo(() => parseSvg(props.src, { color: props.color ?? theme.color.text }))
|
|
25
31
|
|
|
26
32
|
return (
|
|
27
|
-
<view
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
<view
|
|
34
|
+
repaintBoundary
|
|
35
|
+
pointerEvents="all"
|
|
36
|
+
width={size()}
|
|
37
|
+
height={size()}
|
|
38
|
+
viewBox={[doc().width, doc().height]}
|
|
39
|
+
{...props.layout}
|
|
40
|
+
>
|
|
41
|
+
<For each={doc().draws}>{(draw) => <d-path {...draw} />}</For>
|
|
35
42
|
</view>
|
|
36
43
|
)
|
|
37
44
|
}
|
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
|
-
|
|
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:
|
|
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(() =>
|
|
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={
|
|
67
|
-
onBlur={
|
|
68
|
-
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
|
|
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>
|
package/src/text-input.tsx
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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 }) =>
|
|
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")}
|