@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.
@@ -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,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). 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.
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 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.
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
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
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
- if (blinkId != null) {
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
- setCaretOn(true)
267
+ restartBlink()
213
268
  } else if (e.key === "Delete") {
214
269
  buffer.deleteForward()
215
- setCaretOn(true)
270
+ restartBlink()
216
271
  } else if (e.key === "ArrowLeft") {
217
272
  buffer.move("left", { extend: e.shiftKey })
218
- setCaretOn(true)
273
+ restartBlink()
219
274
  } else if (e.key === "ArrowRight") {
220
275
  buffer.move("right", { extend: e.shiftKey })
221
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
289
+ restartBlink()
235
290
  } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
236
291
  buffer.setSelection(0, value().length)
237
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
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
- setCaretOn(true)
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 = () => focused() && caretOn() && !showPlaceholder()
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 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)
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
- // 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 => {
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
- props.ref?.(n)
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
- flexDirection="row"
399
- alignItems="center"
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
- {...props.layout}
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
- alignSelf={props.multiline ? "stretch" : undefined}
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
- 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
  >