@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/AGENTS.md +18 -15
- package/README.md +40 -17
- package/docs/button.md +2 -2
- package/docs/checkbox.md +1 -1
- package/docs/context-menu.md +1 -1
- package/docs/focus-nav.md +16 -3
- package/docs/modal.md +1 -1
- package/docs/policy.md +1 -1
- package/docs/progress-bar.md +1 -1
- package/docs/radio.md +1 -1
- package/docs/segmented-control.md +1 -1
- package/docs/select.md +1 -1
- package/docs/switch.md +1 -1
- package/docs/theme.md +8 -0
- package/docs/tooltip.md +1 -1
- package/docs/types.md +3 -1
- package/package.json +3 -3
- package/src/badge.tsx +4 -2
- package/src/button.tsx +16 -15
- package/src/card.tsx +5 -4
- package/src/checkbox.tsx +45 -21
- package/src/context-menu.tsx +22 -14
- package/src/divider.tsx +3 -2
- package/src/editor-field.tsx +116 -19
- package/src/field.tsx +3 -1
- package/src/focus-nav.ts +152 -53
- package/src/index.ts +1 -1
- package/src/item.tsx +11 -14
- package/src/modal.tsx +7 -1
- package/src/motion.tsx +122 -0
- package/src/nav-shell.tsx +24 -13
- package/src/press.ts +6 -11
- package/src/pressable.tsx +7 -4
- package/src/progress-bar.tsx +19 -6
- package/src/radio.tsx +36 -7
- package/src/segmented-control.tsx +70 -20
- package/src/select.tsx +58 -43
- package/src/slider.tsx +7 -3
- package/src/spinner.tsx +2 -0
- package/src/switch.tsx +30 -11
- package/src/text.tsx +3 -2
- package/src/theme.ts +25 -1
- package/src/tooltip.tsx +10 -4
- package/src/types.ts +74 -6
- package/src/view.tsx +4 -3
- package/src/window.tsx +2 -1
package/src/focus-nav.ts
CHANGED
|
@@ -1,37 +1,132 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createEffect,
|
|
3
|
+
createInputMap,
|
|
3
4
|
createSignal,
|
|
4
|
-
|
|
5
|
+
gamepad,
|
|
5
6
|
getBoundingBoxViewport,
|
|
6
7
|
getFocusables,
|
|
7
8
|
focusedNode,
|
|
8
9
|
getNodePath,
|
|
10
|
+
keyboard,
|
|
11
|
+
onCleanup,
|
|
9
12
|
onLayout,
|
|
10
13
|
setFocus,
|
|
14
|
+
untrack,
|
|
11
15
|
} from "@solidrt/core"
|
|
12
|
-
import type {
|
|
16
|
+
import type { Binding, GamepadDevice, InputMap, KeyboardDevice, Vec2 } from "@solidrt/core"
|
|
13
17
|
|
|
14
18
|
// Focus navigation: the pointer-free control path, so a TV remote, a
|
|
15
19
|
// keyboard, or a gamepad can drive an app. Two movement types over the same
|
|
16
20
|
// candidates, both steering real focus (setFocus) across the elements
|
|
17
21
|
// declaring `focusable`:
|
|
18
22
|
//
|
|
19
|
-
// - Spatial (
|
|
23
|
+
// - Spatial (`navigate`): judged by on-screen boxes at press time - the
|
|
20
24
|
// nearest candidate with progress in the pressed direction wins, so
|
|
21
25
|
// registration order never matters.
|
|
22
|
-
// - Sequential (
|
|
23
|
-
//
|
|
26
|
+
// - Sequential (`cycle`): visual reading order - rows top to bottom, left
|
|
27
|
+
// to right within a row, wrapping at the ends. Derived from the same
|
|
24
28
|
// boxes rather than registration order, which reordering mounts (a <For>
|
|
25
29
|
// shuffle) silently scrambles.
|
|
26
30
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
31
|
+
// The nav reads no device (ARCHITECTURE.md: controls through an
|
|
32
|
+
// abstraction, never direct event handling). It consumes the three UI
|
|
33
|
+
// actions of an input map - `navigate` (vec2), `cycle` (axis), `select`
|
|
34
|
+
// (button) - the way Flutter's focus widgets consume intents and Godot's
|
|
35
|
+
// controls the ui_* actions. A component library must work with nothing
|
|
36
|
+
// wired, as those two do, so a nav created bare binds the standard set
|
|
37
|
+
// itself (uiBindings over the keyboard and every pad) on a map of its own,
|
|
38
|
+
// reachable as `nav.input` for rebinding; given `input`, it consumes the
|
|
39
|
+
// app's map, which may hold the app's other actions too. Either way the
|
|
40
|
+
// app spreads the map's handlers on the window: keys arrive there only when
|
|
41
|
+
// no focused component consumed them, so a focused TextInput keeps its
|
|
42
|
+
// caret keys and the Slider its arrows.
|
|
43
|
+
//
|
|
44
|
+
// A held direction repeats on the nav's own timing over the action's rate
|
|
45
|
+
// (a first step on the edge, the next after NAV_REPEAT_DELAY, then every
|
|
46
|
+
// NAV_REPEAT_INTERVAL), so pads and remotes walk the way keyboards used to
|
|
47
|
+
// through key repeat. Activation is one path: the `select` press runs the
|
|
48
|
+
// focused node's action from the registry below, for Enter, Space, the
|
|
49
|
+
// remote's center key and the pad's south button alike. Purely additive to
|
|
30
50
|
// pointer input: nothing is focused until the first navigation press, and
|
|
31
51
|
// pointer presses work unchanged throughout.
|
|
32
52
|
|
|
33
53
|
type Direction = "up" | "down" | "left" | "right"
|
|
34
54
|
|
|
55
|
+
// A held direction walks: the first step on the edge, the next after this
|
|
56
|
+
// many ms, then one every NAV_REPEAT_INTERVAL ms while it holds.
|
|
57
|
+
const NAV_REPEAT_DELAY = 400
|
|
58
|
+
const NAV_REPEAT_INTERVAL = 100
|
|
59
|
+
// Deflection along the dominant axis of `navigate` (a stick) or on `cycle`
|
|
60
|
+
// below which the action reads as no direction; dpad and arrows read 1.
|
|
61
|
+
const NAV_THRESHOLD = 0.5
|
|
62
|
+
|
|
63
|
+
/** The UI actions the focus navigation consumes: `navigate` (vec2, the
|
|
64
|
+
* direction to move focus in the screen convention, x right and y down),
|
|
65
|
+
* `cycle` (axis: +1 steps to the next candidate in reading order, -1 to
|
|
66
|
+
* the previous) and `select` (button: activate the focused control). An
|
|
67
|
+
* app's own map declares them by spreading this object. */
|
|
68
|
+
export const uiActions = { navigate: "vec2", cycle: "axis", select: "button" } as const
|
|
69
|
+
export type UiActions = typeof uiActions
|
|
70
|
+
|
|
71
|
+
export type UiDevices = { keyboard?: KeyboardDevice; gamepad?: GamepadDevice }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The standard UI bindings: the arrows on `navigate`, Tab and Shift+Tab
|
|
75
|
+
* on `cycle`, Enter, Space and the remote's center key (code "Select") on
|
|
76
|
+
* `select` for the keyboard; the dpad and the left stick on `navigate` and
|
|
77
|
+
* the south button on `select` for a pad. What a bare createFocusNav
|
|
78
|
+
* binds; an app with its own map applies it with `input.bind(...)`.
|
|
79
|
+
*/
|
|
80
|
+
export function uiBindings(devices: UiDevices): Binding[] {
|
|
81
|
+
let out: Binding[] = []
|
|
82
|
+
let kb = devices.keyboard
|
|
83
|
+
if (kb) {
|
|
84
|
+
out.push(
|
|
85
|
+
{ action: "navigate", source: kb.arrows },
|
|
86
|
+
{ action: "cycle", source: kb.axis("Shift+Tab", "Tab") },
|
|
87
|
+
{ action: "select", source: kb.key("Enter") },
|
|
88
|
+
{ action: "select", source: kb.key("Space") },
|
|
89
|
+
{ action: "select", source: kb.key("Select") },
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
let pad = devices.gamepad
|
|
93
|
+
if (pad) {
|
|
94
|
+
out.push({ action: "navigate", source: pad.dpad }, { action: "navigate", source: pad.leftStick }, { action: "select", source: pad.button("south") })
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Steps on a held value: once on the edge, again after NAV_REPEAT_DELAY,
|
|
100
|
+
// then every NAV_REPEAT_INTERVAL until it reads null or changes (a change
|
|
101
|
+
// restarts the delay). Runs under the calling scope: the effect and the
|
|
102
|
+
// timer die with it.
|
|
103
|
+
function repeating<T>(read: () => T | null, step: (value: T) => void): void {
|
|
104
|
+
let current: T | null = null
|
|
105
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
106
|
+
let stop = () => {
|
|
107
|
+
if (timer == null) return
|
|
108
|
+
clearTimeout(timer)
|
|
109
|
+
timer = null
|
|
110
|
+
}
|
|
111
|
+
let later = (value: T, delay: number) => {
|
|
112
|
+
timer = setTimeout(() => {
|
|
113
|
+
step(value)
|
|
114
|
+
later(value, NAV_REPEAT_INTERVAL)
|
|
115
|
+
}, delay)
|
|
116
|
+
}
|
|
117
|
+
// The step reads focus and the candidates for itself, a snapshot, not a
|
|
118
|
+
// dependency: untracked so the strict-mode read diagnostic stays quiet.
|
|
119
|
+
createEffect(read, (value) => {
|
|
120
|
+
if (value === current) return
|
|
121
|
+
current = value
|
|
122
|
+
stop()
|
|
123
|
+
if (value === null) return
|
|
124
|
+
untrack(() => step(value))
|
|
125
|
+
later(value, NAV_REPEAT_DELAY)
|
|
126
|
+
})
|
|
127
|
+
onCleanup(stop)
|
|
128
|
+
}
|
|
129
|
+
|
|
35
130
|
// What "select" means per focusable node, registered by createPress's ref.
|
|
36
131
|
// Only the controller path consults it - key activation reaches the focused
|
|
37
132
|
// node by bubbling and never comes through here. Package-internal.
|
|
@@ -56,6 +151,15 @@ export function pushNavScope(node: { id: number }): () => void {
|
|
|
56
151
|
}
|
|
57
152
|
|
|
58
153
|
export interface FocusNavOptions {
|
|
154
|
+
/**
|
|
155
|
+
* The input map to consume the UI actions from: it must declare
|
|
156
|
+
* `navigate`, `cycle` and `select` with the kinds of `uiActions`, and the
|
|
157
|
+
* app binds it (uiBindings or its own) and spreads its handlers on the
|
|
158
|
+
* window. Without one the nav makes its own map bound to the standard set
|
|
159
|
+
* over the keyboard and every pad, which `nav.input` exposes for
|
|
160
|
+
* rebinding.
|
|
161
|
+
*/
|
|
162
|
+
input?: InputMap<any>
|
|
59
163
|
/**
|
|
60
164
|
* Restricts reachable candidates to this node's subtree while it returns
|
|
61
165
|
* one, overriding the default (the topmost open Modal, which traps
|
|
@@ -71,16 +175,23 @@ type Placed = { id: number; x: number; y: number }
|
|
|
71
175
|
|
|
72
176
|
/**
|
|
73
177
|
* Creates focus navigation over the `focusable` elements: spatial movement on
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
178
|
+
* `navigate`, sequential on `cycle`, activation on `select`, consumed from an
|
|
179
|
+
* input map (see FocusNavOptions.input). Spread the map's handlers on the
|
|
180
|
+
* window - keys arrive there only when no focused component consumed them,
|
|
181
|
+
* so a focused TextInput keeps its caret keys:
|
|
77
182
|
*
|
|
78
183
|
* let nav = createFocusNav()
|
|
79
|
-
* <window
|
|
184
|
+
* <window {...nav.handlers}>...
|
|
80
185
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
186
|
+
* Call it inside a component/root scope: its effects and repeat timers live
|
|
187
|
+
* there. move/tab/activate are the verbs, for custom triggers.
|
|
83
188
|
*/
|
|
189
|
+
let defaultMap = (): InputMap<UiActions> => {
|
|
190
|
+
let map = createInputMap(uiActions)
|
|
191
|
+
map.bind(uiBindings({ keyboard, gamepad: gamepad() }))
|
|
192
|
+
return map
|
|
193
|
+
}
|
|
194
|
+
|
|
84
195
|
export function createFocusNav(options?: FocusNavOptions) {
|
|
85
196
|
let currentScope = () => options?.scope?.() ?? scopeStack()[scopeStack().length - 1]
|
|
86
197
|
|
|
@@ -157,9 +268,9 @@ export function createFocusNav(options?: FocusNavOptions) {
|
|
|
157
268
|
}
|
|
158
269
|
|
|
159
270
|
// Sequential step in reading order, wrapping at the ends. With no
|
|
160
|
-
// reachable focus it resumes near the last position; lacking one,
|
|
161
|
-
// enters at the first element and
|
|
162
|
-
// into" the set from either side).
|
|
271
|
+
// reachable focus it resumes near the last position; lacking one, a
|
|
272
|
+
// forward step enters at the first element and a backward one at the
|
|
273
|
+
// last (the step "wraps into" the set from either side).
|
|
163
274
|
let tab = (delta: 1 | -1) => {
|
|
164
275
|
let placed = reachable()
|
|
165
276
|
if (placed.length === 0) return
|
|
@@ -173,9 +284,9 @@ export function createFocusNav(options?: FocusNavOptions) {
|
|
|
173
284
|
focusCandidate(row[(i + delta + row.length) % row.length]!)
|
|
174
285
|
}
|
|
175
286
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// focus instead of acting.
|
|
287
|
+
// The `select` press (Enter, the remote's center key, the pad's south
|
|
288
|
+
// button, whatever the app bound): runs the focused node's registered
|
|
289
|
+
// action; with no reachable focus the press lands focus instead of acting.
|
|
179
290
|
let activate = () => {
|
|
180
291
|
let placed = reachable()
|
|
181
292
|
if (placed.length === 0) return
|
|
@@ -188,18 +299,6 @@ export function createFocusNav(options?: FocusNavOptions) {
|
|
|
188
299
|
navActions.get(hit.id)?.()
|
|
189
300
|
}
|
|
190
301
|
|
|
191
|
-
// Keyboard and TV remote. Arrows and Tab use key repeat (holding walks
|
|
192
|
-
// through candidates); activation ignores it. The remote center key's `key`
|
|
193
|
-
// is "Unidentified", so it is matched by code.
|
|
194
|
-
let onKeyDown = (e: KeyEvent) => {
|
|
195
|
-
if (e.key === "ArrowUp") move("up")
|
|
196
|
-
else if (e.key === "ArrowDown") move("down")
|
|
197
|
-
else if (e.key === "ArrowLeft") move("left")
|
|
198
|
-
else if (e.key === "ArrowRight") move("right")
|
|
199
|
-
else if (e.key === "Tab") tab(e.shiftKey ? -1 : 1)
|
|
200
|
-
else if ((e.key === "Enter" || e.code === "Select") && !e.repeat) activate()
|
|
201
|
-
}
|
|
202
|
-
|
|
203
302
|
// The focused control vanishing (replaced by its own action, a screen
|
|
204
303
|
// change) clears focus; hand it to the nearest successor so the ring
|
|
205
304
|
// never disappears mid-navigation. Only when the node actually died: a
|
|
@@ -243,26 +342,26 @@ export function createFocusNav(options?: FocusNavOptions) {
|
|
|
243
342
|
},
|
|
244
343
|
)
|
|
245
344
|
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
)
|
|
345
|
+
// The map: the app's, checked for the UI actions, or the platform default.
|
|
346
|
+
let input: InputMap<any> = options?.input ?? defaultMap()
|
|
347
|
+
for (let [name, kind] of Object.entries(uiActions)) {
|
|
348
|
+
if (input.actions[name] !== kind) throw new Error(`createFocusNav: the input map needs a ${kind} action "${name}" (declare it with uiActions)`)
|
|
349
|
+
}
|
|
350
|
+
// The direction an action reads now, or null: the dominant axis of
|
|
351
|
+
// `navigate` past the threshold, the sign of `cycle`.
|
|
352
|
+
let direction = (): Direction | null => {
|
|
353
|
+
let [x, y] = input.value("navigate") as Vec2
|
|
354
|
+
if (Math.max(Math.abs(x), Math.abs(y)) < NAV_THRESHOLD) return null
|
|
355
|
+
if (Math.abs(x) > Math.abs(y)) return x > 0 ? "right" : "left"
|
|
356
|
+
return y > 0 ? "down" : "up"
|
|
357
|
+
}
|
|
358
|
+
let cycleStep = (): 1 | -1 | null => {
|
|
359
|
+
let v = input.value("cycle") as number
|
|
360
|
+
return v >= NAV_THRESHOLD ? 1 : v <= -NAV_THRESHOLD ? -1 : null
|
|
361
|
+
}
|
|
362
|
+
repeating(direction, move)
|
|
363
|
+
repeating(cycleStep, tab)
|
|
364
|
+
onCleanup(input.onPress("select", activate))
|
|
266
365
|
|
|
267
|
-
return {
|
|
366
|
+
return { input, handlers: input.handlers, move, tab, activate }
|
|
268
367
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { createDocumentBuffer, plainDocument, ATOM, type Document, type Document
|
|
|
9
9
|
export { ScrollView, type ScrollViewProps } from "./scroll-view"
|
|
10
10
|
export { Pressable, type PressableProps, type PressState } from "./pressable"
|
|
11
11
|
export { Button, type ButtonProps, type ButtonVariant } from "./button"
|
|
12
|
-
export { createFocusNav, type FocusNavOptions } from "./focus-nav"
|
|
12
|
+
export { createFocusNav, uiActions, uiBindings, type FocusNavOptions, type UiActions, type UiDevices } from "./focus-nav"
|
|
13
13
|
export { Switch, type SwitchProps } from "./switch"
|
|
14
14
|
export { Checkbox, type CheckboxProps } from "./checkbox"
|
|
15
15
|
export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
|
package/src/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
|
|
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
|
-
<
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// activation shows no pressed state - the focus ring is the feedback.
|
|
48
|
+
// Keyboard/remote/controller activation reads no key here: the ref registers
|
|
49
|
+
// onPress as the node's nav action, and the focus navigation's `select`
|
|
50
|
+
// action (Enter, Space, the remote center key, the pad's south button, or
|
|
51
|
+
// whatever the app bound) runs it while the node holds focus (see
|
|
52
|
+
// focus-nav.ts). `focused` mirrors the node's focus for styling (a ring).
|
|
53
|
+
// Key activation shows no pressed state - the focus ring is the feedback.
|
|
54
54
|
// The arena and the movement recognizers (createPan, createTransform) live in
|
|
55
55
|
// core; press stays here because it couples to this package's focus-nav
|
|
56
56
|
// (registerNavAction) and nothing outside components needs it yet
|
|
@@ -187,11 +187,6 @@ export function createPress(options: PressOptions) {
|
|
|
187
187
|
options.onPointerLeave?.(e)
|
|
188
188
|
},
|
|
189
189
|
onKeyDown: (e: KeyEvent) => {
|
|
190
|
-
// The remote center key's `key` is "Unidentified"; match its code.
|
|
191
|
-
if ((e.key === "Enter" || e.key === " " || e.code === "Select") && !e.repeat && !options.disabled) {
|
|
192
|
-
e.stopPropagation()
|
|
193
|
-
activate()
|
|
194
|
-
}
|
|
195
190
|
options.onKeyDown?.(e)
|
|
196
191
|
},
|
|
197
192
|
onFocus: () => {
|