@solidrt/components 0.0.27 → 0.0.29

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/README.md CHANGED
@@ -151,7 +151,7 @@ import { Text } from "@solidrt/components"
151
151
  </Text>
152
152
  ```
153
153
 
154
- `layout` accepts all `LayoutProps` plus the font fields `fontFamily`, `fontSize`, `lineHeight`, `fontStyle`, `fontWeight`, `textAlign`, and `maxLines`.
154
+ `layout` accepts all `LayoutProps` plus the font fields `fontFamily`, `fontSize`, `lineHeight`, `fontStyle`, `fontWeight`, `textAlign`, and `maxLines`. Note that `lineHeight` is a multiplier of `fontSize` (the theme uses 1.3-1.6), not a pixel value.
155
155
 
156
156
  **Props**
157
157
 
@@ -166,13 +166,24 @@ Accepts all pointer event props, plus:
166
166
 
167
167
  ### Image
168
168
 
169
- Loads and displays an image from a URL or raw bytes.
169
+ Loads and displays an image from a URL or raw bytes. URL loads are shared
170
+ runtime-wide: mounts of the same URL reuse one fetch and one texture, and the
171
+ bytes are cached on disk (fetched with `cache: "force-cache"` - no freshness
172
+ check, so use versioned URLs for content that changes). The runtime keeps
173
+ concurrent asset fetches polite with a per-host limit; a failed load rejects
174
+ the mounts sharing it and a later remount retries.
170
175
 
171
176
  ```jsx
172
177
  import { Image } from "@solidrt/components"
173
178
 
174
179
  function Avatar() {
175
- return <Image src="https://example.com/avatar.png" layout={{ width: 64, height: 64 }} />
180
+ return (
181
+ <Image
182
+ src="https://example.com/avatar.png"
183
+ fallback={PLACEHOLDER_PNG}
184
+ layout={{ width: 64, height: 64 }}
185
+ />
186
+ )
176
187
  }
177
188
  ```
178
189
 
@@ -180,9 +191,15 @@ function Avatar() {
180
191
 
181
192
  Accepts `layout`, `style`, and all pointer event props, plus:
182
193
 
183
- | Prop | Type | Description |
184
- | ----- | ---------------------- | ------------------------------------------ |
185
- | `src` | `string \| Uint8Array` | URL to fetch, or raw image bytes to decode |
194
+ | Prop | Type | Description |
195
+ | ---------- | ------------------------ | ------------------------------------------------------------------------------------ |
196
+ | `src` | `string \| Uint8Array` | URL to fetch, or raw image bytes to decode |
197
+ | `fallback` | `string \| Uint8Array` | Source shown when `src` fails; if it also fails, the `backgroundColor` placeholder stays |
198
+ | `onLoad` | `() => void` | Called each time a source finishes loading |
199
+ | `onError` | `(err: unknown) => void` | Called when `src` fails to load or decode |
200
+
201
+ A failing `src` is contained by the component (the fallback or placeholder
202
+ shows); it does not propagate to an outer `<Errored>` boundary.
186
203
 
187
204
  ### TextInput
188
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -18,6 +18,6 @@
18
18
  },
19
19
  "peerDependencies": {
20
20
  "@solidjs/signals": "2.0.0-beta.17",
21
- "@solidrt/core": "0.0.27"
21
+ "@solidrt/core": "0.0.29"
22
22
  }
23
23
  }
package/src/badge.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Show } from "@solidrt/core"
1
+ import { Show, children } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { typeStyle, lightOnDark } from "./typography"
@@ -38,7 +38,10 @@ export function Badge(props: BadgeProps) {
38
38
  let bg = () => props.style?.backgroundColor ?? colors().bg
39
39
  let fg = () => props.style?.color ?? colors().fg
40
40
  let radius = () => props.style?.borderRadius ?? RADIUS
41
- let isText = () => typeof props.children === "string" || typeof props.children === "number"
41
+ // Resolved once via children(): the typeof probe and the mount sites must
42
+ // share one build - reading the raw getter again would orphan native nodes.
43
+ let resolved = children(() => props.children)
44
+ let isText = () => typeof resolved() === "string" || typeof resolved() === "number"
42
45
  let labelOnDark = () => lightOnDark(fg(), bg())
43
46
 
44
47
  return (
@@ -58,9 +61,9 @@ export function Badge(props: BadgeProps) {
58
61
  opacity={props.style?.opacity}
59
62
  >
60
63
  <d-rect color={bg()} radius={radius()} />
61
- <Show when={isText()} fallback={props.children}>
64
+ <Show when={isText()} fallback={resolved()}>
62
65
  <text color={fg()} {...typeStyle("label", labelOnDark())}>
63
- {props.children}
66
+ {resolved()}
64
67
  </text>
65
68
  </Show>
66
69
  </view>
package/src/button.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Show } from "@solidrt/core"
1
+ import { Show, children } from "@solidrt/core"
2
2
  import { Pressable, type PressState } from "./pressable"
3
3
  import { theme } from "./theme"
4
4
  import { policy } from "./policy"
@@ -55,7 +55,11 @@ export function Button(props: ButtonProps) {
55
55
  : colors().fill)
56
56
  let radius = () => props.style?.borderRadius ?? theme.radius.sm
57
57
  let label = () => (props.disabled ? theme.color.textMuted : colors().label)
58
- let isText = () => typeof props.children === "string" || typeof props.children === "number"
58
+ // Resolved once via children(): reading the raw children getter builds a new
59
+ // subtree per read, so the typeof probe and the two mount sites below must
60
+ // share this single memoized build (an unmounted build leaks native nodes).
61
+ let resolved = children(() => props.children)
62
+ let isText = () => typeof resolved() === "string" || typeof resolved() === "number"
59
63
  // The label's polarity against the idle fill: onPrimary on a saturated fill
60
64
  // is light-on-dark even in a light theme, so it needs the low-DPI weight
61
65
  // compensation there too.
@@ -92,9 +96,9 @@ export function Button(props: ButtonProps) {
92
96
  scale: (props.style?.scale ?? 1) * (s.pressed && policy.motion !== "none" ? 0.97 : 1),
93
97
  })}
94
98
  >
95
- <Show when={isText()} fallback={props.children}>
99
+ <Show when={isText()} fallback={resolved()}>
96
100
  <text color={label()} {...typeStyle("body", labelOnDark())}>
97
- {props.children}
101
+ {resolved()}
98
102
  </text>
99
103
  </Show>
100
104
  </Pressable>
package/src/image.tsx CHANGED
@@ -1,14 +1,36 @@
1
- import { createImage, Loading } from "@solidrt/core"
2
- import type { LayoutProps, PointerProps } from "@solidrt/core"
1
+ import { createImage, createEffect, Loading, Errored } from "@solidrt/core"
2
+ import type { ImageSource, LayoutProps, PointerProps } from "@solidrt/core"
3
3
  import type { StyleProps } from "./types"
4
4
 
5
5
  export interface ImageProps extends PointerProps {
6
6
  src: string | Uint8Array
7
+ /** Image source to show when `src` fails to load. If this also fails, only
8
+ * the `backgroundColor` placeholder remains. */
9
+ fallback?: ImageSource
10
+ /** Called each time a source finishes loading (including reloads on a
11
+ * reactive `src`). */
12
+ onLoad?: () => void
13
+ /** Called when `src` fails to load or decode. The fallback (if any) is shown
14
+ * regardless; without one the error stays contained here instead of
15
+ * propagating to an outer boundary. */
16
+ onError?: (err: unknown) => void
7
17
  layout?: LayoutProps
8
18
  style?: StyleProps
9
19
  }
10
20
 
11
- //TODO onLoad, onError
21
+ // Renders the fallback source when the main one failed. Its own <Errored>
22
+ // keeps a broken fallback from escaping: the placeholder stays instead.
23
+ // Both fallbacks here take the error argument: an arity >= 1 fallback tells
24
+ // <Errored> the error is handled, so dev builds do not console.error it.
25
+ function FallbackTexture(props: { src: ImageSource; width?: number; height?: number }) {
26
+ let tex = createImage(() => props.src)
27
+ return (
28
+ <Errored fallback={(_err: unknown) => null}>
29
+ <texture src={tex()} width={props.width} height={props.height} />
30
+ </Errored>
31
+ )
32
+ }
33
+
12
34
  export function Image(props: ImageProps) {
13
35
  // createImage fetches/decodes/uploads, swaps the texture when src changes, and
14
36
  // frees it on cleanup, returning the texture id. Pass an accessor so a reactive
@@ -17,6 +39,14 @@ export function Image(props: ImageProps) {
17
39
  let src = createImage(() => props.src)
18
40
  let hasBorder = () => (props.style?.borderWidth ?? 0) > 0
19
41
 
42
+ // Load/error callbacks ride a separate effect read of the same async value;
43
+ // the error handler also keeps the failure out of the console when the
44
+ // render side already contains it with a fallback.
45
+ createEffect(() => src(), {
46
+ effect: () => props.onLoad?.(),
47
+ error: (err: unknown) => props.onError?.(err),
48
+ })
49
+
20
50
  // A texture sizes from its own width/height (not the box around it), so the
21
51
  // numeric layout dimensions are forwarded to it. Omitting height lets the
22
52
  // texture follow the image's intrinsic aspect ratio.
@@ -50,7 +80,13 @@ export function Image(props: ImageProps) {
50
80
  <d-rect color={props.style?.backgroundColor} radius={props.style?.borderRadius} />
51
81
  ) : null}
52
82
  <Loading fallback={null}>
53
- <texture src={src()} width={texW()} height={texH()} />
83
+ <Errored
84
+ fallback={(_err: unknown) =>
85
+ props.fallback != null ? <FallbackTexture src={props.fallback} width={texW()} height={texH()} /> : null
86
+ }
87
+ >
88
+ <texture src={src()} width={texW()} height={texH()} />
89
+ </Errored>
54
90
  </Loading>
55
91
  {hasBorder() ? (
56
92
  <d-rect
package/src/modal.tsx CHANGED
@@ -20,7 +20,9 @@ export interface ModalProps {
20
20
  * escapes the layout and stacking of its surrounding tree. It fills the window
21
21
  * with a dimming backdrop and centers `children` on top. Control visibility by
22
22
  * mounting/unmounting it, e.g. `<Show when={open()}><Modal .../></Show>`: the
23
- * portal's onCleanup removes it when the surrounding scope disposes.
23
+ * portal's onCleanup removes it when the surrounding scope disposes. The
24
+ * gating signal must start false: portals cannot mount during the app's
25
+ * initial render (see createPortal), so a modal visible at startup throws.
24
26
  *
25
27
  * Pressing the backdrop calls `onClose`; pressing the content does not. This
26
28
  * works because pointer events dispatch to the whole hit path with no
package/src/pressable.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
1
+ import { createSignal, children } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerEvent, PointerProps } from "@solidrt/core"
3
3
  import type { StyleProps } from "./types"
4
4
 
@@ -26,7 +26,17 @@ export function Pressable(props: PressableProps) {
26
26
 
27
27
  let state = (): PressState => ({ pressed: pressed(), hovered: hovered() })
28
28
  let style = () => (typeof props.style === "function" ? props.style(state()) : props.style)
29
- let kids = () => (typeof props.children === "function" ? props.children(state()) : props.children)
29
+ // Element-valued props build a fresh native subtree on every read, and a
30
+ // subtree that is never inserted is never destroyed - probing the raw getter
31
+ // with typeof would orphan one full copy per evaluation. children() memoizes
32
+ // the resolve so probe and mount share one build; a render-prop child
33
+ // ((state) => ...) passes through it intact because flatten only unwraps
34
+ // zero-arg functions.
35
+ let resolved = children(() => props.children)
36
+ let kids = () => {
37
+ let c = resolved()
38
+ return typeof c === "function" ? c(state()) : c
39
+ }
30
40
 
31
41
  let handleDown = (e: PointerEvent) => {
32
42
  if (e.button != null && e.button !== 0) return
@@ -1,5 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
2
- import { onFrame, onLayout, getBoundingBox, Show } from "@solidrt/core"
1
+ import { createSignal, onFrame, onLayout, getBoundingBox, Show } from "@solidrt/core"
3
2
  import type { LayoutProps } from "@solidrt/core"
4
3
  import { theme } from "./theme"
5
4
  import { policy } from "./policy"
package/src/qrcode.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createMemo } from "@solidjs/signals"
1
+ import { createMemo } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import qrcode from "qrcode-generator"
4
4
 
package/src/radio.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal, createContext, useContext, Show } from "@solidrt/core"
1
+ import { createSignal, createContext, useContext, Show, children } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { Pressable } from "./pressable"
4
4
  import { theme } from "./theme"
@@ -72,7 +72,10 @@ export function Radio(props: RadioProps) {
72
72
  let selected = () => ctx.value() === props.value
73
73
  let disabled = () => props.disabled || ctx.disabled()
74
74
  let ringColor = () => (selected() ? theme.color.primary : theme.color.border)
75
- let isText = () => typeof props.children === "string" || typeof props.children === "number"
75
+ // Resolved once via children(): the typeof probe and the mount sites must
76
+ // share one build - reading the raw getter again would orphan native nodes.
77
+ let resolved = children(() => props.children)
78
+ let isText = () => typeof resolved() === "string" || typeof resolved() === "number"
76
79
 
77
80
  let ring = () => Math.round(RING * densityScale())
78
81
  // Inner dot inset as a fraction of the ring, so it scales with the density.
@@ -91,9 +94,9 @@ export function Radio(props: RadioProps) {
91
94
  <d-oval x={inset()} y={inset()} w={ring() - inset() * 2} h={ring() - inset() * 2} color={theme.color.primary} />
92
95
  </Show>
93
96
  </view>
94
- <Show when={isText()} fallback={props.children}>
97
+ <Show when={isText()} fallback={resolved()}>
95
98
  <text color={theme.color.text} {...typeStyle("body")}>
96
- {props.children}
99
+ {resolved()}
97
100
  </text>
98
101
  </Show>
99
102
  </Pressable>
package/src/slider.tsx CHANGED
@@ -1,5 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
2
- import { getBoundingBox, onLayout, setPointerCapture } from "@solidrt/core"
1
+ import { createSignal, getBoundingBox, onLayout, setPointerCapture } from "@solidrt/core"
3
2
  import type { LayoutProps, PointerEvent } from "@solidrt/core"
4
3
  import { theme } from "./theme"
5
4
  import { densityScale } from "./policy"
package/src/spinner.tsx CHANGED
@@ -1,5 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
2
- import { onFrame, Show } from "@solidrt/core"
1
+ import { createSignal, onFrame, Show } from "@solidrt/core"
3
2
  import type { LayoutProps } from "@solidrt/core"
4
3
  import { theme } from "./theme"
5
4
  import { policy } from "./policy"
package/src/switch.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal } from "@solidjs/signals"
1
+ import { createSignal } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { Pressable } from "./pressable"
4
4
  import { theme } from "./theme"
@@ -1,7 +1,6 @@
1
- import { createEffect, createSignal, onCleanup } from "@solidjs/signals"
2
- import { measureText, setFocus } from "@solidrt/core"
1
+ import { createEffect, createSignal, onCleanup, measureText, setFocus } from "@solidrt/core"
3
2
  import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
4
- import type { LayoutProps } from "@solidrt/core"
3
+ import type { Color, Gradient, LayoutProps } from "@solidrt/core"
5
4
  import type { StyleProps } from "./types"
6
5
  import { theme } from "./theme"
7
6
  import { policy } from "./policy"
@@ -166,7 +165,7 @@ export function TextInput(props: TextInputProps) {
166
165
  }),
167
166
  )
168
167
 
169
- let textStyle = (color: string) => ({
168
+ let textStyle = (color: Color | Gradient) => ({
170
169
  w: TEXT_SHAPE_WIDTH,
171
170
  fontSize: fontSize(),
172
171
  lineHeight: theme.text.body.lineHeight,
package/src/text.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createMemo } from "@solidjs/signals"
1
+ import { createMemo } from "@solidrt/core"
2
2
  import type { PointerProps } from "@solidrt/core"
3
3
  import type { StyleProps, TextLayoutProps } from "./types"
4
4
  import { theme, type TextVariant } from "./theme"
package/src/tooltip.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, env } from "@solidrt/core"
1
+ import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, env, children } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { policy } from "./policy"
@@ -66,7 +66,10 @@ export function Tooltip(props: TooltipProps) {
66
66
  let cur = pos()
67
67
  if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
68
68
  })
69
- let isText = () => typeof props.content === "string" || typeof props.content === "number"
69
+ // Resolved once via children(): the typeof probe and the mount sites must
70
+ // share one build - reading the raw getter again would orphan native nodes.
71
+ let content = children(() => props.content)
72
+ let isText = () => typeof content() === "string" || typeof content() === "number"
70
73
  return createPortal(
71
74
  <view
72
75
  ref={(n: { id: number }) => (bubble = n)}
@@ -83,9 +86,9 @@ export function Tooltip(props: TooltipProps) {
83
86
  pointerEvents="none"
84
87
  >
85
88
  <d-rect color={theme.color.surfaceAlt} radius={theme.radius.sm} />
86
- <Show when={isText()} fallback={props.content}>
89
+ <Show when={isText()} fallback={content()}>
87
90
  <text color={theme.color.text} {...typeStyle("body")}>
88
- {props.content}
91
+ {content()}
89
92
  </text>
90
93
  </Show>
91
94
  <d-rect