@solidrt/components 0.0.60 → 0.0.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/README.md +58 -10
- package/docs/carousel.md +15 -0
- package/docs/context-menu.md +1 -1
- package/docs/dismissible.md +18 -0
- package/docs/nav-shell.md +1 -1
- package/docs/pressable.md +2 -2
- package/docs/rich-text-editor.md +1 -1
- package/docs/scroll-view.md +1 -1
- package/docs/text-input.md +11 -2
- package/docs/types.md +1 -1
- package/package.json +3 -3
- package/src/button.tsx +11 -7
- package/src/carousel.tsx +112 -0
- package/src/checkbox.tsx +1 -0
- package/src/context-menu.tsx +13 -33
- package/src/dismissible.tsx +124 -0
- package/src/editor-field.tsx +141 -55
- package/src/index.ts +4 -0
- package/src/item.tsx +6 -2
- package/src/nav-shell.tsx +2 -0
- package/src/press.ts +14 -3
- package/src/pressable.tsx +6 -2
- package/src/radio.tsx +1 -0
- package/src/rich-text-editor.tsx +9 -5
- package/src/scroll-view.tsx +85 -15
- package/src/segmented-control.tsx +1 -0
- package/src/select.tsx +2 -0
- package/src/switch.tsx +1 -0
- package/src/text-input.tsx +15 -7
- package/src/text.tsx +4 -22
- package/src/types.ts +46 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createSignal, createSwipe, getLayoutBox } from "@solidrt/core"
|
|
2
|
+
import type { LayoutProps, SwipeDirection, TransitionEndEvent } from "@solidrt/core"
|
|
3
|
+
import type { StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
|
|
4
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
5
|
+
|
|
6
|
+
export type DismissDirection = "Left" | "Right"
|
|
7
|
+
|
|
8
|
+
export interface DismissibleProps extends TransitionProps<TransitionViewProp | TransitionStyleProp> {
|
|
9
|
+
children?: any
|
|
10
|
+
ref?: (node: { id: number }) => void
|
|
11
|
+
layout?: LayoutProps
|
|
12
|
+
style?: StyleProps
|
|
13
|
+
/** Which swipes dismiss: either way (default), or leftward or rightward
|
|
14
|
+
* only. A drag the other way is not started at all. */
|
|
15
|
+
direction?: "horizontal" | "left" | "right"
|
|
16
|
+
/** The content has left the box, with the swipe's direction. Remove
|
|
17
|
+
* the row here; until then it stays parked past the edge. */
|
|
18
|
+
onDismiss?: (direction: DismissDirection) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The leave after a qualifying swipe finishes the remaining travel at the
|
|
22
|
+
// swipe's own speed, within these bounds (ms): a flick never drags on, a
|
|
23
|
+
// slow qualifying swipe never snaps.
|
|
24
|
+
const LEAVE_MIN_MS = 120
|
|
25
|
+
const LEAVE_MAX_MS = 400
|
|
26
|
+
// The spring back to rest after a drag that did not qualify.
|
|
27
|
+
const SNAP_BACK = { duration: 250 }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Swipe-to-dismiss: the content follows the finger sideways and, on a
|
|
31
|
+
* qualifying swipe (core's swipe recognizer: enough travel and speed,
|
|
32
|
+
* within 30 degrees of the axis), leaves the box that way and reports
|
|
33
|
+
* `onDismiss`; a drag that stops short springs back. The recognizer steals
|
|
34
|
+
* the pointer at its slop, so a pressable row still presses on a tap and
|
|
35
|
+
* retracts once the drag is one; a vertical drag over the row is left to an
|
|
36
|
+
* enclosing ScrollView. Mouse and touch alike. The box itself does not
|
|
37
|
+
* shrink: the caller removes the row (its exit transition then plays).
|
|
38
|
+
*/
|
|
39
|
+
export function Dismissible(props: DismissibleProps) {
|
|
40
|
+
let node: { id: number } | undefined
|
|
41
|
+
let [offset, setOffset] = createSignal(0)
|
|
42
|
+
let [dragging, setDragging] = createSignal(false)
|
|
43
|
+
// The leave in flight: its tween, and the direction to report at its
|
|
44
|
+
// end. Plain state is the truth (onSwipe and onSwipeEnd run in one
|
|
45
|
+
// handler, before any signal flush); the signal drives the motion.
|
|
46
|
+
let leave: { duration: number; direction: DismissDirection } | null = null
|
|
47
|
+
let [leaving, setLeaving] = createSignal<{ duration: number; direction: DismissDirection } | null>(null)
|
|
48
|
+
|
|
49
|
+
let directions = (): SwipeDirection[] => (props.direction === "left" ? ["Left"] : props.direction === "right" ? ["Right"] : ["Left", "Right"])
|
|
50
|
+
let width = () => (node && getLayoutBox(node)?.width) || 0
|
|
51
|
+
|
|
52
|
+
let swipe = createSwipe({
|
|
53
|
+
get directions() {
|
|
54
|
+
return directions()
|
|
55
|
+
},
|
|
56
|
+
onSwipeStart: () => setDragging(true),
|
|
57
|
+
onSwipeMove: dx => setOffset(offset() + dx),
|
|
58
|
+
onSwipe: (direction, velocity) => {
|
|
59
|
+
let w = width()
|
|
60
|
+
let target = direction === "Left" ? -w : w
|
|
61
|
+
let remaining = Math.abs(target - offset())
|
|
62
|
+
let speed = Math.abs(velocity.vx)
|
|
63
|
+
let duration = speed > 0 ? Math.min(LEAVE_MAX_MS, Math.max(LEAVE_MIN_MS, (remaining / speed) * 1000)) : LEAVE_MAX_MS
|
|
64
|
+
leave = { duration, direction: direction as DismissDirection }
|
|
65
|
+
setLeaving(leave)
|
|
66
|
+
setOffset(target)
|
|
67
|
+
},
|
|
68
|
+
onSwipeEnd: () => {
|
|
69
|
+
setDragging(false)
|
|
70
|
+
if (!leave) setOffset(0)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
let settled = (e: TransitionEndEvent) => {
|
|
75
|
+
if (e.property === "x" && leave) {
|
|
76
|
+
let done = leave
|
|
77
|
+
leave = null
|
|
78
|
+
setLeaving(null)
|
|
79
|
+
props.onDismiss?.(done.direction)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// The content's x: tracked exactly under the finger, tweened out on a
|
|
83
|
+
// leave, sprung back otherwise.
|
|
84
|
+
let motion = () => {
|
|
85
|
+
if (dragging()) return null
|
|
86
|
+
let leave = leaving()
|
|
87
|
+
return leave ? { x: { duration: leave.duration, curve: "ease-out" as const } } : { x: SNAP_BACK }
|
|
88
|
+
}
|
|
89
|
+
let split = () => splitTransition(props.transition)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<view
|
|
93
|
+
transition={split().root}
|
|
94
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
95
|
+
ref={(n: { id: number }) => {
|
|
96
|
+
node = n
|
|
97
|
+
props.ref?.(n)
|
|
98
|
+
}}
|
|
99
|
+
overflow="hidden"
|
|
100
|
+
{...props.layout}
|
|
101
|
+
x={props.style?.x}
|
|
102
|
+
y={props.style?.y}
|
|
103
|
+
scale={props.style?.scale}
|
|
104
|
+
rotate={props.style?.rotate}
|
|
105
|
+
opacity={props.style?.opacity}
|
|
106
|
+
{...swipe.handlers}
|
|
107
|
+
>
|
|
108
|
+
{props.style?.backgroundColor != null || props.style?.borderRadius != null ? (
|
|
109
|
+
<d-rect
|
|
110
|
+
transition={split().background}
|
|
111
|
+
onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
112
|
+
color={props.style?.backgroundColor ?? "transparent"}
|
|
113
|
+
radius={props.style?.borderRadius}
|
|
114
|
+
/>
|
|
115
|
+
) : null}
|
|
116
|
+
{/* transition before x: props apply in source order, and the leave's
|
|
117
|
+
tween must be declared before the value that starts it lands in
|
|
118
|
+
the same flush, or the write snaps and no end ever fires. */}
|
|
119
|
+
<view flexDirection="column" transition={motion()} onTransitionEnd={settled} x={offset()}>
|
|
120
|
+
{props.children}
|
|
121
|
+
</view>
|
|
122
|
+
</view>
|
|
123
|
+
)
|
|
124
|
+
}
|
package/src/editor-field.tsx
CHANGED
|
@@ -19,21 +19,25 @@ import {
|
|
|
19
19
|
} from "@solidrt/core"
|
|
20
20
|
import { createTextEditorLayout } from "@solidrt/core/text-input"
|
|
21
21
|
import type { EditorLine, TextBuffer } from "@solidrt/core/text-input"
|
|
22
|
-
import type { Color, Gradient, KeyEvent,
|
|
22
|
+
import type { Color, Gradient, KeyEvent, PointerEvent, TextInputHints } from "@solidrt/core"
|
|
23
23
|
import type { Element } from "solid-js"
|
|
24
24
|
import type { MeasureTextOptions, TextRunRange } from "flux:rendertree"
|
|
25
25
|
import { registerNavAction } from "./focus-nav"
|
|
26
|
-
import type { StyleProps, TransitionProps } from "./types"
|
|
27
|
-
import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
26
|
+
import type { EditorLayoutProps, StyleProps, TransitionProps } from "./types"
|
|
27
|
+
import { splitTextLayout, splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
28
28
|
import { colorFade } from "./motion"
|
|
29
29
|
import { theme } from "./theme"
|
|
30
30
|
import { policy } from "./policy"
|
|
31
31
|
import { space } from "./spacing"
|
|
32
|
+
import { typeWeight } from "./typography"
|
|
32
33
|
|
|
33
34
|
// Caret thickness. Shared so the drawn caret and the scroll offset's reserved
|
|
34
35
|
// edge column cannot drift apart.
|
|
35
36
|
const CARET_WIDTH = 1
|
|
36
37
|
|
|
38
|
+
// Caret blink half-period: how long the caret shows, then hides.
|
|
39
|
+
const CARET_BLINK_MS = 500
|
|
40
|
+
|
|
37
41
|
// Shaping width of the placeholder: effectively unbounded, so it never wraps;
|
|
38
42
|
// the viewport clips it.
|
|
39
43
|
const PLACEHOLDER_SHAPE_WIDTH = 1e9
|
|
@@ -41,7 +45,7 @@ const PLACEHOLDER_SHAPE_WIDTH = 1e9
|
|
|
41
45
|
/** What the shell hands renderLine for one laid-out line. */
|
|
42
46
|
export type LineRender = {
|
|
43
47
|
line: () => EditorLine
|
|
44
|
-
/** The field's font
|
|
48
|
+
/** The field's resolved font (family, size, line height, style, weight), the base for the line's text. */
|
|
45
49
|
font: () => MeasureTextOptions
|
|
46
50
|
/** The field's text color. */
|
|
47
51
|
color: () => Color | Gradient
|
|
@@ -68,7 +72,8 @@ export interface EditorFieldProps extends TransitionProps {
|
|
|
68
72
|
maxRows?: number
|
|
69
73
|
hints?: TextInputHints
|
|
70
74
|
ref?: (node: { id: number }) => void
|
|
71
|
-
|
|
75
|
+
/** The box, plus the font the text is shaped in (theme body font by default; fontSize is scaled by policy.textScale). */
|
|
76
|
+
layout?: EditorLayoutProps
|
|
72
77
|
style?: StyleProps
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -85,7 +90,15 @@ export interface EditorFieldProps extends TransitionProps {
|
|
|
85
90
|
// all; the highlight draws behind the lines while focused, and edits on a
|
|
86
91
|
// range replace it (the buffer's own behavior). Ctrl/Cmd+C/X/V go through
|
|
87
92
|
// navigator.clipboard (copy and cut need a range; single-line paste flattens
|
|
88
|
-
// line breaks).
|
|
93
|
+
// line breaks). A tap outside the field blurs it (core's outside-tap blur in
|
|
94
|
+
// window.ts), as does Escape.
|
|
95
|
+
// Font options compared field by field: the same keys with the same values.
|
|
96
|
+
function sameFont(a: MeasureTextOptions, b: MeasureTextOptions): boolean {
|
|
97
|
+
let ka = Object.keys(a) as (keyof MeasureTextOptions)[]
|
|
98
|
+
let kb = Object.keys(b) as (keyof MeasureTextOptions)[]
|
|
99
|
+
return ka.length === kb.length && ka.every((k) => a[k] === b[k])
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
export function EditorField(props: EditorFieldProps) {
|
|
90
103
|
let [caretOn, setCaretOn] = createSignal(true)
|
|
91
104
|
|
|
@@ -93,17 +106,61 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
93
106
|
let viewport: { id: number } | undefined
|
|
94
107
|
let blinkId: any = null
|
|
95
108
|
|
|
109
|
+
// The caret shows solid for a full half-period after any activity (the
|
|
110
|
+
// session starting, a move, an edit, a tap) and blinks from there: the
|
|
111
|
+
// timer restarts on each, as editors do, so the caret never vanishes under
|
|
112
|
+
// a typing hand and a read right after an input finds it drawn. A
|
|
113
|
+
// wall-clock interval on purpose: it wakes twice a second, where an onFrame
|
|
114
|
+
// loop would hold a standing frame request for every editing field. Timers
|
|
115
|
+
// follow the dev clock control (pause, step), so a frozen frame keeps the
|
|
116
|
+
// caret it had.
|
|
117
|
+
let startBlink = () => {
|
|
118
|
+
setCaretOn(true)
|
|
119
|
+
if (blinkId != null) clearInterval(blinkId)
|
|
120
|
+
blinkId = setInterval(() => setCaretOn((v) => !v), CARET_BLINK_MS)
|
|
121
|
+
}
|
|
122
|
+
// Activity before the session (a tap's own pointerdown precedes it, and a
|
|
123
|
+
// navigation-focused field moves its caret with no session yet) or after
|
|
124
|
+
// it ended (a paste that settles late) has no blink to restart; the
|
|
125
|
+
// editing effect below starts it.
|
|
126
|
+
let restartBlink = () => {
|
|
127
|
+
if (blinkId != null) startBlink()
|
|
128
|
+
}
|
|
129
|
+
|
|
96
130
|
// Derived from core's reactive focus (setFocus is the only writer); the
|
|
97
|
-
// onFocus/onBlur handlers below keep only
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// false forever.
|
|
131
|
+
// onFocus/onBlur handlers below keep only the caller callbacks.
|
|
132
|
+
// focusedNode() is read FIRST, unconditionally: the memo may first compute
|
|
133
|
+
// before the ref has set `node`, and short-circuiting past the read would
|
|
134
|
+
// leave it dependency-free, frozen false forever.
|
|
102
135
|
let focused = createMemo(() => {
|
|
103
136
|
let id = focusedNode()
|
|
104
137
|
return id != null && id === node?.id
|
|
105
138
|
})
|
|
106
139
|
|
|
140
|
+
// Focused and editing are distinct (see activateField): a field focused by
|
|
141
|
+
// navigation, or by autoFocus/setFocus on a device whose on-screen keyboard
|
|
142
|
+
// would come up, has no text session until a tap or startTextInput(), and
|
|
143
|
+
// typing does nothing. The caret means "you can type", so it follows the
|
|
144
|
+
// session, not focus: a focused-not-editing field shows only its focus
|
|
145
|
+
// ring. Where the session starts invisibly at focus (desktop, physical
|
|
146
|
+
// keyboard) the two coincide.
|
|
147
|
+
let editing = createMemo(() => focused() && textInputActive())
|
|
148
|
+
|
|
149
|
+
// The blink runs exactly while editing. An effect rather than the focus
|
|
150
|
+
// handler: setFocus fires onFocus before it starts the session, so even
|
|
151
|
+
// on desktop the handler runs with textInputActive() still false.
|
|
152
|
+
createEffect(
|
|
153
|
+
() => editing(),
|
|
154
|
+
(active) => {
|
|
155
|
+
if (!active) return
|
|
156
|
+
startBlink()
|
|
157
|
+
return () => {
|
|
158
|
+
if (blinkId != null) clearInterval(blinkId)
|
|
159
|
+
blinkId = null
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
107
164
|
// Grapheme steps from the editor's caret stops; the editor is created
|
|
108
165
|
// below and only consulted from event handlers, after both exist. The
|
|
109
166
|
// factory is a one-shot by contract: read once, deliberately untracked.
|
|
@@ -162,7 +219,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
162
219
|
let offset = offsetAt(e)
|
|
163
220
|
buffer.setSelection(e.shiftKey ? buffer.selection().anchor : offset, offset)
|
|
164
221
|
if (e.pointerType !== "touch" && (e.button == null || e.button === 0)) dragArmed = e.pointerId
|
|
165
|
-
|
|
222
|
+
restartBlink()
|
|
166
223
|
}
|
|
167
224
|
|
|
168
225
|
let handleViewportPointerMove = (e: PointerEvent) => {
|
|
@@ -173,7 +230,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
173
230
|
}
|
|
174
231
|
if (dragActive === e.pointerId) {
|
|
175
232
|
buffer.setSelection(buffer.selection().anchor, offsetAt(e))
|
|
176
|
-
|
|
233
|
+
restartBlink()
|
|
177
234
|
}
|
|
178
235
|
}
|
|
179
236
|
|
|
@@ -185,20 +242,18 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
185
242
|
}
|
|
186
243
|
}
|
|
187
244
|
|
|
245
|
+
// The caller's handlers are read untracked: setFocus runs the focus and
|
|
246
|
+
// blur handlers synchronously, and a caller focusing from an effect (this
|
|
247
|
+
// field's own autoFocus, an app's setFocus) would otherwise land these
|
|
248
|
+
// prop reads inside its owned scope, where dev warns STRICT_READ_UNTRACKED.
|
|
249
|
+
// A handler is a one-shot read by nature; nothing here re-runs on a
|
|
250
|
+
// handler swap, and the swapped-in one is read at the next event.
|
|
188
251
|
let handleFocus = () => {
|
|
189
|
-
|
|
190
|
-
if (blinkId == null) {
|
|
191
|
-
blinkId = setInterval(() => setCaretOn((v) => !v), 500)
|
|
192
|
-
}
|
|
193
|
-
props.onFocus?.()
|
|
252
|
+
untrack(() => props.onFocus)?.()
|
|
194
253
|
}
|
|
195
254
|
|
|
196
255
|
let handleBlur = () => {
|
|
197
|
-
|
|
198
|
-
clearInterval(blinkId)
|
|
199
|
-
blinkId = null
|
|
200
|
-
}
|
|
201
|
-
props.onBlur?.()
|
|
256
|
+
untrack(() => props.onBlur)?.()
|
|
202
257
|
}
|
|
203
258
|
|
|
204
259
|
// Keys the input consumes stop propagating: an ancestor (or an app-global
|
|
@@ -209,16 +264,16 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
209
264
|
let consumed = true
|
|
210
265
|
if (e.key === "Backspace") {
|
|
211
266
|
buffer.deleteBackward()
|
|
212
|
-
|
|
267
|
+
restartBlink()
|
|
213
268
|
} else if (e.key === "Delete") {
|
|
214
269
|
buffer.deleteForward()
|
|
215
|
-
|
|
270
|
+
restartBlink()
|
|
216
271
|
} else if (e.key === "ArrowLeft") {
|
|
217
272
|
buffer.move("left", { extend: e.shiftKey })
|
|
218
|
-
|
|
273
|
+
restartBlink()
|
|
219
274
|
} else if (e.key === "ArrowRight") {
|
|
220
275
|
buffer.move("right", { extend: e.shiftKey })
|
|
221
|
-
|
|
276
|
+
restartBlink()
|
|
222
277
|
} else if (e.key === "Home" || e.key === "End") {
|
|
223
278
|
// Multiline: the current line's ends (offsetAtX at 0 / far right, so a
|
|
224
279
|
// wrap boundary resolves to the position that shows on this line).
|
|
@@ -228,13 +283,13 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
228
283
|
} else {
|
|
229
284
|
buffer.move(e.key === "Home" ? "start" : "end", { extend: e.shiftKey })
|
|
230
285
|
}
|
|
231
|
-
|
|
286
|
+
restartBlink()
|
|
232
287
|
} else if (props.multiline && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
|
233
288
|
moveLine(e.key === "ArrowUp" ? -1 : 1, e.shiftKey)
|
|
234
|
-
|
|
289
|
+
restartBlink()
|
|
235
290
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
|
|
236
291
|
buffer.setSelection(0, value().length)
|
|
237
|
-
|
|
292
|
+
restartBlink()
|
|
238
293
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
|
239
294
|
// Copy acts only on a range; an empty selection leaves the key (and the
|
|
240
295
|
// clipboard) alone and lets it bubble to app shortcuts.
|
|
@@ -249,7 +304,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
249
304
|
// clipboard write is reported, not undone.
|
|
250
305
|
navigator.clipboard.writeText(text).catch((err) => console.warn("Clipboard cut failed: " + err))
|
|
251
306
|
buffer.insertText("")
|
|
252
|
-
|
|
307
|
+
restartBlink()
|
|
253
308
|
}
|
|
254
309
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
|
|
255
310
|
navigator.clipboard.readText().then(
|
|
@@ -257,13 +312,13 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
257
312
|
if (text.length === 0) return
|
|
258
313
|
if (!props.multiline) text = text.replace(/\r?\n/g, " ")
|
|
259
314
|
buffer.insertText(text)
|
|
260
|
-
|
|
315
|
+
restartBlink()
|
|
261
316
|
},
|
|
262
317
|
(err) => console.warn("Clipboard paste failed: " + err),
|
|
263
318
|
)
|
|
264
319
|
} else if (props.multiline && e.key === "Enter" && textInputActive()) {
|
|
265
320
|
buffer.insertText("\n")
|
|
266
|
-
|
|
321
|
+
restartBlink()
|
|
267
322
|
} else if (e.key === "Enter" || e.code === "Select") {
|
|
268
323
|
// The remote center key's `key` is "Unidentified"; match its code.
|
|
269
324
|
activateField()
|
|
@@ -282,7 +337,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
282
337
|
let handleTextInput = (e: any) => {
|
|
283
338
|
if (props.disabled) return
|
|
284
339
|
buffer.insertText(e.text ?? "")
|
|
285
|
-
|
|
340
|
+
restartBlink()
|
|
286
341
|
}
|
|
287
342
|
|
|
288
343
|
// Up/Down: the offset on the neighbouring line nearest the caret's x; on
|
|
@@ -318,7 +373,6 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
318
373
|
let unregisterNav: (() => void) | null = null
|
|
319
374
|
|
|
320
375
|
onCleanup(() => {
|
|
321
|
-
if (blinkId != null) clearInterval(blinkId)
|
|
322
376
|
unregisterNav?.()
|
|
323
377
|
// An unmount mid-drag must not leave a resolved arena claim behind.
|
|
324
378
|
if (dragActive != null) arena.release(dragActive, dragOwner)
|
|
@@ -335,7 +389,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
335
389
|
let borderRadius = () => props.style?.borderRadius ?? theme.radius.md
|
|
336
390
|
|
|
337
391
|
let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
|
|
338
|
-
let showCaret = () =>
|
|
392
|
+
let showCaret = () => editing() && caretOn() && !showPlaceholder()
|
|
339
393
|
|
|
340
394
|
// Everything inside the viewport is detached: the value is drawn per
|
|
341
395
|
// laid-out line by renderLine (createTextEditorLayout breaks the lines from
|
|
@@ -344,14 +398,32 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
344
398
|
// movement, blink and scroll never touch layout. The single-line viewport
|
|
345
399
|
// carries an explicit height (detached content takes no layout slot) equal
|
|
346
400
|
// to the one-line height; a multiline viewport stretches to the field's
|
|
347
|
-
// height. The editor layout keeps the caret in view
|
|
348
|
-
// before paint; scrollX/scrollY are paint-time
|
|
349
|
-
// to detached children.
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
401
|
+
// height. The editor layout keeps the caret in view (its offsets land in the
|
|
402
|
+
// post-layout flush, before paint); scrollX/scrollY are paint-time
|
|
403
|
+
// translates that also apply to detached children.
|
|
404
|
+
// The font is the layout's font fields over the theme's body role,
|
|
405
|
+
// resolved like Text resolves its own (family, scaled size, line height,
|
|
406
|
+
// style, compensated weight); every metric - the rows, the caret, the
|
|
407
|
+
// scroll math, the placeholder - derives from it, so a field at a larger
|
|
408
|
+
// size grows as one. The box half of the layout is what the view takes.
|
|
409
|
+
let layout = createMemo(() => splitTextLayout(props.layout))
|
|
410
|
+
let layoutFont = () => layout().text as EditorLayoutProps
|
|
411
|
+
let fontSize = () => (layoutFont().fontSize ?? theme.text.body.size) * policy.textScale
|
|
412
|
+
let lineHeight = () => layoutFont().lineHeight ?? theme.text.body.lineHeight
|
|
413
|
+
// A memo, equal by field: the editor layout reads the font on every
|
|
414
|
+
// caret, text or size change, and rebuilding it (theme, policy and the
|
|
415
|
+
// weight compensation) per read cost more than the geometry it fed.
|
|
416
|
+
let font = createMemo(
|
|
417
|
+
(): MeasureTextOptions => ({
|
|
418
|
+
fontFamily: layoutFont().fontFamily ?? theme.text.fontFamily,
|
|
419
|
+
fontSize: fontSize(),
|
|
420
|
+
lineHeight: lineHeight(),
|
|
421
|
+
fontStyle: layoutFont().fontStyle,
|
|
422
|
+
fontWeight: typeWeight(layoutFont().fontWeight ?? theme.text.body.weight, fontSize()),
|
|
423
|
+
}),
|
|
424
|
+
{ equals: sameFont },
|
|
425
|
+
)
|
|
426
|
+
let rowHeight = () => Math.round(fontSize() * lineHeight())
|
|
355
427
|
let editor = createTextEditorLayout(
|
|
356
428
|
() => viewport,
|
|
357
429
|
() => ({
|
|
@@ -368,12 +440,20 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
368
440
|
)
|
|
369
441
|
let caret = editor.caret
|
|
370
442
|
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
443
|
+
// The viewport's natural height: one row single-line; multiline, the
|
|
444
|
+
// content's height, at least one row and at most maxRows rows. It is the
|
|
445
|
+
// viewport's flex basis in the field's column, not a fixed size: a
|
|
446
|
+
// multiline viewport grows and shrinks with the field's box (flexGrow and
|
|
447
|
+
// flexShrink below), so a field the caller sizes - `layout.height`,
|
|
448
|
+
// `flexGrow: 1` in a sized parent, a parent too small for the content -
|
|
449
|
+
// is a fixed box that scrolls to the caret, and an unconstrained one
|
|
450
|
+
// grows with its content. Both the field and the viewport take minHeight
|
|
451
|
+
// 0 for that: a flex item's automatic minimum is its content height,
|
|
452
|
+
// which would pin the content-sized basis and overflow the parent
|
|
453
|
+
// instead of shrinking (CSS needs the same `min-height: 0`). Single-line
|
|
454
|
+
// never flexes: it is one row, centered in a taller box.
|
|
455
|
+
let viewportHeight = (): number => {
|
|
375
456
|
if (!props.multiline) return rowHeight()
|
|
376
|
-
if (props.layout?.height != null) return undefined
|
|
377
457
|
let lines = editor.lines()
|
|
378
458
|
let last = lines[lines.length - 1]!
|
|
379
459
|
let content = Math.ceil(last.y + last.height)
|
|
@@ -391,17 +471,21 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
391
471
|
node = n
|
|
392
472
|
unregisterNav?.()
|
|
393
473
|
unregisterNav = registerNavAction(n.id, activateField)
|
|
394
|
-
|
|
474
|
+
// A ref callback runs in the element's owned scope, so the caller's
|
|
475
|
+
// ref is read untracked (see handleFocus).
|
|
476
|
+
untrack(() => props.ref)?.(n)
|
|
395
477
|
}}
|
|
396
478
|
textInputHints={props.multiline ? { multiline: true, ...props.hints } : props.hints}
|
|
397
479
|
focusable
|
|
398
|
-
|
|
399
|
-
|
|
480
|
+
cursor="text"
|
|
481
|
+
flexDirection="column"
|
|
482
|
+
justifyContent="center"
|
|
483
|
+
minHeight={props.multiline ? 0 : undefined}
|
|
400
484
|
paddingLeft={space("md")}
|
|
401
485
|
paddingRight={space("md")}
|
|
402
486
|
paddingTop={space("md")}
|
|
403
487
|
paddingBottom={space("md")}
|
|
404
|
-
{...
|
|
488
|
+
{...layout().box}
|
|
405
489
|
x={props.style?.x}
|
|
406
490
|
y={props.style?.y}
|
|
407
491
|
scale={props.style?.scale}
|
|
@@ -424,9 +508,11 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
424
508
|
/>
|
|
425
509
|
<view
|
|
426
510
|
ref={(n: { id: number }) => (viewport = n)}
|
|
427
|
-
flex={1}
|
|
428
511
|
height={viewportHeight()}
|
|
429
|
-
|
|
512
|
+
minHeight={props.multiline ? 0 : undefined}
|
|
513
|
+
flexGrow={props.multiline ? 1 : 0}
|
|
514
|
+
flexShrink={props.multiline ? 1 : 0}
|
|
515
|
+
alignSelf="stretch"
|
|
430
516
|
overflow="hidden"
|
|
431
517
|
scrollX={editor.scrollX()}
|
|
432
518
|
scrollY={editor.scrollY()}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { TextInput, type TextInputProps } from "./text-input"
|
|
|
7
7
|
export { RichTextEditor, type RichTextEditorProps } from "./rich-text-editor"
|
|
8
8
|
export { createDocumentBuffer, plainDocument, ATOM, type Document, type DocumentRun, type DocumentBuffer, type DocumentBufferOptions, type Attributes, type AttributePatch } from "./rich-text-document"
|
|
9
9
|
export { ScrollView, type ScrollViewProps } from "./scroll-view"
|
|
10
|
+
export { Dismissible, type DismissibleProps, type DismissDirection } from "./dismissible"
|
|
11
|
+
export { Carousel, type CarouselProps } from "./carousel"
|
|
10
12
|
export { Pressable, type PressableProps, type PressState } from "./pressable"
|
|
11
13
|
export { Button, type ButtonProps, type ButtonVariant } from "./button"
|
|
12
14
|
export { createFocusNav, uiActions, uiBindings, type FocusNavOptions, type UiActions, type UiDevices } from "./focus-nav"
|
|
@@ -62,7 +64,9 @@ export { typeStyle, typeWeight, lightOnDark } from "./typography"
|
|
|
62
64
|
export { space } from "./spacing"
|
|
63
65
|
export type {
|
|
64
66
|
StyleProps,
|
|
67
|
+
FontProps,
|
|
65
68
|
TextLayoutProps,
|
|
69
|
+
EditorLayoutProps,
|
|
66
70
|
Option,
|
|
67
71
|
TransitionProps,
|
|
68
72
|
ComponentTransition,
|
package/src/item.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Show, children } from "@solidrt/core"
|
|
1
|
+
import { Show, children, untrack } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { createPress } from "./press"
|
|
4
4
|
import { theme } from "./theme"
|
|
@@ -65,7 +65,10 @@ export function Item(props: ItemProps) {
|
|
|
65
65
|
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
66
66
|
ref={(n: { id: number }) => {
|
|
67
67
|
press.ref(n)
|
|
68
|
-
|
|
68
|
+
// A ref callback runs in the element's owned scope, where a prop
|
|
69
|
+
// read warns in dev (STRICT_READ_UNTRACKED); the caller's ref is a
|
|
70
|
+
// one-shot, read untracked.
|
|
71
|
+
untrack(() => props.ref)?.(n)
|
|
69
72
|
}}
|
|
70
73
|
repaintBoundary
|
|
71
74
|
flexDirection="row"
|
|
@@ -85,6 +88,7 @@ export function Item(props: ItemProps) {
|
|
|
85
88
|
// pointers from its children (a Switch in a settings row) or from an
|
|
86
89
|
// enclosing pressable. Interactivity is decided at mount.
|
|
87
90
|
{...(props.onPress != null ? press.handlers : {})}
|
|
91
|
+
cursor={props.onPress != null ? "pointer" : undefined}
|
|
88
92
|
focusable={(props.focusable ?? true) && interactive()}
|
|
89
93
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
90
94
|
>
|
package/src/nav-shell.tsx
CHANGED
|
@@ -66,6 +66,7 @@ export function NavShell(props: NavShellProps) {
|
|
|
66
66
|
paddingBottom={p.padY}
|
|
67
67
|
{...p.layout}
|
|
68
68
|
{...press.handlers}
|
|
69
|
+
cursor="pointer"
|
|
69
70
|
>
|
|
70
71
|
<d-rect transition={colorFade()} color={itemBg(p.item)} radius={theme.radius.sm} />
|
|
71
72
|
<PressFeedback
|
|
@@ -122,6 +123,7 @@ export function NavShell(props: NavShellProps) {
|
|
|
122
123
|
marginLeft={theme.spacing.sm}
|
|
123
124
|
marginRight={theme.spacing.sm}
|
|
124
125
|
{...press.handlers}
|
|
126
|
+
cursor="pointer"
|
|
125
127
|
>
|
|
126
128
|
<d-rect transition={colorFade()} color={itemBg(item)} radius={theme.radius.sm} />
|
|
127
129
|
<PressFeedback
|
package/src/press.ts
CHANGED
|
@@ -38,7 +38,10 @@ export interface PressOptions {
|
|
|
38
38
|
// Moves and the up arrive on the frozen down path, so the press survives
|
|
39
39
|
// leaving the node: while outside its window-relative bounds the pressed state
|
|
40
40
|
// clears (visual feedback retracts), wandering back in restores it (press
|
|
41
|
-
// retention), and only an up inside fires onPress
|
|
41
|
+
// retention), and only an up inside fires onPress - at once, unless a
|
|
42
|
+
// double-tap recognizer is pending on the pointer (core's arena relation),
|
|
43
|
+
// when the firing waits for that recognizer to fail (the double-tap
|
|
44
|
+
// window passing) and is dropped when it wins. Enter/leave drive hover
|
|
42
45
|
// alone. Non-primary buttons (right/middle) do not start a press. cancel() is
|
|
43
46
|
// the external-cancel hook; it ends the press without firing. Options are read
|
|
44
47
|
// at event time, so passing a component's reactive props object keeps handler
|
|
@@ -147,11 +150,17 @@ export function createPress(options: PressOptions) {
|
|
|
147
150
|
let owner = { cancel }
|
|
148
151
|
|
|
149
152
|
// A press abandoned mid-flight (unmount during a drag) must not leave its
|
|
150
|
-
// claim behind, or that pointer id could never press anything again.
|
|
153
|
+
// claim behind, or that pointer id could never press anything again. A
|
|
154
|
+
// firing the arena deferred (see onPointerUp) past the unmount is dropped.
|
|
155
|
+
let disposed = false
|
|
151
156
|
onSettled(() => () => {
|
|
157
|
+
disposed = true
|
|
152
158
|
disengage()
|
|
153
159
|
unregisterNav?.()
|
|
154
160
|
})
|
|
161
|
+
let fireDeferred = () => {
|
|
162
|
+
if (!disposed) activate()
|
|
163
|
+
}
|
|
155
164
|
|
|
156
165
|
let handlers = {
|
|
157
166
|
onPointerDown: (e: PointerEvent) => {
|
|
@@ -174,7 +183,9 @@ export function createPress(options: PressOptions) {
|
|
|
174
183
|
if (active === e.pointerId) {
|
|
175
184
|
let fire = inside
|
|
176
185
|
cancel()
|
|
177
|
-
|
|
186
|
+
// A double-tap pending on this pointer (arena.pend) holds the
|
|
187
|
+
// firing until it fails; with nothing pending the press fires now.
|
|
188
|
+
if (fire && !arena.defer(e.pointerId, fireDeferred)) activate()
|
|
178
189
|
}
|
|
179
190
|
options.onPointerUp?.(e)
|
|
180
191
|
},
|
package/src/pressable.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { children } from "@solidrt/core"
|
|
1
|
+
import { children, untrack } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps, PointerProps } from "@solidrt/core"
|
|
3
3
|
import type { StyleProps, TransitionProps } from "./types"
|
|
4
4
|
import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
@@ -62,7 +62,10 @@ export function Pressable(props: PressableProps) {
|
|
|
62
62
|
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
63
63
|
ref={(n: { id: number }) => {
|
|
64
64
|
press.ref(n)
|
|
65
|
-
|
|
65
|
+
// A ref callback runs in the element's owned scope, where a prop
|
|
66
|
+
// read warns in dev (STRICT_READ_UNTRACKED); the caller's ref is a
|
|
67
|
+
// one-shot, read untracked.
|
|
68
|
+
untrack(() => props.ref)?.(n)
|
|
66
69
|
}}
|
|
67
70
|
repaintBoundary
|
|
68
71
|
{...props.layout}
|
|
@@ -84,6 +87,7 @@ export function Pressable(props: PressableProps) {
|
|
|
84
87
|
onTextInput={props.onTextInput}
|
|
85
88
|
focusable={props.focusable === true && props.disabled !== true}
|
|
86
89
|
pointerEvents={props.disabled ? "none" : props.pointerEvents}
|
|
90
|
+
cursor={props.cursor ?? "pointer"}
|
|
87
91
|
>
|
|
88
92
|
{hasBackground() ? (
|
|
89
93
|
<d-rect
|
package/src/radio.tsx
CHANGED