@solidrt/components 0.0.59 → 0.0.61
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 +7 -5
- package/README.md +57 -9
- package/docs/carousel.md +15 -0
- package/docs/context-menu.md +1 -1
- package/docs/dismissible.md +18 -0
- 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 +128 -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,8 @@ 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.
|
|
89
95
|
export function EditorField(props: EditorFieldProps) {
|
|
90
96
|
let [caretOn, setCaretOn] = createSignal(true)
|
|
91
97
|
|
|
@@ -93,17 +99,61 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
93
99
|
let viewport: { id: number } | undefined
|
|
94
100
|
let blinkId: any = null
|
|
95
101
|
|
|
102
|
+
// The caret shows solid for a full half-period after any activity (the
|
|
103
|
+
// session starting, a move, an edit, a tap) and blinks from there: the
|
|
104
|
+
// timer restarts on each, as editors do, so the caret never vanishes under
|
|
105
|
+
// a typing hand and a read right after an input finds it drawn. A
|
|
106
|
+
// wall-clock interval on purpose: it wakes twice a second, where an onFrame
|
|
107
|
+
// loop would hold a standing frame request for every editing field. Timers
|
|
108
|
+
// follow the dev clock control (pause, step), so a frozen frame keeps the
|
|
109
|
+
// caret it had.
|
|
110
|
+
let startBlink = () => {
|
|
111
|
+
setCaretOn(true)
|
|
112
|
+
if (blinkId != null) clearInterval(blinkId)
|
|
113
|
+
blinkId = setInterval(() => setCaretOn((v) => !v), CARET_BLINK_MS)
|
|
114
|
+
}
|
|
115
|
+
// Activity before the session (a tap's own pointerdown precedes it, and a
|
|
116
|
+
// navigation-focused field moves its caret with no session yet) or after
|
|
117
|
+
// it ended (a paste that settles late) has no blink to restart; the
|
|
118
|
+
// editing effect below starts it.
|
|
119
|
+
let restartBlink = () => {
|
|
120
|
+
if (blinkId != null) startBlink()
|
|
121
|
+
}
|
|
122
|
+
|
|
96
123
|
// 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.
|
|
124
|
+
// onFocus/onBlur handlers below keep only the caller callbacks.
|
|
125
|
+
// focusedNode() is read FIRST, unconditionally: the memo may first compute
|
|
126
|
+
// before the ref has set `node`, and short-circuiting past the read would
|
|
127
|
+
// leave it dependency-free, frozen false forever.
|
|
102
128
|
let focused = createMemo(() => {
|
|
103
129
|
let id = focusedNode()
|
|
104
130
|
return id != null && id === node?.id
|
|
105
131
|
})
|
|
106
132
|
|
|
133
|
+
// Focused and editing are distinct (see activateField): a field focused by
|
|
134
|
+
// navigation, or by autoFocus/setFocus on a device whose on-screen keyboard
|
|
135
|
+
// would come up, has no text session until a tap or startTextInput(), and
|
|
136
|
+
// typing does nothing. The caret means "you can type", so it follows the
|
|
137
|
+
// session, not focus: a focused-not-editing field shows only its focus
|
|
138
|
+
// ring. Where the session starts invisibly at focus (desktop, physical
|
|
139
|
+
// keyboard) the two coincide.
|
|
140
|
+
let editing = createMemo(() => focused() && textInputActive())
|
|
141
|
+
|
|
142
|
+
// The blink runs exactly while editing. An effect rather than the focus
|
|
143
|
+
// handler: setFocus fires onFocus before it starts the session, so even
|
|
144
|
+
// on desktop the handler runs with textInputActive() still false.
|
|
145
|
+
createEffect(
|
|
146
|
+
() => editing(),
|
|
147
|
+
(active) => {
|
|
148
|
+
if (!active) return
|
|
149
|
+
startBlink()
|
|
150
|
+
return () => {
|
|
151
|
+
if (blinkId != null) clearInterval(blinkId)
|
|
152
|
+
blinkId = null
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
107
157
|
// Grapheme steps from the editor's caret stops; the editor is created
|
|
108
158
|
// below and only consulted from event handlers, after both exist. The
|
|
109
159
|
// factory is a one-shot by contract: read once, deliberately untracked.
|
|
@@ -162,7 +212,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
162
212
|
let offset = offsetAt(e)
|
|
163
213
|
buffer.setSelection(e.shiftKey ? buffer.selection().anchor : offset, offset)
|
|
164
214
|
if (e.pointerType !== "touch" && (e.button == null || e.button === 0)) dragArmed = e.pointerId
|
|
165
|
-
|
|
215
|
+
restartBlink()
|
|
166
216
|
}
|
|
167
217
|
|
|
168
218
|
let handleViewportPointerMove = (e: PointerEvent) => {
|
|
@@ -173,7 +223,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
173
223
|
}
|
|
174
224
|
if (dragActive === e.pointerId) {
|
|
175
225
|
buffer.setSelection(buffer.selection().anchor, offsetAt(e))
|
|
176
|
-
|
|
226
|
+
restartBlink()
|
|
177
227
|
}
|
|
178
228
|
}
|
|
179
229
|
|
|
@@ -185,20 +235,18 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
185
235
|
}
|
|
186
236
|
}
|
|
187
237
|
|
|
238
|
+
// The caller's handlers are read untracked: setFocus runs the focus and
|
|
239
|
+
// blur handlers synchronously, and a caller focusing from an effect (this
|
|
240
|
+
// field's own autoFocus, an app's setFocus) would otherwise land these
|
|
241
|
+
// prop reads inside its owned scope, where dev warns STRICT_READ_UNTRACKED.
|
|
242
|
+
// A handler is a one-shot read by nature; nothing here re-runs on a
|
|
243
|
+
// handler swap, and the swapped-in one is read at the next event.
|
|
188
244
|
let handleFocus = () => {
|
|
189
|
-
|
|
190
|
-
if (blinkId == null) {
|
|
191
|
-
blinkId = setInterval(() => setCaretOn((v) => !v), 500)
|
|
192
|
-
}
|
|
193
|
-
props.onFocus?.()
|
|
245
|
+
untrack(() => props.onFocus)?.()
|
|
194
246
|
}
|
|
195
247
|
|
|
196
248
|
let handleBlur = () => {
|
|
197
|
-
|
|
198
|
-
clearInterval(blinkId)
|
|
199
|
-
blinkId = null
|
|
200
|
-
}
|
|
201
|
-
props.onBlur?.()
|
|
249
|
+
untrack(() => props.onBlur)?.()
|
|
202
250
|
}
|
|
203
251
|
|
|
204
252
|
// Keys the input consumes stop propagating: an ancestor (or an app-global
|
|
@@ -209,16 +257,16 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
209
257
|
let consumed = true
|
|
210
258
|
if (e.key === "Backspace") {
|
|
211
259
|
buffer.deleteBackward()
|
|
212
|
-
|
|
260
|
+
restartBlink()
|
|
213
261
|
} else if (e.key === "Delete") {
|
|
214
262
|
buffer.deleteForward()
|
|
215
|
-
|
|
263
|
+
restartBlink()
|
|
216
264
|
} else if (e.key === "ArrowLeft") {
|
|
217
265
|
buffer.move("left", { extend: e.shiftKey })
|
|
218
|
-
|
|
266
|
+
restartBlink()
|
|
219
267
|
} else if (e.key === "ArrowRight") {
|
|
220
268
|
buffer.move("right", { extend: e.shiftKey })
|
|
221
|
-
|
|
269
|
+
restartBlink()
|
|
222
270
|
} else if (e.key === "Home" || e.key === "End") {
|
|
223
271
|
// Multiline: the current line's ends (offsetAtX at 0 / far right, so a
|
|
224
272
|
// wrap boundary resolves to the position that shows on this line).
|
|
@@ -228,13 +276,13 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
228
276
|
} else {
|
|
229
277
|
buffer.move(e.key === "Home" ? "start" : "end", { extend: e.shiftKey })
|
|
230
278
|
}
|
|
231
|
-
|
|
279
|
+
restartBlink()
|
|
232
280
|
} else if (props.multiline && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
|
233
281
|
moveLine(e.key === "ArrowUp" ? -1 : 1, e.shiftKey)
|
|
234
|
-
|
|
282
|
+
restartBlink()
|
|
235
283
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
|
|
236
284
|
buffer.setSelection(0, value().length)
|
|
237
|
-
|
|
285
|
+
restartBlink()
|
|
238
286
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
|
239
287
|
// Copy acts only on a range; an empty selection leaves the key (and the
|
|
240
288
|
// clipboard) alone and lets it bubble to app shortcuts.
|
|
@@ -249,7 +297,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
249
297
|
// clipboard write is reported, not undone.
|
|
250
298
|
navigator.clipboard.writeText(text).catch((err) => console.warn("Clipboard cut failed: " + err))
|
|
251
299
|
buffer.insertText("")
|
|
252
|
-
|
|
300
|
+
restartBlink()
|
|
253
301
|
}
|
|
254
302
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
|
|
255
303
|
navigator.clipboard.readText().then(
|
|
@@ -257,13 +305,13 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
257
305
|
if (text.length === 0) return
|
|
258
306
|
if (!props.multiline) text = text.replace(/\r?\n/g, " ")
|
|
259
307
|
buffer.insertText(text)
|
|
260
|
-
|
|
308
|
+
restartBlink()
|
|
261
309
|
},
|
|
262
310
|
(err) => console.warn("Clipboard paste failed: " + err),
|
|
263
311
|
)
|
|
264
312
|
} else if (props.multiline && e.key === "Enter" && textInputActive()) {
|
|
265
313
|
buffer.insertText("\n")
|
|
266
|
-
|
|
314
|
+
restartBlink()
|
|
267
315
|
} else if (e.key === "Enter" || e.code === "Select") {
|
|
268
316
|
// The remote center key's `key` is "Unidentified"; match its code.
|
|
269
317
|
activateField()
|
|
@@ -282,7 +330,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
282
330
|
let handleTextInput = (e: any) => {
|
|
283
331
|
if (props.disabled) return
|
|
284
332
|
buffer.insertText(e.text ?? "")
|
|
285
|
-
|
|
333
|
+
restartBlink()
|
|
286
334
|
}
|
|
287
335
|
|
|
288
336
|
// Up/Down: the offset on the neighbouring line nearest the caret's x; on
|
|
@@ -318,7 +366,6 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
318
366
|
let unregisterNav: (() => void) | null = null
|
|
319
367
|
|
|
320
368
|
onCleanup(() => {
|
|
321
|
-
if (blinkId != null) clearInterval(blinkId)
|
|
322
369
|
unregisterNav?.()
|
|
323
370
|
// An unmount mid-drag must not leave a resolved arena claim behind.
|
|
324
371
|
if (dragActive != null) arena.release(dragActive, dragOwner)
|
|
@@ -335,7 +382,7 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
335
382
|
let borderRadius = () => props.style?.borderRadius ?? theme.radius.md
|
|
336
383
|
|
|
337
384
|
let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
|
|
338
|
-
let showCaret = () =>
|
|
385
|
+
let showCaret = () => editing() && caretOn() && !showPlaceholder()
|
|
339
386
|
|
|
340
387
|
// Everything inside the viewport is detached: the value is drawn per
|
|
341
388
|
// laid-out line by renderLine (createTextEditorLayout breaks the lines from
|
|
@@ -344,14 +391,26 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
344
391
|
// movement, blink and scroll never touch layout. The single-line viewport
|
|
345
392
|
// carries an explicit height (detached content takes no layout slot) equal
|
|
346
393
|
// 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
|
-
|
|
394
|
+
// height. The editor layout keeps the caret in view (its offsets land in the
|
|
395
|
+
// post-layout flush, before paint); scrollX/scrollY are paint-time
|
|
396
|
+
// translates that also apply to detached children.
|
|
397
|
+
// The font is the layout's font fields over the theme's body role,
|
|
398
|
+
// resolved like Text resolves its own (family, scaled size, line height,
|
|
399
|
+
// style, compensated weight); every metric - the rows, the caret, the
|
|
400
|
+
// scroll math, the placeholder - derives from it, so a field at a larger
|
|
401
|
+
// size grows as one. The box half of the layout is what the view takes.
|
|
402
|
+
let layout = createMemo(() => splitTextLayout(props.layout))
|
|
403
|
+
let layoutFont = () => layout().text as EditorLayoutProps
|
|
404
|
+
let fontSize = () => (layoutFont().fontSize ?? theme.text.body.size) * policy.textScale
|
|
405
|
+
let lineHeight = () => layoutFont().lineHeight ?? theme.text.body.lineHeight
|
|
406
|
+
let font = (): MeasureTextOptions => ({
|
|
407
|
+
fontFamily: layoutFont().fontFamily ?? theme.text.fontFamily,
|
|
408
|
+
fontSize: fontSize(),
|
|
409
|
+
lineHeight: lineHeight(),
|
|
410
|
+
fontStyle: layoutFont().fontStyle,
|
|
411
|
+
fontWeight: typeWeight(layoutFont().fontWeight ?? theme.text.body.weight, fontSize()),
|
|
412
|
+
})
|
|
413
|
+
let rowHeight = () => Math.round(fontSize() * lineHeight())
|
|
355
414
|
let editor = createTextEditorLayout(
|
|
356
415
|
() => viewport,
|
|
357
416
|
() => ({
|
|
@@ -368,12 +427,20 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
368
427
|
)
|
|
369
428
|
let caret = editor.caret
|
|
370
429
|
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
430
|
+
// The viewport's natural height: one row single-line; multiline, the
|
|
431
|
+
// content's height, at least one row and at most maxRows rows. It is the
|
|
432
|
+
// viewport's flex basis in the field's column, not a fixed size: a
|
|
433
|
+
// multiline viewport grows and shrinks with the field's box (flexGrow and
|
|
434
|
+
// flexShrink below), so a field the caller sizes - `layout.height`,
|
|
435
|
+
// `flexGrow: 1` in a sized parent, a parent too small for the content -
|
|
436
|
+
// is a fixed box that scrolls to the caret, and an unconstrained one
|
|
437
|
+
// grows with its content. Both the field and the viewport take minHeight
|
|
438
|
+
// 0 for that: a flex item's automatic minimum is its content height,
|
|
439
|
+
// which would pin the content-sized basis and overflow the parent
|
|
440
|
+
// instead of shrinking (CSS needs the same `min-height: 0`). Single-line
|
|
441
|
+
// never flexes: it is one row, centered in a taller box.
|
|
442
|
+
let viewportHeight = (): number => {
|
|
375
443
|
if (!props.multiline) return rowHeight()
|
|
376
|
-
if (props.layout?.height != null) return undefined
|
|
377
444
|
let lines = editor.lines()
|
|
378
445
|
let last = lines[lines.length - 1]!
|
|
379
446
|
let content = Math.ceil(last.y + last.height)
|
|
@@ -391,17 +458,21 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
391
458
|
node = n
|
|
392
459
|
unregisterNav?.()
|
|
393
460
|
unregisterNav = registerNavAction(n.id, activateField)
|
|
394
|
-
|
|
461
|
+
// A ref callback runs in the element's owned scope, so the caller's
|
|
462
|
+
// ref is read untracked (see handleFocus).
|
|
463
|
+
untrack(() => props.ref)?.(n)
|
|
395
464
|
}}
|
|
396
465
|
textInputHints={props.multiline ? { multiline: true, ...props.hints } : props.hints}
|
|
397
466
|
focusable
|
|
398
|
-
|
|
399
|
-
|
|
467
|
+
cursor="text"
|
|
468
|
+
flexDirection="column"
|
|
469
|
+
justifyContent="center"
|
|
470
|
+
minHeight={props.multiline ? 0 : undefined}
|
|
400
471
|
paddingLeft={space("md")}
|
|
401
472
|
paddingRight={space("md")}
|
|
402
473
|
paddingTop={space("md")}
|
|
403
474
|
paddingBottom={space("md")}
|
|
404
|
-
{...
|
|
475
|
+
{...layout().box}
|
|
405
476
|
x={props.style?.x}
|
|
406
477
|
y={props.style?.y}
|
|
407
478
|
scale={props.style?.scale}
|
|
@@ -424,9 +495,11 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
424
495
|
/>
|
|
425
496
|
<view
|
|
426
497
|
ref={(n: { id: number }) => (viewport = n)}
|
|
427
|
-
flex={1}
|
|
428
498
|
height={viewportHeight()}
|
|
429
|
-
|
|
499
|
+
minHeight={props.multiline ? 0 : undefined}
|
|
500
|
+
flexGrow={props.multiline ? 1 : 0}
|
|
501
|
+
flexShrink={props.multiline ? 1 : 0}
|
|
502
|
+
alignSelf="stretch"
|
|
430
503
|
overflow="hidden"
|
|
431
504
|
scrollX={editor.scrollX()}
|
|
432
505
|
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
package/src/rich-text-editor.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { untrack } from "@solidrt/core"
|
|
2
|
-
import type {
|
|
2
|
+
import type { TextInputHints } from "@solidrt/core"
|
|
3
3
|
import type { TextRunRange } from "flux:rendertree"
|
|
4
4
|
import { EditorField } from "./editor-field"
|
|
5
5
|
import { createDocumentBuffer, type Attributes, type Document, type DocumentBuffer } from "./rich-text-document"
|
|
6
|
-
import type { StyleProps, TransitionProps } from "./types"
|
|
6
|
+
import type { EditorLayoutProps, StyleProps, TransitionProps } from "./types"
|
|
7
7
|
import { theme } from "./theme"
|
|
8
8
|
import { policy } from "./policy"
|
|
9
9
|
|
|
@@ -24,12 +24,13 @@ export interface RichTextEditorProps extends TransitionProps {
|
|
|
24
24
|
placeholder?: string
|
|
25
25
|
disabled?: boolean
|
|
26
26
|
autoFocus?: boolean
|
|
27
|
-
/**
|
|
27
|
+
/** Unconstrained (no `layout.height`, no flex sizing): rows to grow to before scrolling. Default unbounded. */
|
|
28
28
|
maxRows?: number
|
|
29
29
|
hints?: TextInputHints
|
|
30
30
|
|
|
31
31
|
ref?: (node: { id: number }) => void
|
|
32
|
-
|
|
32
|
+
/** The box, plus the base font (`fontSize` etc., as on TextInput) the document's runs style from. */
|
|
33
|
+
layout?: EditorLayoutProps
|
|
33
34
|
style?: StyleProps
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -84,7 +85,10 @@ function intervals(doc: Document): Interval[] {
|
|
|
84
85
|
*/
|
|
85
86
|
export function RichTextEditor(props: RichTextEditorProps) {
|
|
86
87
|
let editor!: DocumentBuffer
|
|
87
|
-
|
|
88
|
+
// The base size the run fonts derive from: the layout's fontSize when
|
|
89
|
+
// given (the same field EditorField shapes the base text in), else the
|
|
90
|
+
// theme body size, scaled either way.
|
|
91
|
+
let base = () => (props.layout?.fontSize ?? theme.text.body.size) * policy.textScale
|
|
88
92
|
let doc = () => editor.document()
|
|
89
93
|
|
|
90
94
|
// Geometry runs: every interval whose font differs from the base.
|