@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
package/src/scroll-view.tsx
CHANGED
|
@@ -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
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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:
|
|
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
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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={
|
|
217
|
+
onTransitionEnd={settled}
|
|
150
218
|
scrollX={scroll.offset().x}
|
|
151
219
|
scrollY={scroll.offset().y}
|
|
152
|
-
{
|
|
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
package/src/text-input.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { untrack } from "@solidrt/core"
|
|
2
2
|
import { createTextBuffer } from "@solidrt/core/text-input"
|
|
3
|
-
import type {
|
|
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.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
239
|
-
//
|
|
240
|
-
|
|
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
|
+
}
|