@solidrt/components 0.0.19 → 0.0.20

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
@@ -70,6 +70,13 @@ Most components group props into two objects, plus top-level event handlers:
70
70
  `moduleSize`/`margin`/`radius`/`level` (L/M/Q/H). Paints black-on-white by
71
71
  default (NOT the theme) to stay scannable; override `color`/`background` only
72
72
  if contrast holds. Deps on `qrcode-generator`.
73
+ - `Icon` - thin themed wrapper over the core `<svg>` document primitive. `src`
74
+ is an SVG string (an imported `.svg` asset, a `lucide-static` string export, or
75
+ an inline literal); `size` sets a square box (default 24); `color` drives
76
+ `currentColor` (default `theme.color.text`). Carries no icon set of its own and
77
+ no icon-name registry: pass the SVG string in, so any currentColor set (Lucide,
78
+ Feather, Heroicons) works and only used icons are bundled. Multi-color
79
+ documents keep their own fills. Reach for `<svg>` directly for a non-square box.
73
80
  - `SafeArea` - pads children clear of system UI (notches, status bars); top and
74
81
  bottom on by default, pass `false`/a number per edge.
75
82
  - `theme` / `setTheme` / `darkTheme` / `lightTheme` - shared REACTIVE appearance
package/README.md CHANGED
@@ -536,6 +536,29 @@ import { QrCode } from "@solidrt/components"
536
536
  | `radius` | `number` | `8` | Corner radius of the background panel. |
537
537
  | `layout` | `LayoutProps` | - | Layout of the outer box. |
538
538
 
539
+ ### Icon
540
+
541
+ A thin themed wrapper over the core `<svg>` primitive. `src` is a whole SVG document as a string; the component draws it in a square box and, for monochrome icons that stroke/fill with `currentColor`, recolors it from the theme. It carries no icon set and no name registry, so any `currentColor` SVG works (Lucide, Feather, Heroicons) and only the icons you import are bundled. Multi-color documents keep their own fills. For a non-square box, use `<svg>` directly.
542
+
543
+ Icons are just SVG strings. Import them as assets (`import House from "lucide-static/icons/house.svg"`, resolved to a string), pull them from a string export, or inline a literal:
544
+
545
+ ```jsx
546
+ import { Icon } from "@solidrt/components"
547
+ import House from "lucide-static/icons/house.svg"
548
+
549
+ <Icon src={House} />
550
+ <Icon src={House} size={32} color={theme.color.primary} />
551
+ ```
552
+
553
+ **Props**
554
+
555
+ | Prop | Type | Default | Description |
556
+ | -------- | ------------- | ------------------ | -------------------------------------------------------------- |
557
+ | `src` | `string` | - | The SVG document to draw. |
558
+ | `size` | `number` | `24` | Square box side in pixels. |
559
+ | `color` | `string` | `theme.color.text` | Drives `currentColor`; explicit fills/strokes still win. |
560
+ | `layout` | `LayoutProps` | - | Layout of the box. |
561
+
539
562
  ## License
540
563
 
541
564
  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.19",
3
+ "version": "0.0.20",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -17,6 +17,6 @@
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@solidjs/signals": "2.0.0-beta.15",
20
- "@solidrt/core": "0.0.19"
20
+ "@solidrt/core": "0.0.20"
21
21
  }
22
22
  }
package/src/icon.tsx ADDED
@@ -0,0 +1,35 @@
1
+ import type { LayoutProps } from "@solidrt/core"
2
+ import { theme } from "./theme"
3
+
4
+ export interface IconProps {
5
+ // An SVG document as a string: an imported `.svg` asset, a `lucide-static`
6
+ // string export, or an inline template literal. Monochrome icons that stroke/
7
+ // fill with `currentColor` (Lucide, Feather, Heroicons, ...) get recolored by
8
+ // `color`; a multi-color document keeps its own fills.
9
+ src: string
10
+ // Rendered box in pixels, square. Defaults to 24 (the common icon grid).
11
+ size?: number
12
+ // Drives `currentColor` in the document. Defaults to the theme text color.
13
+ color?: string
14
+ layout?: LayoutProps
15
+ }
16
+
17
+ const SIZE = 24
18
+
19
+ // A themed wrapper over the core <svg> document primitive: a square box sized to
20
+ // `size` and colored from the theme by default. This is the only value it adds
21
+ // over `<svg src>` directly, so reach for the primitive when you need a
22
+ // non-square box or want no theme coupling.
23
+ export function Icon(props: IconProps) {
24
+ let size = () => props.size ?? SIZE
25
+
26
+ return (
27
+ <svg
28
+ width={size()}
29
+ height={size()}
30
+ src={props.src}
31
+ color={props.color ?? theme.color.text}
32
+ {...props.layout}
33
+ />
34
+ )
35
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export { ContextMenu, type ContextMenuProps, type ContextMenuItem } from "./cont
24
24
  export { NavShell, type NavShellProps, type NavItem } from "./nav-shell"
25
25
  export { SplitView, type SplitViewProps } from "./split-view"
26
26
  export { QrCode, type QrCodeProps } from "./qrcode"
27
+ export { Icon, type IconProps } from "./icon"
27
28
  export { theme, setTheme, darkTheme, lightTheme, type Theme } from "./theme"
28
29
  export {
29
30
  policy,
package/src/qrcode.tsx CHANGED
@@ -27,45 +27,51 @@ const MODULE_SIZE = 6
27
27
  const MARGIN = 16
28
28
  const RADIUS = 8
29
29
 
30
- // Render a QR for `data` as primitives: merge horizontal runs of same-color
31
- // modules per row into a single sized box, on a light quiet-zone panel. The
32
- // module grid recomputes only when the data or error-correction level changes.
30
+ // Render a QR for `data` as primitives: merge horizontal runs of dark modules
31
+ // per row into a single d-rect, placed at explicit coordinates on a light
32
+ // quiet-zone panel. Everything inside the panel is detached, so a data change
33
+ // repaints without touching layout; the panel view itself has a fixed size
34
+ // (module count * module size + margins) that only changes when the data
35
+ // crosses a QR version boundary.
33
36
  export function QrCode(props: QrCodeProps) {
34
- let rows = createMemo(() => {
37
+ let grid = createMemo(() => {
35
38
  let qr = qrcode(0, props.level ?? "M")
36
39
  qr.addData(props.data)
37
40
  qr.make()
38
41
  let n = qr.getModuleCount()
39
42
 
40
- let out: { dark: boolean; len: number }[][] = []
43
+ let runs: { x: number; y: number; len: number }[] = []
41
44
  for (let y = 0; y < n; y++) {
42
- let runs: { dark: boolean; len: number }[] = []
43
45
  let x = 0
44
46
  while (x < n) {
45
- let dark = qr.isDark(y, x)
47
+ if (!qr.isDark(y, x)) {
48
+ x++
49
+ continue
50
+ }
46
51
  let len = 1
47
- while (x + len < n && qr.isDark(y, x + len) === dark) len++
48
- runs.push({ dark, len })
52
+ while (x + len < n && qr.isDark(y, x + len)) len++
53
+ runs.push({ x, y, len })
49
54
  x += len
50
55
  }
51
- out.push(runs)
52
56
  }
53
- return out
57
+ return { n, runs }
54
58
  })
55
59
 
56
60
  let size = () => props.moduleSize ?? MODULE_SIZE
61
+ let margin = () => props.margin ?? MARGIN
62
+ let side = () => grid().n * size() + 2 * margin()
57
63
 
58
64
  return (
59
- <view flexDirection="column" padding={props.margin ?? MARGIN} {...props.layout}>
65
+ <view width={side()} height={side()} {...props.layout}>
60
66
  <d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? RADIUS} />
61
- {rows().map((runs) => (
62
- <view flexDirection="row">
63
- {runs.map((run) => (
64
- <view width={run.len * size()} height={size()}>
65
- {run.dark ? <d-rect color={props.color ?? "#000000"} /> : null}
66
- </view>
67
- ))}
68
- </view>
67
+ {grid().runs.map((run) => (
68
+ <d-rect
69
+ x={margin() + run.x * size()}
70
+ y={margin() + run.y * size()}
71
+ w={run.len * size()}
72
+ h={size()}
73
+ color={props.color ?? "#000000"}
74
+ />
69
75
  ))}
70
76
  </view>
71
77
  )
@@ -1,5 +1,5 @@
1
1
  import { createEffect, createSignal, onCleanup } from "@solidjs/signals"
2
- import { setFocus } from "@solidrt/core"
2
+ import { measureText, setFocus } from "@solidrt/core"
3
3
  import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
4
4
  import type { LayoutProps } from "@solidrt/core"
5
5
  import type { StyleProps } from "./types"
@@ -10,6 +10,11 @@ import { policy, densityScale } from "./policy"
10
10
  // edge column cannot drift apart.
11
11
  const CARET_WIDTH = 1
12
12
 
13
+ // Shaping width handed to the detached value/placeholder text: effectively
14
+ // unbounded, so a single line never wraps. The viewport clips it and scrollX
15
+ // slides it.
16
+ const TEXT_SHAPE_WIDTH = 1e9
17
+
13
18
  export interface TextInputProps {
14
19
  value?: string
15
20
  defaultValue?: string
@@ -134,20 +139,16 @@ export function TextInput(props: TextInputProps) {
134
139
  let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
135
140
  let showCaret = () => focused() && caretOn() && !showPlaceholder()
136
141
 
137
- // The text is split at the caret into two nodes with a zero-size anchor view
138
- // between them. Flow places the anchor at the caret x (the before-text width),
139
- // and the caret is a detached d-rect inside it: detached nodes take no layout
140
- // slot, so the anchor stays zero-width and the after-text is not shifted. The
141
- // anchor stays mounted while the caret blinks; only a detached d-view toggles
142
- // inside it, so turning the caret on and off never relays the row. The
143
- // anchor sits at the row's vertical center (alignItems center, zero height),
144
- // so the caret is offset up by half its height to straddle it. While the
145
- // placeholder shows, value() is "" so the slices and the scroll offset are 0
146
- // with no special case. The viewport node is the inner scroll container;
147
- // createCaretScroll reads its laid-out width after layout, keeps the caret in
148
- // view, and flushes the offset before paint.
149
- let beforeCaret = () => value().slice(0, buffer.caret())
150
- let afterCaret = () => value().slice(buffer.caret())
142
+ // Everything inside the viewport is detached: the value is one d-text shaped
143
+ // at an unbounded width and the caret a d-rect at the measured before-caret
144
+ // width, so typing, caret movement, blink and scroll never touch layout. The
145
+ // viewport carries an explicit height (detached content takes no layout
146
+ // slot) equal to the one-line paragraph height, which keeps the text where
147
+ // the old centered attached row sat. createCaretScroll keeps the caret in
148
+ // view and flushes the offset before paint; scrollX is a paint-time
149
+ // translate that also applies to detached children.
150
+ let rowHeight = () => Math.round(theme.text.body.size * theme.text.body.lineHeight)
151
+ let caretX = () => measureText(value().slice(0, buffer.caret()), { fontSize: theme.text.body.size }).width
151
152
  let scrollX = createCaretScroll(
152
153
  () => viewport,
153
154
  () => ({
@@ -162,11 +163,11 @@ export function TextInput(props: TextInputProps) {
162
163
  )
163
164
 
164
165
  let textStyle = (color: string) => ({
166
+ w: TEXT_SHAPE_WIDTH,
165
167
  fontSize: theme.text.body.size,
166
168
  lineHeight: theme.text.body.lineHeight,
167
169
  color,
168
170
  maxLines: 1,
169
- flexShrink: 0,
170
171
  })
171
172
 
172
173
  return (
@@ -199,30 +200,25 @@ export function TextInput(props: TextInputProps) {
199
200
  <view
200
201
  ref={(n: { id: number }) => (viewport = n)}
201
202
  flex={1}
202
- flexDirection="row"
203
- alignItems="center"
203
+ height={rowHeight()}
204
204
  overflow="hidden"
205
205
  scrollX={scrollX()}
206
206
  >
207
207
  {showPlaceholder() ? (
208
- <text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</text>
208
+ <d-text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</d-text>
209
209
  ) : (
210
- <view flexDirection="row" alignItems="center" flexShrink={0}>
211
- <text {...textStyle(textColor())}>{beforeCaret()}</text>
212
- <view>
213
- {showCaret() ? (
214
- <d-view>
215
- <d-rect
216
- color={textColor()}
217
- y={-theme.text.body.size / 2}
218
- w={CARET_WIDTH}
219
- h={theme.text.body.size}
220
- />
221
- </d-view>
222
- ) : null}
223
- </view>
224
- <text {...textStyle(textColor())}>{afterCaret()}</text>
225
- </view>
210
+ <>
211
+ <d-text {...textStyle(textColor())}>{value()}</d-text>
212
+ {showCaret() ? (
213
+ <d-rect
214
+ color={textColor()}
215
+ x={caretX()}
216
+ y={(rowHeight() - theme.text.body.size) / 2}
217
+ w={CARET_WIDTH}
218
+ h={theme.text.body.size}
219
+ />
220
+ ) : null}
221
+ </>
226
222
  )}
227
223
  </view>
228
224
  </view>