@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.
- package/AGENTS.md +93 -85
- package/README.md +421 -311
- package/demos/README.md +24 -0
- package/demos/assets/icon.png +0 -0
- package/demos/assets/icon.svg +23 -0
- package/demos/package.json +9 -0
- package/demos/src/gallery.tsx +866 -0
- package/demos/tsconfig.json +15 -0
- package/docs/badge.md +10 -0
- package/docs/button.md +21 -0
- package/docs/card.md +11 -0
- package/docs/checkbox.md +9 -0
- package/docs/context-menu.md +17 -0
- package/docs/density.md +13 -0
- package/docs/divider.md +10 -0
- package/docs/field.md +11 -0
- package/docs/focus-nav.md +18 -0
- package/docs/icon.md +13 -0
- package/docs/image.md +21 -0
- package/docs/index.md +13 -0
- package/docs/item.md +25 -0
- package/docs/modal.md +15 -0
- package/docs/nav-shell.md +18 -0
- package/docs/policy.md +21 -0
- package/docs/portal.md +15 -0
- package/docs/pressable.md +17 -0
- package/docs/progress-bar.md +10 -0
- package/docs/qrcode.md +10 -0
- package/docs/radio.md +13 -0
- package/docs/rich-text-document.md +5 -0
- package/docs/rich-text-editor.md +24 -0
- package/docs/safe-area.md +12 -0
- package/docs/scroll-view.md +35 -0
- package/docs/segmented-control.md +13 -0
- package/docs/select.md +15 -0
- package/docs/slider.md +9 -0
- package/docs/spacing.md +7 -0
- package/docs/spinner.md +10 -0
- package/docs/split-view.md +14 -0
- package/docs/switch.md +13 -0
- package/docs/text-input.md +23 -0
- package/docs/text.md +15 -0
- package/docs/theme.md +68 -0
- package/docs/tooltip.md +11 -0
- package/docs/types.md +9 -0
- package/docs/typography.md +5 -0
- package/docs/view.md +14 -0
- package/docs/window.md +15 -0
- package/package.json +8 -4
- package/src/badge.tsx +30 -21
- package/src/button.tsx +50 -32
- package/src/card.tsx +22 -13
- package/src/checkbox.tsx +29 -11
- package/src/context-menu.tsx +14 -9
- package/src/density.tsx +39 -0
- package/src/divider.tsx +11 -4
- package/src/editor-field.tsx +368 -0
- package/src/field.tsx +47 -0
- package/src/icon.tsx +8 -4
- package/src/image.tsx +10 -3
- package/src/index.ts +19 -2
- package/src/item.tsx +121 -0
- package/src/nav-shell.tsx +7 -17
- package/src/policy.ts +9 -12
- package/src/press.ts +37 -8
- package/src/pressable.tsx +15 -3
- package/src/progress-bar.tsx +8 -5
- package/src/qrcode.tsx +7 -5
- package/src/radio.tsx +26 -16
- package/src/rich-text-document.ts +247 -0
- package/src/rich-text-editor.tsx +151 -0
- package/src/scroll-view.tsx +76 -7
- package/src/segmented-control.tsx +33 -19
- package/src/select.tsx +59 -30
- package/src/slider.tsx +32 -6
- package/src/spacing.ts +1 -1
- package/src/spinner.tsx +11 -6
- package/src/split-view.tsx +3 -4
- package/src/switch.tsx +10 -3
- package/src/text-input.tsx +54 -264
- package/src/text.tsx +22 -2
- package/src/theme.ts +220 -81
- package/src/tooltip.tsx +23 -9
- package/src/types.ts +119 -1
- package/src/view.tsx +11 -2
package/src/density.tsx
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
63
|
+
export type {
|
|
64
|
+
StyleProps,
|
|
65
|
+
TextLayoutProps,
|
|
66
|
+
Option,
|
|
67
|
+
TransitionProps,
|
|
68
|
+
ComponentTransition,
|
|
69
|
+
TransitionViewProp,
|
|
70
|
+
TransitionStyleProp,
|
|
71
|
+
TransitionScrollProp,
|
|
72
|
+
} from "./types"
|