@solidrt/components 0.0.49 → 0.0.51
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 +66 -85
- package/README.md +387 -314
- 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 +14 -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 +56 -0
- package/docs/tooltip.md +11 -0
- package/docs/types.md +5 -0
- package/docs/typography.md +5 -0
- package/docs/view.md +14 -0
- package/docs/window.md +15 -0
- package/package.json +6 -3
- package/src/badge.tsx +10 -8
- package/src/button.tsx +40 -28
- package/src/card.tsx +12 -10
- package/src/checkbox.tsx +21 -9
- package/src/context-menu.tsx +5 -3
- package/src/density.tsx +39 -0
- package/src/divider.tsx +3 -1
- package/src/editor-field.tsx +361 -0
- package/src/field.tsx +45 -0
- package/src/index.ts +9 -1
- package/src/item.tsx +116 -0
- package/src/nav-shell.tsx +1 -1
- package/src/policy.ts +0 -8
- package/src/press.ts +37 -8
- package/src/pressable.tsx +4 -1
- package/src/progress-bar.tsx +4 -2
- package/src/radio.tsx +14 -12
- package/src/rich-text-document.ts +247 -0
- package/src/rich-text-editor.tsx +149 -0
- package/src/segmented-control.tsx +19 -14
- package/src/select.tsx +37 -19
- package/src/slider.tsx +1 -1
- package/src/spacing.ts +1 -1
- package/src/spinner.tsx +6 -4
- package/src/switch.tsx +2 -1
- package/src/text-input.tsx +50 -262
- package/src/theme.ts +163 -76
- package/src/tooltip.tsx +7 -4
|
@@ -0,0 +1,361 @@
|
|
|
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 } from "./types"
|
|
26
|
+
import { theme } from "./theme"
|
|
27
|
+
import { policy } from "./policy"
|
|
28
|
+
import { space } from "./spacing"
|
|
29
|
+
|
|
30
|
+
// Caret thickness. Shared so the drawn caret and the scroll offset's reserved
|
|
31
|
+
// edge column cannot drift apart.
|
|
32
|
+
const CARET_WIDTH = 1
|
|
33
|
+
|
|
34
|
+
// Shaping width of the placeholder: effectively unbounded, so it never wraps;
|
|
35
|
+
// the viewport clips it.
|
|
36
|
+
const PLACEHOLDER_SHAPE_WIDTH = 1e9
|
|
37
|
+
|
|
38
|
+
/** What the shell hands renderLine for one laid-out line. */
|
|
39
|
+
export type LineRender = {
|
|
40
|
+
line: () => EditorLine
|
|
41
|
+
/** The field's font options (size, line height), the base for the line's text. */
|
|
42
|
+
font: () => MeasureTextOptions
|
|
43
|
+
/** The field's text color. */
|
|
44
|
+
color: () => Color | Gradient
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EditorFieldProps {
|
|
48
|
+
/**
|
|
49
|
+
* Creates the buffer the field edits, given the grapheme `step` from the
|
|
50
|
+
* field's geometry (createTextEditorLayout.step); called once.
|
|
51
|
+
*/
|
|
52
|
+
buffer: (step: (text: string, offset: number, direction: "left" | "right") => number) => TextBuffer
|
|
53
|
+
/** Styled ranges over the text for the geometry (prepareText `runs`). */
|
|
54
|
+
runs?: () => TextRunRange[] | undefined
|
|
55
|
+
/** Draws one line: detached content at the line's y inside the viewport. */
|
|
56
|
+
renderLine: (r: LineRender) => Element
|
|
57
|
+
|
|
58
|
+
onSubmit?: (value: string) => void
|
|
59
|
+
onFocus?: () => void
|
|
60
|
+
onBlur?: () => void
|
|
61
|
+
placeholder?: string
|
|
62
|
+
disabled?: boolean
|
|
63
|
+
autoFocus?: boolean
|
|
64
|
+
multiline?: boolean
|
|
65
|
+
maxRows?: number
|
|
66
|
+
hints?: TextInputHints
|
|
67
|
+
ref?: (node: { id: number }) => void
|
|
68
|
+
layout?: LayoutProps
|
|
69
|
+
style?: StyleProps
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The caret moves through the text (Left/Right/Home/End, Up/Down by line when
|
|
73
|
+
// multiline), edits happen at the caret, and the inner box scrolls to keep it
|
|
74
|
+
// in view. Printable text arrives via onTextInput (post-IME commit).
|
|
75
|
+
// onKeyDown handles caret movement, Backspace/Delete, Enter/select, Escape -
|
|
76
|
+
// and stops those keys from bubbling further. Focused and editing are
|
|
77
|
+
// distinct (see activateField): navigation focuses, select begins editing,
|
|
78
|
+
// Enter while editing submits (single-line) or inserts a newline
|
|
79
|
+
// (multiline). A tap puts the caret at the nearest position. Range selection
|
|
80
|
+
// (shift-movement, highlight) is not wired yet. Outside-click-to-blur is the
|
|
81
|
+
// caller's job.
|
|
82
|
+
export function EditorField(props: EditorFieldProps) {
|
|
83
|
+
let [caretOn, setCaretOn] = createSignal(true)
|
|
84
|
+
|
|
85
|
+
let node: { id: number } | undefined
|
|
86
|
+
let viewport: { id: number } | undefined
|
|
87
|
+
let blinkId: any = null
|
|
88
|
+
|
|
89
|
+
// Derived from core's reactive focus (setFocus is the only writer); the
|
|
90
|
+
// onFocus/onBlur handlers below keep only their side effects (blink timer,
|
|
91
|
+
// caller callbacks). focusedNode() is read FIRST, unconditionally: the
|
|
92
|
+
// memo may first compute before the ref has set `node`, and
|
|
93
|
+
// short-circuiting past the read would leave it dependency-free, frozen
|
|
94
|
+
// false forever.
|
|
95
|
+
let focused = createMemo(() => {
|
|
96
|
+
let id = focusedNode()
|
|
97
|
+
return id != null && id === node?.id
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Grapheme steps from the editor's caret stops; the editor is created
|
|
101
|
+
// below and only consulted from event handlers, after both exist. The
|
|
102
|
+
// factory is a one-shot by contract: read once, deliberately untracked.
|
|
103
|
+
let buffer = untrack(() => props.buffer)((_text, offset, direction) => editor.step(offset, direction))
|
|
104
|
+
let value = buffer.value
|
|
105
|
+
|
|
106
|
+
// autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
|
|
107
|
+
// the node's onTextInput handler to toggle the keyboard, and those handlers
|
|
108
|
+
// are only registered after the element's props are applied. The ref can fire
|
|
109
|
+
// before that, so focusing there would no-op.
|
|
110
|
+
createEffect(
|
|
111
|
+
() => props.autoFocus,
|
|
112
|
+
(autoFocus) => {
|
|
113
|
+
if (autoFocus && node) setFocus(node.id)
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
let handlePointerDown = () => {
|
|
118
|
+
if (props.disabled) return
|
|
119
|
+
if (node) setFocus(node.id)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Tap-to-position: the viewport's local point plus its scroll is a content
|
|
123
|
+
// point; the nearest caret stop on the line under it takes the caret. Runs
|
|
124
|
+
// before the field's own handler above (bubbling), which focuses.
|
|
125
|
+
let handleViewportPointerDown = (e: PointerEvent) => {
|
|
126
|
+
if (props.disabled) return
|
|
127
|
+
let line = editor.lineAtY(e.localY + editor.scrollY())
|
|
128
|
+
let offset = editor.offsetAtX(line, e.localX + editor.scrollX())
|
|
129
|
+
buffer.setSelection(offset, offset)
|
|
130
|
+
setCaretOn(true)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let handleFocus = () => {
|
|
134
|
+
setCaretOn(true)
|
|
135
|
+
if (blinkId == null) {
|
|
136
|
+
blinkId = setInterval(() => setCaretOn((v) => !v), 500)
|
|
137
|
+
}
|
|
138
|
+
props.onFocus?.()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let handleBlur = () => {
|
|
142
|
+
if (blinkId != null) {
|
|
143
|
+
clearInterval(blinkId)
|
|
144
|
+
blinkId = null
|
|
145
|
+
}
|
|
146
|
+
props.onBlur?.()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Keys the input consumes stop propagating: an ancestor (or an app-global
|
|
150
|
+
// shortcut on the window) must not also act on an ArrowLeft that moved the
|
|
151
|
+
// caret. Anything else (e.g. ctrl+s) bubbles on.
|
|
152
|
+
let handleKeyDown = (e: KeyEvent) => {
|
|
153
|
+
if (props.disabled) return
|
|
154
|
+
let consumed = true
|
|
155
|
+
if (e.key === "Backspace") {
|
|
156
|
+
buffer.deleteBackward()
|
|
157
|
+
setCaretOn(true)
|
|
158
|
+
} else if (e.key === "Delete") {
|
|
159
|
+
buffer.deleteForward()
|
|
160
|
+
setCaretOn(true)
|
|
161
|
+
} else if (e.key === "ArrowLeft") {
|
|
162
|
+
buffer.move("left")
|
|
163
|
+
setCaretOn(true)
|
|
164
|
+
} else if (e.key === "ArrowRight") {
|
|
165
|
+
buffer.move("right")
|
|
166
|
+
setCaretOn(true)
|
|
167
|
+
} else if (e.key === "Home" || e.key === "End") {
|
|
168
|
+
// Multiline: the current line's ends (offsetAtX at 0 / far right, so a
|
|
169
|
+
// wrap boundary resolves to the position that shows on this line).
|
|
170
|
+
if (props.multiline) {
|
|
171
|
+
let offset = editor.offsetAtX(editor.caretLine(), e.key === "Home" ? 0 : 1e9)
|
|
172
|
+
buffer.setSelection(offset, offset)
|
|
173
|
+
} else {
|
|
174
|
+
buffer.move(e.key === "Home" ? "start" : "end")
|
|
175
|
+
}
|
|
176
|
+
setCaretOn(true)
|
|
177
|
+
} else if (props.multiline && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
|
178
|
+
moveLine(e.key === "ArrowUp" ? -1 : 1)
|
|
179
|
+
setCaretOn(true)
|
|
180
|
+
} else if (props.multiline && e.key === "Enter" && textInputActive()) {
|
|
181
|
+
buffer.insertText("\n")
|
|
182
|
+
setCaretOn(true)
|
|
183
|
+
} else if (e.key === "Enter" || e.code === "Select") {
|
|
184
|
+
// The remote center key's `key` is "Unidentified"; match its code.
|
|
185
|
+
activateField()
|
|
186
|
+
} else if (e.key === "Escape") {
|
|
187
|
+
if (node) setFocus(null)
|
|
188
|
+
} else {
|
|
189
|
+
consumed = false
|
|
190
|
+
}
|
|
191
|
+
if (consumed) e.stopPropagation()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let handleTextInput = (e: any) => {
|
|
195
|
+
if (props.disabled) return
|
|
196
|
+
buffer.insertText(e.text ?? "")
|
|
197
|
+
setCaretOn(true)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Up/Down: the offset on the neighbouring line nearest the caret's x; on
|
|
201
|
+
// the first/last line they go to the text's start/end, as editors do.
|
|
202
|
+
let moveLine = (delta: number) => {
|
|
203
|
+
let target = editor.caretLine() + delta
|
|
204
|
+
let count = editor.lines().length
|
|
205
|
+
let offset =
|
|
206
|
+
target < 0 ? 0 : target >= count ? value().length : editor.offsetAtX(target, editor.caret().x)
|
|
207
|
+
buffer.setSelection(offset, offset)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Select on the focused field: focused and editing are distinct states. A
|
|
211
|
+
// field reached by navigation is focused but has no text session yet -
|
|
212
|
+
// select begins one (raising the on-screen keyboard where used, e.g. a TV
|
|
213
|
+
// with no keyboard attached); while editing, it submits (a multiline field
|
|
214
|
+
// has no submit: Enter inserts a newline and select is left to bubble to
|
|
215
|
+
// the caller). On platforms where the session starts invisibly at focus
|
|
216
|
+
// (desktop, physical keyboard) the first branch never runs and Enter
|
|
217
|
+
// submits as always. Registered as the nav action too, for a controller's
|
|
218
|
+
// south button.
|
|
219
|
+
let activateField = () => {
|
|
220
|
+
if (props.disabled) return
|
|
221
|
+
if (!textInputActive()) {
|
|
222
|
+
startTextInput()
|
|
223
|
+
} else if (!props.multiline) {
|
|
224
|
+
props.onSubmit?.(value())
|
|
225
|
+
setFocus(null)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let unregisterNav: (() => void) | null = null
|
|
230
|
+
|
|
231
|
+
onCleanup(() => {
|
|
232
|
+
if (blinkId != null) clearInterval(blinkId)
|
|
233
|
+
unregisterNav?.()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// Style overrides fall back to theme defaults. The border doubles as the
|
|
237
|
+
// focus ring: primary while focused, when the focus-ring policy asks for a
|
|
238
|
+
// visible indicator.
|
|
239
|
+
let textColor = () => props.style?.color ?? theme.color.text
|
|
240
|
+
let surfaceColor = () => props.style?.backgroundColor ?? theme.color.surface
|
|
241
|
+
let borderColor = () =>
|
|
242
|
+
props.style?.borderColor ?? (focused() && policy.focusRing ? theme.color.primary : theme.color.border)
|
|
243
|
+
let borderWidth = () => props.style?.borderWidth ?? theme.borderWidth.sm
|
|
244
|
+
let borderRadius = () => props.style?.borderRadius ?? theme.radius.sm
|
|
245
|
+
|
|
246
|
+
let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
|
|
247
|
+
let showCaret = () => focused() && caretOn() && !showPlaceholder()
|
|
248
|
+
|
|
249
|
+
// Everything inside the viewport is detached: the value is drawn per
|
|
250
|
+
// laid-out line by renderLine (createTextEditorLayout breaks the lines from
|
|
251
|
+
// the prepared text, at the viewport width when multiline) and the caret is
|
|
252
|
+
// a d-rect at the measured before-caret width on its line, so typing, caret
|
|
253
|
+
// movement, blink and scroll never touch layout. The single-line viewport
|
|
254
|
+
// carries an explicit height (detached content takes no layout slot) equal
|
|
255
|
+
// to the one-line height; a multiline viewport stretches to the field's
|
|
256
|
+
// height. The editor layout keeps the caret in view and flushes the offsets
|
|
257
|
+
// before paint; scrollX/scrollY are paint-time translates that also apply
|
|
258
|
+
// to detached children.
|
|
259
|
+
// All metrics derive from the scaled body size, so the field, the caret,
|
|
260
|
+
// and the scroll math grow together under policy.textScale.
|
|
261
|
+
let fontSize = () => theme.text.body.size * policy.textScale
|
|
262
|
+
let font = () => ({ fontSize: fontSize(), lineHeight: theme.text.body.lineHeight })
|
|
263
|
+
let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
|
|
264
|
+
let editor = createTextEditorLayout(
|
|
265
|
+
() => viewport,
|
|
266
|
+
() => ({
|
|
267
|
+
text: value(),
|
|
268
|
+
font: font(),
|
|
269
|
+
runs: props.runs?.(),
|
|
270
|
+
caret: buffer.caret(),
|
|
271
|
+
// Constant, not tied to caret visibility: the caret's footprint does not
|
|
272
|
+
// change as it blinks, so reserving the column only when shown would swing
|
|
273
|
+
// the scroll offset every blink and shift text that exactly fills the box.
|
|
274
|
+
caretWidth: CARET_WIDTH,
|
|
275
|
+
wrap: props.multiline ?? false,
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
let caret = editor.caret
|
|
279
|
+
|
|
280
|
+
// Multiline viewport height: a caller-given field height stretches the
|
|
281
|
+
// viewport (fixed box, scrolls); otherwise the content height, at least one
|
|
282
|
+
// row and at most maxRows rows. Single-line is always one row.
|
|
283
|
+
let viewportHeight = (): number | undefined => {
|
|
284
|
+
if (!props.multiline) return rowHeight()
|
|
285
|
+
if (props.layout?.height != null) return undefined
|
|
286
|
+
let lines = editor.lines()
|
|
287
|
+
let last = lines[lines.length - 1]!
|
|
288
|
+
let content = Math.ceil(last.y + last.height)
|
|
289
|
+
let max = props.maxRows != null ? props.maxRows * rowHeight() : Infinity
|
|
290
|
+
return Math.max(rowHeight(), Math.min(content, max))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<view
|
|
295
|
+
ref={(n: { id: number }) => {
|
|
296
|
+
node = n
|
|
297
|
+
unregisterNav?.()
|
|
298
|
+
unregisterNav = registerNavAction(n.id, activateField)
|
|
299
|
+
props.ref?.(n)
|
|
300
|
+
}}
|
|
301
|
+
textInputHints={props.multiline ? { multiline: true, ...props.hints } : props.hints}
|
|
302
|
+
focusable
|
|
303
|
+
flexDirection="row"
|
|
304
|
+
alignItems="center"
|
|
305
|
+
paddingLeft={space("md")}
|
|
306
|
+
paddingRight={space("md")}
|
|
307
|
+
paddingTop={space("sm")}
|
|
308
|
+
paddingBottom={space("sm")}
|
|
309
|
+
{...props.layout}
|
|
310
|
+
x={props.style?.x}
|
|
311
|
+
y={props.style?.y}
|
|
312
|
+
scale={props.style?.scale}
|
|
313
|
+
rotate={props.style?.rotate}
|
|
314
|
+
opacity={props.style?.opacity}
|
|
315
|
+
onPointerDown={handlePointerDown}
|
|
316
|
+
onFocus={handleFocus}
|
|
317
|
+
onBlur={handleBlur}
|
|
318
|
+
onKeyDown={handleKeyDown}
|
|
319
|
+
onTextInput={handleTextInput}
|
|
320
|
+
>
|
|
321
|
+
<d-rect color={surfaceColor()} radius={borderRadius()} />
|
|
322
|
+
<d-rect
|
|
323
|
+
drawStyle="stroke"
|
|
324
|
+
color={borderColor()}
|
|
325
|
+
strokeWidth={borderWidth()}
|
|
326
|
+
radius={borderRadius()}
|
|
327
|
+
/>
|
|
328
|
+
<view
|
|
329
|
+
ref={(n: { id: number }) => (viewport = n)}
|
|
330
|
+
flex={1}
|
|
331
|
+
height={viewportHeight()}
|
|
332
|
+
alignSelf={props.multiline ? "stretch" : undefined}
|
|
333
|
+
overflow="hidden"
|
|
334
|
+
scrollX={editor.scrollX()}
|
|
335
|
+
scrollY={editor.scrollY()}
|
|
336
|
+
onPointerDown={handleViewportPointerDown}
|
|
337
|
+
>
|
|
338
|
+
{showPlaceholder() ? (
|
|
339
|
+
<d-text w={PLACEHOLDER_SHAPE_WIDTH} {...font()} color={theme.color.textMuted} maxLines={1}>
|
|
340
|
+
{props.placeholder ?? ""}
|
|
341
|
+
</d-text>
|
|
342
|
+
) : (
|
|
343
|
+
<>
|
|
344
|
+
<For each={editor.lines()} keyed={false}>
|
|
345
|
+
{(line) => props.renderLine({ line, font, color: textColor })}
|
|
346
|
+
</For>
|
|
347
|
+
{showCaret() ? (
|
|
348
|
+
<d-rect
|
|
349
|
+
color={textColor()}
|
|
350
|
+
x={caret().x}
|
|
351
|
+
y={caret().y + (caret().height - fontSize()) / 2}
|
|
352
|
+
w={CARET_WIDTH}
|
|
353
|
+
h={fontSize()}
|
|
354
|
+
/>
|
|
355
|
+
) : null}
|
|
356
|
+
</>
|
|
357
|
+
)}
|
|
358
|
+
</view>
|
|
359
|
+
</view>
|
|
360
|
+
)
|
|
361
|
+
}
|
package/src/field.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
|
|
7
|
+
export interface FieldProps {
|
|
8
|
+
// Label above the control.
|
|
9
|
+
label?: string
|
|
10
|
+
// Help text under the control; replaced by `error` while one is set.
|
|
11
|
+
description?: string
|
|
12
|
+
// Validation message: rendered in the danger color in place of the
|
|
13
|
+
// description.
|
|
14
|
+
error?: string
|
|
15
|
+
// The control (TextInput, Select, Slider, ...), rendered as-is.
|
|
16
|
+
children?: any
|
|
17
|
+
layout?: LayoutProps
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A form row: label above, control, help or error line below. It draws no
|
|
21
|
+
// chrome and does not reach into the control - error styling of the input
|
|
22
|
+
// itself stays the input's style prop (no hidden magic). The message line
|
|
23
|
+
// only occupies space while there is one, so forms do not jump on the first
|
|
24
|
+
// keystroke unless an error appears; reserve the space with a constant
|
|
25
|
+
// `description` if that matters.
|
|
26
|
+
export function Field(props: FieldProps) {
|
|
27
|
+
return (
|
|
28
|
+
<view flexDirection="column" gap={space("sm")} {...props.layout}>
|
|
29
|
+
<Show when={props.label != null}>
|
|
30
|
+
<text color={theme.color.text} {...typeStyle("label")}>
|
|
31
|
+
{props.label}
|
|
32
|
+
</text>
|
|
33
|
+
</Show>
|
|
34
|
+
{props.children}
|
|
35
|
+
<Show when={props.error != null || props.description != null}>
|
|
36
|
+
<text
|
|
37
|
+
color={props.error != null ? theme.color.danger : theme.color.textMuted}
|
|
38
|
+
{...typeStyle("caption")}
|
|
39
|
+
>
|
|
40
|
+
{props.error ?? props.description}
|
|
41
|
+
</text>
|
|
42
|
+
</Show>
|
|
43
|
+
</view>
|
|
44
|
+
)
|
|
45
|
+
}
|
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,7 @@ 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
63
|
export type { StyleProps, TextLayoutProps, Option } from "./types"
|
package/src/item.tsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Show, children } from "@solidrt/core"
|
|
2
|
+
import type { LayoutProps } from "@solidrt/core"
|
|
3
|
+
import { createPress, type PressState } from "./press"
|
|
4
|
+
import { theme } from "./theme"
|
|
5
|
+
import { policy } from "./policy"
|
|
6
|
+
import { space } from "./spacing"
|
|
7
|
+
import { typeStyle } from "./typography"
|
|
8
|
+
import type { StyleProps } from "./types"
|
|
9
|
+
|
|
10
|
+
export interface ItemProps {
|
|
11
|
+
// Leading content: an icon, avatar, checkbox, ...
|
|
12
|
+
startContent?: any
|
|
13
|
+
// Primary text. A string/number renders as themed body text; anything else
|
|
14
|
+
// as-is.
|
|
15
|
+
label: any
|
|
16
|
+
// Secondary line under the label. A string/number renders as themed muted
|
|
17
|
+
// caption text; anything else as-is.
|
|
18
|
+
description?: any
|
|
19
|
+
// Trailing content: a badge, timestamp, chevron, action, ...
|
|
20
|
+
endContent?: any
|
|
21
|
+
// Present = the row is interactive: hover/pressed overlay tints, focusable,
|
|
22
|
+
// Enter/remote activation. A returned promise defers further activations
|
|
23
|
+
// until it settles; non-thenable returns are ignored.
|
|
24
|
+
onPress?: () => unknown
|
|
25
|
+
// Fills the row with surfaceAlt to mark the current selection.
|
|
26
|
+
selected?: boolean
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
// Focus-navigation candidacy; defaults to true for interactive rows.
|
|
29
|
+
focusable?: boolean
|
|
30
|
+
ref?: (node: { id: number }) => void
|
|
31
|
+
layout?: LayoutProps
|
|
32
|
+
style?: StyleProps
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A list row: leading content, a label with an optional description under it,
|
|
36
|
+
// trailing content pushed to the end. The dense-data workhorse - rows compose
|
|
37
|
+
// with <For> inside a plain column (or a ScrollView); this package ships no
|
|
38
|
+
// List wrapper because a column view IS the list. Paddings and the gap are
|
|
39
|
+
// density-scaled, so a <Density> region compacts rows wholesale. With onPress
|
|
40
|
+
// the row presses like a menu entry: overlay tints for hover/pressed (no
|
|
41
|
+
// scale - rows sit flush in a list), focus ring under the focusRing policy.
|
|
42
|
+
export function Item(props: ItemProps) {
|
|
43
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
44
|
+
let styled = () => ({ ...theme.components.item, ...props.style })
|
|
45
|
+
let interactive = () => props.onPress != null && !props.disabled
|
|
46
|
+
let press = createPress(props)
|
|
47
|
+
|
|
48
|
+
let bg = () => styled().backgroundColor ?? (props.selected ? theme.color.surfaceAlt : "transparent")
|
|
49
|
+
let overlay = (s: PressState) =>
|
|
50
|
+
!interactive()
|
|
51
|
+
? "transparent"
|
|
52
|
+
: s.pressed
|
|
53
|
+
? theme.color.overlayPressed
|
|
54
|
+
: s.hovered && policy.interaction !== "touch"
|
|
55
|
+
? theme.color.overlayHover
|
|
56
|
+
: "transparent"
|
|
57
|
+
let radius = () => styled().borderRadius ?? theme.radius.sm
|
|
58
|
+
|
|
59
|
+
// Resolved once via children(): the typeof probe and the mount site must
|
|
60
|
+
// share one build (see Button).
|
|
61
|
+
let label = children(() => props.label)
|
|
62
|
+
let labelIsText = () => typeof label() === "string" || typeof label() === "number"
|
|
63
|
+
let description = children(() => props.description)
|
|
64
|
+
let descriptionIsText = () => typeof description() === "string" || typeof description() === "number"
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<view
|
|
68
|
+
ref={(n: { id: number }) => {
|
|
69
|
+
press.ref(n)
|
|
70
|
+
props.ref?.(n)
|
|
71
|
+
}}
|
|
72
|
+
repaintBoundary
|
|
73
|
+
flexDirection="row"
|
|
74
|
+
alignItems="center"
|
|
75
|
+
gap={space("md")}
|
|
76
|
+
paddingTop={space("md")}
|
|
77
|
+
paddingBottom={space("md")}
|
|
78
|
+
paddingLeft={space("lg")}
|
|
79
|
+
paddingRight={space("lg")}
|
|
80
|
+
{...props.layout}
|
|
81
|
+
x={styled().x}
|
|
82
|
+
y={styled().y}
|
|
83
|
+
scale={styled().scale}
|
|
84
|
+
rotate={styled().rotate}
|
|
85
|
+
opacity={props.disabled ? 0.5 : styled().opacity}
|
|
86
|
+
// A passive row attaches no press recognizer: it must not claim
|
|
87
|
+
// pointers from its children (a Switch in a settings row) or from an
|
|
88
|
+
// enclosing pressable. Interactivity is decided at mount.
|
|
89
|
+
{...(props.onPress != null ? press.handlers : {})}
|
|
90
|
+
focusable={(props.focusable ?? true) && interactive()}
|
|
91
|
+
pointerEvents={props.disabled ? "none" : undefined}
|
|
92
|
+
>
|
|
93
|
+
<d-rect color={bg()} radius={radius()} />
|
|
94
|
+
<d-rect color={overlay(press.state())} radius={radius()} />
|
|
95
|
+
{props.startContent}
|
|
96
|
+
<view flexDirection="column" flexGrow={1} flexShrink={1} gap={2}>
|
|
97
|
+
<Show when={labelIsText()} fallback={label()}>
|
|
98
|
+
<text color={theme.color.text} {...typeStyle("body")} maxLines={1}>
|
|
99
|
+
{label()}
|
|
100
|
+
</text>
|
|
101
|
+
</Show>
|
|
102
|
+
<Show when={props.description != null}>
|
|
103
|
+
<Show when={descriptionIsText()} fallback={description()}>
|
|
104
|
+
<text color={theme.color.textMuted} {...typeStyle("caption")} maxLines={1}>
|
|
105
|
+
{description()}
|
|
106
|
+
</text>
|
|
107
|
+
</Show>
|
|
108
|
+
</Show>
|
|
109
|
+
</view>
|
|
110
|
+
{props.endContent}
|
|
111
|
+
<Show when={press.focused() && policy.focusRing}>
|
|
112
|
+
<d-rect drawStyle="stroke" color={theme.color.text} strokeWidth={2} radius={radius()} />
|
|
113
|
+
</Show>
|
|
114
|
+
</view>
|
|
115
|
+
)
|
|
116
|
+
}
|
package/src/nav-shell.tsx
CHANGED
|
@@ -49,7 +49,7 @@ export function NavShell(props: NavShellProps) {
|
|
|
49
49
|
item.value === value()
|
|
50
50
|
? theme.color.surfaceAlt
|
|
51
51
|
: hovered && policy.interaction !== "touch"
|
|
52
|
-
? theme.color.
|
|
52
|
+
? theme.color.overlayHover
|
|
53
53
|
: "transparent"
|
|
54
54
|
|
|
55
55
|
// Icon over a small label, centered; shared by the tab bar and the rail.
|
package/src/policy.ts
CHANGED
|
@@ -118,11 +118,3 @@ export function setPolicy(partial: Partial<Policies>) {
|
|
|
118
118
|
setOverrides((prev) => ({ ...prev, ...partial }))
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// How density maps to component metrics: a multiplier on control sizes,
|
|
122
|
-
// paddings, and hit targets. Comfortable is the components' designed size.
|
|
123
|
-
const DENSITY_SCALE: Record<DensityPolicy, number> = { comfortable: 1, compact: 0.85, dense: 0.7 }
|
|
124
|
-
|
|
125
|
-
/** Reactive density multiplier for control metrics. */
|
|
126
|
-
export function densityScale(): number {
|
|
127
|
-
return DENSITY_SCALE[policy.density]
|
|
128
|
-
}
|
package/src/press.ts
CHANGED
|
@@ -6,10 +6,16 @@ import { registerNavAction } from "./focus-nav"
|
|
|
6
6
|
// so a consumer that reads one inside a JSX prop or child expression tracks that
|
|
7
7
|
// signal there and nothing else re-runs. Read them in those positions, not
|
|
8
8
|
// eagerly into a local, or the read lands in whatever scope destructured it.
|
|
9
|
-
export type PressState = { pressed: boolean; hovered: boolean; focused: boolean }
|
|
9
|
+
export type PressState = { pressed: boolean; hovered: boolean; focused: boolean; pending: boolean }
|
|
10
10
|
|
|
11
11
|
export interface PressOptions {
|
|
12
|
-
|
|
12
|
+
// A returned promise marks the press `pending` until it settles - further
|
|
13
|
+
// activations (pointer, key, remote) are ignored meanwhile, so an async
|
|
14
|
+
// action (save, submit) cannot double-fire. A rejection still clears
|
|
15
|
+
// pending and surfaces as an unhandled rejection. Typed `unknown` (not
|
|
16
|
+
// `void | Promise<void>`) so plain handlers like `() => setOpen(true)`
|
|
17
|
+
// keep compiling; any non-thenable return is ignored.
|
|
18
|
+
onPress?: () => unknown
|
|
13
19
|
disabled?: boolean
|
|
14
20
|
onPointerDown?: (e: PointerEvent) => void
|
|
15
21
|
onPointerUp?: (e: PointerEvent) => void
|
|
@@ -55,6 +61,28 @@ export function createPress(options: PressOptions) {
|
|
|
55
61
|
let node: { id: number } | null = null
|
|
56
62
|
let unregisterNav: (() => void) | null = null
|
|
57
63
|
|
|
64
|
+
// Async onPress: while a returned promise is unsettled the press is pending
|
|
65
|
+
// and activations are ignored. `inflight` is a plain boolean because signal
|
|
66
|
+
// writes flush on the microtask, so two activations in one dispatch would
|
|
67
|
+
// both read pending() as false; the signal exists for the UI.
|
|
68
|
+
let [pending, setPending] = createSignal(false)
|
|
69
|
+
let inflight = false
|
|
70
|
+
let activate = () => {
|
|
71
|
+
if (options.disabled || inflight) return
|
|
72
|
+
let result = options.onPress?.()
|
|
73
|
+
if (result && typeof (result as Promise<void>).then === "function") {
|
|
74
|
+
inflight = true
|
|
75
|
+
setPending(true)
|
|
76
|
+
// finally, not then(clear, clear): pending clears either way, but a
|
|
77
|
+
// rejection keeps propagating to the unhandled-rejection report
|
|
78
|
+
// instead of being swallowed here.
|
|
79
|
+
;(result as Promise<void>).finally(() => {
|
|
80
|
+
inflight = false
|
|
81
|
+
setPending(false)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
// Focus is derived from core's reactive focus rather than tracked through
|
|
59
87
|
// the onFocus/onBlur handlers - one source of truth. Memoized so a focus
|
|
60
88
|
// move propagates into styling only for the two controls whose value flips.
|
|
@@ -89,14 +117,15 @@ export function createPress(options: PressOptions) {
|
|
|
89
117
|
get focused() {
|
|
90
118
|
return focused()
|
|
91
119
|
},
|
|
120
|
+
get pending() {
|
|
121
|
+
return pending()
|
|
122
|
+
},
|
|
92
123
|
}
|
|
93
124
|
let state = (): PressState => live
|
|
94
125
|
let ref = (n: { id: number }) => {
|
|
95
126
|
node = n
|
|
96
127
|
unregisterNav?.()
|
|
97
|
-
unregisterNav = registerNavAction(n.id,
|
|
98
|
-
if (!options.disabled) options.onPress?.()
|
|
99
|
-
})
|
|
128
|
+
unregisterNav = registerNavAction(n.id, activate)
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
let within = (e: PointerEvent) => {
|
|
@@ -145,7 +174,7 @@ export function createPress(options: PressOptions) {
|
|
|
145
174
|
if (active === e.pointerId) {
|
|
146
175
|
let fire = inside
|
|
147
176
|
cancel()
|
|
148
|
-
if (fire)
|
|
177
|
+
if (fire) activate()
|
|
149
178
|
}
|
|
150
179
|
options.onPointerUp?.(e)
|
|
151
180
|
},
|
|
@@ -161,7 +190,7 @@ export function createPress(options: PressOptions) {
|
|
|
161
190
|
// The remote center key's `key` is "Unidentified"; match its code.
|
|
162
191
|
if ((e.key === "Enter" || e.key === " " || e.code === "Select") && !e.repeat && !options.disabled) {
|
|
163
192
|
e.stopPropagation()
|
|
164
|
-
|
|
193
|
+
activate()
|
|
165
194
|
}
|
|
166
195
|
options.onKeyDown?.(e)
|
|
167
196
|
},
|
|
@@ -173,5 +202,5 @@ export function createPress(options: PressOptions) {
|
|
|
173
202
|
},
|
|
174
203
|
}
|
|
175
204
|
|
|
176
|
-
return { pressed, hovered, focused, state, ref, handlers, cancel }
|
|
205
|
+
return { pressed, hovered, focused, pending, state, ref, handlers, cancel }
|
|
177
206
|
}
|