@solidrt/components 0.0.55 → 0.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/focus-nav.ts CHANGED
@@ -1,37 +1,132 @@
1
1
  import {
2
2
  createEffect,
3
+ createInputMap,
3
4
  createSignal,
4
- gamepads,
5
+ gamepad,
5
6
  getBoundingBoxViewport,
6
7
  getFocusables,
7
8
  focusedNode,
8
9
  getNodePath,
10
+ keyboard,
11
+ onCleanup,
9
12
  onLayout,
10
13
  setFocus,
14
+ untrack,
11
15
  } from "@solidrt/core"
12
- import type { KeyEvent } from "@solidrt/core"
16
+ import type { Binding, GamepadDevice, InputMap, KeyboardDevice, Vec2 } from "@solidrt/core"
13
17
 
14
18
  // Focus navigation: the pointer-free control path, so a TV remote, a
15
19
  // keyboard, or a gamepad can drive an app. Two movement types over the same
16
20
  // candidates, both steering real focus (setFocus) across the elements
17
21
  // declaring `focusable`:
18
22
  //
19
- // - Spatial (arrows, dpad): judged by on-screen boxes at press time - the
23
+ // - Spatial (`navigate`): judged by on-screen boxes at press time - the
20
24
  // nearest candidate with progress in the pressed direction wins, so
21
25
  // registration order never matters.
22
- // - Sequential (Tab / Shift+Tab): visual reading order - rows top to bottom,
23
- // left to right within a row, wrapping at the ends. Derived from the same
26
+ // - Sequential (`cycle`): visual reading order - rows top to bottom, left
27
+ // to right within a row, wrapping at the ends. Derived from the same
24
28
  // boxes rather than registration order, which reordering mounts (a <For>
25
29
  // shuffle) silently scrambles.
26
30
  //
27
- // Activation is split by source: Enter / the remote center key bubble to the
28
- // focused node itself (createPress consumes them there), while a controller's
29
- // south button goes through the action registry below. Purely additive to
31
+ // The nav reads no device (ARCHITECTURE.md: controls through an
32
+ // abstraction, never direct event handling). It consumes the three UI
33
+ // actions of an input map - `navigate` (vec2), `cycle` (axis), `select`
34
+ // (button) - the way Flutter's focus widgets consume intents and Godot's
35
+ // controls the ui_* actions. A component library must work with nothing
36
+ // wired, as those two do, so a nav created bare binds the standard set
37
+ // itself (uiBindings over the keyboard and every pad) on a map of its own,
38
+ // reachable as `nav.input` for rebinding; given `input`, it consumes the
39
+ // app's map, which may hold the app's other actions too. Either way the
40
+ // app spreads the map's handlers on the window: keys arrive there only when
41
+ // no focused component consumed them, so a focused TextInput keeps its
42
+ // caret keys and the Slider its arrows.
43
+ //
44
+ // A held direction repeats on the nav's own timing over the action's rate
45
+ // (a first step on the edge, the next after NAV_REPEAT_DELAY, then every
46
+ // NAV_REPEAT_INTERVAL), so pads and remotes walk the way keyboards used to
47
+ // through key repeat. Activation is one path: the `select` press runs the
48
+ // focused node's action from the registry below, for Enter, Space, the
49
+ // remote's center key and the pad's south button alike. Purely additive to
30
50
  // pointer input: nothing is focused until the first navigation press, and
31
51
  // pointer presses work unchanged throughout.
32
52
 
33
53
  type Direction = "up" | "down" | "left" | "right"
34
54
 
55
+ // A held direction walks: the first step on the edge, the next after this
56
+ // many ms, then one every NAV_REPEAT_INTERVAL ms while it holds.
57
+ const NAV_REPEAT_DELAY = 400
58
+ const NAV_REPEAT_INTERVAL = 100
59
+ // Deflection along the dominant axis of `navigate` (a stick) or on `cycle`
60
+ // below which the action reads as no direction; dpad and arrows read 1.
61
+ const NAV_THRESHOLD = 0.5
62
+
63
+ /** The UI actions the focus navigation consumes: `navigate` (vec2, the
64
+ * direction to move focus in the screen convention, x right and y down),
65
+ * `cycle` (axis: +1 steps to the next candidate in reading order, -1 to
66
+ * the previous) and `select` (button: activate the focused control). An
67
+ * app's own map declares them by spreading this object. */
68
+ export const uiActions = { navigate: "vec2", cycle: "axis", select: "button" } as const
69
+ export type UiActions = typeof uiActions
70
+
71
+ export type UiDevices = { keyboard?: KeyboardDevice; gamepad?: GamepadDevice }
72
+
73
+ /**
74
+ * The standard UI bindings: the arrows on `navigate`, Tab and Shift+Tab
75
+ * on `cycle`, Enter, Space and the remote's center key (code "Select") on
76
+ * `select` for the keyboard; the dpad and the left stick on `navigate` and
77
+ * the south button on `select` for a pad. What a bare createFocusNav
78
+ * binds; an app with its own map applies it with `input.bind(...)`.
79
+ */
80
+ export function uiBindings(devices: UiDevices): Binding[] {
81
+ let out: Binding[] = []
82
+ let kb = devices.keyboard
83
+ if (kb) {
84
+ out.push(
85
+ { action: "navigate", source: kb.arrows },
86
+ { action: "cycle", source: kb.axis("Shift+Tab", "Tab") },
87
+ { action: "select", source: kb.key("Enter") },
88
+ { action: "select", source: kb.key("Space") },
89
+ { action: "select", source: kb.key("Select") },
90
+ )
91
+ }
92
+ let pad = devices.gamepad
93
+ if (pad) {
94
+ out.push({ action: "navigate", source: pad.dpad }, { action: "navigate", source: pad.leftStick }, { action: "select", source: pad.button("south") })
95
+ }
96
+ return out
97
+ }
98
+
99
+ // Steps on a held value: once on the edge, again after NAV_REPEAT_DELAY,
100
+ // then every NAV_REPEAT_INTERVAL until it reads null or changes (a change
101
+ // restarts the delay). Runs under the calling scope: the effect and the
102
+ // timer die with it.
103
+ function repeating<T>(read: () => T | null, step: (value: T) => void): void {
104
+ let current: T | null = null
105
+ let timer: ReturnType<typeof setTimeout> | null = null
106
+ let stop = () => {
107
+ if (timer == null) return
108
+ clearTimeout(timer)
109
+ timer = null
110
+ }
111
+ let later = (value: T, delay: number) => {
112
+ timer = setTimeout(() => {
113
+ step(value)
114
+ later(value, NAV_REPEAT_INTERVAL)
115
+ }, delay)
116
+ }
117
+ // The step reads focus and the candidates for itself, a snapshot, not a
118
+ // dependency: untracked so the strict-mode read diagnostic stays quiet.
119
+ createEffect(read, (value) => {
120
+ if (value === current) return
121
+ current = value
122
+ stop()
123
+ if (value === null) return
124
+ untrack(() => step(value))
125
+ later(value, NAV_REPEAT_DELAY)
126
+ })
127
+ onCleanup(stop)
128
+ }
129
+
35
130
  // What "select" means per focusable node, registered by createPress's ref.
36
131
  // Only the controller path consults it - key activation reaches the focused
37
132
  // node by bubbling and never comes through here. Package-internal.
@@ -56,6 +151,15 @@ export function pushNavScope(node: { id: number }): () => void {
56
151
  }
57
152
 
58
153
  export interface FocusNavOptions {
154
+ /**
155
+ * The input map to consume the UI actions from: it must declare
156
+ * `navigate`, `cycle` and `select` with the kinds of `uiActions`, and the
157
+ * app binds it (uiBindings or its own) and spreads its handlers on the
158
+ * window. Without one the nav makes its own map bound to the standard set
159
+ * over the keyboard and every pad, which `nav.input` exposes for
160
+ * rebinding.
161
+ */
162
+ input?: InputMap<any>
59
163
  /**
60
164
  * Restricts reachable candidates to this node's subtree while it returns
61
165
  * one, overriding the default (the topmost open Modal, which traps
@@ -71,16 +175,23 @@ type Placed = { id: number; x: number; y: number }
71
175
 
72
176
  /**
73
177
  * Creates focus navigation over the `focusable` elements: spatial movement on
74
- * arrows/dpad, sequential on Tab/Shift+Tab. Attach the returned onKeyDown to
75
- * the window - keys arrive there only when no focused component consumed
76
- * them, so a focused TextInput keeps its caret keys:
178
+ * `navigate`, sequential on `cycle`, activation on `select`, consumed from an
179
+ * input map (see FocusNavOptions.input). Spread the map's handlers on the
180
+ * window - keys arrive there only when no focused component consumed them,
181
+ * so a focused TextInput keeps its caret keys:
77
182
  *
78
183
  * let nav = createFocusNav()
79
- * <window onKeyDown={nav.onKeyDown}>...
184
+ * <window {...nav.handlers}>...
80
185
  *
81
- * Gamepad dpad/south edges are wired automatically (call it inside a
82
- * component/root scope). move/tab/activate are exposed for custom triggers.
186
+ * Call it inside a component/root scope: its effects and repeat timers live
187
+ * there. move/tab/activate are the verbs, for custom triggers.
83
188
  */
189
+ let defaultMap = (): InputMap<UiActions> => {
190
+ let map = createInputMap(uiActions)
191
+ map.bind(uiBindings({ keyboard, gamepad: gamepad() }))
192
+ return map
193
+ }
194
+
84
195
  export function createFocusNav(options?: FocusNavOptions) {
85
196
  let currentScope = () => options?.scope?.() ?? scopeStack()[scopeStack().length - 1]
86
197
 
@@ -157,9 +268,9 @@ export function createFocusNav(options?: FocusNavOptions) {
157
268
  }
158
269
 
159
270
  // Sequential step in reading order, wrapping at the ends. With no
160
- // reachable focus it resumes near the last position; lacking one, Tab
161
- // enters at the first element and Shift+Tab at the last (the step "wraps
162
- // into" the set from either side).
271
+ // reachable focus it resumes near the last position; lacking one, a
272
+ // forward step enters at the first element and a backward one at the
273
+ // last (the step "wraps into" the set from either side).
163
274
  let tab = (delta: 1 | -1) => {
164
275
  let placed = reachable()
165
276
  if (placed.length === 0) return
@@ -173,9 +284,9 @@ export function createFocusNav(options?: FocusNavOptions) {
173
284
  focusCandidate(row[(i + delta + row.length) % row.length]!)
174
285
  }
175
286
 
176
- // Controller select, and Enter reaching the window (nothing focused, or the
177
- // focused node did not consume it): with no reachable focus the press lands
178
- // focus instead of acting.
287
+ // The `select` press (Enter, the remote's center key, the pad's south
288
+ // button, whatever the app bound): runs the focused node's registered
289
+ // action; with no reachable focus the press lands focus instead of acting.
179
290
  let activate = () => {
180
291
  let placed = reachable()
181
292
  if (placed.length === 0) return
@@ -188,18 +299,6 @@ export function createFocusNav(options?: FocusNavOptions) {
188
299
  navActions.get(hit.id)?.()
189
300
  }
190
301
 
191
- // Keyboard and TV remote. Arrows and Tab use key repeat (holding walks
192
- // through candidates); activation ignores it. The remote center key's `key`
193
- // is "Unidentified", so it is matched by code.
194
- let onKeyDown = (e: KeyEvent) => {
195
- if (e.key === "ArrowUp") move("up")
196
- else if (e.key === "ArrowDown") move("down")
197
- else if (e.key === "ArrowLeft") move("left")
198
- else if (e.key === "ArrowRight") move("right")
199
- else if (e.key === "Tab") tab(e.shiftKey ? -1 : 1)
200
- else if ((e.key === "Enter" || e.code === "Select") && !e.repeat) activate()
201
- }
202
-
203
302
  // The focused control vanishing (replaced by its own action, a screen
204
303
  // change) clears focus; hand it to the nearest successor so the ring
205
304
  // never disappears mid-navigation. Only when the node actually died: a
@@ -243,26 +342,26 @@ export function createFocusNav(options?: FocusNavOptions) {
243
342
  },
244
343
  )
245
344
 
246
- // Gamepads: edge-detect the dpad and south button on the union of all pads'
247
- // pressed buttons. The sticky replay on the first read seeds the baseline
248
- // (a button already held at creation fires its edge once).
249
- let prevButtons = new Set<string>()
250
- createEffect(
251
- () => gamepads(),
252
- (pads) => {
253
- let now = new Set<string>()
254
- for (let pad of pads) for (let b of pad?.buttons ?? []) now.add(b)
255
- for (let b of now) {
256
- if (prevButtons.has(b)) continue
257
- if (b === "dpadUp") move("up")
258
- else if (b === "dpadDown") move("down")
259
- else if (b === "dpadLeft") move("left")
260
- else if (b === "dpadRight") move("right")
261
- else if (b === "south") activate()
262
- }
263
- prevButtons = now
264
- },
265
- )
345
+ // The map: the app's, checked for the UI actions, or the platform default.
346
+ let input: InputMap<any> = options?.input ?? defaultMap()
347
+ for (let [name, kind] of Object.entries(uiActions)) {
348
+ if (input.actions[name] !== kind) throw new Error(`createFocusNav: the input map needs a ${kind} action "${name}" (declare it with uiActions)`)
349
+ }
350
+ // The direction an action reads now, or null: the dominant axis of
351
+ // `navigate` past the threshold, the sign of `cycle`.
352
+ let direction = (): Direction | null => {
353
+ let [x, y] = input.value("navigate") as Vec2
354
+ if (Math.max(Math.abs(x), Math.abs(y)) < NAV_THRESHOLD) return null
355
+ if (Math.abs(x) > Math.abs(y)) return x > 0 ? "right" : "left"
356
+ return y > 0 ? "down" : "up"
357
+ }
358
+ let cycleStep = (): 1 | -1 | null => {
359
+ let v = input.value("cycle") as number
360
+ return v >= NAV_THRESHOLD ? 1 : v <= -NAV_THRESHOLD ? -1 : null
361
+ }
362
+ repeating(direction, move)
363
+ repeating(cycleStep, tab)
364
+ onCleanup(input.onPress("select", activate))
266
365
 
267
- return { onKeyDown, move, tab, activate }
366
+ return { input, handlers: input.handlers, move, tab, activate }
268
367
  }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export { createDocumentBuffer, plainDocument, ATOM, type Document, type Document
9
9
  export { ScrollView, type ScrollViewProps } from "./scroll-view"
10
10
  export { Pressable, type PressableProps, type PressState } from "./pressable"
11
11
  export { Button, type ButtonProps, type ButtonVariant } from "./button"
12
- export { createFocusNav, type FocusNavOptions } from "./focus-nav"
12
+ export { createFocusNav, uiActions, uiBindings, type FocusNavOptions, type UiActions, type UiDevices } from "./focus-nav"
13
13
  export { Switch, type SwitchProps } from "./switch"
14
14
  export { Checkbox, type CheckboxProps } from "./checkbox"
15
15
  export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
package/src/item.tsx CHANGED
@@ -1,12 +1,13 @@
1
1
  import { Show, children } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
- import { createPress, type PressState } from "./press"
3
+ import { createPress } from "./press"
4
4
  import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { space } from "./spacing"
7
7
  import { typeStyle } from "./typography"
8
8
  import type { StyleProps, TransitionProps } from "./types"
9
- import { splitTransition, transitionEndFor } from "./types"
9
+ import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
10
+ import { colorFade, PressFeedback } from "./motion"
10
11
 
11
12
  export interface ItemProps extends TransitionProps {
12
13
  // Leading content: an icon, avatar, checkbox, ...
@@ -47,14 +48,6 @@ export function Item(props: ItemProps) {
47
48
  let press = createPress(props)
48
49
 
49
50
  let bg = () => styled().backgroundColor ?? (props.selected ? theme.color.surfaceAlt : "transparent")
50
- let overlay = (s: PressState) =>
51
- !interactive()
52
- ? "transparent"
53
- : s.pressed
54
- ? theme.color.overlayPressed
55
- : s.hovered && policy.interaction !== "touch"
56
- ? theme.color.overlayHover
57
- : "transparent"
58
51
  let radius = () => styled().borderRadius ?? theme.radius.sm
59
52
 
60
53
  // Resolved once via children(): the typeof probe and the mount site must
@@ -95,18 +88,22 @@ export function Item(props: ItemProps) {
95
88
  focusable={(props.focusable ?? true) && interactive()}
96
89
  pointerEvents={props.disabled ? "none" : undefined}
97
90
  >
98
- <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
99
- <d-rect color={overlay(press.state())} radius={radius()} />
91
+ <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
92
+ <PressFeedback
93
+ pressed={interactive() && press.pressed()}
94
+ hovered={interactive() && press.hovered() && policy.interaction !== "touch"}
95
+ radius={radius()}
96
+ />
100
97
  {props.startContent}
101
98
  <view flexDirection="column" flexGrow={1} flexShrink={1} gap={Math.round(space("sm") / 2)}>
102
99
  <Show when={labelIsText()} fallback={label()}>
103
- <text color={theme.color.text} {...typeStyle("body")} maxLines={1}>
100
+ <text transition={colorFade()} color={theme.color.text} {...typeStyle("body")} maxLines={1}>
104
101
  {label()}
105
102
  </text>
106
103
  </Show>
107
104
  <Show when={props.description != null}>
108
105
  <Show when={descriptionIsText()} fallback={description()}>
109
- <text color={theme.color.textMuted} {...typeStyle("body")} maxLines={1}>
106
+ <text transition={colorFade()} color={theme.color.textMuted} {...typeStyle("body")} maxLines={1}>
110
107
  {description()}
111
108
  </text>
112
109
  </Show>
package/src/modal.tsx CHANGED
@@ -2,6 +2,7 @@ import { createPortal, onCleanup } from "@solidrt/core"
2
2
  import type { Color, PointerEvent } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { pushNavScope } from "./focus-nav"
5
+ import { colorFade, popupFade } from "./motion"
5
6
 
6
7
  export interface ModalProps {
7
8
  // Called when the backdrop (the area around the content) is pressed, unless
@@ -40,6 +41,9 @@ export function Modal(props: ModalProps) {
40
41
  let popNavScope: (() => void) | null = null
41
42
  onCleanup(() => popNavScope?.())
42
43
 
44
+ // The whole overlay (scrim and content together) fades in at mount and
45
+ // out on removal via popupFade; an exiting modal is hit-test invisible,
46
+ // so a press during the fade-out reaches what is behind it.
43
47
  return createPortal(
44
48
  <view
45
49
  ref={(n: { id: number }) => {
@@ -52,6 +56,8 @@ export function Modal(props: ModalProps) {
52
56
  bottom={0}
53
57
  alignItems="center"
54
58
  justifyContent="center"
59
+ opacity={1}
60
+ transition={popupFade()}
55
61
  >
56
62
  <view
57
63
  position="absolute"
@@ -61,7 +67,7 @@ export function Modal(props: ModalProps) {
61
67
  bottom={0}
62
68
  onPointerDown={dismiss}
63
69
  >
64
- <d-rect color={props.backdropColor ?? theme.color.scrim} />
70
+ <d-rect transition={colorFade()} color={props.backdropColor ?? theme.color.scrim} />
65
71
  </view>
66
72
  {props.children}
67
73
  </view>
package/src/motion.tsx ADDED
@@ -0,0 +1,122 @@
1
+ import { withAlpha } from "@solidrt/core"
2
+ import type { Transition } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import { policy } from "./policy"
5
+
6
+ // The components' built-in motion. Every default transition in the package
7
+ // is declared through these helpers, so timing comes from theme.motion and
8
+ // the whole set degrades together under policy.motion: "reduced" keeps the
9
+ // fades (color and opacity carry state; a fade is not movement) but snaps
10
+ // everything that travels or scales, "none" snaps it all. Each helper
11
+ // returns undefined while its motion is off. A caller's `transition` prop
12
+ // overrides a default per property (see withTransitionDefaults in types.ts)
13
+ // and `transition={null}` suppresses the defaults outright.
14
+
15
+ // Scale a pressed control shrinks to; the release springs back.
16
+ const PRESS_SCALE = 0.97
17
+ // Overshoot of the travel springs (switch knob, segmented indicator): a
18
+ // touch of bounce reads as physical without wobble.
19
+ const TRAVEL_BOUNCE = 0.15
20
+ // Fraction of its size a state mark (checkmark, radio dot) grows in from.
21
+ const MARK_FROM_SCALE = 0.4
22
+
23
+ /** State-change fade for colors and opacity - also the theme cross-fade. */
24
+ export function fadeMotion(): Transition | undefined {
25
+ if (policy.motion === "none") return undefined
26
+ return { duration: theme.motion.base, curve: "ease-out" } satisfies Transition
27
+ }
28
+
29
+ /** Press/hover feedback fade (the overlay tints): faster than fadeMotion so a press reads as immediate. */
30
+ export function feedbackFade(): Transition | undefined {
31
+ if (policy.motion === "none") return undefined
32
+ return { duration: theme.motion.fast, curve: "ease-out" } satisfies Transition
33
+ }
34
+
35
+ /** Travel spring for a control's moving part. Movement, so "reduced" snaps it too. */
36
+ export function travelMotion(bounce: number = TRAVEL_BOUNCE): Transition | undefined {
37
+ if (policy.motion !== "normal") return undefined
38
+ return { duration: theme.motion.slow, bounce } satisfies Transition
39
+ }
40
+
41
+ /** Default `{ color: fade }` declaration for a themed paint node. */
42
+ export function colorFade(): { color: Transition } | undefined {
43
+ let fade = fadeMotion()
44
+ return fade === undefined ? undefined : { color: fade }
45
+ }
46
+
47
+ /** Default `{ scale: spring }` declaration for a control that shrinks on press. */
48
+ export function scaleFeedback(): { scale: Transition } | undefined {
49
+ if (policy.motion !== "normal") return undefined
50
+ return { scale: { duration: theme.motion.fast } satisfies Transition }
51
+ }
52
+
53
+ /** The scale a pressed control renders at: PRESS_SCALE under full motion, 1 otherwise (a scale snap is worse than none; the overlay tint still marks the press). */
54
+ export function pressScale(pressed: boolean): number {
55
+ return pressed && policy.motion === "normal" ? PRESS_SCALE : 1
56
+ }
57
+
58
+ /**
59
+ * Enter/exit fade for an overlay surface mounted in place (modal, bottom
60
+ * sheet): fades in at mount, fades out on removal. The node must set
61
+ * `opacity` explicitly - only carried properties animate.
62
+ */
63
+ export function popupFade(): { opacity: Transition } | undefined {
64
+ if (policy.motion === "none") return undefined
65
+ return { opacity: { duration: theme.motion.base, curve: "ease-out", from: 0, exit: 0 } satisfies Transition }
66
+ }
67
+
68
+ /**
69
+ * Exit-only fade for popups that park offscreen until measured (tooltip,
70
+ * menus): they fade in by writing opacity 0 -> 1 once positioned - a
71
+ * mount-time `from` would play unseen - and fade out on removal.
72
+ */
73
+ export function popupFadeOut(): { opacity: Transition } | undefined {
74
+ if (policy.motion === "none") return undefined
75
+ return { opacity: { duration: theme.motion.base, curve: "ease-out", exit: 0 } satisfies Transition }
76
+ }
77
+
78
+ /**
79
+ * Enter/exit pop for a control's state mark (checkbox check, radio dot),
80
+ * declared on a wrapper view that sets `opacity={1} scale={1}`: it fades
81
+ * in/out, with a small scale pop under full motion only.
82
+ */
83
+ export function markMotion(): { opacity: Transition; scale?: Transition } | undefined {
84
+ if (policy.motion === "none") return undefined
85
+ let declaration: { opacity: Transition; scale?: Transition } = {
86
+ opacity: { duration: theme.motion.base, curve: "ease-out", from: 0, exit: 0 } satisfies Transition,
87
+ }
88
+ if (policy.motion === "normal")
89
+ declaration.scale = {
90
+ duration: theme.motion.slow,
91
+ bounce: TRAVEL_BOUNCE,
92
+ from: MARK_FROM_SCALE,
93
+ exit: MARK_FROM_SCALE,
94
+ } satisfies Transition
95
+ return declaration
96
+ }
97
+
98
+ /**
99
+ * The hover/pressed tint every pressable control draws over its own fill.
100
+ * One always-mounted rect whose hidden state is the same tint at alpha 0,
101
+ * not "transparent": a fade from transparent black would darken a dark
102
+ * scheme's white tint midway. Pressed swaps in the deeper tint; showing,
103
+ * hiding and the pressed/hover swap all fade at feedback speed.
104
+ */
105
+ export function PressFeedback(props: {
106
+ pressed?: boolean
107
+ hovered?: boolean
108
+ radius?: number | [number, number, number, number]
109
+ }) {
110
+ let tint = () => (props.pressed ? theme.color.overlayPressed : theme.color.overlayHover)
111
+ let fade = () => {
112
+ let f = feedbackFade()
113
+ return f === undefined ? undefined : { color: f }
114
+ }
115
+ return (
116
+ <d-rect
117
+ transition={fade()}
118
+ color={props.pressed || props.hovered ? tint() : withAlpha(tint(), 0)}
119
+ radius={props.radius}
120
+ />
121
+ )
122
+ }
package/src/nav-shell.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal, Switch, Match, For } from "@solidrt/core"
1
+ import { createSignal, Switch, Match, For, withAlpha } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { createPress } from "./press"
4
4
  import { theme } from "./theme"
@@ -7,6 +7,7 @@ import { space } from "./spacing"
7
7
  import { typeStyle } from "./typography"
8
8
  import type { TransitionProps } from "./types"
9
9
  import { splitTransition, transitionEndFor } from "./types"
10
+ import { colorFade, PressFeedback } from "./motion"
10
11
 
11
12
  export interface NavItem {
12
13
  value: unknown
@@ -44,12 +45,11 @@ export function NavShell(props: NavShellProps) {
44
45
  }
45
46
 
46
47
  let labelColor = (item: NavItem) => (item.value === value() ? theme.color.primary : theme.color.textMuted)
47
- let itemBg = (item: NavItem, hovered: boolean) =>
48
- item.value === value()
49
- ? theme.color.surfaceAlt
50
- : hovered && policy.interaction !== "touch"
51
- ? theme.color.overlayHover
52
- : "transparent"
48
+ // The selection fill: the surfaceAlt tint at alpha 0 while unselected, so
49
+ // selecting fades it in (same trap as PressFeedback: a fade from
50
+ // "transparent" would pass through the wrong hue).
51
+ let itemBg = (item: NavItem) =>
52
+ item.value === value() ? theme.color.surfaceAlt : withAlpha(theme.color.surfaceAlt, 0)
53
53
 
54
54
  // Icon over a small label, centered; shared by the tab bar and the rail.
55
55
  let StackedItem = (p: { item: NavItem; padY: number; layout?: LayoutProps }) => {
@@ -67,9 +67,14 @@ export function NavShell(props: NavShellProps) {
67
67
  {...p.layout}
68
68
  {...press.handlers}
69
69
  >
70
- <d-rect color={itemBg(p.item, press.hovered())} radius={theme.radius.sm} />
70
+ <d-rect transition={colorFade()} color={itemBg(p.item)} radius={theme.radius.sm} />
71
+ <PressFeedback
72
+ pressed={press.pressed()}
73
+ hovered={press.hovered() && policy.interaction !== "touch" && p.item.value !== value()}
74
+ radius={theme.radius.sm}
75
+ />
71
76
  {p.item.icon}
72
- <text color={labelColor(p.item)} {...typeStyle("caption")}>
77
+ <text transition={colorFade()} color={labelColor(p.item)} {...typeStyle("caption")}>
73
78
  {p.item.label}
74
79
  </text>
75
80
  </view>
@@ -79,7 +84,7 @@ export function NavShell(props: NavShellProps) {
79
84
  let Tabs = () => (
80
85
  <view flexDirection="column" flexShrink={0}>
81
86
  <view flexDirection="row">
82
- <d-rect color={theme.color.surface} />
87
+ <d-rect transition={colorFade()} color={theme.color.surface} />
83
88
  <For each={props.items}>
84
89
  {(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} layout={{ flex: 1 }} />}
85
90
  </For>
@@ -90,7 +95,7 @@ export function NavShell(props: NavShellProps) {
90
95
  let Rail = () => (
91
96
  <view flexDirection="row" flexShrink={0}>
92
97
  <view flexDirection="column" width={theme.size.navRail} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
93
- <d-rect color={theme.color.surface} />
98
+ <d-rect transition={colorFade()} color={theme.color.surface} />
94
99
  <For each={props.items}>{(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} />}</For>
95
100
  </view>
96
101
  </view>
@@ -99,7 +104,7 @@ export function NavShell(props: NavShellProps) {
99
104
  let Sidebar = () => (
100
105
  <view flexDirection="row" flexShrink={0}>
101
106
  <view flexDirection="column" width={theme.size.navSidebar} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
102
- <d-rect color={theme.color.surface} />
107
+ <d-rect transition={colorFade()} color={theme.color.surface} />
103
108
  <For each={props.items}>
104
109
  {(item: NavItem) => {
105
110
  let press = createPress({ onPress: () => select(item.value) })
@@ -118,9 +123,15 @@ export function NavShell(props: NavShellProps) {
118
123
  marginRight={theme.spacing.sm}
119
124
  {...press.handlers}
120
125
  >
121
- <d-rect color={itemBg(item, press.hovered())} radius={theme.radius.sm} />
126
+ <d-rect transition={colorFade()} color={itemBg(item)} radius={theme.radius.sm} />
127
+ <PressFeedback
128
+ pressed={press.pressed()}
129
+ hovered={press.hovered() && policy.interaction !== "touch" && item.value !== value()}
130
+ radius={theme.radius.sm}
131
+ />
122
132
  {item.icon}
123
133
  <text
134
+ transition={colorFade()}
124
135
  color={item.value === value() ? theme.color.primary : theme.color.text}
125
136
  {...typeStyle("body")}
126
137
  >
package/src/press.ts CHANGED
@@ -45,12 +45,12 @@ export interface PressOptions {
45
45
  // changes live. The host view must attach `ref` for retention bounds; without
46
46
  // it every position counts as inside (the up always fires).
47
47
  //
48
- // Keyboard/remote activation: when the host node holds focus (spatial nav or
49
- // setFocus), Enter, Space, or the remote center key fires onPress and stops
50
- // propagating; `focused` mirrors the node's focus for styling (a ring). The
51
- // ref also registers onPress as the node's nav action, the path a
52
- // controller's south button activates through (see focus-nav.ts). Key
53
- // activation shows no pressed state - the focus ring is the feedback.
48
+ // Keyboard/remote/controller activation reads no key here: the ref registers
49
+ // onPress as the node's nav action, and the focus navigation's `select`
50
+ // action (Enter, Space, the remote center key, the pad's south button, or
51
+ // whatever the app bound) runs it while the node holds focus (see
52
+ // focus-nav.ts). `focused` mirrors the node's focus for styling (a ring).
53
+ // Key activation shows no pressed state - the focus ring is the feedback.
54
54
  // The arena and the movement recognizers (createPan, createTransform) live in
55
55
  // core; press stays here because it couples to this package's focus-nav
56
56
  // (registerNavAction) and nothing outside components needs it yet
@@ -187,11 +187,6 @@ export function createPress(options: PressOptions) {
187
187
  options.onPointerLeave?.(e)
188
188
  },
189
189
  onKeyDown: (e: KeyEvent) => {
190
- // The remote center key's `key` is "Unidentified"; match its code.
191
- if ((e.key === "Enter" || e.key === " " || e.code === "Select") && !e.repeat && !options.disabled) {
192
- e.stopPropagation()
193
- activate()
194
- }
195
190
  options.onKeyDown?.(e)
196
191
  },
197
192
  onFocus: () => {