@solidrt/components 0.0.13 → 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.13",
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.13"
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/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
+ }
@@ -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
+ }
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 },