@solidrt/components 0.0.11 → 0.0.14

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 CHANGED
@@ -35,6 +35,14 @@ Most components group props into two objects, plus top-level event handlers:
35
35
  - `Image` - fetches/decodes/uploads an image: `src: string | Uint8Array`.
36
36
  - `TextInput` - single-line input; `value`/`onInput`/`onSubmit`, controlled or
37
37
  uncontrolled, plus `placeholder`, `maxLength`, `autoFocus`, `disabled`.
38
+ - `ScrollView` - scrollable region; vertical by default, `horizontal` to flip.
39
+ Wheel + drag, no momentum yet. Backed by `createScroll` from
40
+ `@solidrt/core/scroll` (headless offset+clamp geometry).
41
+ - `Pressable` - pressable box; `onPress` on a primary press released inside,
42
+ `disabled` opts out. `children`/`style` can be functions of `{ pressed,
43
+ hovered }`. No pointer capture: a drag out cancels via onPointerLeave.
44
+ - `Button` - themed Pressable: accent box + label (string/number child), scales
45
+ on press (via reactive style); colors from `theme.color.primary`/`onPrimary`.
38
46
  - `SafeArea` - pads children clear of system UI (notches, status bars); top and
39
47
  bottom on by default, pass `false`/a number per edge.
40
48
  - `theme` / `setTheme` - shared appearance (colors, spacing, radii, font sizes);
package/README.md CHANGED
@@ -217,6 +217,82 @@ Top and bottom insets are applied by default. Pass `false` to opt out of an edge
217
217
  | `right` | `boolean \| number` | `false` | Apply right inset. A number sets the minimum padding. |
218
218
  | `children` | `any` | - | Content to render inside the safe area. |
219
219
 
220
+ ### ScrollView
221
+
222
+ A scrollable region. Scrolls vertically by default; pass `horizontal` to scroll the other axis instead. Both the wheel and dragging scroll the content. There is no momentum/fling yet, and (with no pointer capture) a drag that leaves the box ends the gesture.
223
+
224
+ ```jsx
225
+ import { ScrollView, Text } from "@solidrt/components"
226
+ import { For } from "@solidrt/core"
227
+
228
+ <ScrollView layout={{ height: 300 }} style={{ backgroundColor: "#111", borderRadius: 8 }}>
229
+ <For each={items()}>{(item) => <Text style={{ color: "#fff" }}>{item}</Text>}</For>
230
+ </ScrollView>
231
+ ```
232
+
233
+ **Props**
234
+
235
+ Accepts all pointer event props, plus:
236
+
237
+ | Prop | Type | Description |
238
+ | ------------ | -------------------------------- | -------------------------------------------- |
239
+ | `horizontal` | `boolean` | Scroll the horizontal axis instead of vertical. |
240
+ | `layout` | `LayoutProps` | Layout of the outer box (e.g. `height`). |
241
+ | `style` | `StyleProps` | Background, border, and transform. |
242
+ | `ref` | `(node: { id: number }) => void` | Reference to the outer box. |
243
+ | `children` | `any` | Scrollable content. |
244
+
245
+ The underlying geometry primitive `createScroll` is available from `@solidrt/core/scroll` for building custom scrollers.
246
+
247
+ ### Pressable
248
+
249
+ A pressable box. `onPress` fires on a primary-button press released over the box; a drag out of the box (or a non-primary button) does not fire it. `children` and `style` may each be a function of the `{ pressed, hovered }` state, so the box can restyle on press/hover without extra signals.
250
+
251
+ ```jsx
252
+ import { Pressable, Text } from "@solidrt/components"
253
+
254
+ <Pressable
255
+ onPress={() => setCount((c) => c + 1)}
256
+ layout={{ padding: 12 }}
257
+ style={(s) => ({ backgroundColor: s.pressed ? "#333" : "#222", borderRadius: 8 })}
258
+ >
259
+ <Text style={{ color: "#fff" }}>Tap me</Text>
260
+ </Pressable>
261
+ ```
262
+
263
+ **Props**
264
+
265
+ Accepts all pointer event props, plus:
266
+
267
+ | Prop | Type | Description |
268
+ | ---------- | --------------------------------------------------- | -------------------------------------------- |
269
+ | `onPress` | `() => void` | Fires on a completed press. |
270
+ | `disabled` | `boolean` | Takes no pointer events when true. |
271
+ | `layout` | `LayoutProps` | Layout properties. |
272
+ | `style` | `StyleProps \| (state) => StyleProps` | Paint properties, or a function of state. |
273
+ | `children` | `any \| (state) => any` | Content, or a function of state. |
274
+ | `ref` | `(node: { id: number }) => void` | Node reference. |
275
+
276
+ ### Button
277
+
278
+ Themed convenience over `Pressable`: a padded, centered, accent-colored box with a label that scales slightly on press. A string or number child is rendered as the themed label; any other child renders as-is. Colors come from the theme (`color.primary`, `color.onPrimary`); override per-button via `style`.
279
+
280
+ ```jsx
281
+ import { Button } from "@solidrt/components"
282
+
283
+ <Button onPress={save} layout={{ minWidth: 120 }}>Save</Button>
284
+ ```
285
+
286
+ **Props**
287
+
288
+ | Prop | Type | Description |
289
+ | ---------- | ------------- | ---------------------------------------------- |
290
+ | `onPress` | `() => void` | Fires on a completed press. |
291
+ | `disabled` | `boolean` | Mutes colors and ignores presses. |
292
+ | `layout` | `LayoutProps` | Overrides padding/sizing. |
293
+ | `style` | `StyleProps` | Overrides background, radius, etc. |
294
+ | `children` | `any` | Label text, or custom content. |
295
+
220
296
  ## License
221
297
 
222
298
  MIT. Copyright (c) 2026 Antoine van Wel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.11",
3
+ "version": "0.0.14",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "AGENTS.md"
14
14
  ],
15
15
  "peerDependencies": {
16
- "@solidjs/signals": "2.0.0-beta.14",
17
- "@solidrt/core": "0.0.11"
16
+ "@solidjs/signals": "2.0.0-beta.15",
17
+ "@solidrt/core": "0.0.0"
18
18
  }
19
19
  }
package/src/button.tsx ADDED
@@ -0,0 +1,62 @@
1
+ import { Show } from "@solidrt/core"
2
+ import { Pressable, type PressState } from "./pressable"
3
+ import { theme } from "./theme"
4
+ import type { LayoutProps } from "@solidrt/core"
5
+ import type { StyleProps } from "./types"
6
+
7
+ export interface ButtonProps {
8
+ // A string/number is rendered as the themed label; anything else is rendered
9
+ // as-is, so a button can hold custom content (an icon, a row, ...).
10
+ children?: any
11
+ onPress?: () => void
12
+ disabled?: boolean
13
+ layout?: LayoutProps
14
+ style?: StyleProps
15
+ }
16
+
17
+ // Themed convenience over Pressable: a padded, centered, accent-colored box with
18
+ // a label. Press feedback is a slight scale, driven through Pressable's reactive
19
+ // style so no nodes are recreated on press. Override the box via style and the
20
+ // padding/sizing via layout.
21
+ export function Button(props: ButtonProps) {
22
+ let bg = () => props.style?.backgroundColor ?? (props.disabled ? theme.color.surface : theme.color.primary)
23
+ let radius = () => props.style?.borderRadius ?? theme.radius.sm
24
+ let label = () => (props.disabled ? theme.color.textMuted : theme.color.onPrimary)
25
+ let isText = () => typeof props.children === "string" || typeof props.children === "number"
26
+
27
+ return (
28
+ <Pressable
29
+ onPress={props.onPress}
30
+ disabled={props.disabled}
31
+ layout={{
32
+ flexDirection: "row",
33
+ alignItems: "center",
34
+ justifyContent: "center",
35
+ paddingTop: theme.spacing.sm,
36
+ paddingBottom: theme.spacing.sm,
37
+ paddingLeft: theme.spacing.md,
38
+ paddingRight: theme.spacing.md,
39
+ ...props.layout,
40
+ }}
41
+ style={(s: PressState) => ({
42
+ ...props.style,
43
+ backgroundColor: bg(),
44
+ borderRadius: radius(),
45
+ // Always a number: a scale that flips from a number back to undefined
46
+ // hits the transform decoder, which rejects null. Multiply so a
47
+ // caller-set scale is preserved under the press feedback.
48
+ scale: (props.style?.scale ?? 1) * (s.pressed ? 0.97 : 1),
49
+ })}
50
+ >
51
+ <Show when={isText()} fallback={props.children}>
52
+ <text
53
+ color={label()}
54
+ fontSize={theme.text.body.size}
55
+ lineHeight={theme.text.body.lineHeight}
56
+ >
57
+ {props.children}
58
+ </text>
59
+ </Show>
60
+ </Pressable>
61
+ )
62
+ }
package/src/image.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createSignal, createEffect, onCleanup } from "@solidjs/signals"
2
- import { decodeImage, createTexture } from "@solidrt/core/gpu"
2
+ import { createTexture, destroyTexture } from "@solidrt/core/gpu"
3
+ import { decodeImage } from "@solidrt/core/image"
3
4
  import type { LayoutProps, PointerProps } from "@solidrt/core"
4
5
  import type { StyleProps } from "./types"
5
6
 
@@ -10,13 +11,14 @@ export interface ImageProps extends PointerProps {
10
11
  }
11
12
 
12
13
  //TODO onLoad, onError
13
- //TODO release texture in onCleanup
14
14
  export function Image(props: ImageProps) {
15
15
  let [res, setRes] = createSignal<{ id: number; width: number; height: number }>()
16
16
 
17
17
  // Load (and decode) whenever src changes. A url is fetched; bytes are used
18
18
  // directly. The async result is pushed into the signal so the texture shows
19
- // once ready; a stale flag drops results from a superseded src.
19
+ // once ready; a stale flag drops results from a superseded src. The old
20
+ // texture is destroyed before installing the new one; on unmount the current
21
+ // texture is destroyed by onCleanup below.
20
22
  createEffect(
21
23
  () => props.src,
22
24
  (source) => {
@@ -33,7 +35,9 @@ export function Image(props: ImageProps) {
33
35
  if (stale) return
34
36
  let { data, width, height } = decodeImage(bytes)
35
37
  let id = createTexture(data, width, height)
38
+ let old = res()
36
39
  setRes({ id, width, height })
40
+ if (old !== undefined) destroyTexture(old.id)
37
41
  })()
38
42
 
39
43
  return () => {
@@ -52,7 +56,8 @@ export function Image(props: ImageProps) {
52
56
  let texH = () => (typeof props.layout?.height === "number" ? props.layout.height : undefined)
53
57
 
54
58
  onCleanup(() => {
55
- //TODO release texture
59
+ let r = res()
60
+ if (r !== undefined) destroyTexture(r.id)
56
61
  })
57
62
 
58
63
  return (
package/src/index.ts CHANGED
@@ -4,5 +4,10 @@ export { Text, type TextProps } from "./text"
4
4
  export { Image, type ImageProps } from "./image"
5
5
  export { SafeArea } from "./safe-area"
6
6
  export { TextInput, type TextInputProps } from "./text-input"
7
+ export { ScrollView, type ScrollViewProps } from "./scroll-view"
8
+ export { Pressable, type PressableProps, type PressState } from "./pressable"
9
+ export { Button, type ButtonProps } from "./button"
10
+ export { Portal, type PortalProps } from "./portal"
11
+ export { Modal, type ModalProps } from "./modal"
7
12
  export { theme, setTheme, type Theme } from "./theme"
8
13
  export type { StyleProps, TextLayoutProps } from "./types"
package/src/modal.tsx ADDED
@@ -0,0 +1,58 @@
1
+ import { createPortal } from "@solidrt/core"
2
+ import type { Color, PointerEvent } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+
5
+ export interface ModalProps {
6
+ // Called when the backdrop (the area around the content) is pressed, unless
7
+ // `dismissable` is false.
8
+ onClose?: () => void
9
+ // The modal content, centered over the backdrop.
10
+ children?: any
11
+ // Scrim color behind the content. Defaults to the theme scrim; pass
12
+ // "transparent" for no dim.
13
+ backdropColor?: Color
14
+ // Whether pressing the backdrop calls onClose. Defaults to true.
15
+ dismissable?: boolean
16
+ }
17
+
18
+ /**
19
+ * A centered overlay rendered at the window root via core's createPortal, so it
20
+ * escapes the layout and stacking of its surrounding tree. It fills the window
21
+ * with a dimming backdrop and centers `children` on top. Control visibility by
22
+ * mounting/unmounting it, e.g. `<Show when={open()}><Modal .../></Show>`: the
23
+ * portal's onCleanup removes it when the surrounding scope disposes.
24
+ *
25
+ * Pressing the backdrop calls `onClose`; pressing the content does not. This
26
+ * works because pointer events dispatch to the whole hit path with no
27
+ * stopPropagation, so the content is kept a sibling of the backdrop (not a
28
+ * child): a press on the content never has the backdrop on its path.
29
+ */
30
+ export function Modal(props: ModalProps) {
31
+ let dismiss = (_e: PointerEvent) => {
32
+ if (props.dismissable !== false) props.onClose?.()
33
+ }
34
+
35
+ return createPortal(
36
+ <view
37
+ position="absolute"
38
+ top={0}
39
+ left={0}
40
+ right={0}
41
+ bottom={0}
42
+ alignItems="center"
43
+ justifyContent="center"
44
+ >
45
+ <view
46
+ position="absolute"
47
+ top={0}
48
+ left={0}
49
+ right={0}
50
+ bottom={0}
51
+ onPointerDown={dismiss}
52
+ >
53
+ <d-rect color={props.backdropColor ?? theme.color.scrim} />
54
+ </view>
55
+ {props.children}
56
+ </view> as any,
57
+ )
58
+ }
package/src/portal.tsx ADDED
@@ -0,0 +1,21 @@
1
+ import { createPortal } from "@solidrt/core"
2
+
3
+ export interface PortalProps {
4
+ children?: any
5
+ // Where to mount the content. A node, typically captured from another
6
+ // element's `ref`. Defaults to the window root.
7
+ mount?: { id: number }
8
+ }
9
+
10
+ /**
11
+ * Renders its child somewhere other than its lexical position: by default at the
12
+ * window root, so overlays (modals, menus, tooltips) escape the clipping and
13
+ * stacking of their surrounding layout. Thin JSX wrapper over core's
14
+ * createPortal; the child should be a single element positioned absolutely
15
+ * (`position="absolute"`), since it is inserted into the window's flex root.
16
+ */
17
+ export function Portal(props: PortalProps) {
18
+ // A JSX element's runtime value is the built node; createPortal relocates it.
19
+ createPortal(props.children, props.mount as any)
20
+ return undefined
21
+ }
@@ -0,0 +1,92 @@
1
+ import { createSignal } from "@solidjs/signals"
2
+ import type { LayoutProps, PointerEvent, PointerProps } from "@solidrt/core"
3
+ import type { StyleProps } from "./types"
4
+
5
+ export type PressState = { pressed: boolean; hovered: boolean }
6
+
7
+ export interface PressableProps extends PointerProps {
8
+ // children and style may be functions of the press state, so a caller can
9
+ // restyle on press/hover without wiring their own signals.
10
+ children?: any | ((state: PressState) => any)
11
+ ref?: (node: { id: number }) => void
12
+ layout?: LayoutProps
13
+ style?: StyleProps | ((state: PressState) => StyleProps)
14
+ onPress?: () => void
15
+ disabled?: boolean
16
+ }
17
+
18
+ // A pressable box. onPress fires on a primary-button down followed by an up over
19
+ // the same node. Because there is no pointer capture, a drag that leaves the box
20
+ // fires onPointerLeave, which cancels the press (no onPress) and clears hover --
21
+ // this also covers the no-up-outside case. Non-primary buttons (right/middle) do
22
+ // not start a press. When disabled, it takes no pointer events at all.
23
+ export function Pressable(props: PressableProps) {
24
+ let [pressed, setPressed] = createSignal(false)
25
+ let [hovered, setHovered] = createSignal(false)
26
+
27
+ let state = (): PressState => ({ pressed: pressed(), hovered: hovered() })
28
+ let style = () => (typeof props.style === "function" ? props.style(state()) : props.style)
29
+ let kids = () => (typeof props.children === "function" ? props.children(state()) : props.children)
30
+
31
+ let handleDown = (e: PointerEvent) => {
32
+ if (e.button != null && e.button !== 0) return
33
+ setPressed(true)
34
+ props.onPointerDown?.(e)
35
+ }
36
+ let handleUp = (e: PointerEvent) => {
37
+ if (pressed()) props.onPress?.()
38
+ setPressed(false)
39
+ props.onPointerUp?.(e)
40
+ }
41
+ let handleEnter = (e: PointerEvent) => {
42
+ setHovered(true)
43
+ props.onPointerEnter?.(e)
44
+ }
45
+ let handleLeave = (e: PointerEvent) => {
46
+ setHovered(false)
47
+ setPressed(false)
48
+ props.onPointerLeave?.(e)
49
+ }
50
+
51
+ let hasBackground = () => style()?.backgroundColor != null || style()?.borderRadius != null
52
+ let hasBorder = () => (style()?.borderWidth ?? 0) > 0
53
+
54
+ return (
55
+ <view
56
+ ref={props.ref}
57
+ {...props.layout}
58
+ x={style()?.x}
59
+ y={style()?.y}
60
+ scale={style()?.scale}
61
+ rotate={style()?.rotate}
62
+ onPointerEnter={handleEnter}
63
+ onPointerLeave={handleLeave}
64
+ onPointerDown={handleDown}
65
+ onPointerUp={handleUp}
66
+ onPointerMove={props.onPointerMove}
67
+ onWheel={props.onWheel}
68
+ onFocus={props.onFocus}
69
+ onBlur={props.onBlur}
70
+ onKeyDown={props.onKeyDown}
71
+ onKeyUp={props.onKeyUp}
72
+ onTextInput={props.onTextInput}
73
+ pointerEvents={props.disabled ? "none" : props.pointerEvents}
74
+ >
75
+ {hasBackground() ? (
76
+ <d-rect
77
+ color={style()?.backgroundColor ?? "transparent"}
78
+ radius={style()?.borderRadius}
79
+ />
80
+ ) : null}
81
+ {kids()}
82
+ {hasBorder() ? (
83
+ <d-rect
84
+ drawStyle="stroke"
85
+ color={style()?.borderColor ?? "transparent"}
86
+ strokeWidth={style()?.borderWidth}
87
+ radius={style()?.borderRadius}
88
+ />
89
+ ) : null}
90
+ </view>
91
+ )
92
+ }
package/src/safe-area.tsx CHANGED
@@ -1,5 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
2
- import { onResize } from "@solidrt/core"
1
+ import { safeArea } from "@solidrt/core"
3
2
 
4
3
  export function SafeArea(props: {
5
4
  top?: boolean | number
@@ -9,21 +8,12 @@ export function SafeArea(props: {
9
8
  relative?: boolean
10
9
  children?: any
11
10
  }) {
12
- let [insets, setInsets] = createSignal({ top: 0, bottom: 0, left: 0, right: 0 })
13
- onResize(({ width, height, safeArea }) => {
14
- setInsets({
15
- top: safeArea.top,
16
- left: safeArea.left,
17
- bottom: height - safeArea.bottom,
18
- right: width - safeArea.right,
19
- })
20
- })
21
11
  let pad = (edge: "top" | "bottom" | "left" | "right") => {
22
12
  let defaultOn = edge === "top" || edge === "bottom"
23
13
  let p = props[edge] ?? defaultOn
24
14
  if (p === false) return 0
25
- if (p === true) return insets()[edge]
26
- return Math.max(insets()[edge], p as number)
15
+ if (p === true) return safeArea()[edge]
16
+ return Math.max(safeArea()[edge], p as number)
27
17
  }
28
18
  return (
29
19
  <view
@@ -0,0 +1,106 @@
1
+ import { createScroll } from "@solidrt/core/scroll"
2
+ import type { LayoutProps, PointerEvent, PointerProps, WheelEvent } from "@solidrt/core"
3
+ import type { StyleProps } from "./types"
4
+
5
+ export interface ScrollViewProps extends PointerProps {
6
+ children?: any
7
+ ref?: (node: { id: number }) => void
8
+ layout?: LayoutProps
9
+ style?: StyleProps
10
+ /** Scroll the horizontal axis instead of the vertical one. */
11
+ horizontal?: boolean
12
+ }
13
+
14
+ // A scrollable region. The outer box carries layout/style/transform and the
15
+ // optional background and border; inside it a clipping viewport (overflow
16
+ // hidden) holds a content wrapper that takes the children's natural size. The
17
+ // offset from createScroll translates the content via scrollX/scrollY. Wheel and
18
+ // drag map to scroll deltas: positive moves the content up/left, so dragging a
19
+ // finger up reveals content below (natural scrolling). There is no momentum yet;
20
+ // a fling stops when the finger lifts. With no pointer capture, a drag that
21
+ // leaves the box stops scrolling (pointerLeave ends the gesture).
22
+ export function ScrollView(props: ScrollViewProps) {
23
+ let viewport: { id: number } | undefined
24
+ let content: { id: number } | undefined
25
+
26
+ let scroll = createScroll(
27
+ () => viewport,
28
+ () => content,
29
+ { axis: props.horizontal ? "horizontal" : "vertical" },
30
+ )
31
+
32
+ // Last pointer position during an active drag, in window coordinates. null
33
+ // when no drag is in progress.
34
+ let last: { x: number; y: number } | null = null
35
+
36
+ let onPointerDown = (e: PointerEvent) => {
37
+ last = { x: e.clientX, y: e.clientY }
38
+ }
39
+ let onPointerMove = (e: PointerEvent) => {
40
+ if (!last) return
41
+ scroll.scrollBy(last.x - e.clientX, last.y - e.clientY)
42
+ last = { x: e.clientX, y: e.clientY }
43
+ }
44
+ let endDrag = () => {
45
+ last = null
46
+ }
47
+ let onWheel = (e: WheelEvent) => {
48
+ scroll.scrollBy(e.deltaX, e.deltaY)
49
+ }
50
+
51
+ let direction = () => (props.horizontal ? "row" : "column")
52
+ let hasBackground = () =>
53
+ props.style?.backgroundColor != null || props.style?.borderRadius != null
54
+ let hasBorder = () => (props.style?.borderWidth ?? 0) > 0
55
+
56
+ return (
57
+ <view
58
+ ref={props.ref}
59
+ {...props.layout}
60
+ x={props.style?.x}
61
+ y={props.style?.y}
62
+ scale={props.style?.scale}
63
+ rotate={props.style?.rotate}
64
+ onPointerEnter={props.onPointerEnter}
65
+ onPointerLeave={props.onPointerLeave}
66
+ onPointerDown={props.onPointerDown}
67
+ onPointerUp={props.onPointerUp}
68
+ onPointerMove={props.onPointerMove}
69
+ onWheel={props.onWheel}
70
+ pointerEvents={props.pointerEvents}
71
+ >
72
+ {hasBackground() ? (
73
+ <d-rect
74
+ color={props.style?.backgroundColor ?? "transparent"}
75
+ radius={props.style?.borderRadius}
76
+ />
77
+ ) : null}
78
+ <view
79
+ ref={(n: { id: number }) => (viewport = n)}
80
+ flex={1}
81
+ overflow="hidden"
82
+ clipRadius={props.style?.borderRadius}
83
+ flexDirection={direction()}
84
+ scrollX={scroll.offset().x}
85
+ scrollY={scroll.offset().y}
86
+ onPointerDown={onPointerDown}
87
+ onPointerMove={onPointerMove}
88
+ onPointerUp={endDrag}
89
+ onPointerLeave={endDrag}
90
+ onWheel={onWheel}
91
+ >
92
+ <view ref={(n: { id: number }) => (content = n)} flexShrink={0} flexDirection={direction()}>
93
+ {props.children}
94
+ </view>
95
+ </view>
96
+ {hasBorder() ? (
97
+ <d-rect
98
+ drawStyle="stroke"
99
+ color={props.style?.borderColor ?? "transparent"}
100
+ strokeWidth={props.style?.borderWidth}
101
+ radius={props.style?.borderRadius}
102
+ />
103
+ ) : null}
104
+ </view>
105
+ )
106
+ }
@@ -1,9 +1,14 @@
1
- import { createSignal, flush, onCleanup } from "@solidjs/signals"
2
- import { getBoundingBox, measureText, onLayout, setFocus } from "@solidrt/core"
1
+ import { createEffect, createSignal, onCleanup } from "@solidjs/signals"
2
+ import { setFocus } from "@solidrt/core"
3
+ import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
3
4
  import type { LayoutProps } from "@solidrt/core"
4
5
  import type { StyleProps } from "./types"
5
6
  import { theme } from "./theme"
6
7
 
8
+ // Caret thickness. Shared so the drawn caret and the scroll offset's reserved
9
+ // edge column cannot drift apart.
10
+ const CARET_WIDTH = 1
11
+
7
12
  export interface TextInputProps {
8
13
  value?: string
9
14
  defaultValue?: string
@@ -21,28 +26,38 @@ export interface TextInputProps {
21
26
  style?: StyleProps
22
27
  }
23
28
 
24
- // V1: single-line, caret-at-end only, no selection, no mid-string editing.
25
- // Printable text arrives via onTextInput (post-IME commit). onKeyDown handles
26
- // Backspace, Enter, Escape. Outside-click-to-blur is the caller's job.
29
+ // Single-line. The caret moves through the text (Left/Right/Home/End), edits
30
+ // happen at the caret, and the inner box scrolls to keep it in view. Printable
31
+ // text arrives via onTextInput (post-IME commit). onKeyDown handles caret
32
+ // movement, Backspace/Delete, Enter, Escape. Range selection (shift-movement,
33
+ // highlight, click-to-position) is not wired yet. Outside-click-to-blur is the
34
+ // caller's job.
27
35
  export function TextInput(props: TextInputProps) {
28
- let [internalValue, setInternalValue] = createSignal(props.defaultValue ?? "")
29
36
  let [focused, setFocused] = createSignal(false)
30
37
  let [caretOn, setCaretOn] = createSignal(true)
31
- let [viewportWidth, setViewportWidth] = createSignal(0)
32
38
 
33
39
  let node: { id: number } | undefined
34
40
  let viewport: { id: number } | undefined
35
41
  let blinkId: any = null
36
42
 
37
- let value = () => props.value ?? internalValue()
38
-
39
- let commit = (next: string) => {
40
- if (props.maxLength != null && next.length > props.maxLength) {
41
- next = next.slice(0, props.maxLength)
42
- }
43
- if (props.value == null) setInternalValue(next)
44
- props.onInput?.(next)
45
- }
43
+ let buffer = createTextBuffer({
44
+ value: () => props.value,
45
+ defaultValue: props.defaultValue,
46
+ onInput: (v) => props.onInput?.(v),
47
+ maxLength: () => props.maxLength,
48
+ })
49
+ let value = buffer.value
50
+
51
+ // autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
52
+ // the node's onTextInput handler to toggle the keyboard, and those handlers
53
+ // are only registered after the element's props are applied. The ref can fire
54
+ // before that, so focusing there would no-op.
55
+ createEffect(
56
+ () => props.autoFocus,
57
+ (autoFocus) => {
58
+ if (autoFocus && node) setFocus(node.id)
59
+ },
60
+ )
46
61
 
47
62
  let handlePointerDown = () => {
48
63
  if (props.disabled) return
@@ -70,11 +85,26 @@ export function TextInput(props: TextInputProps) {
70
85
  let handleKeyDown = (e: any) => {
71
86
  if (props.disabled) return
72
87
  if (e.key === "Backspace") {
73
- let v = value()
74
- if (v.length > 0) commit(v.slice(0, -1))
88
+ buffer.deleteBackward()
89
+ setCaretOn(true)
90
+ } else if (e.key === "Delete") {
91
+ buffer.deleteForward()
92
+ setCaretOn(true)
93
+ } else if (e.key === "Left") {
94
+ buffer.move("left")
95
+ setCaretOn(true)
96
+ } else if (e.key === "Right") {
97
+ buffer.move("right")
98
+ setCaretOn(true)
99
+ } else if (e.key === "Home") {
100
+ buffer.move("start")
101
+ setCaretOn(true)
102
+ } else if (e.key === "End") {
103
+ buffer.move("end")
75
104
  setCaretOn(true)
76
105
  } else if (e.key === "Return" || e.key === "Enter") {
77
106
  props.onSubmit?.(value())
107
+ setFocus(null)
78
108
  } else if (e.key === "Escape") {
79
109
  if (node) setFocus(null)
80
110
  }
@@ -82,7 +112,7 @@ export function TextInput(props: TextInputProps) {
82
112
 
83
113
  let handleTextInput = (e: any) => {
84
114
  if (props.disabled) return
85
- commit(value() + (e.text ?? ""))
115
+ buffer.insertText(e.text ?? "")
86
116
  setCaretOn(true)
87
117
  }
88
118
 
@@ -98,37 +128,44 @@ export function TextInput(props: TextInputProps) {
98
128
  let borderRadius = () => props.style?.borderRadius ?? theme.radius.sm
99
129
 
100
130
  let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
101
- let displayText = () => (showPlaceholder() ? (props.placeholder ?? "") : value())
102
- let displayColor = () => (showPlaceholder() ? theme.color.textMuted : textColor())
103
-
104
- // Viewport width is the inner scroll container's own laid-out width, read
105
- // after layout. This works for numeric, "auto" and "%" widths alike (no need
106
- // to subtract padding: the padding lives on the outer view). getBoundingBox
107
- // is a snapshot, so we push it into a signal from onLayout. The flush drains
108
- // the resulting scrollX update synchronously, before paint, so the scroll
109
- // tracks the width in the same frame instead of trailing it by one.
110
- onLayout(() => {
111
- if (!viewport) return
112
- let w = getBoundingBox(viewport)?.width ?? 0
113
- if (w !== viewportWidth()) setViewportWidth(w)
114
- flush()
115
- })
131
+ let showCaret = () => focused() && caretOn() && !showPlaceholder()
132
+
133
+ // The text is split at the caret into two nodes with a zero-size anchor view
134
+ // between them. Flow places the anchor at the caret x (the before-text width),
135
+ // and the caret is a detached d-rect inside it: detached nodes take no layout
136
+ // slot, so the anchor stays zero-width and the after-text is not shifted. The
137
+ // anchor sits at the row's vertical center (alignItems center, zero height),
138
+ // so the caret is offset up by half its height to straddle it. While the
139
+ // placeholder shows, value() is "" so the slices and the scroll offset are 0
140
+ // with no special case. The viewport node is the inner scroll container;
141
+ // createCaretScroll reads its laid-out width after layout, keeps the caret in
142
+ // view, and flushes the offset before paint.
143
+ let beforeCaret = () => value().slice(0, buffer.caret())
144
+ let afterCaret = () => value().slice(buffer.caret())
145
+ let scrollX = createCaretScroll(
146
+ () => viewport,
147
+ () => ({
148
+ text: value(),
149
+ fontSize: theme.text.body.size,
150
+ caret: buffer.caret(),
151
+ // Constant, not tied to caret visibility: the caret's footprint does not
152
+ // change as it blinks, so reserving the column only when shown would swing
153
+ // the scroll offset every blink and shift text that exactly fills the box.
154
+ caretWidth: CARET_WIDTH,
155
+ }),
156
+ )
116
157
 
117
- let caretWidth = () => (focused() && !showPlaceholder() ? 1 : 0)
118
- let scrollX = () => {
119
- if (showPlaceholder()) return 0
120
- let tw = measureText(value(), { fontSize: theme.text.body.size }).width
121
- let vw = viewportWidth()
122
- if (vw <= 0) return 0
123
- return Math.max(0, tw + caretWidth() - vw)
124
- }
158
+ let textStyle = (color: string) => ({
159
+ fontSize: theme.text.body.size,
160
+ lineHeight: theme.text.body.lineHeight,
161
+ color,
162
+ maxLines: 1,
163
+ flexShrink: 0,
164
+ })
125
165
 
126
166
  return (
127
167
  <view
128
- ref={(n: { id: number }) => {
129
- node = n
130
- if (props.autoFocus) setFocus(n.id)
131
- }}
168
+ ref={(n: { id: number }) => (node = n)}
132
169
  flexDirection="row"
133
170
  alignItems="center"
134
171
  paddingLeft={theme.spacing.md}
@@ -161,18 +198,24 @@ export function TextInput(props: TextInputProps) {
161
198
  overflow="hidden"
162
199
  scrollX={scrollX()}
163
200
  >
164
- <text
165
- fontSize={theme.text.body.size}
166
- lineHeight={theme.text.body.lineHeight}
167
- color={displayColor()}
168
- maxLines={1}
169
- flexShrink={0}
170
- >
171
- {displayText()}
172
- </text>
173
- {focused() && caretOn() && !showPlaceholder() ? (
174
- <rect color={theme.color.text} w={1} h={theme.text.body.size} flexShrink={0} />
175
- ) : null}
201
+ {showPlaceholder() ? (
202
+ <text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</text>
203
+ ) : (
204
+ <view flexDirection="row" alignItems="center" flexShrink={0}>
205
+ <text {...textStyle(textColor())}>{beforeCaret()}</text>
206
+ {showCaret() ? (
207
+ <view>
208
+ <d-rect
209
+ color={textColor()}
210
+ y={-theme.text.body.size / 2}
211
+ w={CARET_WIDTH}
212
+ h={theme.text.body.size}
213
+ />
214
+ </view>
215
+ ) : null}
216
+ <text {...textStyle(textColor())}>{afterCaret()}</text>
217
+ </view>
218
+ )}
176
219
  </view>
177
220
  </view>
178
221
  )
package/src/theme.ts CHANGED
@@ -10,6 +10,9 @@ export type Theme = {
10
10
  textMuted: string
11
11
  surface: string
12
12
  border: string
13
+ primary: string
14
+ onPrimary: string
15
+ scrim: string
13
16
  }
14
17
  spacing: { sm: number; md: number }
15
18
  radius: { sm: number }
@@ -25,6 +28,9 @@ export let theme: Theme = {
25
28
  textMuted: "rgba(0,0,0,0.4)",
26
29
  surface: "#ccc",
27
30
  border: "rgba(0,0,0,0.2)",
31
+ primary: "#1f6feb",
32
+ onPrimary: "#ffffff",
33
+ scrim: "rgba(0,0,0,0.6)",
28
34
  },
29
35
  spacing: { sm: 4, md: 8 },
30
36
  radius: { sm: 4 },
package/src/window.tsx CHANGED
@@ -1,7 +1,7 @@
1
- import type { LayoutProps } from "@solidrt/core"
1
+ import type { LayoutProps, PointerProps } from "@solidrt/core"
2
2
  import type { StyleProps } from "./types"
3
3
 
4
- export interface WindowProps {
4
+ export interface WindowProps extends PointerProps {
5
5
  children?: any
6
6
  title?: string
7
7
  fullscreen?: boolean
@@ -21,6 +21,18 @@ export function Window(props: WindowProps) {
21
21
  fullscreen={props.fullscreen}
22
22
  vsync={props.vsync}
23
23
  fps={props.fps}
24
+ onPointerEnter={props.onPointerEnter}
25
+ onPointerLeave={props.onPointerLeave}
26
+ onPointerDown={props.onPointerDown}
27
+ onPointerUp={props.onPointerUp}
28
+ onPointerMove={props.onPointerMove}
29
+ onWheel={props.onWheel}
30
+ onFocus={props.onFocus}
31
+ onBlur={props.onBlur}
32
+ onKeyDown={props.onKeyDown}
33
+ onKeyUp={props.onKeyUp}
34
+ onTextInput={props.onTextInput}
35
+ pointerEvents={props.pointerEvents}
24
36
  >
25
37
  {props.style?.backgroundColor != null ? (
26
38
  <d-rect color={props.style.backgroundColor} />