@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 +8 -0
- package/README.md +76 -0
- package/package.json +3 -3
- package/src/button.tsx +62 -0
- package/src/image.tsx +9 -4
- package/src/index.ts +5 -0
- package/src/modal.tsx +58 -0
- package/src/portal.tsx +21 -0
- package/src/pressable.tsx +92 -0
- package/src/safe-area.tsx +3 -13
- package/src/scroll-view.tsx +106 -0
- package/src/text-input.tsx +101 -58
- package/src/theme.ts +6 -0
- package/src/window.tsx +14 -2
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.
|
|
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.
|
|
17
|
-
"@solidrt/core": "0.0.
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
26
|
-
return Math.max(
|
|
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
|
+
}
|
package/src/text-input.tsx
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// the
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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} />
|