@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
package/src/pressable.tsx
CHANGED
|
@@ -14,7 +14,10 @@ export interface PressableProps extends PointerProps {
|
|
|
14
14
|
ref?: (node: { id: number }) => void
|
|
15
15
|
layout?: LayoutProps
|
|
16
16
|
style?: StyleProps | ((state: PressState) => StyleProps)
|
|
17
|
-
|
|
17
|
+
// A returned promise sets `state.pending` until it settles; further
|
|
18
|
+
// presses are ignored meanwhile (no double-fire). Non-thenable returns
|
|
19
|
+
// are ignored.
|
|
20
|
+
onPress?: () => unknown
|
|
18
21
|
disabled?: boolean
|
|
19
22
|
}
|
|
20
23
|
|
package/src/progress-bar.tsx
CHANGED
|
@@ -27,8 +27,10 @@ let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi :
|
|
|
27
27
|
export function ProgressBar(props: ProgressBarProps) {
|
|
28
28
|
let h = () => (props.layout?.height as number) ?? HEIGHT
|
|
29
29
|
let radius = () => h() / 2
|
|
30
|
-
|
|
31
|
-
let
|
|
30
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
31
|
+
let styled = () => ({ ...theme.components.progressBar, ...props.style })
|
|
32
|
+
let track = () => styled().backgroundColor ?? theme.color.surfaceAlt
|
|
33
|
+
let fill = () => styled().color ?? theme.color.primary
|
|
32
34
|
let indeterminate = () => props.value === undefined
|
|
33
35
|
|
|
34
36
|
// Measured track width in pixels. Both the determinate fill and the
|
package/src/radio.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { createSignal, createContext, useContext, Show, children } from "@solidr
|
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { createPress } from "./press"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
|
-
import { densityScale } from "./
|
|
5
|
+
import { densityScale } from "./density"
|
|
6
6
|
import { typeStyle } from "./typography"
|
|
7
7
|
import type { StyleProps } from "./types"
|
|
8
8
|
|
|
@@ -81,6 +81,8 @@ export function Radio(props: RadioProps) {
|
|
|
81
81
|
// Inner dot inset as a fraction of the ring, so it scales with the density.
|
|
82
82
|
let inset = () => ring() * 0.3
|
|
83
83
|
|
|
84
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
85
|
+
let styled = () => ({ ...theme.components.radio, ...props.style })
|
|
84
86
|
let press = createPress({ onPress: () => ctx.select(props.value) })
|
|
85
87
|
|
|
86
88
|
return (
|
|
@@ -91,16 +93,16 @@ export function Radio(props: RadioProps) {
|
|
|
91
93
|
alignItems="center"
|
|
92
94
|
gap={theme.spacing.md}
|
|
93
95
|
{...props.layout}
|
|
94
|
-
x={
|
|
95
|
-
y={
|
|
96
|
-
scale={
|
|
97
|
-
rotate={
|
|
98
|
-
opacity={
|
|
96
|
+
x={styled().x}
|
|
97
|
+
y={styled().y}
|
|
98
|
+
scale={styled().scale}
|
|
99
|
+
rotate={styled().rotate}
|
|
100
|
+
opacity={styled().opacity}
|
|
99
101
|
{...press.handlers}
|
|
100
102
|
pointerEvents={disabled() ? "none" : undefined}
|
|
101
103
|
>
|
|
102
|
-
<Show when={
|
|
103
|
-
<d-rect color={
|
|
104
|
+
<Show when={styled().backgroundColor != null || styled().borderRadius != null}>
|
|
105
|
+
<d-rect color={styled().backgroundColor ?? "transparent"} radius={styled().borderRadius} />
|
|
104
106
|
</Show>
|
|
105
107
|
<view width={ring()} height={ring()}>
|
|
106
108
|
<d-oval drawStyle="stroke" color={ringColor()} strokeWidth={2} />
|
|
@@ -113,12 +115,12 @@ export function Radio(props: RadioProps) {
|
|
|
113
115
|
{resolved()}
|
|
114
116
|
</text>
|
|
115
117
|
</Show>
|
|
116
|
-
<Show when={(
|
|
118
|
+
<Show when={(styled().borderWidth ?? 0) > 0}>
|
|
117
119
|
<d-rect
|
|
118
120
|
drawStyle="stroke"
|
|
119
|
-
color={
|
|
120
|
-
strokeWidth={
|
|
121
|
-
radius={
|
|
121
|
+
color={styled().borderColor ?? "transparent"}
|
|
122
|
+
strokeWidth={styled().borderWidth}
|
|
123
|
+
radius={styled().borderRadius}
|
|
122
124
|
/>
|
|
123
125
|
</Show>
|
|
124
126
|
</view>
|
|
@@ -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,149 @@
|
|
|
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 } from "./types"
|
|
7
|
+
import { theme } from "./theme"
|
|
8
|
+
import { policy } from "./policy"
|
|
9
|
+
|
|
10
|
+
export interface RichTextEditorProps {
|
|
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 = "mono"
|
|
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
|
+
buffer={(step) => {
|
|
115
|
+
editor = createDocumentBuffer({
|
|
116
|
+
value: () => props.value,
|
|
117
|
+
defaultValue: untrack(() => props.defaultValue),
|
|
118
|
+
onInput: (d) => props.onInput?.(d),
|
|
119
|
+
step,
|
|
120
|
+
})
|
|
121
|
+
props.editorRef?.(editor)
|
|
122
|
+
return editor
|
|
123
|
+
}}
|
|
124
|
+
runs={runs}
|
|
125
|
+
renderLine={({ line, font, color }) => (
|
|
126
|
+
<d-text y={line().y} w={line().width + 1} {...font()} color={color()} maxLines={1}>
|
|
127
|
+
{intervals(doc())
|
|
128
|
+
.filter((i) => i.end > line().start && i.start < line().end)
|
|
129
|
+
.map((i) => (
|
|
130
|
+
<span {...spanProps(i)}>
|
|
131
|
+
{doc().text.slice(Math.max(i.start, line().start), Math.min(i.end, line().end))}
|
|
132
|
+
</span>
|
|
133
|
+
))}
|
|
134
|
+
</d-text>
|
|
135
|
+
)}
|
|
136
|
+
onFocus={props.onFocus}
|
|
137
|
+
onBlur={props.onBlur}
|
|
138
|
+
placeholder={props.placeholder}
|
|
139
|
+
disabled={props.disabled}
|
|
140
|
+
autoFocus={props.autoFocus}
|
|
141
|
+
multiline
|
|
142
|
+
maxRows={props.maxRows}
|
|
143
|
+
hints={props.hints}
|
|
144
|
+
ref={props.ref}
|
|
145
|
+
layout={props.layout}
|
|
146
|
+
style={{ ...theme.components.richTextEditor, ...props.style }}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -37,8 +37,12 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
37
37
|
props.onChange?.(v)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
41
|
+
let styled = () => ({ ...theme.components.segmentedControl, ...props.style })
|
|
42
|
+
let radius = () => {
|
|
43
|
+
let r = styled().borderRadius
|
|
44
|
+
return typeof r === "number" ? r : theme.radius.md
|
|
45
|
+
}
|
|
42
46
|
// Per-corner radii: round only the corners on the control's outer edge, so
|
|
43
47
|
// the segments read as one joined control. [tl, tr, br, bl].
|
|
44
48
|
let corners = (i: number): number | [number, number, number, number] => {
|
|
@@ -50,7 +54,7 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
50
54
|
return 0
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
let idleFill = () =>
|
|
57
|
+
let idleFill = () => styled().backgroundColor ?? theme.color.surfaceAlt
|
|
54
58
|
let activeFill = () => (props.disabled ? theme.color.surface : theme.color.primary)
|
|
55
59
|
let label = (active: boolean) =>
|
|
56
60
|
props.disabled ? theme.color.textMuted : active ? theme.color.onPrimary : theme.color.text
|
|
@@ -60,23 +64,23 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
60
64
|
flexDirection="row"
|
|
61
65
|
gap={DIVIDER}
|
|
62
66
|
{...props.layout}
|
|
63
|
-
x={
|
|
64
|
-
y={
|
|
65
|
-
scale={
|
|
66
|
-
rotate={
|
|
67
|
-
opacity={
|
|
67
|
+
x={styled().x}
|
|
68
|
+
y={styled().y}
|
|
69
|
+
scale={styled().scale}
|
|
70
|
+
rotate={styled().rotate}
|
|
71
|
+
opacity={styled().opacity}
|
|
68
72
|
>
|
|
69
73
|
<d-rect color={theme.color.border} radius={radius()} />
|
|
70
74
|
<For each={props.options}>
|
|
71
75
|
{(opt, i) => {
|
|
72
76
|
let active = () => value() === opt.value
|
|
73
77
|
let press = createPress({ onPress: () => select(opt.value) })
|
|
74
|
-
let fill = () =>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
let fill = () => (active() ? activeFill() : idleFill())
|
|
79
|
+
// Hover feedback: the theme overlay tint drawn over the segment fill.
|
|
80
|
+
let overlay = () =>
|
|
81
|
+
press.hovered() && !props.disabled && policy.interaction !== "touch"
|
|
82
|
+
? theme.color.overlayHover
|
|
83
|
+
: "transparent"
|
|
80
84
|
return (
|
|
81
85
|
<view
|
|
82
86
|
ref={press.ref}
|
|
@@ -92,6 +96,7 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
92
96
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
93
97
|
>
|
|
94
98
|
<d-rect color={fill()} radius={corners(i())} />
|
|
99
|
+
<d-rect color={overlay()} radius={corners(i())} />
|
|
95
100
|
<text
|
|
96
101
|
color={label(active())}
|
|
97
102
|
{...typeStyle("body", active() ? lightOnDark(label(true), activeFill()) : undefined)}
|
package/src/select.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { policy } from "./policy"
|
|
|
6
6
|
import { space } from "./spacing"
|
|
7
7
|
import { typeStyle } from "./typography"
|
|
8
8
|
import type { Option, StyleProps } from "./types"
|
|
9
|
+
import { Icon } from "./icon"
|
|
9
10
|
|
|
10
11
|
export interface SelectProps {
|
|
11
12
|
options: Option[]
|
|
@@ -64,9 +65,11 @@ export function Select(props: SelectProps) {
|
|
|
64
65
|
>
|
|
65
66
|
<d-rect
|
|
66
67
|
color={
|
|
67
|
-
press.pressed()
|
|
68
|
-
? theme.color.
|
|
69
|
-
: "
|
|
68
|
+
press.pressed()
|
|
69
|
+
? theme.color.overlayPressed
|
|
70
|
+
: press.hovered() && policy.interaction !== "touch"
|
|
71
|
+
? theme.color.overlayHover
|
|
72
|
+
: "transparent"
|
|
70
73
|
}
|
|
71
74
|
/>
|
|
72
75
|
<text {...bodyText(p.option.value === value() ? theme.color.primary : theme.color.text)}>{p.option.label}</text>
|
|
@@ -147,18 +150,25 @@ export function Select(props: SelectProps) {
|
|
|
147
150
|
</view>,
|
|
148
151
|
)
|
|
149
152
|
|
|
150
|
-
let press = createPress({
|
|
153
|
+
let press = createPress({
|
|
154
|
+
onPress: () => {
|
|
155
|
+
setOpen(!open())
|
|
156
|
+
},
|
|
157
|
+
})
|
|
151
158
|
let style = () => ({
|
|
152
159
|
borderColor: theme.color.border,
|
|
153
160
|
borderWidth: theme.borderWidth.sm,
|
|
154
161
|
borderRadius: theme.radius.sm,
|
|
162
|
+
backgroundColor: theme.color.surface,
|
|
163
|
+
...theme.components.select,
|
|
155
164
|
...props.style,
|
|
156
|
-
backgroundColor:
|
|
157
|
-
props.style?.backgroundColor ??
|
|
158
|
-
(press.hovered() && !props.disabled && policy.interaction !== "touch"
|
|
159
|
-
? theme.color.surfaceHover
|
|
160
|
-
: theme.color.surface),
|
|
161
165
|
})
|
|
166
|
+
// Hover feedback: the theme overlay tint drawn over the trigger fill.
|
|
167
|
+
let overlay = () =>
|
|
168
|
+
press.hovered() && !props.disabled && policy.interaction !== "touch"
|
|
169
|
+
? theme.color.overlayHover
|
|
170
|
+
: "transparent"
|
|
171
|
+
|
|
162
172
|
|
|
163
173
|
return (
|
|
164
174
|
<view
|
|
@@ -185,22 +195,30 @@ export function Select(props: SelectProps) {
|
|
|
185
195
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
186
196
|
>
|
|
187
197
|
<d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
|
|
198
|
+
<d-rect color={overlay()} radius={style().borderRadius} />
|
|
188
199
|
<Show
|
|
189
200
|
when={selected()}
|
|
190
201
|
fallback={<text {...bodyText(theme.color.textMuted)}>{props.placeholder ?? ""}</text>}
|
|
191
202
|
>
|
|
192
203
|
<text {...bodyText(props.disabled ? theme.color.textMuted : theme.color.text)}>{selected()!.label}</text>
|
|
193
204
|
</Show>
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
<Show
|
|
206
|
+
when={theme.icons.chevronDown}
|
|
207
|
+
fallback={
|
|
208
|
+
<view width={12} height={8}>
|
|
209
|
+
<d-path
|
|
210
|
+
d="M 2 2 L 6 6 L 10 2"
|
|
211
|
+
drawStyle="stroke"
|
|
212
|
+
color={theme.color.textMuted}
|
|
213
|
+
strokeWidth={2}
|
|
214
|
+
strokeCap="round"
|
|
215
|
+
strokeJoin="round"
|
|
216
|
+
/>
|
|
217
|
+
</view>
|
|
218
|
+
}
|
|
219
|
+
>
|
|
220
|
+
<Icon src={theme.icons.chevronDown!} size={12} color={theme.color.textMuted} />
|
|
221
|
+
</Show>
|
|
204
222
|
<Show when={open()}>
|
|
205
223
|
<Show when={policy.interaction === "touch"} fallback={<Dropdown />}>
|
|
206
224
|
<Sheet />
|
package/src/slider.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { arena, createSignal, getBoundingBox, onLayout, onSettled } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps, PointerEvent } from "@solidrt/core"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
|
-
import { densityScale } from "./
|
|
4
|
+
import { densityScale } from "./density"
|
|
5
5
|
import type { StyleProps } from "./types"
|
|
6
6
|
|
|
7
7
|
export interface SliderProps {
|
package/src/spacing.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { theme, type Theme } from "./theme"
|
|
2
|
-
import { densityScale } from "./
|
|
2
|
+
import { densityScale } from "./density"
|
|
3
3
|
|
|
4
4
|
// Density-scaled spacing: a theme.spacing token multiplied by the density
|
|
5
5
|
// policy's metric scale, rounded to whole pixels. Use it for gaps and paddings
|