@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.
@@ -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
+ }
@@ -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, LayoutProps, PointerEvent, TextInputHints } from "@solidrt/core"
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 options (size, line height), the base for the line's text. */
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
- layout?: LayoutProps
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). Outside-click-to-blur is the caller's job.
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 their side effects (blink timer,
98
- // caller callbacks). focusedNode() is read FIRST, unconditionally: the
99
- // memo may first compute before the ref has set `node`, and
100
- // short-circuiting past the read would leave it dependency-free, frozen
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
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
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
- if (blinkId != null) {
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
- setCaretOn(true)
260
+ restartBlink()
213
261
  } else if (e.key === "Delete") {
214
262
  buffer.deleteForward()
215
- setCaretOn(true)
263
+ restartBlink()
216
264
  } else if (e.key === "ArrowLeft") {
217
265
  buffer.move("left", { extend: e.shiftKey })
218
- setCaretOn(true)
266
+ restartBlink()
219
267
  } else if (e.key === "ArrowRight") {
220
268
  buffer.move("right", { extend: e.shiftKey })
221
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
282
+ restartBlink()
235
283
  } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
236
284
  buffer.setSelection(0, value().length)
237
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
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 = () => focused() && caretOn() && !showPlaceholder()
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 and flushes the offsets
348
- // before paint; scrollX/scrollY are paint-time translates that also apply
349
- // to detached children.
350
- // All metrics derive from the scaled body size, so the field, the caret,
351
- // and the scroll math grow together under policy.textScale.
352
- let fontSize = () => theme.text.body.size * policy.textScale
353
- let font = () => ({ fontSize: fontSize(), lineHeight: theme.text.body.lineHeight })
354
- let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
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
- // Multiline viewport height: a caller-given field height stretches the
372
- // viewport (fixed box, scrolls); otherwise the content height, at least one
373
- // row and at most maxRows rows. Single-line is always one row.
374
- let viewportHeight = (): number | undefined => {
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
- props.ref?.(n)
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
- flexDirection="row"
399
- alignItems="center"
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
- {...props.layout}
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
- alignSelf={props.multiline ? "stretch" : undefined}
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
- props.ref?.(n)
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. Enter/leave drive hover
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
- if (fire) activate()
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
- props.ref?.(n)
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
@@ -111,6 +111,7 @@ export function Radio(props: RadioProps) {
111
111
  rotate={styled().rotate}
112
112
  opacity={styled().opacity}
113
113
  {...press.handlers}
114
+ cursor="pointer"
114
115
  focusable={!disabled()}
115
116
  pointerEvents={disabled() ? "none" : undefined}
116
117
  >
@@ -1,9 +1,9 @@
1
1
  import { untrack } from "@solidrt/core"
2
- import type { LayoutProps, TextInputHints } from "@solidrt/core"
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
- /** Without a `layout.height`: rows to grow to before scrolling. Default unbounded. */
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
- layout?: LayoutProps
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
- let base = () => theme.text.body.size * policy.textScale
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.