@solidrt/components 0.0.50 → 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.
Files changed (71) hide show
  1. package/AGENTS.md +66 -85
  2. package/README.md +387 -314
  3. package/docs/badge.md +10 -0
  4. package/docs/button.md +21 -0
  5. package/docs/card.md +11 -0
  6. package/docs/checkbox.md +9 -0
  7. package/docs/context-menu.md +17 -0
  8. package/docs/density.md +13 -0
  9. package/docs/divider.md +10 -0
  10. package/docs/field.md +11 -0
  11. package/docs/focus-nav.md +18 -0
  12. package/docs/icon.md +13 -0
  13. package/docs/image.md +21 -0
  14. package/docs/index.md +13 -0
  15. package/docs/item.md +25 -0
  16. package/docs/modal.md +15 -0
  17. package/docs/nav-shell.md +18 -0
  18. package/docs/policy.md +21 -0
  19. package/docs/portal.md +15 -0
  20. package/docs/pressable.md +17 -0
  21. package/docs/progress-bar.md +10 -0
  22. package/docs/qrcode.md +10 -0
  23. package/docs/radio.md +13 -0
  24. package/docs/rich-text-document.md +5 -0
  25. package/docs/rich-text-editor.md +24 -0
  26. package/docs/safe-area.md +12 -0
  27. package/docs/scroll-view.md +14 -0
  28. package/docs/segmented-control.md +13 -0
  29. package/docs/select.md +15 -0
  30. package/docs/slider.md +9 -0
  31. package/docs/spacing.md +7 -0
  32. package/docs/spinner.md +10 -0
  33. package/docs/split-view.md +14 -0
  34. package/docs/switch.md +13 -0
  35. package/docs/text-input.md +23 -0
  36. package/docs/text.md +15 -0
  37. package/docs/theme.md +56 -0
  38. package/docs/tooltip.md +11 -0
  39. package/docs/types.md +5 -0
  40. package/docs/typography.md +5 -0
  41. package/docs/view.md +14 -0
  42. package/docs/window.md +15 -0
  43. package/package.json +6 -3
  44. package/src/badge.tsx +10 -8
  45. package/src/button.tsx +40 -28
  46. package/src/card.tsx +12 -10
  47. package/src/checkbox.tsx +21 -9
  48. package/src/context-menu.tsx +5 -3
  49. package/src/density.tsx +39 -0
  50. package/src/divider.tsx +3 -1
  51. package/src/editor-field.tsx +361 -0
  52. package/src/field.tsx +45 -0
  53. package/src/index.ts +9 -1
  54. package/src/item.tsx +116 -0
  55. package/src/nav-shell.tsx +1 -1
  56. package/src/policy.ts +0 -8
  57. package/src/press.ts +37 -8
  58. package/src/pressable.tsx +4 -1
  59. package/src/progress-bar.tsx +4 -2
  60. package/src/radio.tsx +14 -12
  61. package/src/rich-text-document.ts +247 -0
  62. package/src/rich-text-editor.tsx +149 -0
  63. package/src/segmented-control.tsx +19 -14
  64. package/src/select.tsx +37 -19
  65. package/src/slider.tsx +1 -1
  66. package/src/spacing.ts +1 -1
  67. package/src/spinner.tsx +6 -4
  68. package/src/switch.tsx +2 -1
  69. package/src/text-input.tsx +50 -262
  70. package/src/theme.ts +163 -76
  71. 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
- onPress?: () => void
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
 
@@ -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
- let track = () => props.style?.backgroundColor ?? theme.color.surfaceAlt
31
- let fill = () => props.style?.color ?? theme.color.primary
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 "./policy"
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={props.style?.x}
95
- y={props.style?.y}
96
- scale={props.style?.scale}
97
- rotate={props.style?.rotate}
98
- opacity={props.style?.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={props.style?.backgroundColor != null || props.style?.borderRadius != null}>
103
- <d-rect color={props.style?.backgroundColor ?? "transparent"} radius={props.style?.borderRadius} />
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={(props.style?.borderWidth ?? 0) > 0}>
118
+ <Show when={(styled().borderWidth ?? 0) > 0}>
117
119
  <d-rect
118
120
  drawStyle="stroke"
119
- color={props.style?.borderColor ?? "transparent"}
120
- strokeWidth={props.style?.borderWidth}
121
- radius={props.style?.borderRadius}
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
- let radius = () =>
41
- typeof props.style?.borderRadius === "number" ? props.style.borderRadius : theme.radius.md
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 = () => props.style?.backgroundColor ?? theme.color.surfaceAlt
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={props.style?.x}
64
- y={props.style?.y}
65
- scale={props.style?.scale}
66
- rotate={props.style?.rotate}
67
- opacity={props.style?.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
- active()
76
- ? activeFill()
77
- : press.hovered() && !props.disabled && policy.interaction !== "touch"
78
- ? theme.color.surfaceHover
79
- : idleFill()
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() || (press.hovered() && policy.interaction !== "touch")
68
- ? theme.color.surfaceHover
69
- : "transparent"
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({ onPress: () => setOpen(!open()) })
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
- <view width={12} height={8}>
195
- <d-path
196
- d="M 2 2 L 6 6 L 10 2"
197
- drawStyle="stroke"
198
- color={theme.color.textMuted}
199
- strokeWidth={2}
200
- strokeCap="round"
201
- strokeJoin="round"
202
- />
203
- </view>
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 "./policy"
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 "./policy"
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