@solidrt/components 0.0.50 → 0.0.52

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.
Files changed (85) hide show
  1. package/AGENTS.md +93 -85
  2. package/README.md +421 -311
  3. package/demos/README.md +24 -0
  4. package/demos/assets/icon.png +0 -0
  5. package/demos/assets/icon.svg +23 -0
  6. package/demos/package.json +9 -0
  7. package/demos/src/gallery.tsx +866 -0
  8. package/demos/tsconfig.json +15 -0
  9. package/docs/badge.md +10 -0
  10. package/docs/button.md +21 -0
  11. package/docs/card.md +11 -0
  12. package/docs/checkbox.md +9 -0
  13. package/docs/context-menu.md +17 -0
  14. package/docs/density.md +13 -0
  15. package/docs/divider.md +10 -0
  16. package/docs/field.md +11 -0
  17. package/docs/focus-nav.md +18 -0
  18. package/docs/icon.md +13 -0
  19. package/docs/image.md +21 -0
  20. package/docs/index.md +13 -0
  21. package/docs/item.md +25 -0
  22. package/docs/modal.md +15 -0
  23. package/docs/nav-shell.md +18 -0
  24. package/docs/policy.md +21 -0
  25. package/docs/portal.md +15 -0
  26. package/docs/pressable.md +17 -0
  27. package/docs/progress-bar.md +10 -0
  28. package/docs/qrcode.md +10 -0
  29. package/docs/radio.md +13 -0
  30. package/docs/rich-text-document.md +5 -0
  31. package/docs/rich-text-editor.md +24 -0
  32. package/docs/safe-area.md +12 -0
  33. package/docs/scroll-view.md +35 -0
  34. package/docs/segmented-control.md +13 -0
  35. package/docs/select.md +15 -0
  36. package/docs/slider.md +9 -0
  37. package/docs/spacing.md +7 -0
  38. package/docs/spinner.md +10 -0
  39. package/docs/split-view.md +14 -0
  40. package/docs/switch.md +13 -0
  41. package/docs/text-input.md +23 -0
  42. package/docs/text.md +15 -0
  43. package/docs/theme.md +68 -0
  44. package/docs/tooltip.md +11 -0
  45. package/docs/types.md +9 -0
  46. package/docs/typography.md +5 -0
  47. package/docs/view.md +14 -0
  48. package/docs/window.md +15 -0
  49. package/package.json +8 -4
  50. package/src/badge.tsx +30 -21
  51. package/src/button.tsx +50 -32
  52. package/src/card.tsx +22 -13
  53. package/src/checkbox.tsx +29 -11
  54. package/src/context-menu.tsx +14 -9
  55. package/src/density.tsx +39 -0
  56. package/src/divider.tsx +11 -4
  57. package/src/editor-field.tsx +368 -0
  58. package/src/field.tsx +47 -0
  59. package/src/icon.tsx +8 -4
  60. package/src/image.tsx +10 -3
  61. package/src/index.ts +19 -2
  62. package/src/item.tsx +121 -0
  63. package/src/nav-shell.tsx +7 -17
  64. package/src/policy.ts +9 -12
  65. package/src/press.ts +37 -8
  66. package/src/pressable.tsx +15 -3
  67. package/src/progress-bar.tsx +8 -5
  68. package/src/qrcode.tsx +7 -5
  69. package/src/radio.tsx +26 -16
  70. package/src/rich-text-document.ts +247 -0
  71. package/src/rich-text-editor.tsx +151 -0
  72. package/src/scroll-view.tsx +76 -7
  73. package/src/segmented-control.tsx +33 -19
  74. package/src/select.tsx +59 -30
  75. package/src/slider.tsx +32 -6
  76. package/src/spacing.ts +1 -1
  77. package/src/spinner.tsx +11 -6
  78. package/src/split-view.tsx +3 -4
  79. package/src/switch.tsx +10 -3
  80. package/src/text-input.tsx +54 -264
  81. package/src/text.tsx +22 -2
  82. package/src/theme.ts +220 -81
  83. package/src/tooltip.tsx +23 -9
  84. package/src/types.ts +119 -1
  85. package/src/view.tsx +11 -2
@@ -0,0 +1,39 @@
1
+ import { createContext, useContext } from "@solidrt/core"
2
+ import { policy, type DensityPolicy } from "./policy"
3
+
4
+ // Subtree density: a region (a toolbar, a table, a sidebar) tightens its
5
+ // controls wholesale instead of per-child props. The context holds an
6
+ // accessor so a reactive `value` stays live for consumers.
7
+ let DensityContext = createContext<() => DensityPolicy | undefined>(() => undefined)
8
+
9
+ export interface DensityProps {
10
+ value: DensityPolicy
11
+ children?: any
12
+ }
13
+
14
+ /**
15
+ * Overrides the density policy for its subtree: every density-scaled metric
16
+ * below - `space()`, control sizes, `Item` row paddings - resolves this value
17
+ * instead of the global `policy.density`. Nests; the nearest wins.
18
+ *
19
+ * <Density value="compact">
20
+ * <Item label="..." /> // compact rows, no per-item props
21
+ * </Density>
22
+ */
23
+ export function Density(props: DensityProps) {
24
+ return <DensityContext value={() => props.value}>{props.children}</DensityContext>
25
+ }
26
+
27
+ // How density maps to component metrics: a multiplier on control sizes,
28
+ // paddings, and hit targets. Comfortable is the components' designed size.
29
+ const DENSITY_SCALE: Record<DensityPolicy, number> = { comfortable: 1, compact: 0.85, dense: 0.7 }
30
+
31
+ /**
32
+ * Reactive density multiplier for control metrics: the nearest <Density>
33
+ * above the calling scope, falling back to the global `policy.density`.
34
+ * Call it during component setup or inside JSX/thunks (an owner is needed to
35
+ * see a <Density> region; without one it reads the global policy).
36
+ */
37
+ export function densityScale(): number {
38
+ return DENSITY_SCALE[useContext(DensityContext)() ?? policy.density]
39
+ }
package/src/divider.tsx CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { LayoutProps } from "@solidrt/core"
2
2
  import { theme } from "./theme"
3
- import type { StyleProps } from "./types"
3
+ import type { StyleProps, TransitionProps } from "./types"
4
+ import { splitTransition, transitionEndFor } from "./types"
4
5
 
5
- export interface DividerProps {
6
+ export interface DividerProps extends TransitionProps {
6
7
  // Line direction. Horizontal (default) is a full-width rule; vertical is a
7
8
  // full-height rule for use inside a row.
8
9
  orientation?: "horizontal" | "vertical"
@@ -18,16 +19,22 @@ export interface DividerProps {
18
19
  export function Divider(props: DividerProps) {
19
20
  let vertical = () => props.orientation === "vertical"
20
21
  let thickness = () => props.thickness ?? 1
21
- let color = () => props.style?.backgroundColor ?? theme.color.border
22
+ // Theme-level per-component overrides merged under the instance style.
23
+ let styled = () => ({ ...theme.components.divider, ...props.style })
24
+ let color = () => styled().backgroundColor ?? theme.color.border
25
+
26
+ let split = () => splitTransition(props.transition)
22
27
 
23
28
  return (
24
29
  <view
30
+ transition={split().root}
31
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
25
32
  width={vertical() ? thickness() : "auto"}
26
33
  height={vertical() ? "auto" : thickness()}
27
34
  alignSelf="stretch"
28
35
  {...props.layout}
29
36
  >
30
- <d-rect color={color()} />
37
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={color()} />
31
38
  </view>
32
39
  )
33
40
  }
@@ -0,0 +1,368 @@
1
+ // The editable field shared by TextInput and the rich text editor: a
2
+ // focusable box that edits a text buffer through the keyboard and text
3
+ // session, lays its lines out with core's createTextEditorLayout, keeps the
4
+ // caret in view and draws the caret; how a line's text is drawn and what the
5
+ // buffer holds are the caller's (renderLine, buffer). Internal to the
6
+ // package.
7
+ import {
8
+ For,
9
+ createEffect,
10
+ createMemo,
11
+ createSignal,
12
+ onCleanup,
13
+ focusedNode,
14
+ setFocus,
15
+ startTextInput,
16
+ textInputActive,
17
+ untrack,
18
+ } from "@solidrt/core"
19
+ import { createTextEditorLayout } from "@solidrt/core/text-input"
20
+ import type { EditorLine, TextBuffer } from "@solidrt/core/text-input"
21
+ import type { Color, Gradient, KeyEvent, LayoutProps, PointerEvent, TextInputHints } from "@solidrt/core"
22
+ import type { Element } from "solid-js"
23
+ import type { MeasureTextOptions, TextRunRange } from "flux:rendertree"
24
+ import { registerNavAction } from "./focus-nav"
25
+ import type { StyleProps, TransitionProps } from "./types"
26
+ import { splitTransition, transitionEndFor } from "./types"
27
+ import { theme } from "./theme"
28
+ import { policy } from "./policy"
29
+ import { space } from "./spacing"
30
+
31
+ // Caret thickness. Shared so the drawn caret and the scroll offset's reserved
32
+ // edge column cannot drift apart.
33
+ const CARET_WIDTH = 1
34
+
35
+ // Shaping width of the placeholder: effectively unbounded, so it never wraps;
36
+ // the viewport clips it.
37
+ const PLACEHOLDER_SHAPE_WIDTH = 1e9
38
+
39
+ /** What the shell hands renderLine for one laid-out line. */
40
+ export type LineRender = {
41
+ line: () => EditorLine
42
+ /** The field's font options (size, line height), the base for the line's text. */
43
+ font: () => MeasureTextOptions
44
+ /** The field's text color. */
45
+ color: () => Color | Gradient
46
+ }
47
+
48
+ export interface EditorFieldProps extends TransitionProps {
49
+ /**
50
+ * Creates the buffer the field edits, given the grapheme `step` from the
51
+ * field's geometry (createTextEditorLayout.step); called once.
52
+ */
53
+ buffer: (step: (text: string, offset: number, direction: "left" | "right") => number) => TextBuffer
54
+ /** Styled ranges over the text for the geometry (prepareText `runs`). */
55
+ runs?: () => TextRunRange[] | undefined
56
+ /** Draws one line: detached content at the line's y inside the viewport. */
57
+ renderLine: (r: LineRender) => Element
58
+
59
+ onSubmit?: (value: string) => void
60
+ onFocus?: () => void
61
+ onBlur?: () => void
62
+ placeholder?: string
63
+ disabled?: boolean
64
+ autoFocus?: boolean
65
+ multiline?: boolean
66
+ maxRows?: number
67
+ hints?: TextInputHints
68
+ ref?: (node: { id: number }) => void
69
+ layout?: LayoutProps
70
+ style?: StyleProps
71
+ }
72
+
73
+ // The caret moves through the text (Left/Right/Home/End, Up/Down by line when
74
+ // multiline), edits happen at the caret, and the inner box scrolls to keep it
75
+ // in view. Printable text arrives via onTextInput (post-IME commit).
76
+ // onKeyDown handles caret movement, Backspace/Delete, Enter/select, Escape -
77
+ // and stops those keys from bubbling further. Focused and editing are
78
+ // distinct (see activateField): navigation focuses, select begins editing,
79
+ // Enter while editing submits (single-line) or inserts a newline
80
+ // (multiline). A tap puts the caret at the nearest position. Range selection
81
+ // (shift-movement, highlight) is not wired yet. Outside-click-to-blur is the
82
+ // caller's job.
83
+ export function EditorField(props: EditorFieldProps) {
84
+ let [caretOn, setCaretOn] = createSignal(true)
85
+
86
+ let node: { id: number } | undefined
87
+ let viewport: { id: number } | undefined
88
+ let blinkId: any = null
89
+
90
+ // Derived from core's reactive focus (setFocus is the only writer); the
91
+ // onFocus/onBlur handlers below keep only their side effects (blink timer,
92
+ // caller callbacks). focusedNode() is read FIRST, unconditionally: the
93
+ // memo may first compute before the ref has set `node`, and
94
+ // short-circuiting past the read would leave it dependency-free, frozen
95
+ // false forever.
96
+ let focused = createMemo(() => {
97
+ let id = focusedNode()
98
+ return id != null && id === node?.id
99
+ })
100
+
101
+ // Grapheme steps from the editor's caret stops; the editor is created
102
+ // below and only consulted from event handlers, after both exist. The
103
+ // factory is a one-shot by contract: read once, deliberately untracked.
104
+ let buffer = untrack(() => props.buffer)((_text, offset, direction) => editor.step(offset, direction))
105
+ let value = buffer.value
106
+
107
+ // autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
108
+ // the node's onTextInput handler to toggle the keyboard, and those handlers
109
+ // are only registered after the element's props are applied. The ref can fire
110
+ // before that, so focusing there would no-op.
111
+ createEffect(
112
+ () => props.autoFocus,
113
+ (autoFocus) => {
114
+ if (autoFocus && node) setFocus(node.id)
115
+ },
116
+ )
117
+
118
+ let handlePointerDown = () => {
119
+ if (props.disabled) return
120
+ if (node) setFocus(node.id)
121
+ }
122
+
123
+ // Tap-to-position: the viewport's local point plus its scroll is a content
124
+ // point; the nearest caret stop on the line under it takes the caret. Runs
125
+ // before the field's own handler above (bubbling), which focuses.
126
+ let handleViewportPointerDown = (e: PointerEvent) => {
127
+ if (props.disabled) return
128
+ let line = editor.lineAtY(e.localY + editor.scrollY())
129
+ let offset = editor.offsetAtX(line, e.localX + editor.scrollX())
130
+ buffer.setSelection(offset, offset)
131
+ setCaretOn(true)
132
+ }
133
+
134
+ let handleFocus = () => {
135
+ setCaretOn(true)
136
+ if (blinkId == null) {
137
+ blinkId = setInterval(() => setCaretOn((v) => !v), 500)
138
+ }
139
+ props.onFocus?.()
140
+ }
141
+
142
+ let handleBlur = () => {
143
+ if (blinkId != null) {
144
+ clearInterval(blinkId)
145
+ blinkId = null
146
+ }
147
+ props.onBlur?.()
148
+ }
149
+
150
+ // Keys the input consumes stop propagating: an ancestor (or an app-global
151
+ // shortcut on the window) must not also act on an ArrowLeft that moved the
152
+ // caret. Anything else (e.g. ctrl+s) bubbles on.
153
+ let handleKeyDown = (e: KeyEvent) => {
154
+ if (props.disabled) return
155
+ let consumed = true
156
+ if (e.key === "Backspace") {
157
+ buffer.deleteBackward()
158
+ setCaretOn(true)
159
+ } else if (e.key === "Delete") {
160
+ buffer.deleteForward()
161
+ setCaretOn(true)
162
+ } else if (e.key === "ArrowLeft") {
163
+ buffer.move("left")
164
+ setCaretOn(true)
165
+ } else if (e.key === "ArrowRight") {
166
+ buffer.move("right")
167
+ setCaretOn(true)
168
+ } else if (e.key === "Home" || e.key === "End") {
169
+ // Multiline: the current line's ends (offsetAtX at 0 / far right, so a
170
+ // wrap boundary resolves to the position that shows on this line).
171
+ if (props.multiline) {
172
+ let offset = editor.offsetAtX(editor.caretLine(), e.key === "Home" ? 0 : 1e9)
173
+ buffer.setSelection(offset, offset)
174
+ } else {
175
+ buffer.move(e.key === "Home" ? "start" : "end")
176
+ }
177
+ setCaretOn(true)
178
+ } else if (props.multiline && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
179
+ moveLine(e.key === "ArrowUp" ? -1 : 1)
180
+ setCaretOn(true)
181
+ } else if (props.multiline && e.key === "Enter" && textInputActive()) {
182
+ buffer.insertText("\n")
183
+ setCaretOn(true)
184
+ } else if (e.key === "Enter" || e.code === "Select") {
185
+ // The remote center key's `key` is "Unidentified"; match its code.
186
+ activateField()
187
+ } else if (e.key === "Escape") {
188
+ if (node) setFocus(null)
189
+ } else {
190
+ consumed = false
191
+ }
192
+ if (consumed) e.stopPropagation()
193
+ }
194
+
195
+ let handleTextInput = (e: any) => {
196
+ if (props.disabled) return
197
+ buffer.insertText(e.text ?? "")
198
+ setCaretOn(true)
199
+ }
200
+
201
+ // Up/Down: the offset on the neighbouring line nearest the caret's x; on
202
+ // the first/last line they go to the text's start/end, as editors do.
203
+ let moveLine = (delta: number) => {
204
+ let target = editor.caretLine() + delta
205
+ let count = editor.lines().length
206
+ let offset =
207
+ target < 0 ? 0 : target >= count ? value().length : editor.offsetAtX(target, editor.caret().x)
208
+ buffer.setSelection(offset, offset)
209
+ }
210
+
211
+ // Select on the focused field: focused and editing are distinct states. A
212
+ // field reached by navigation is focused but has no text session yet -
213
+ // select begins one (raising the on-screen keyboard where used, e.g. a TV
214
+ // with no keyboard attached); while editing, it submits (a multiline field
215
+ // has no submit: Enter inserts a newline and select is left to bubble to
216
+ // the caller). On platforms where the session starts invisibly at focus
217
+ // (desktop, physical keyboard) the first branch never runs and Enter
218
+ // submits as always. Registered as the nav action too, for a controller's
219
+ // south button.
220
+ let activateField = () => {
221
+ if (props.disabled) return
222
+ if (!textInputActive()) {
223
+ startTextInput()
224
+ } else if (!props.multiline) {
225
+ props.onSubmit?.(value())
226
+ setFocus(null)
227
+ }
228
+ }
229
+
230
+ let unregisterNav: (() => void) | null = null
231
+
232
+ onCleanup(() => {
233
+ if (blinkId != null) clearInterval(blinkId)
234
+ unregisterNav?.()
235
+ })
236
+
237
+ // Style overrides fall back to theme defaults. The border doubles as the
238
+ // focus ring (ring color at the focus width) while focused, when the
239
+ // focus-ring policy asks for a visible indicator.
240
+ let textColor = () => props.style?.color ?? theme.color.text
241
+ let surfaceColor = () => props.style?.backgroundColor ?? theme.color.surface
242
+ let ring = () => focused() && policy.focusRing
243
+ let borderColor = () => props.style?.borderColor ?? (ring() ? theme.color.ring : theme.color.border)
244
+ let borderWidth = () => props.style?.borderWidth ?? (ring() ? theme.borderWidth.focus : theme.borderWidth.sm)
245
+ let borderRadius = () => props.style?.borderRadius ?? theme.radius.md
246
+
247
+ let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
248
+ let showCaret = () => focused() && caretOn() && !showPlaceholder()
249
+
250
+ // Everything inside the viewport is detached: the value is drawn per
251
+ // laid-out line by renderLine (createTextEditorLayout breaks the lines from
252
+ // the prepared text, at the viewport width when multiline) and the caret is
253
+ // a d-rect at the measured before-caret width on its line, so typing, caret
254
+ // movement, blink and scroll never touch layout. The single-line viewport
255
+ // carries an explicit height (detached content takes no layout slot) equal
256
+ // to the one-line height; a multiline viewport stretches to the field's
257
+ // height. The editor layout keeps the caret in view and flushes the offsets
258
+ // before paint; scrollX/scrollY are paint-time translates that also apply
259
+ // to detached children.
260
+ // All metrics derive from the scaled body size, so the field, the caret,
261
+ // and the scroll math grow together under policy.textScale.
262
+ let fontSize = () => theme.text.body.size * policy.textScale
263
+ let font = () => ({ fontSize: fontSize(), lineHeight: theme.text.body.lineHeight })
264
+ let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
265
+ let editor = createTextEditorLayout(
266
+ () => viewport,
267
+ () => ({
268
+ text: value(),
269
+ font: font(),
270
+ runs: props.runs?.(),
271
+ caret: buffer.caret(),
272
+ // Constant, not tied to caret visibility: the caret's footprint does not
273
+ // change as it blinks, so reserving the column only when shown would swing
274
+ // the scroll offset every blink and shift text that exactly fills the box.
275
+ caretWidth: CARET_WIDTH,
276
+ wrap: props.multiline ?? false,
277
+ }),
278
+ )
279
+ let caret = editor.caret
280
+
281
+ // Multiline viewport height: a caller-given field height stretches the
282
+ // viewport (fixed box, scrolls); otherwise the content height, at least one
283
+ // row and at most maxRows rows. Single-line is always one row.
284
+ let viewportHeight = (): number | undefined => {
285
+ if (!props.multiline) return rowHeight()
286
+ if (props.layout?.height != null) return undefined
287
+ let lines = editor.lines()
288
+ let last = lines[lines.length - 1]!
289
+ let content = Math.ceil(last.y + last.height)
290
+ let max = props.maxRows != null ? props.maxRows * rowHeight() : Infinity
291
+ return Math.max(rowHeight(), Math.min(content, max))
292
+ }
293
+
294
+ let split = () => splitTransition(props.transition)
295
+
296
+ return (
297
+ <view
298
+ transition={split().root}
299
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
300
+ ref={(n: { id: number }) => {
301
+ node = n
302
+ unregisterNav?.()
303
+ unregisterNav = registerNavAction(n.id, activateField)
304
+ props.ref?.(n)
305
+ }}
306
+ textInputHints={props.multiline ? { multiline: true, ...props.hints } : props.hints}
307
+ focusable
308
+ flexDirection="row"
309
+ alignItems="center"
310
+ paddingLeft={space("md")}
311
+ paddingRight={space("md")}
312
+ paddingTop={space("md")}
313
+ paddingBottom={space("md")}
314
+ {...props.layout}
315
+ x={props.style?.x}
316
+ y={props.style?.y}
317
+ scale={props.style?.scale}
318
+ rotate={props.style?.rotate}
319
+ opacity={props.style?.opacity}
320
+ onPointerDown={handlePointerDown}
321
+ onFocus={handleFocus}
322
+ onBlur={handleBlur}
323
+ onKeyDown={handleKeyDown}
324
+ onTextInput={handleTextInput}
325
+ >
326
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={surfaceColor()} radius={borderRadius()} />
327
+ <d-rect
328
+ drawStyle="stroke"
329
+ transition={split().border}
330
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
331
+ color={borderColor()}
332
+ strokeWidth={borderWidth()}
333
+ radius={borderRadius()}
334
+ />
335
+ <view
336
+ ref={(n: { id: number }) => (viewport = n)}
337
+ flex={1}
338
+ height={viewportHeight()}
339
+ alignSelf={props.multiline ? "stretch" : undefined}
340
+ overflow="hidden"
341
+ scrollX={editor.scrollX()}
342
+ scrollY={editor.scrollY()}
343
+ onPointerDown={handleViewportPointerDown}
344
+ >
345
+ {showPlaceholder() ? (
346
+ <d-text w={PLACEHOLDER_SHAPE_WIDTH} {...font()} color={theme.color.textMuted} maxLines={1}>
347
+ {props.placeholder ?? ""}
348
+ </d-text>
349
+ ) : (
350
+ <>
351
+ <For each={editor.lines()} keyed={false}>
352
+ {(line) => props.renderLine({ line, font, color: textColor })}
353
+ </For>
354
+ {showCaret() ? (
355
+ <d-rect
356
+ color={textColor()}
357
+ x={caret().x}
358
+ y={caret().y + (caret().height - fontSize()) / 2}
359
+ w={CARET_WIDTH}
360
+ h={fontSize()}
361
+ />
362
+ ) : null}
363
+ </>
364
+ )}
365
+ </view>
366
+ </view>
367
+ )
368
+ }
package/src/field.tsx ADDED
@@ -0,0 +1,47 @@
1
+ import { Show } from "@solidrt/core"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import { space } from "./spacing"
5
+ import { typeStyle } from "./typography"
6
+ import type { TransitionProps } from "./types"
7
+ import { splitTransition, transitionEndFor } from "./types"
8
+
9
+ export interface FieldProps extends TransitionProps {
10
+ // Label above the control.
11
+ label?: string
12
+ // Help text under the control; replaced by `error` while one is set.
13
+ description?: string
14
+ // Validation message: rendered in the danger color in place of the
15
+ // description.
16
+ error?: string
17
+ // The control (TextInput, Select, Slider, ...), rendered as-is.
18
+ children?: any
19
+ layout?: LayoutProps
20
+ }
21
+
22
+ // A form row: label above, control, help or error line below. It draws no
23
+ // chrome and does not reach into the control - error styling of the input
24
+ // itself stays the input's style prop (no hidden magic). The message line
25
+ // only occupies space while there is one, so forms do not jump on the first
26
+ // keystroke unless an error appears; reserve the space with a constant
27
+ // `description` if that matters.
28
+ export function Field(props: FieldProps) {
29
+ return (
30
+ <view flexDirection="column" gap={space("sm")} transition={splitTransition(props.transition).root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
31
+ <Show when={props.label != null}>
32
+ <text color={theme.color.text} {...typeStyle("label")}>
33
+ {props.label}
34
+ </text>
35
+ </Show>
36
+ {props.children}
37
+ <Show when={props.error != null || props.description != null}>
38
+ <text
39
+ color={props.error != null ? theme.color.danger : theme.color.textMuted}
40
+ {...typeStyle("caption")}
41
+ >
42
+ {props.error ?? props.description}
43
+ </text>
44
+ </Show>
45
+ </view>
46
+ )
47
+ }
package/src/icon.tsx CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createMemo, parseSvg, For } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
+ import type { TransitionProps } from "./types"
5
+ import { splitTransition, transitionEndFor } from "./types"
4
6
 
5
- export interface IconProps {
7
+ export interface IconProps extends TransitionProps {
6
8
  // An SVG document as a string: an imported `.svg` asset, a `lucide-static`
7
9
  // string export, or an inline template literal. Monochrome icons that stroke/
8
10
  // fill with `currentColor` (Lucide, Feather, Heroicons, ...) get recolored by
@@ -18,9 +20,9 @@ export interface IconProps {
18
20
  const SIZE = 24
19
21
 
20
22
  // A themed wrapper over parseSvg: the document parsed once per src/color (a
21
- // memo), its draws mapped to <d-path> in a square viewBox-fitted box colored
23
+ // memo), its draws mapped to <d-path> in a square designSize-fitted box colored
22
24
  // from the theme by default. That is the only value it adds, so reach for
23
- // parseSvg plus your own <view viewBox> when you need a non-square box or
25
+ // parseSvg plus your own <view designSize> when you need a non-square box or
24
26
  // want no theme coupling. The plain repaintBoundary keeps the static subtree
25
27
  // from re-recording alongside animating siblings; pointerEvents="all" makes
26
28
  // the box one hit unit (and skips per-path outline tests) - an icon's shapes
@@ -31,11 +33,13 @@ export function Icon(props: IconProps) {
31
33
 
32
34
  return (
33
35
  <view
36
+ transition={splitTransition(props.transition).root}
37
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
34
38
  repaintBoundary
35
39
  pointerEvents="all"
36
40
  width={size()}
37
41
  height={size()}
38
- viewBox={[doc().width, doc().height]}
42
+ designSize={[doc().width, doc().height]}
39
43
  {...props.layout}
40
44
  >
41
45
  <For each={doc().draws}>{(draw) => <d-path {...draw} />}</For>
package/src/image.tsx CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createImage, createEffect, Loading, Errored, pct } from "@solidrt/core"
2
2
  import type { ImageSource, LayoutProps, Pct, PointerProps, TextureProps } from "@solidrt/core"
3
- import type { StyleProps } from "./types"
3
+ import type { StyleProps, TransitionProps } from "./types"
4
+ import { splitTransition, transitionEndFor } from "./types"
4
5
 
5
- export interface ImageProps extends PointerProps {
6
+ export interface ImageProps extends PointerProps, TransitionProps {
6
7
  src: string | Uint8Array
7
8
  /**
8
9
  * How the image maps into the Image's box (CSS object-fit): "fill"
@@ -38,6 +39,7 @@ function FallbackTexture(props: {
38
39
  height?: number | Pct
39
40
  }) {
40
41
  let tex = createImage(() => props.src)
42
+
41
43
  return (
42
44
  <Errored fallback={(_err: unknown) => null}>
43
45
  <texture src={tex()} fit={props.fit} width={props.width} height={props.height} />
@@ -69,9 +71,12 @@ export function Image(props: ImageProps) {
69
71
  let texW = () => (props.fit != null ? pct(100) : typeof props.layout?.width === "number" ? props.layout.width : undefined)
70
72
  let texH = () =>
71
73
  props.fit != null ? pct(100) : typeof props.layout?.height === "number" ? props.layout.height : undefined
74
+ let split = () => splitTransition(props.transition)
72
75
 
73
76
  return (
74
77
  <view
78
+ transition={split().root}
79
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
75
80
  {...props.layout}
76
81
  overflow={props.style?.borderRadius != null ? "hidden" : props.layout?.overflow}
77
82
  clipRadius={props.style?.borderRadius}
@@ -94,7 +99,7 @@ export function Image(props: ImageProps) {
94
99
  pointerEvents={props.pointerEvents}
95
100
  >
96
101
  {props.style?.backgroundColor != null ? (
97
- <d-rect color={props.style?.backgroundColor} radius={props.style?.borderRadius} />
102
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={props.style?.backgroundColor} radius={props.style?.borderRadius} />
98
103
  ) : null}
99
104
  <Loading fallback={null}>
100
105
  <Errored
@@ -110,6 +115,8 @@ export function Image(props: ImageProps) {
110
115
  {hasBorder() ? (
111
116
  <d-rect
112
117
  drawStyle="stroke"
118
+ transition={split().border}
119
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
113
120
  color={props.style?.borderColor ?? "transparent"}
114
121
  strokeWidth={props.style?.borderWidth}
115
122
  radius={props.style?.borderRadius}
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ export { Text, type TextProps, type TextColor } 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 { RichTextEditor, type RichTextEditorProps } from "./rich-text-editor"
8
+ export { createDocumentBuffer, plainDocument, ATOM, type Document, type DocumentRun, type DocumentBuffer, type DocumentBufferOptions, type Attributes, type AttributePatch } from "./rich-text-document"
7
9
  export { ScrollView, type ScrollViewProps } from "./scroll-view"
8
10
  export { Pressable, type PressableProps, type PressState } from "./pressable"
9
11
  export { Button, type ButtonProps, type ButtonVariant } from "./button"
@@ -13,6 +15,8 @@ export { Checkbox, type CheckboxProps } from "./checkbox"
13
15
  export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
14
16
  export { Slider, type SliderProps } from "./slider"
15
17
  export { Card, type CardProps } from "./card"
18
+ export { Item, type ItemProps } from "./item"
19
+ export { Field, type FieldProps } from "./field"
16
20
  export { Divider, type DividerProps } from "./divider"
17
21
  export { Badge, type BadgeProps, type BadgeVariant } from "./badge"
18
22
  export { Spinner, type SpinnerProps } from "./spinner"
@@ -30,9 +34,13 @@ export { Icon, type IconProps } from "./icon"
30
34
  export {
31
35
  theme,
32
36
  setTheme,
37
+ defineTheme,
33
38
  darkTheme,
34
39
  lightTheme,
35
40
  type Theme,
41
+ type ThemeDefinition,
42
+ type ThemeColor,
43
+ type ThemedComponent,
36
44
  type TextStyle,
37
45
  type TextVariant,
38
46
  } from "./theme"
@@ -41,7 +49,6 @@ export {
41
49
  setPolicy,
42
50
  setPolicyResolver,
43
51
  defaultPolicyResolver,
44
- densityScale,
45
52
  type Policies,
46
53
  type PolicyResolver,
47
54
  type InteractionPolicy,
@@ -50,6 +57,16 @@ export {
50
57
  type NavigationPolicy,
51
58
  type LayoutPolicy,
52
59
  } from "./policy"
60
+ export { Density, type DensityProps, densityScale } from "./density"
53
61
  export { typeStyle, typeWeight, lightOnDark } from "./typography"
54
62
  export { space } from "./spacing"
55
- export type { StyleProps, TextLayoutProps, Option } from "./types"
63
+ export type {
64
+ StyleProps,
65
+ TextLayoutProps,
66
+ Option,
67
+ TransitionProps,
68
+ ComponentTransition,
69
+ TransitionViewProp,
70
+ TransitionStyleProp,
71
+ TransitionScrollProp,
72
+ } from "./types"