@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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Headless rich-text document buffer: the value model of a rich text editor
|
|
2
|
+
// (okf/backlog/rich-text-editor.md) over the same string offsets the text
|
|
3
|
+
// buffer and the editor geometry use. Flat, Delta-like: one text with "\n"
|
|
4
|
+
// between paragraphs, attributed runs tiling it, one attribute set per
|
|
5
|
+
// paragraph. Attributes are opaque key/values here; what {bold: true} or an
|
|
6
|
+
// atom's {atom: "image"} mean is the component's business.
|
|
7
|
+
|
|
8
|
+
import { createSignal, flush } from "@solidjs/signals"
|
|
9
|
+
import { createTextBuffer, type TextBuffer, type TextBufferOptions } from "@solidrt/core/text-input"
|
|
10
|
+
|
|
11
|
+
/** Formatting as opaque key/values, compared with `===`. */
|
|
12
|
+
export type Attributes = Record<string, string | number | boolean>
|
|
13
|
+
/** A change to apply: a `null` value removes the key. */
|
|
14
|
+
export type AttributePatch = Record<string, string | number | boolean | null>
|
|
15
|
+
|
|
16
|
+
/** An attributed range of the document text; runs tile the text. */
|
|
17
|
+
export type DocumentRun = { start: number; end: number; attributes: Attributes }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A rich text value. `text` is the plain text (what a caret moves over):
|
|
21
|
+
* "\n" separates paragraphs, an inline atom is one U+FFFC ({@link ATOM}).
|
|
22
|
+
* `runs` tile the text (none when it is empty) with no two neighbours
|
|
23
|
+
* carrying equal attributes; `blocks` holds one attribute set per paragraph
|
|
24
|
+
* (`\n` count + 1). Plain data: build one with {@link plainDocument}, or
|
|
25
|
+
* literally.
|
|
26
|
+
*/
|
|
27
|
+
export type Document = { text: string; runs: DocumentRun[]; blocks: Attributes[] }
|
|
28
|
+
|
|
29
|
+
/** The character standing in for an inline atom (object replacement character). */
|
|
30
|
+
export const ATOM = "\uFFFC"
|
|
31
|
+
|
|
32
|
+
/** A document of `text` with no formatting. */
|
|
33
|
+
export function plainDocument(text = ""): Document {
|
|
34
|
+
return {
|
|
35
|
+
text,
|
|
36
|
+
runs: text ? [{ start: 0, end: text.length, attributes: {} }] : [],
|
|
37
|
+
blocks: Array.from({ length: countNewlines(text, 0, text.length) + 1 }, () => ({})),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DocumentBufferOptions = {
|
|
42
|
+
/** Controlled document accessor; edits then flow out through onInput only. */
|
|
43
|
+
value?: () => Document | undefined
|
|
44
|
+
/** Initial document when uncontrolled. */
|
|
45
|
+
defaultValue?: Document
|
|
46
|
+
/** Called with the new document after every edit. */
|
|
47
|
+
onInput?: (document: Document) => void
|
|
48
|
+
maxLength?: TextBufferOptions["maxLength"]
|
|
49
|
+
step?: TextBufferOptions["step"]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type DocumentBuffer = TextBuffer & {
|
|
53
|
+
/** The current document (`value()` is its text). */
|
|
54
|
+
document(): Document
|
|
55
|
+
/**
|
|
56
|
+
* Inline attributes at the caret: the pending typing attributes if
|
|
57
|
+
* {@link format} was called on a collapsed selection, else those of the
|
|
58
|
+
* character before the caret (the ones typed text will take).
|
|
59
|
+
*/
|
|
60
|
+
attributes(): Attributes
|
|
61
|
+
/**
|
|
62
|
+
* Set or remove (`null`) inline attributes on the selection. On a
|
|
63
|
+
* collapsed selection they become the typing attributes of the next
|
|
64
|
+
* insert instead, dropped when the caret moves.
|
|
65
|
+
*/
|
|
66
|
+
format(patch: AttributePatch): void
|
|
67
|
+
/** Set or remove (`null`) block attributes on the paragraphs the selection touches. */
|
|
68
|
+
formatBlock(patch: AttributePatch): void
|
|
69
|
+
/** Replace the selection with an inline atom carrying `attributes`. */
|
|
70
|
+
insertAtom(attributes: Attributes): void
|
|
71
|
+
/** Replace the whole document, caret to the end. */
|
|
72
|
+
setDocument(next: Document): void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An editable rich text document with the contract of createTextBuffer
|
|
77
|
+
* (controlled/uncontrolled, buffer-owned selection, grapheme `step`,
|
|
78
|
+
* synchronous commits) plus formatting. Inserted text takes the inline
|
|
79
|
+
* attributes of the character before the caret; a "\n" splits its paragraph
|
|
80
|
+
* into two with the same block attributes, deleting one merges (the first
|
|
81
|
+
* paragraph's attributes win).
|
|
82
|
+
*/
|
|
83
|
+
export function createDocumentBuffer(options: DocumentBufferOptions = {}): DocumentBuffer {
|
|
84
|
+
let [internal, setInternal] = createSignal<Document>(options.defaultValue ?? plainDocument())
|
|
85
|
+
let current = () => options.value?.() ?? internal()
|
|
86
|
+
// Typing attributes set on a collapsed selection, valid while the caret
|
|
87
|
+
// stays where they were set.
|
|
88
|
+
let [pending, setPending] = createSignal<{ at: number; attributes: Attributes } | null>(null)
|
|
89
|
+
|
|
90
|
+
let commit = (next: Document) => {
|
|
91
|
+
if (options.value?.() == null) setInternal(next)
|
|
92
|
+
options.onInput?.(next)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let inherited = (offset: number): Attributes => {
|
|
96
|
+
let runs = current().runs
|
|
97
|
+
let at = offset > 0 ? offset - 1 : 0
|
|
98
|
+
return runs.find((r) => r.start <= at && at < r.end)?.attributes ?? {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let text = createTextBuffer({
|
|
102
|
+
value: () => current().text,
|
|
103
|
+
maxLength: options.maxLength,
|
|
104
|
+
step: options.step,
|
|
105
|
+
onReplace: (start, end, inserted) => {
|
|
106
|
+
let doc = current()
|
|
107
|
+
let typing = pending()
|
|
108
|
+
let attributes = typing && typing.at === start && start === end ? typing.attributes : inherited(start)
|
|
109
|
+
setPending(null)
|
|
110
|
+
let paragraph = countNewlines(doc.text, 0, start)
|
|
111
|
+
let removed = countNewlines(doc.text, start, end)
|
|
112
|
+
let added = countNewlines(inserted, 0, inserted.length)
|
|
113
|
+
let block = doc.blocks[paragraph] ?? {}
|
|
114
|
+
commit({
|
|
115
|
+
text: doc.text.slice(0, start) + inserted + doc.text.slice(end),
|
|
116
|
+
runs: spliceRuns(doc.runs, start, end, inserted.length, attributes),
|
|
117
|
+
blocks: [
|
|
118
|
+
...doc.blocks.slice(0, paragraph),
|
|
119
|
+
...Array.from({ length: added + 1 }, () => block),
|
|
120
|
+
...doc.blocks.slice(paragraph + removed + 1),
|
|
121
|
+
],
|
|
122
|
+
})
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
let range = (): [number, number] => {
|
|
127
|
+
let { anchor, focus } = text.selection()
|
|
128
|
+
return anchor <= focus ? [anchor, focus] : [focus, anchor]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let attributes = (): Attributes => {
|
|
132
|
+
let typing = pending()
|
|
133
|
+
let caret = text.caret()
|
|
134
|
+
return typing && typing.at === caret ? typing.attributes : inherited(caret)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
...text,
|
|
139
|
+
move: (direction, opts) => {
|
|
140
|
+
setPending(null)
|
|
141
|
+
text.move(direction, opts)
|
|
142
|
+
},
|
|
143
|
+
setSelection: (anchor, focus) => {
|
|
144
|
+
setPending(null)
|
|
145
|
+
text.setSelection(anchor, focus)
|
|
146
|
+
},
|
|
147
|
+
document: current,
|
|
148
|
+
attributes,
|
|
149
|
+
format: (patch) => {
|
|
150
|
+
let [start, end] = range()
|
|
151
|
+
if (start === end) {
|
|
152
|
+
setPending({ at: start, attributes: patched(attributes(), patch) })
|
|
153
|
+
flush()
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
let doc = current()
|
|
157
|
+
commit({ ...doc, runs: formatRuns(doc.runs, start, end, patch) })
|
|
158
|
+
flush()
|
|
159
|
+
},
|
|
160
|
+
formatBlock: (patch) => {
|
|
161
|
+
let [start, end] = range()
|
|
162
|
+
let doc = current()
|
|
163
|
+
let first = countNewlines(doc.text, 0, start)
|
|
164
|
+
let last = countNewlines(doc.text, 0, end)
|
|
165
|
+
commit({ ...doc, blocks: doc.blocks.map((b, i) => (first <= i && i <= last ? patched(b, patch) : b)) })
|
|
166
|
+
flush()
|
|
167
|
+
},
|
|
168
|
+
insertAtom: (attributes) => {
|
|
169
|
+
let [start, end] = range()
|
|
170
|
+
if (start !== end) text.insertText("")
|
|
171
|
+
setPending({ at: start, attributes })
|
|
172
|
+
flush()
|
|
173
|
+
text.insertText(ATOM)
|
|
174
|
+
},
|
|
175
|
+
setDocument: (next) => {
|
|
176
|
+
commit(next)
|
|
177
|
+
text.setSelection(next.text.length, next.text.length)
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function countNewlines(text: string, start: number, end: number): number {
|
|
183
|
+
let n = 0
|
|
184
|
+
for (let i = start; i < end; i++) if (text.charCodeAt(i) === 10) n++
|
|
185
|
+
return n
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sameAttributes(a: Attributes, b: Attributes): boolean {
|
|
189
|
+
let keys = Object.keys(a)
|
|
190
|
+
return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k])
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function patched(attributes: Attributes, patch: AttributePatch): Attributes {
|
|
194
|
+
let out: Attributes = { ...attributes }
|
|
195
|
+
for (let key of Object.keys(patch)) {
|
|
196
|
+
let value = patch[key]
|
|
197
|
+
if (value == null) delete out[key]
|
|
198
|
+
else out[key] = value
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Drop empty runs and merge equal neighbours: the tiling invariant.
|
|
204
|
+
function normalize(runs: DocumentRun[]): DocumentRun[] {
|
|
205
|
+
let out: DocumentRun[] = []
|
|
206
|
+
for (let run of runs) {
|
|
207
|
+
if (run.end <= run.start) continue
|
|
208
|
+
let last = out[out.length - 1]
|
|
209
|
+
if (last && sameAttributes(last.attributes, run.attributes)) last.end = run.end
|
|
210
|
+
else out.push({ ...run })
|
|
211
|
+
}
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Runs after replacing [start, end) by `length` characters in `attributes`.
|
|
216
|
+
function spliceRuns(runs: DocumentRun[], start: number, end: number, length: number, attributes: Attributes): DocumentRun[] {
|
|
217
|
+
let delta = length - (end - start)
|
|
218
|
+
let out: DocumentRun[] = []
|
|
219
|
+
let inserted = false
|
|
220
|
+
for (let run of runs) {
|
|
221
|
+
if (run.start < start) out.push({ start: run.start, end: Math.min(run.end, start), attributes: run.attributes })
|
|
222
|
+
if (!inserted && run.end >= start) {
|
|
223
|
+
out.push({ start, end: start + length, attributes })
|
|
224
|
+
inserted = true
|
|
225
|
+
}
|
|
226
|
+
if (run.end > end) out.push({ start: Math.max(run.start, end) + delta, end: run.end + delta, attributes: run.attributes })
|
|
227
|
+
}
|
|
228
|
+
if (!inserted) out.push({ start, end: start + length, attributes })
|
|
229
|
+
return normalize(out)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Runs with `patch` applied over [start, end).
|
|
233
|
+
function formatRuns(runs: DocumentRun[], start: number, end: number, patch: AttributePatch): DocumentRun[] {
|
|
234
|
+
let out: DocumentRun[] = []
|
|
235
|
+
for (let run of runs) {
|
|
236
|
+
let from = Math.max(run.start, start)
|
|
237
|
+
let to = Math.min(run.end, end)
|
|
238
|
+
if (from >= to) {
|
|
239
|
+
out.push(run)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
if (run.start < from) out.push({ start: run.start, end: from, attributes: run.attributes })
|
|
243
|
+
out.push({ start: from, end: to, attributes: patched(run.attributes, patch) })
|
|
244
|
+
if (to < run.end) out.push({ start: to, end: run.end, attributes: run.attributes })
|
|
245
|
+
}
|
|
246
|
+
return normalize(out)
|
|
247
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { untrack } from "@solidrt/core"
|
|
2
|
+
import type { LayoutProps, TextInputHints } from "@solidrt/core"
|
|
3
|
+
import type { TextRunRange } from "flux:rendertree"
|
|
4
|
+
import { EditorField } from "./editor-field"
|
|
5
|
+
import { createDocumentBuffer, type Attributes, type Document, type DocumentBuffer } from "./rich-text-document"
|
|
6
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
7
|
+
import { theme } from "./theme"
|
|
8
|
+
import { policy } from "./policy"
|
|
9
|
+
|
|
10
|
+
export interface RichTextEditorProps extends TransitionProps {
|
|
11
|
+
value?: Document
|
|
12
|
+
defaultValue?: Document
|
|
13
|
+
onInput?: (value: Document) => void
|
|
14
|
+
/**
|
|
15
|
+
* Receives the editor's document buffer, the formatting API: `format`,
|
|
16
|
+
* `formatBlock`, `insertAtom`, `attributes` (for toolbar state), plus the
|
|
17
|
+
* text buffer's selection and edit methods. The app renders its own
|
|
18
|
+
* controls around the editor and calls these.
|
|
19
|
+
*/
|
|
20
|
+
editorRef?: (editor: DocumentBuffer) => void
|
|
21
|
+
onFocus?: () => void
|
|
22
|
+
onBlur?: () => void
|
|
23
|
+
|
|
24
|
+
placeholder?: string
|
|
25
|
+
disabled?: boolean
|
|
26
|
+
autoFocus?: boolean
|
|
27
|
+
/** Without a `layout.height`: rows to grow to before scrolling. Default unbounded. */
|
|
28
|
+
maxRows?: number
|
|
29
|
+
hints?: TextInputHints
|
|
30
|
+
|
|
31
|
+
ref?: (node: { id: number }) => void
|
|
32
|
+
layout?: LayoutProps
|
|
33
|
+
style?: StyleProps
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The attributes the editor draws (anything else is carried in the document
|
|
37
|
+
// and ignored here). Inline: bold, italic, underline, code (mono), color,
|
|
38
|
+
// link (a URL string: primary color, underlined). Block: heading 1-3.
|
|
39
|
+
// Font-affecting ones also feed the geometry (prepareText runs), so the
|
|
40
|
+
// caret and wrapping follow the drawn glyphs.
|
|
41
|
+
type Font = Pick<TextRunRange, "fontFamily" | "fontSize" | "fontStyle" | "fontWeight">
|
|
42
|
+
|
|
43
|
+
function fontOf(inline: Attributes, block: Attributes, base: number): Font {
|
|
44
|
+
let font: Font = {}
|
|
45
|
+
let heading = block.heading
|
|
46
|
+
if (heading === 1) font.fontSize = theme.text.heading.size * policy.textScale
|
|
47
|
+
else if (heading === 2) font.fontSize = theme.text.title.size * policy.textScale
|
|
48
|
+
else if (heading === 3) font.fontSize = base
|
|
49
|
+
if (heading === 1 || heading === 2 || heading === 3 || inline.bold) font.fontWeight = 700
|
|
50
|
+
if (inline.italic) font.fontStyle = "italic"
|
|
51
|
+
if (inline.code) font.fontFamily = theme.text.monoFamily
|
|
52
|
+
return font
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A document as style intervals: the document runs cut at paragraph
|
|
56
|
+
// boundaries, each with the paragraph's block attributes alongside.
|
|
57
|
+
type Interval = { start: number; end: number; inline: Attributes; block: Attributes }
|
|
58
|
+
|
|
59
|
+
function intervals(doc: Document): Interval[] {
|
|
60
|
+
let out: Interval[] = []
|
|
61
|
+
let paragraph = 0
|
|
62
|
+
for (let run of doc.runs) {
|
|
63
|
+
let at = run.start
|
|
64
|
+
// Runs are clamped to the text: a malformed document draws what it can.
|
|
65
|
+
let runEnd = Math.min(run.end, doc.text.length)
|
|
66
|
+
while (at < runEnd) {
|
|
67
|
+
let next = doc.text.indexOf("\n", at)
|
|
68
|
+
let paragraphEnd = next < 0 ? doc.text.length : next + 1
|
|
69
|
+
let end = Math.min(runEnd, paragraphEnd)
|
|
70
|
+
out.push({ start: at, end, inline: run.attributes, block: doc.blocks[paragraph] ?? {} })
|
|
71
|
+
if (end === paragraphEnd && next >= 0) paragraph++
|
|
72
|
+
at = end
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Edits a rich text {@link Document} (styled runs, paragraph attributes,
|
|
80
|
+
* inline atoms as U+FFFC) in the TextInput field: same focus, caret, keys,
|
|
81
|
+
* wrapping and scrolling, always multiline. Formatting is driven through
|
|
82
|
+
* `editorRef` (the document buffer), not a built-in toolbar. Atoms are drawn
|
|
83
|
+
* as their placeholder character for now.
|
|
84
|
+
*/
|
|
85
|
+
export function RichTextEditor(props: RichTextEditorProps) {
|
|
86
|
+
let editor!: DocumentBuffer
|
|
87
|
+
let base = () => theme.text.body.size * policy.textScale
|
|
88
|
+
let doc = () => editor.document()
|
|
89
|
+
|
|
90
|
+
// Geometry runs: every interval whose font differs from the base.
|
|
91
|
+
let runs = (): TextRunRange[] => {
|
|
92
|
+
let size = base()
|
|
93
|
+
let out: TextRunRange[] = []
|
|
94
|
+
for (let { start, end, inline, block } of intervals(doc())) {
|
|
95
|
+
let font = fontOf(inline, block, size)
|
|
96
|
+
if (Object.keys(font).length === 0) continue
|
|
97
|
+
out.push({ start, end, ...font })
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Only set props are passed: an explicit undefined would reach the tree.
|
|
103
|
+
let spanProps = (i: Interval): Record<string, unknown> => {
|
|
104
|
+
let out: Record<string, unknown> = fontOf(i.inline, i.block, base())
|
|
105
|
+
let link = typeof i.inline.link === "string"
|
|
106
|
+
if (typeof i.inline.color === "string") out.color = i.inline.color
|
|
107
|
+
else if (link) out.color = theme.color.primary
|
|
108
|
+
if (i.inline.underline || link) out.textDecoration = "underline"
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<EditorField
|
|
114
|
+
transition={props.transition}
|
|
115
|
+
onTransitionEnd={props.onTransitionEnd}
|
|
116
|
+
buffer={(step) => {
|
|
117
|
+
editor = createDocumentBuffer({
|
|
118
|
+
value: () => props.value,
|
|
119
|
+
defaultValue: untrack(() => props.defaultValue),
|
|
120
|
+
onInput: (d) => props.onInput?.(d),
|
|
121
|
+
step,
|
|
122
|
+
})
|
|
123
|
+
props.editorRef?.(editor)
|
|
124
|
+
return editor
|
|
125
|
+
}}
|
|
126
|
+
runs={runs}
|
|
127
|
+
renderLine={({ line, font, color }) => (
|
|
128
|
+
<d-text y={line().y} w={line().width + 1} {...font()} color={color()} maxLines={1}>
|
|
129
|
+
{intervals(doc())
|
|
130
|
+
.filter((i) => i.end > line().start && i.start < line().end)
|
|
131
|
+
.map((i) => (
|
|
132
|
+
<span {...spanProps(i)}>
|
|
133
|
+
{doc().text.slice(Math.max(i.start, line().start), Math.min(i.end, line().end))}
|
|
134
|
+
</span>
|
|
135
|
+
))}
|
|
136
|
+
</d-text>
|
|
137
|
+
)}
|
|
138
|
+
onFocus={props.onFocus}
|
|
139
|
+
onBlur={props.onBlur}
|
|
140
|
+
placeholder={props.placeholder}
|
|
141
|
+
disabled={props.disabled}
|
|
142
|
+
autoFocus={props.autoFocus}
|
|
143
|
+
multiline
|
|
144
|
+
maxRows={props.maxRows}
|
|
145
|
+
hints={props.hints}
|
|
146
|
+
ref={props.ref}
|
|
147
|
+
layout={props.layout}
|
|
148
|
+
style={{ ...theme.components.richTextEditor, ...props.style }}
|
|
149
|
+
/>
|
|
150
|
+
)
|
|
151
|
+
}
|
package/src/scroll-view.tsx
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { createPan, createScroll } from "@solidrt/core"
|
|
2
|
-
import type { LayoutProps, PointerProps, WheelEvent } from "@solidrt/core"
|
|
3
|
-
import type { StyleProps } from "./types"
|
|
1
|
+
import { createPan, createScroll, createSignal, onSettled, untrack } from "@solidrt/core"
|
|
2
|
+
import type { LayoutProps, PointerProps, Scroll, WheelEvent } from "@solidrt/core"
|
|
3
|
+
import type { StyleProps, TransitionProps, TransitionScrollProp, TransitionStyleProp, TransitionViewProp } from "./types"
|
|
4
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
4
5
|
|
|
5
|
-
export interface ScrollViewProps
|
|
6
|
+
export interface ScrollViewProps
|
|
7
|
+
extends PointerProps,
|
|
8
|
+
TransitionProps<TransitionViewProp | TransitionStyleProp | TransitionScrollProp> {
|
|
6
9
|
children?: any
|
|
7
10
|
ref?: (node: { id: number }) => void
|
|
8
11
|
layout?: LayoutProps
|
|
9
12
|
style?: StyleProps
|
|
10
13
|
/** Scroll the horizontal axis instead of the vertical one. */
|
|
11
14
|
horizontal?: boolean
|
|
15
|
+
/** Receives the scroll handle (offset, range, scrollTo) for driving the view
|
|
16
|
+
* from app code; scroll policies such as following a growing log are written
|
|
17
|
+
* against it. Called once the component has settled, outside any reactive
|
|
18
|
+
* scope, so a signal setter can be passed directly. */
|
|
19
|
+
scrollRef?: (scroll: Scroll) => void
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
// A scrollable region. The outer box carries layout/style/transform and the
|
|
@@ -21,31 +29,81 @@ export interface ScrollViewProps extends PointerProps {
|
|
|
21
29
|
// stealing the pointer from a pressable the drag started on (its press
|
|
22
30
|
// feedback retracts), and keeps scrolling when the pointer leaves the box.
|
|
23
31
|
// There is no momentum yet; a fling stops when the finger lifts.
|
|
32
|
+
//
|
|
33
|
+
// Motion: the offset is written as a target and the runtime springs to it,
|
|
34
|
+
// so a wheel tick glides instead of jumping and a burst of ticks retargets
|
|
35
|
+
// one continuous motion. While a finger drags, the spring is withdrawn from
|
|
36
|
+
// the viewport declaration so the content tracks the finger exactly; the
|
|
37
|
+
// first drag write cancels any spring still in flight. A `scrollX`/`scrollY`
|
|
38
|
+
// entry in the `transition` prop replaces the default.
|
|
39
|
+
const SCROLL_SPRING = { duration: 250 }
|
|
40
|
+
|
|
24
41
|
export function ScrollView(props: ScrollViewProps) {
|
|
25
42
|
let viewport: { id: number } | undefined
|
|
26
43
|
let content: { id: number } | undefined
|
|
44
|
+
let [dragging, setDragging] = createSignal(false)
|
|
27
45
|
|
|
28
46
|
let scroll = createScroll(
|
|
29
47
|
() => viewport,
|
|
30
48
|
() => content,
|
|
31
49
|
{ axis: props.horizontal ? "horizontal" : "vertical" },
|
|
32
50
|
)
|
|
51
|
+
// Handed out from onSettled rather than the body: the body is an owned
|
|
52
|
+
// scope, where a signal write (an app passing its setter) is refused.
|
|
53
|
+
onSettled(() => {
|
|
54
|
+
untrack(() => props.scrollRef)?.(scroll)
|
|
55
|
+
})
|
|
33
56
|
|
|
34
57
|
// Content follows the finger: it moves opposite to scroll offsets, which
|
|
35
58
|
// grow toward the bottom/right.
|
|
36
59
|
let pan = createPan({
|
|
37
60
|
axis: props.horizontal ? "horizontal" : "vertical",
|
|
38
|
-
|
|
61
|
+
onPanStart: () => setDragging(true),
|
|
62
|
+
onPanMove: (dx, dy) => scroll.scrollBy({ x: -dx, y: -dy }),
|
|
63
|
+
onPanEnd: () => setDragging(false),
|
|
39
64
|
})
|
|
40
65
|
|
|
41
66
|
let onWheel = (e: WheelEvent) => {
|
|
42
67
|
// A plain mouse wheel only emits deltaY. On a horizontal scroller, route that
|
|
43
68
|
// vertical delta to the x axis so the wheel still scrolls it (trackpads that
|
|
44
69
|
// emit deltaX take precedence).
|
|
45
|
-
if (props.horizontal) scroll.scrollBy(e.deltaX || e.deltaY
|
|
46
|
-
else scroll.scrollBy(e.deltaX, e.deltaY)
|
|
70
|
+
if (props.horizontal) scroll.scrollBy({ x: e.deltaX || e.deltaY })
|
|
71
|
+
else scroll.scrollBy({ x: e.deltaX, y: e.deltaY })
|
|
47
72
|
}
|
|
48
73
|
|
|
74
|
+
// The viewport owns the scroll offset, the outer box everything else: a
|
|
75
|
+
// scrollX/scrollY entry is lifted out of the root declaration so that a
|
|
76
|
+
// shared `all` does not animate opacity twice (outer times viewport).
|
|
77
|
+
let split = () => {
|
|
78
|
+
let t = splitTransition(props.transition)
|
|
79
|
+
if (t.root == null || typeof t.root === "string") return { ...t, viewport: t.root }
|
|
80
|
+
let { scrollX, scrollY, ...rest } = t.root as Record<string, unknown>
|
|
81
|
+
let viewport: Record<string, unknown> = {}
|
|
82
|
+
if (scrollX !== undefined) viewport.scrollX = scrollX
|
|
83
|
+
if (scrollY !== undefined) viewport.scrollY = scrollY
|
|
84
|
+
if (rest.all !== undefined) viewport.all = rest.all
|
|
85
|
+
return {
|
|
86
|
+
...t,
|
|
87
|
+
root: Object.keys(rest).length ? (rest as typeof t.root) : undefined,
|
|
88
|
+
viewport: Object.keys(viewport).length ? (viewport as typeof t.root) : undefined,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// The viewport's declaration: the user's scroll entries over the default
|
|
92
|
+
// spring. During a drag, and while the latest programmatic write asked for
|
|
93
|
+
// no motion (scrollTo behavior "instant"), the scroll entries go, and a
|
|
94
|
+
// user `all` narrows to the one other property the viewport writes
|
|
95
|
+
// (clipRadius) so it cannot put a spring back under the finger or the
|
|
96
|
+
// instant write.
|
|
97
|
+
let viewportTransition = () => {
|
|
98
|
+
let user = split().viewport
|
|
99
|
+
let entries: Record<string, unknown> = typeof user === "string" ? { all: user } : { ...(user ?? {}) }
|
|
100
|
+
if (dragging() || scroll.behavior() === "instant") {
|
|
101
|
+
let { scrollX, scrollY, all, ...rest } = entries
|
|
102
|
+
if (all !== undefined) rest.clipRadius = all
|
|
103
|
+
return Object.keys(rest).length ? rest : null
|
|
104
|
+
}
|
|
105
|
+
return { scrollX: SCROLL_SPRING, scrollY: SCROLL_SPRING, ...entries }
|
|
106
|
+
}
|
|
49
107
|
let direction = () => (props.horizontal ? "row" : "column")
|
|
50
108
|
let hasBackground = () =>
|
|
51
109
|
props.style?.backgroundColor != null || props.style?.borderRadius != null
|
|
@@ -53,6 +111,8 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
53
111
|
|
|
54
112
|
return (
|
|
55
113
|
<view
|
|
114
|
+
transition={split().root}
|
|
115
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
56
116
|
ref={props.ref}
|
|
57
117
|
{...props.layout}
|
|
58
118
|
x={props.style?.x}
|
|
@@ -70,16 +130,23 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
70
130
|
>
|
|
71
131
|
{hasBackground() ? (
|
|
72
132
|
<d-rect
|
|
133
|
+
transition={split().background}
|
|
134
|
+
onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
73
135
|
color={props.style?.backgroundColor ?? "transparent"}
|
|
74
136
|
radius={props.style?.borderRadius}
|
|
75
137
|
/>
|
|
76
138
|
) : null}
|
|
139
|
+
{/* transition before scrollX/scrollY: props apply in source order, and
|
|
140
|
+
an instant write needs the withdrawn declaration to land before the
|
|
141
|
+
value in the same flush, or the value starts a spring anyway. */}
|
|
77
142
|
<view
|
|
78
143
|
ref={(n: { id: number }) => (viewport = n)}
|
|
79
144
|
flex={1}
|
|
80
145
|
overflow="hidden"
|
|
81
146
|
clipRadius={props.style?.borderRadius}
|
|
82
147
|
flexDirection={direction()}
|
|
148
|
+
transition={viewportTransition()}
|
|
149
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
83
150
|
scrollX={scroll.offset().x}
|
|
84
151
|
scrollY={scroll.offset().y}
|
|
85
152
|
{...pan.handlers}
|
|
@@ -92,6 +159,8 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
92
159
|
{hasBorder() ? (
|
|
93
160
|
<d-rect
|
|
94
161
|
drawStyle="stroke"
|
|
162
|
+
transition={split().border}
|
|
163
|
+
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
95
164
|
color={props.style?.borderColor ?? "transparent"}
|
|
96
165
|
strokeWidth={props.style?.borderWidth}
|
|
97
166
|
radius={props.style?.borderRadius}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { createSignal, For } from "@solidrt/core"
|
|
1
|
+
import { createSignal, For, Show } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { createPress } from "./press"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
5
|
import { policy } from "./policy"
|
|
6
6
|
import { space } from "./spacing"
|
|
7
7
|
import { typeStyle, lightOnDark } from "./typography"
|
|
8
|
-
import type { Option, StyleProps } from "./types"
|
|
8
|
+
import type { Option, StyleProps, TransitionProps } from "./types"
|
|
9
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
9
10
|
|
|
10
|
-
export interface SegmentedControlProps {
|
|
11
|
+
export interface SegmentedControlProps extends TransitionProps {
|
|
11
12
|
options: Option[]
|
|
12
13
|
// Controlled selected value. If omitted, the control is uncontrolled.
|
|
13
14
|
value?: unknown
|
|
@@ -37,8 +38,12 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
37
38
|
props.onChange?.(v)
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
42
|
+
let styled = () => ({ ...theme.components.segmentedControl, ...props.style })
|
|
43
|
+
let radius = () => {
|
|
44
|
+
let r = styled().borderRadius
|
|
45
|
+
return typeof r === "number" ? r : theme.radius.md
|
|
46
|
+
}
|
|
42
47
|
// Per-corner radii: round only the corners on the control's outer edge, so
|
|
43
48
|
// the segments read as one joined control. [tl, tr, br, bl].
|
|
44
49
|
let corners = (i: number): number | [number, number, number, number] => {
|
|
@@ -50,34 +55,38 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
50
55
|
return 0
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
let idleFill = () =>
|
|
58
|
+
let idleFill = () => styled().backgroundColor ?? theme.color.surfaceAlt
|
|
54
59
|
let activeFill = () => (props.disabled ? theme.color.surface : theme.color.primary)
|
|
55
60
|
let label = (active: boolean) =>
|
|
56
61
|
props.disabled ? theme.color.textMuted : active ? theme.color.onPrimary : theme.color.text
|
|
57
62
|
|
|
63
|
+
let split = () => splitTransition(props.transition)
|
|
64
|
+
|
|
58
65
|
return (
|
|
59
66
|
<view
|
|
67
|
+
transition={split().root}
|
|
68
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
60
69
|
flexDirection="row"
|
|
61
70
|
gap={DIVIDER}
|
|
62
71
|
{...props.layout}
|
|
63
|
-
x={
|
|
64
|
-
y={
|
|
65
|
-
scale={
|
|
66
|
-
rotate={
|
|
67
|
-
opacity={
|
|
72
|
+
x={styled().x}
|
|
73
|
+
y={styled().y}
|
|
74
|
+
scale={styled().scale}
|
|
75
|
+
rotate={styled().rotate}
|
|
76
|
+
opacity={styled().opacity}
|
|
68
77
|
>
|
|
69
|
-
<d-rect color={theme.color.border} radius={radius()} />
|
|
78
|
+
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={theme.color.border} radius={radius()} />
|
|
70
79
|
<For each={props.options}>
|
|
71
80
|
{(opt, i) => {
|
|
72
81
|
let active = () => value() === opt.value
|
|
73
82
|
let press = createPress({ onPress: () => select(opt.value) })
|
|
74
|
-
let fill = () =>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
let fill = () => (active() ? activeFill() : idleFill())
|
|
84
|
+
// Hover feedback: the theme overlay tint drawn over the segment fill.
|
|
85
|
+
let overlay = () =>
|
|
86
|
+
press.hovered() && !props.disabled && policy.interaction !== "touch"
|
|
87
|
+
? theme.color.overlayHover
|
|
88
|
+
: "transparent"
|
|
89
|
+
return (
|
|
81
90
|
<view
|
|
82
91
|
ref={press.ref}
|
|
83
92
|
repaintBoundary
|
|
@@ -89,9 +98,14 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
89
98
|
paddingLeft={space("md")}
|
|
90
99
|
paddingRight={space("md")}
|
|
91
100
|
{...press.handlers}
|
|
101
|
+
focusable={!props.disabled}
|
|
92
102
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
93
103
|
>
|
|
94
104
|
<d-rect color={fill()} radius={corners(i())} />
|
|
105
|
+
<d-rect color={overlay()} radius={corners(i())} />
|
|
106
|
+
<Show when={press.focused() && policy.focusRing}>
|
|
107
|
+
<d-rect drawStyle="stroke" color={theme.color.ring} strokeWidth={theme.borderWidth.focus} radius={corners(i())} />
|
|
108
|
+
</Show>
|
|
95
109
|
<text
|
|
96
110
|
color={label(active())}
|
|
97
111
|
{...typeStyle("body", active() ? lightOnDark(label(true), activeFill()) : undefined)}
|