@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.
Files changed (85) hide show
  1. package/AGENTS.md +93 -85
  2. package/README.md +421 -311
  3. package/demos/README.md +24 -0
  4. package/demos/assets/icon.png +0 -0
  5. package/demos/assets/icon.svg +23 -0
  6. package/demos/package.json +9 -0
  7. package/demos/src/gallery.tsx +866 -0
  8. package/demos/tsconfig.json +15 -0
  9. package/docs/badge.md +10 -0
  10. package/docs/button.md +21 -0
  11. package/docs/card.md +11 -0
  12. package/docs/checkbox.md +9 -0
  13. package/docs/context-menu.md +17 -0
  14. package/docs/density.md +13 -0
  15. package/docs/divider.md +10 -0
  16. package/docs/field.md +11 -0
  17. package/docs/focus-nav.md +18 -0
  18. package/docs/icon.md +13 -0
  19. package/docs/image.md +21 -0
  20. package/docs/index.md +13 -0
  21. package/docs/item.md +25 -0
  22. package/docs/modal.md +15 -0
  23. package/docs/nav-shell.md +18 -0
  24. package/docs/policy.md +21 -0
  25. package/docs/portal.md +15 -0
  26. package/docs/pressable.md +17 -0
  27. package/docs/progress-bar.md +10 -0
  28. package/docs/qrcode.md +10 -0
  29. package/docs/radio.md +13 -0
  30. package/docs/rich-text-document.md +5 -0
  31. package/docs/rich-text-editor.md +24 -0
  32. package/docs/safe-area.md +12 -0
  33. package/docs/scroll-view.md +35 -0
  34. package/docs/segmented-control.md +13 -0
  35. package/docs/select.md +15 -0
  36. package/docs/slider.md +9 -0
  37. package/docs/spacing.md +7 -0
  38. package/docs/spinner.md +10 -0
  39. package/docs/split-view.md +14 -0
  40. package/docs/switch.md +13 -0
  41. package/docs/text-input.md +23 -0
  42. package/docs/text.md +15 -0
  43. package/docs/theme.md +68 -0
  44. package/docs/tooltip.md +11 -0
  45. package/docs/types.md +9 -0
  46. package/docs/typography.md +5 -0
  47. package/docs/view.md +14 -0
  48. package/docs/window.md +15 -0
  49. package/package.json +8 -4
  50. package/src/badge.tsx +30 -21
  51. package/src/button.tsx +50 -32
  52. package/src/card.tsx +22 -13
  53. package/src/checkbox.tsx +29 -11
  54. package/src/context-menu.tsx +14 -9
  55. package/src/density.tsx +39 -0
  56. package/src/divider.tsx +11 -4
  57. package/src/editor-field.tsx +368 -0
  58. package/src/field.tsx +47 -0
  59. package/src/icon.tsx +8 -4
  60. package/src/image.tsx +10 -3
  61. package/src/index.ts +19 -2
  62. package/src/item.tsx +121 -0
  63. package/src/nav-shell.tsx +7 -17
  64. package/src/policy.ts +9 -12
  65. package/src/press.ts +37 -8
  66. package/src/pressable.tsx +15 -3
  67. package/src/progress-bar.tsx +8 -5
  68. package/src/qrcode.tsx +7 -5
  69. package/src/radio.tsx +26 -16
  70. package/src/rich-text-document.ts +247 -0
  71. package/src/rich-text-editor.tsx +151 -0
  72. package/src/scroll-view.tsx +76 -7
  73. package/src/segmented-control.tsx +33 -19
  74. package/src/select.tsx +59 -30
  75. package/src/slider.tsx +32 -6
  76. package/src/spacing.ts +1 -1
  77. package/src/spinner.tsx +11 -6
  78. package/src/split-view.tsx +3 -4
  79. package/src/switch.tsx +10 -3
  80. package/src/text-input.tsx +54 -264
  81. package/src/text.tsx +22 -2
  82. package/src/theme.ts +220 -81
  83. package/src/tooltip.tsx +23 -9
  84. package/src/types.ts +119 -1
  85. 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
+ }
@@ -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 extends PointerProps {
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
- onPanMove: (dx, dy) => scroll.scrollBy(-dx, -dy),
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, 0)
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
- let radius = () =>
41
- typeof props.style?.borderRadius === "number" ? props.style.borderRadius : theme.radius.md
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 = () => props.style?.backgroundColor ?? theme.color.surfaceAlt
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={props.style?.x}
64
- y={props.style?.y}
65
- scale={props.style?.scale}
66
- rotate={props.style?.rotate}
67
- opacity={props.style?.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
- active()
76
- ? activeFill()
77
- : press.hovered() && !props.disabled && policy.interaction !== "touch"
78
- ? theme.color.surfaceHover
79
- : idleFill()
80
- return (
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)}