@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.
@@ -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.
@@ -1,5 +1,5 @@
1
- import { createPan, createScroll, createSignal, onSettled, untrack } from "@solidrt/core"
2
- import type { LayoutProps, PointerProps, Scroll, WheelEvent } from "@solidrt/core"
1
+ import { createPan, createScroll, createSignal, getBoundingBoxViewport, getLayoutBox, onSettled, untrack } from "@solidrt/core"
2
+ import type { LayoutProps, PointerEvent, PointerProps, Scroll, TransitionCurve, TransitionEndEvent, WheelEvent } from "@solidrt/core"
3
3
  import type { StyleProps, TransitionProps, TransitionScrollProp, TransitionStyleProp, TransitionViewProp } from "./types"
4
4
  import { splitTransition, transitionEndFor } from "./types"
5
5
 
@@ -28,20 +28,43 @@ export interface ScrollViewProps
28
28
  // a pan recognizer: it activates on movement slop along the scroll axis,
29
29
  // stealing the pointer from a pressable the drag started on (its press
30
30
  // feedback retracts), and keeps scrolling when the pointer leaves the box.
31
- // There is no momentum yet; a fling stops when the finger lifts.
32
31
  //
33
32
  // Motion: the offset is written as a target and the runtime springs to it,
34
33
  // so a wheel tick glides instead of jumping and a burst of ticks retargets
35
34
  // one continuous motion. While a finger drags, the spring is withdrawn from
36
- // the viewport declaration so the content tracks the finger exactly; the
37
- // first drag write cancels any spring still in flight. A `scrollX`/`scrollY`
38
- // entry in the `transition` prop replaces the default.
35
+ // the viewport declaration so the content tracks the finger exactly. A lift
36
+ // at speed flings: the pan's release velocity projects ONE destination
37
+ // (the distance an exponential decay covers, clamped to the range) written
38
+ // under a tween whose curve is that decay, so the runtime animates the
39
+ // whole glide and no JS runs per frame (okf/notes/app-structure-performance.md).
40
+ // A finger landing on a moving list holds it where it is: the animated
41
+ // offset is read back from the boxes and written instantly, since the
42
+ // JS-side offset is the destination, not the position. A `scrollX`/`scrollY`
43
+ // entry in the `transition` prop replaces the default spring; the fling
44
+ // tween is the component's own.
39
45
  const SCROLL_SPRING = { duration: 250 }
46
+ // Momentum after a fling: velocity decay in e-foldings per second (iOS's
47
+ // normal deceleration rate of 0.998 per ms). A fling travels its release
48
+ // speed over this.
49
+ const MOMENTUM_DECAY = 2
50
+ // The curve a fling glides on: easeOutExpo, 1 - 2^(-10 t), the shape of an
51
+ // exponential decay.
52
+ const MOMENTUM_CURVE: TransitionCurve = [0.19, 1, 0.22, 1]
53
+ // The tween's length, ms: where that curve's decay rate is MOMENTUM_DECAY
54
+ // (10 ln 2 over the rate). Constant on purpose: a faster fling travels
55
+ // farther, not longer, as a decay does; the tail is sub-pixel.
56
+ const MOMENTUM_MS = Math.round(((10 * Math.LN2) / MOMENTUM_DECAY) * 1000)
57
+ // A read-back offset this close to the written one means nothing is in
58
+ // flight: no instant write.
59
+ const LIVE_EPSILON = 0.5
40
60
 
41
61
  export function ScrollView(props: ScrollViewProps) {
42
62
  let viewport: { id: number } | undefined
43
63
  let content: { id: number } | undefined
44
64
  let [dragging, setDragging] = createSignal(false)
65
+ // A fling's tween is declared while it runs: cleared by its end, by a
66
+ // wheel and by the next finger.
67
+ let [fling, setFling] = createSignal(false)
45
68
 
46
69
  let scroll = createScroll(
47
70
  () => viewport,
@@ -55,15 +78,56 @@ export function ScrollView(props: ScrollViewProps) {
55
78
  })
56
79
 
57
80
  // Content follows the finger: it moves opposite to scroll offsets, which
58
- // grow toward the bottom/right.
81
+ // grow toward the bottom/right. The lift's velocity (parent-frame px/s,
82
+ // zero for a rested finger) becomes the fling's destination.
59
83
  let pan = createPan({
60
84
  axis: props.horizontal ? "horizontal" : "vertical",
61
85
  onPanStart: () => setDragging(true),
62
86
  onPanMove: (dx, dy) => scroll.scrollBy({ x: -dx, y: -dy }),
63
- onPanEnd: () => setDragging(false),
87
+ onPanEnd: v => {
88
+ setDragging(false)
89
+ let speed = props.horizontal ? v.vx : v.vy
90
+ if (speed === 0) return
91
+ let cur = scroll.offset()
92
+ let range = scroll.range()
93
+ let now = props.horizontal ? cur.x : cur.y
94
+ let dest = Math.max(0, Math.min(now - speed / MOMENTUM_DECAY, props.horizontal ? range.x : range.y))
95
+ if (dest === now) return
96
+ setFling(true)
97
+ scroll.scrollTo(props.horizontal ? { x: dest } : { y: dest })
98
+ },
64
99
  })
100
+ // A finger landing (any down, a tap included) holds a moving list where
101
+ // it is: the animated offset, read back from the boxes (window-relative,
102
+ // scaled back to box pixels through the untransformed layout box), is
103
+ // written instantly, dropping the fling or spring in flight. Nothing is
104
+ // written when nothing moves.
105
+ let hold = (e: PointerEvent) => {
106
+ setFling(false)
107
+ if (viewport && content) {
108
+ let vb = getBoundingBoxViewport(viewport)
109
+ let cb = getBoundingBoxViewport(content)
110
+ let lb = getLayoutBox(viewport)
111
+ if (vb && cb && lb) {
112
+ let scale = props.horizontal ? (lb.width > 0 ? vb.width / lb.width : 0) : lb.height > 0 ? vb.height / lb.height : 0
113
+ if (scale > 0) {
114
+ let live = (props.horizontal ? vb.x - cb.x : vb.y - cb.y) / scale
115
+ let cur = scroll.offset()
116
+ if (Math.abs(live - (props.horizontal ? cur.x : cur.y)) > LIVE_EPSILON) {
117
+ scroll.scrollTo(props.horizontal ? { x: live, behavior: "instant" } : { y: live, behavior: "instant" })
118
+ }
119
+ }
120
+ }
121
+ }
122
+ pan.handlers.onPointerDown(e)
123
+ }
124
+ let settled = (e: TransitionEndEvent) => {
125
+ if (e.property === "scrollX" || e.property === "scrollY") setFling(false)
126
+ transitionEndFor("root", props.onTransitionEnd)?.(e)
127
+ }
65
128
 
66
129
  let onWheel = (e: WheelEvent) => {
130
+ setFling(false)
67
131
  // A plain mouse wheel only emits deltaY. On a horizontal scroller, route that
68
132
  // vertical delta to the x axis so the wheel still scrolls it (trackpads that
69
133
  // emit deltaX take precedence).
@@ -89,11 +153,11 @@ export function ScrollView(props: ScrollViewProps) {
89
153
  }
90
154
  }
91
155
  // The viewport's declaration: the user's scroll entries over the default
92
- // spring. During a drag, and while the latest programmatic write asked for
93
- // no motion (scrollTo behavior "instant"), the scroll entries go, and a
94
- // user `all` narrows to the one other property the viewport writes
95
- // (clipRadius) so it cannot put a spring back under the finger or the
96
- // instant write.
156
+ // spring, the fling tween over both while a fling runs. During a drag,
157
+ // and while the latest programmatic write asked for no motion (scrollTo
158
+ // behavior "instant"), the scroll entries go, and a user `all` narrows to
159
+ // the one other property the viewport writes (clipRadius) so it cannot
160
+ // put a spring back under the finger or the instant write.
97
161
  let viewportTransition = () => {
98
162
  let user = split().viewport
99
163
  let entries: Record<string, unknown> = typeof user === "string" ? { all: user } : { ...(user ?? {}) }
@@ -102,6 +166,10 @@ export function ScrollView(props: ScrollViewProps) {
102
166
  if (all !== undefined) rest.clipRadius = all
103
167
  return Object.keys(rest).length ? rest : null
104
168
  }
169
+ if (fling()) {
170
+ let momentum = { duration: MOMENTUM_MS, curve: MOMENTUM_CURVE }
171
+ return { ...entries, scrollX: momentum, scrollY: momentum }
172
+ }
105
173
  return { scrollX: SCROLL_SPRING, scrollY: SCROLL_SPRING, ...entries }
106
174
  }
107
175
  let direction = () => (props.horizontal ? "row" : "column")
@@ -146,10 +214,12 @@ export function ScrollView(props: ScrollViewProps) {
146
214
  clipRadius={props.style?.borderRadius}
147
215
  flexDirection={direction()}
148
216
  transition={viewportTransition()}
149
- onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
217
+ onTransitionEnd={settled}
150
218
  scrollX={scroll.offset().x}
151
219
  scrollY={scroll.offset().y}
152
- {...pan.handlers}
220
+ onPointerDown={hold}
221
+ onPointerMove={pan.handlers.onPointerMove}
222
+ onPointerUp={pan.handlers.onPointerUp}
153
223
  onWheel={onWheel}
154
224
  >
155
225
  <view ref={(n: { id: number }) => (content = n)} flexShrink={0} flexDirection={direction()}>
@@ -144,6 +144,7 @@ export function SegmentedControl(props: SegmentedControlProps) {
144
144
  paddingLeft={space("md")}
145
145
  paddingRight={space("md")}
146
146
  {...press.handlers}
147
+ cursor="pointer"
147
148
  focusable={!props.disabled}
148
149
  pointerEvents={props.disabled ? "none" : undefined}
149
150
  >
package/src/select.tsx CHANGED
@@ -65,6 +65,7 @@ export function Select(props: SelectProps) {
65
65
  paddingLeft={space("md")}
66
66
  paddingRight={space("md")}
67
67
  {...press.handlers}
68
+ cursor="pointer"
68
69
  focusable
69
70
  >
70
71
  <PressFeedback
@@ -208,6 +209,7 @@ export function Select(props: SelectProps) {
208
209
  rotate={style().rotate}
209
210
  opacity={style().opacity}
210
211
  {...press.handlers}
212
+ cursor="pointer"
211
213
  focusable={!props.disabled}
212
214
  pointerEvents={props.disabled ? "none" : undefined}
213
215
  >
package/src/switch.tsx CHANGED
@@ -70,6 +70,7 @@ export function Switch(props: SwitchProps) {
70
70
  rotate={style().rotate}
71
71
  opacity={style().opacity}
72
72
  {...press.handlers}
73
+ cursor="pointer"
73
74
  focusable={!props.disabled}
74
75
  pointerEvents={props.disabled ? "none" : undefined}
75
76
  >
@@ -1,8 +1,8 @@
1
1
  import { untrack } from "@solidrt/core"
2
2
  import { createTextBuffer } from "@solidrt/core/text-input"
3
- import type { LayoutProps, TextInputHints } from "@solidrt/core"
3
+ import type { TextInputHints } from "@solidrt/core"
4
4
  import { EditorField } from "./editor-field"
5
- import type { StyleProps, TransitionProps } from "./types"
5
+ import type { EditorLayoutProps, StyleProps, TransitionProps } from "./types"
6
6
  import { theme } from "./theme"
7
7
 
8
8
  export interface TextInputProps extends TransitionProps {
@@ -19,12 +19,14 @@ export interface TextInputProps extends TransitionProps {
19
19
  autoFocus?: boolean
20
20
  /**
21
21
  * Multi-line editing: lines wrap at the field's width, Enter inserts a
22
- * newline (onSubmit never fires), Up/Down move by line. Without a
23
- * `layout.height` the field grows with its content (up to `maxRows` rows,
24
- * then scrolls); with one it is a fixed box that scrolls to the caret.
22
+ * newline (onSubmit never fires), Up/Down move by line. Unconstrained,
23
+ * the field grows with its content (up to `maxRows` rows, then scrolls);
24
+ * sized by its layout - a `height`, `flexGrow: 1` in a sized parent, a
25
+ * parent too small for the content - it is a fixed box that scrolls to
26
+ * the caret.
25
27
  */
26
28
  multiline?: boolean
27
- /** Multiline without an explicit height: rows to grow to before scrolling. Default unbounded. */
29
+ /** Multiline, unconstrained: rows to grow to before scrolling. Default unbounded. */
28
30
  maxRows?: number
29
31
  /**
30
32
  * IME behavior for the field's text sessions (keyboard type,
@@ -34,7 +36,13 @@ export interface TextInputProps extends TransitionProps {
34
36
  hints?: TextInputHints
35
37
 
36
38
  ref?: (node: { id: number }) => void
37
- layout?: LayoutProps
39
+ /**
40
+ * The box, plus the font fields (`fontSize`, `fontFamily`, `lineHeight`,
41
+ * `fontStyle`, `fontWeight`) the text is shaped in, as on Text: the theme
42
+ * body font by default, `fontSize` scaled by `policy.textScale`. The rows,
43
+ * caret and scrolling follow the font.
44
+ */
45
+ layout?: EditorLayoutProps
38
46
  style?: StyleProps
39
47
  }
40
48
 
package/src/text.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createMemo } from "@solidrt/core"
2
2
  import type { PointerProps } from "@solidrt/core"
3
3
  import type { StyleProps, TextLayoutProps, TransitionProps, TransitionViewProp } from "./types"
4
- import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
4
+ import { splitTextLayout, splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
5
5
  import { theme, type TextVariant } from "./theme"
6
6
  import { policy } from "./policy"
7
7
  import { typeWeight } from "./typography"
@@ -28,33 +28,15 @@ export interface TextProps extends PointerProps, TransitionProps<TransitionViewP
28
28
  style?: StyleProps
29
29
  }
30
30
 
31
- // Font keys live in layout (they affect measurement) but belong on the inner
32
- // <text> node, not the box <view>. Strip them out before spreading layout.
33
- const FONT_KEYS = [
34
- "fontFamily",
35
- "fontSize",
36
- "lineHeight",
37
- "fontStyle",
38
- "fontWeight",
39
- "textAlign",
40
- "maxLines",
41
- ]
42
-
43
31
  export function Text(props: TextProps) {
44
32
  let role = () => theme.text[props.variant ?? "body"]
45
33
  let size = () => (props.layout?.fontSize ?? role().size) * policy.textScale
46
34
  let color = () =>
47
35
  props.style?.color ?? theme.color[props.color ?? (props.muted ? "textMuted" : "text")]
48
36
 
49
- let box = createMemo(() => {
50
- let l = props.layout
51
- if (!l) return {}
52
- let out: Record<string, unknown> = {}
53
- for (let key in l) {
54
- if (!FONT_KEYS.includes(key)) out[key] = (l as Record<string, unknown>)[key]
55
- }
56
- return out
57
- })
37
+ // Font keys live in layout (they affect measurement) but belong on the
38
+ // inner <text> node, not the box <view>: only the box half is spread.
39
+ let box = createMemo(() => splitTextLayout(props.layout, true).box)
58
40
 
59
41
  // The text node owns `color`; everything else a Text animates is on the
60
42
  // wrapper view. A shorthand or `all` reaches both.
package/src/types.ts CHANGED
@@ -49,6 +49,13 @@ type TransitionEntries<P extends string> = {
49
49
  all?: Transition
50
50
  /** Group stagger (ms) for descendant enter/exit animations; see core. */
51
51
  stagger?: number
52
+ /**
53
+ * Layout slide on the component's root view: a reflow that moves or
54
+ * resizes the component slides it from the box it was painted at, as
55
+ * core's `layout` entry does (`true` borrows `all`). Never covered by
56
+ * `all` or a shorthand on its own.
57
+ */
58
+ layout?: Transition | boolean
52
59
  } & { [K in P]?: Transition }
53
60
 
54
61
  /** A component's transition declaration: view-level and style names, `all`, or a shorthand string. */
@@ -235,14 +242,49 @@ export interface Option {
235
242
  }
236
243
 
237
244
  // Text shaping affects measurement, so font props belong with layout rather
238
- // than style. These end up on the inner <text> node, while the box layout
239
- // fields go on the wrapping <view>.
240
- export interface TextLayoutProps extends LayoutProps {
245
+ // than style. The font fields shape the glyphs (and so measure them); a
246
+ // component draws its text with them and gives the box fields to its
247
+ // wrapping <view>. Text adds the paragraph-level fields; an editable field
248
+ // takes the font fields alone, since it lays its own lines out.
249
+ export interface FontProps {
241
250
  fontFamily?: FontFamily
242
251
  fontSize?: number
243
252
  lineHeight?: number
244
253
  fontStyle?: FontStyle
245
254
  fontWeight?: FontWeight
255
+ }
256
+
257
+ export interface TextLayoutProps extends LayoutProps, FontProps {
246
258
  textAlign?: TextAlign
247
259
  maxLines?: number
248
- }
260
+ }
261
+
262
+ /** The layout of an editable text field: the box, plus the font its text is shaped in. */
263
+ export interface EditorLayoutProps extends LayoutProps, FontProps {}
264
+
265
+ // The keys of FontProps, and with them the paragraph-level fields of
266
+ // TextLayoutProps, for splitting a layout object into what the text node
267
+ // takes and what the box takes.
268
+ const FONT_KEYS: readonly string[] = ["fontFamily", "fontSize", "lineHeight", "fontStyle", "fontWeight"]
269
+ const TEXT_KEYS: readonly string[] = [...FONT_KEYS, "textAlign", "maxLines"]
270
+
271
+ /**
272
+ * Splits a layout object into the fields the text node takes (the font, plus
273
+ * `textAlign`/`maxLines` when `paragraph`) and the fields that stay on the
274
+ * box <view>. Reactive like any props read when called in a tracked scope.
275
+ */
276
+ export function splitTextLayout(
277
+ layout: LayoutProps | undefined,
278
+ paragraph = false,
279
+ ): { text: Record<string, unknown>; box: LayoutProps } {
280
+ let text: Record<string, unknown> = {}
281
+ let box: Record<string, unknown> = {}
282
+ if (!layout) return { text, box }
283
+ let keys = paragraph ? TEXT_KEYS : FONT_KEYS
284
+ for (let key in layout) {
285
+ let value = (layout as Record<string, unknown>)[key]
286
+ if (keys.includes(key)) text[key] = value
287
+ else box[key] = value
288
+ }
289
+ return { text, box: box as LayoutProps }
290
+ }