@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
@@ -1,32 +1,11 @@
1
- import {
2
- createEffect,
3
- createMemo,
4
- createSignal,
5
- onCleanup,
6
- focusedNode,
7
- measureText,
8
- setFocus,
9
- startTextInput,
10
- textInputActive,
11
- } from "@solidrt/core"
12
- import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
13
- import type { Color, Gradient, KeyEvent, LayoutProps, TextInputHints } from "@solidrt/core"
14
- import { registerNavAction } from "./focus-nav"
15
- import type { StyleProps } from "./types"
1
+ import { untrack } from "@solidrt/core"
2
+ import { createTextBuffer } from "@solidrt/core/text-input"
3
+ import type { LayoutProps, TextInputHints } from "@solidrt/core"
4
+ import { EditorField } from "./editor-field"
5
+ import type { StyleProps, TransitionProps } from "./types"
16
6
  import { theme } from "./theme"
17
- import { policy } from "./policy"
18
- import { space } from "./spacing"
19
7
 
20
- // Caret thickness. Shared so the drawn caret and the scroll offset's reserved
21
- // edge column cannot drift apart.
22
- const CARET_WIDTH = 1
23
-
24
- // Shaping width handed to the detached value/placeholder text: effectively
25
- // unbounded, so a single line never wraps. The viewport clips it and scrollX
26
- // slides it.
27
- const TEXT_SHAPE_WIDTH = 1e9
28
-
29
- export interface TextInputProps {
8
+ export interface TextInputProps extends TransitionProps {
30
9
  value?: string
31
10
  defaultValue?: string
32
11
  onInput?: (value: string) => void
@@ -38,6 +17,15 @@ export interface TextInputProps {
38
17
  maxLength?: number
39
18
  disabled?: boolean
40
19
  autoFocus?: boolean
20
+ /**
21
+ * Multi-line editing: lines wrap at the field's width, Enter inserts a
22
+ * newline (onSubmit never fires), Up/Down move by line. Without a
23
+ * `layout.height` the field grows with its content (up to `maxRows` rows,
24
+ * then scrolls); with one it is a fixed box that scrolls to the caret.
25
+ */
26
+ multiline?: boolean
27
+ /** Multiline without an explicit height: rows to grow to before scrolling. Default unbounded. */
28
+ maxRows?: number
41
29
  /**
42
30
  * IME behavior for the field's text sessions (keyboard type,
43
31
  * capitalization, autocorrect). Identifier-like fields want
@@ -50,244 +38,46 @@ export interface TextInputProps {
50
38
  style?: StyleProps
51
39
  }
52
40
 
53
- // Single-line. The caret moves through the text (Left/Right/Home/End), edits
54
- // happen at the caret, and the inner box scrolls to keep it in view. Printable
55
- // text arrives via onTextInput (post-IME commit). onKeyDown handles caret
56
- // movement, Backspace/Delete, Enter/select, Escape - and stops those keys
57
- // from bubbling further. Focused and editing are distinct (see
58
- // activateField): navigation focuses, select begins editing, Enter while
59
- // editing submits. Range selection (shift-movement,
60
- // highlight, click-to-position) is not wired yet. Outside-click-to-blur is the
61
- // caller's job.
41
+ // A plain string field over the shared EditorField shell: the buffer is
42
+ // core's createTextBuffer and every line is one d-text of the value's slice.
43
+ // Detached text takes its width from the box it is drawn in; +1 so the ink
44
+ // width rounding never wraps a line's own text.
62
45
  export function TextInput(props: TextInputProps) {
63
- let [caretOn, setCaretOn] = createSignal(true)
64
-
65
- let node: { id: number } | undefined
66
- let viewport: { id: number } | undefined
67
- let blinkId: any = null
68
-
69
- // Derived from core's reactive focus (setFocus is the only writer); the
70
- // onFocus/onBlur handlers below keep only their side effects (blink timer,
71
- // caller callbacks). focusedNode() is read FIRST, unconditionally: the
72
- // memo may first compute before the ref has set `node`, and
73
- // short-circuiting past the read would leave it dependency-free, frozen
74
- // false forever.
75
- let focused = createMemo(() => {
76
- let id = focusedNode()
77
- return id != null && id === node?.id
78
- })
79
-
80
- let buffer = createTextBuffer({
81
- value: () => props.value,
82
- defaultValue: props.defaultValue,
83
- onInput: (v) => props.onInput?.(v),
84
- maxLength: () => props.maxLength,
85
- })
86
- let value = buffer.value
87
-
88
- // autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
89
- // the node's onTextInput handler to toggle the keyboard, and those handlers
90
- // are only registered after the element's props are applied. The ref can fire
91
- // before that, so focusing there would no-op.
92
- createEffect(
93
- () => props.autoFocus,
94
- (autoFocus) => {
95
- if (autoFocus && node) setFocus(node.id)
96
- },
97
- )
98
-
99
- let handlePointerDown = () => {
100
- if (props.disabled) return
101
- if (node) setFocus(node.id)
102
- }
103
-
104
- let handleFocus = () => {
105
- setCaretOn(true)
106
- if (blinkId == null) {
107
- blinkId = setInterval(() => setCaretOn((v) => !v), 500)
108
- }
109
- props.onFocus?.()
110
- }
111
-
112
- let handleBlur = () => {
113
- if (blinkId != null) {
114
- clearInterval(blinkId)
115
- blinkId = null
116
- }
117
- props.onBlur?.()
118
- }
119
-
120
- // Keys the input consumes stop propagating: an ancestor (or an app-global
121
- // shortcut on the window) must not also act on an ArrowLeft that moved the
122
- // caret. Anything else (e.g. ctrl+s) bubbles on.
123
- let handleKeyDown = (e: KeyEvent) => {
124
- if (props.disabled) return
125
- let consumed = true
126
- if (e.key === "Backspace") {
127
- buffer.deleteBackward()
128
- setCaretOn(true)
129
- } else if (e.key === "Delete") {
130
- buffer.deleteForward()
131
- setCaretOn(true)
132
- } else if (e.key === "ArrowLeft") {
133
- buffer.move("left")
134
- setCaretOn(true)
135
- } else if (e.key === "ArrowRight") {
136
- buffer.move("right")
137
- setCaretOn(true)
138
- } else if (e.key === "Home") {
139
- buffer.move("start")
140
- setCaretOn(true)
141
- } else if (e.key === "End") {
142
- buffer.move("end")
143
- setCaretOn(true)
144
- } else if (e.key === "Enter" || e.code === "Select") {
145
- // The remote center key's `key` is "Unidentified"; match its code.
146
- activateField()
147
- } else if (e.key === "Escape") {
148
- if (node) setFocus(null)
149
- } else {
150
- consumed = false
151
- }
152
- if (consumed) e.stopPropagation()
153
- }
154
-
155
- let handleTextInput = (e: any) => {
156
- if (props.disabled) return
157
- buffer.insertText(e.text ?? "")
158
- setCaretOn(true)
159
- }
160
-
161
- // Select on the focused field: focused and editing are distinct states. A
162
- // field reached by navigation is focused but has no text session yet -
163
- // select begins one (raising the on-screen keyboard where used, e.g. a TV
164
- // with no keyboard attached); while editing, it submits. On platforms
165
- // where the session starts invisibly at focus (desktop, physical
166
- // keyboard) the first branch never runs and Enter submits as always.
167
- // Registered as the nav action too, for a controller's south button.
168
- let activateField = () => {
169
- if (props.disabled) return
170
- if (!textInputActive()) {
171
- startTextInput()
172
- } else {
173
- props.onSubmit?.(value())
174
- setFocus(null)
175
- }
176
- }
177
-
178
- let unregisterNav: (() => void) | null = null
179
-
180
- onCleanup(() => {
181
- if (blinkId != null) clearInterval(blinkId)
182
- unregisterNav?.()
183
- })
184
-
185
- // Style overrides fall back to theme defaults. The border doubles as the
186
- // focus ring: primary while focused, when the focus-ring policy asks for a
187
- // visible indicator.
188
- let textColor = () => props.style?.color ?? theme.color.text
189
- let surfaceColor = () => props.style?.backgroundColor ?? theme.color.surface
190
- let borderColor = () =>
191
- props.style?.borderColor ?? (focused() && policy.focusRing ? theme.color.primary : theme.color.border)
192
- let borderWidth = () => props.style?.borderWidth ?? theme.borderWidth.sm
193
- let borderRadius = () => props.style?.borderRadius ?? theme.radius.sm
194
-
195
- let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
196
- let showCaret = () => focused() && caretOn() && !showPlaceholder()
197
-
198
- // Everything inside the viewport is detached: the value is one d-text shaped
199
- // at an unbounded width and the caret a d-rect at the measured before-caret
200
- // width, so typing, caret movement, blink and scroll never touch layout. The
201
- // viewport carries an explicit height (detached content takes no layout
202
- // slot) equal to the one-line paragraph height, which keeps the text where
203
- // the old centered attached row sat. createCaretScroll keeps the caret in
204
- // view and flushes the offset before paint; scrollX is a paint-time
205
- // translate that also applies to detached children.
206
- // All one-line metrics derive from the scaled body size, so the field, the
207
- // caret, and the scroll math grow together under policy.textScale.
208
- let fontSize = () => theme.text.body.size * policy.textScale
209
- let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
210
- let caretX = () => measureText(value().slice(0, buffer.caret()), { fontSize: fontSize() }).width
211
- let scrollX = createCaretScroll(
212
- () => viewport,
213
- () => ({
214
- text: value(),
215
- fontSize: fontSize(),
216
- caret: buffer.caret(),
217
- // Constant, not tied to caret visibility: the caret's footprint does not
218
- // change as it blinks, so reserving the column only when shown would swing
219
- // the scroll offset every blink and shift text that exactly fills the box.
220
- caretWidth: CARET_WIDTH,
221
- }),
222
- )
223
-
224
- let textStyle = (color: Color | Gradient) => ({
225
- w: TEXT_SHAPE_WIDTH,
226
- fontSize: fontSize(),
227
- lineHeight: theme.text.body.lineHeight,
228
- color,
229
- maxLines: 1,
230
- })
231
-
46
+ let value = (): string => ""
232
47
  return (
233
- <view
234
- ref={(n: { id: number }) => {
235
- node = n
236
- unregisterNav?.()
237
- unregisterNav = registerNavAction(n.id, activateField)
238
- props.ref?.(n)
48
+ <EditorField
49
+ transition={props.transition}
50
+ onTransitionEnd={props.onTransitionEnd}
51
+ buffer={(step) => {
52
+ let buffer = createTextBuffer({
53
+ value: () => props.value,
54
+ // A one-shot initial value by contract (see createTextBuffer): read
55
+ // once, deliberately untracked.
56
+ defaultValue: untrack(() => props.defaultValue),
57
+ onInput: (v) => props.onInput?.(v),
58
+ maxLength: () => props.maxLength,
59
+ step,
60
+ })
61
+ value = buffer.value
62
+ return buffer
239
63
  }}
240
- textInputHints={props.hints}
241
- focusable
242
- flexDirection="row"
243
- alignItems="center"
244
- paddingLeft={space("md")}
245
- paddingRight={space("md")}
246
- paddingTop={space("sm")}
247
- paddingBottom={space("sm")}
248
- {...props.layout}
249
- x={props.style?.x}
250
- y={props.style?.y}
251
- scale={props.style?.scale}
252
- rotate={props.style?.rotate}
253
- opacity={props.style?.opacity}
254
- onPointerDown={handlePointerDown}
255
- onFocus={handleFocus}
256
- onBlur={handleBlur}
257
- onKeyDown={handleKeyDown}
258
- onTextInput={handleTextInput}
259
- >
260
- <d-rect color={surfaceColor()} radius={borderRadius()} />
261
- <d-rect
262
- drawStyle="stroke"
263
- color={borderColor()}
264
- strokeWidth={borderWidth()}
265
- radius={borderRadius()}
266
- />
267
- <view
268
- ref={(n: { id: number }) => (viewport = n)}
269
- flex={1}
270
- height={rowHeight()}
271
- overflow="hidden"
272
- scrollX={scrollX()}
273
- >
274
- {showPlaceholder() ? (
275
- <d-text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</d-text>
276
- ) : (
277
- <>
278
- <d-text {...textStyle(textColor())}>{value()}</d-text>
279
- {showCaret() ? (
280
- <d-rect
281
- color={textColor()}
282
- x={caretX()}
283
- y={(rowHeight() - fontSize()) / 2}
284
- w={CARET_WIDTH}
285
- h={fontSize()}
286
- />
287
- ) : null}
288
- </>
289
- )}
290
- </view>
291
- </view>
64
+ renderLine={({ line, font, color }) => (
65
+ <d-text y={line().y} w={line().width + 1} {...font()} color={color()} maxLines={1}>
66
+ {value().slice(line().start, line().end)}
67
+ </d-text>
68
+ )}
69
+ onSubmit={props.onSubmit}
70
+ onFocus={props.onFocus}
71
+ onBlur={props.onBlur}
72
+ placeholder={props.placeholder}
73
+ disabled={props.disabled}
74
+ autoFocus={props.autoFocus}
75
+ multiline={props.multiline}
76
+ maxRows={props.maxRows}
77
+ hints={props.hints}
78
+ ref={props.ref}
79
+ layout={props.layout}
80
+ style={{ ...theme.components.textInput, ...props.style }}
81
+ />
292
82
  )
293
- }
83
+ }
package/src/text.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createMemo } from "@solidrt/core"
2
2
  import type { PointerProps } from "@solidrt/core"
3
- import type { StyleProps, TextLayoutProps } from "./types"
3
+ import type { StyleProps, TextLayoutProps, TransitionProps, TransitionViewProp } from "./types"
4
+ import { splitTransition, transitionEndFor } from "./types"
4
5
  import { theme, type TextVariant } from "./theme"
5
6
  import { policy } from "./policy"
6
7
  import { typeWeight } from "./typography"
@@ -9,7 +10,7 @@ import { typeWeight } from "./typography"
9
10
  // make sense as a text fill; style.color takes raw values for anything else.
10
11
  export type TextColor = "text" | "textMuted" | "primary" | "onPrimary" | "danger"
11
12
 
12
- export interface TextProps extends PointerProps {
13
+ export interface TextProps extends PointerProps, TransitionProps<TransitionViewProp | "color"> {
13
14
  children?: any
14
15
  // Typography role from the theme's type scale; defaults to "body". Explicit
15
16
  // layout font props override the role's fields individually. fontSize
@@ -54,8 +55,25 @@ export function Text(props: TextProps) {
54
55
  return out
55
56
  })
56
57
 
58
+ // The text node owns `color`; everything else a Text animates is on the
59
+ // wrapper view. A shorthand or `all` reaches both.
60
+ let split = () => {
61
+ let t = splitTransition(props.transition)
62
+ if (t.root == null || typeof t.root === "string") return { root: t.root, text: t.root }
63
+ let { color, ...rest } = t.root as Record<string, unknown>
64
+ let text: Record<string, unknown> = {}
65
+ if (color !== undefined) text.color = color
66
+ if (rest.all !== undefined) text.all = rest.all
67
+ return {
68
+ root: Object.keys(rest).length ? (rest as typeof t.root) : undefined,
69
+ text: Object.keys(text).length ? (text as typeof t.root) : undefined,
70
+ }
71
+ }
72
+
57
73
  return (
58
74
  <view
75
+ transition={split().root}
76
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
59
77
  ref={props.ref}
60
78
  {...box()}
61
79
  x={props.style?.x}
@@ -77,6 +95,8 @@ export function Text(props: TextProps) {
77
95
  pointerEvents={props.pointerEvents}
78
96
  >
79
97
  <text
98
+ transition={split().text}
99
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
80
100
  color={color()}
81
101
  fontFamily={props.layout?.fontFamily ?? theme.text.fontFamily}
82
102
  fontSize={size()}