@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.
- 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/spinner.tsx
CHANGED
|
@@ -24,7 +24,9 @@ const THICKNESS = 3
|
|
|
24
24
|
export function Spinner(props: SpinnerProps) {
|
|
25
25
|
let size = () => props.size ?? SIZE
|
|
26
26
|
let thickness = () => props.thickness ?? THICKNESS
|
|
27
|
-
|
|
27
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
28
|
+
let styled = () => ({ ...theme.components.spinner, ...props.style })
|
|
29
|
+
let color = () => styled().color ?? theme.color.primary
|
|
28
30
|
let speed = () => props.speed ?? 1
|
|
29
31
|
|
|
30
32
|
// tick is in milliseconds (like performance.now()). The frame loop is mounted
|
|
@@ -55,9 +57,9 @@ export function Spinner(props: SpinnerProps) {
|
|
|
55
57
|
height={size()}
|
|
56
58
|
{...props.layout}
|
|
57
59
|
rotate={angle()}
|
|
58
|
-
x={
|
|
59
|
-
y={
|
|
60
|
-
opacity={
|
|
60
|
+
x={styled().x}
|
|
61
|
+
y={styled().y}
|
|
62
|
+
opacity={styled().opacity}
|
|
61
63
|
>
|
|
62
64
|
<Show when={policy.motion !== "none"}>
|
|
63
65
|
<Animate />
|
package/src/switch.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { createSignal, 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
|
-
import { densityScale } from "./
|
|
5
|
+
import { densityScale } from "./density"
|
|
6
6
|
import type { StyleProps } from "./types"
|
|
7
7
|
|
|
8
8
|
export interface SwitchProps {
|
|
@@ -43,6 +43,7 @@ export function Switch(props: SwitchProps) {
|
|
|
43
43
|
let style = () => ({
|
|
44
44
|
backgroundColor: on() ? theme.color.primary : theme.color.surfaceAlt,
|
|
45
45
|
borderRadius: h() / 2,
|
|
46
|
+
...theme.components.switch,
|
|
46
47
|
...props.style,
|
|
47
48
|
})
|
|
48
49
|
|
package/src/text-input.tsx
CHANGED
|
@@ -1,30 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
onCleanup,
|
|
6
|
-
focusedNode,
|
|
7
|
-
measureText,
|
|
8
|
-
setFocus,
|
|
9
|
-
startTextInput,
|
|
10
|
-
textInputActive,
|
|
11
|
-
} from "@solidrt/core"
|
|
12
|
-
import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
|
|
13
|
-
import type { Color, Gradient, KeyEvent, LayoutProps, TextInputHints } from "@solidrt/core"
|
|
14
|
-
import { registerNavAction } from "./focus-nav"
|
|
1
|
+
import { untrack } from "@solidrt/core"
|
|
2
|
+
import { createTextBuffer } from "@solidrt/core/text-input"
|
|
3
|
+
import type { LayoutProps, TextInputHints } from "@solidrt/core"
|
|
4
|
+
import { EditorField } from "./editor-field"
|
|
15
5
|
import type { StyleProps } from "./types"
|
|
16
6
|
import { theme } from "./theme"
|
|
17
|
-
import { policy } from "./policy"
|
|
18
|
-
import { space } from "./spacing"
|
|
19
|
-
|
|
20
|
-
// Caret thickness. Shared so the drawn caret and the scroll offset's reserved
|
|
21
|
-
// edge column cannot drift apart.
|
|
22
|
-
const CARET_WIDTH = 1
|
|
23
|
-
|
|
24
|
-
// Shaping width handed to the detached value/placeholder text: effectively
|
|
25
|
-
// unbounded, so a single line never wraps. The viewport clips it and scrollX
|
|
26
|
-
// slides it.
|
|
27
|
-
const TEXT_SHAPE_WIDTH = 1e9
|
|
28
7
|
|
|
29
8
|
export interface TextInputProps {
|
|
30
9
|
value?: string
|
|
@@ -38,6 +17,15 @@ export interface TextInputProps {
|
|
|
38
17
|
maxLength?: number
|
|
39
18
|
disabled?: boolean
|
|
40
19
|
autoFocus?: boolean
|
|
20
|
+
/**
|
|
21
|
+
* Multi-line editing: lines wrap at the field's width, Enter inserts a
|
|
22
|
+
* newline (onSubmit never fires), Up/Down move by line. Without a
|
|
23
|
+
* `layout.height` the field grows with its content (up to `maxRows` rows,
|
|
24
|
+
* then scrolls); with one it is a fixed box that scrolls to the caret.
|
|
25
|
+
*/
|
|
26
|
+
multiline?: boolean
|
|
27
|
+
/** Multiline without an explicit height: rows to grow to before scrolling. Default unbounded. */
|
|
28
|
+
maxRows?: number
|
|
41
29
|
/**
|
|
42
30
|
* IME behavior for the field's text sessions (keyboard type,
|
|
43
31
|
* capitalization, autocorrect). Identifier-like fields want
|
|
@@ -50,244 +38,44 @@ export interface TextInputProps {
|
|
|
50
38
|
style?: StyleProps
|
|
51
39
|
}
|
|
52
40
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// text
|
|
56
|
-
//
|
|
57
|
-
// from bubbling further. Focused and editing are distinct (see
|
|
58
|
-
// activateField): navigation focuses, select begins editing, Enter while
|
|
59
|
-
// editing submits. Range selection (shift-movement,
|
|
60
|
-
// highlight, click-to-position) is not wired yet. Outside-click-to-blur is the
|
|
61
|
-
// caller's job.
|
|
41
|
+
// A plain string field over the shared EditorField shell: the buffer is
|
|
42
|
+
// core's createTextBuffer and every line is one d-text of the value's slice.
|
|
43
|
+
// Detached text takes its width from the box it is drawn in; +1 so the ink
|
|
44
|
+
// width rounding never wraps a line's own text.
|
|
62
45
|
export function TextInput(props: TextInputProps) {
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
let node: { id: number } | undefined
|
|
66
|
-
let viewport: { id: number } | undefined
|
|
67
|
-
let blinkId: any = null
|
|
68
|
-
|
|
69
|
-
// Derived from core's reactive focus (setFocus is the only writer); the
|
|
70
|
-
// onFocus/onBlur handlers below keep only their side effects (blink timer,
|
|
71
|
-
// caller callbacks). focusedNode() is read FIRST, unconditionally: the
|
|
72
|
-
// memo may first compute before the ref has set `node`, and
|
|
73
|
-
// short-circuiting past the read would leave it dependency-free, frozen
|
|
74
|
-
// false forever.
|
|
75
|
-
let focused = createMemo(() => {
|
|
76
|
-
let id = focusedNode()
|
|
77
|
-
return id != null && id === node?.id
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
let buffer = createTextBuffer({
|
|
81
|
-
value: () => props.value,
|
|
82
|
-
defaultValue: props.defaultValue,
|
|
83
|
-
onInput: (v) => props.onInput?.(v),
|
|
84
|
-
maxLength: () => props.maxLength,
|
|
85
|
-
})
|
|
86
|
-
let value = buffer.value
|
|
87
|
-
|
|
88
|
-
// autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
|
|
89
|
-
// the node's onTextInput handler to toggle the keyboard, and those handlers
|
|
90
|
-
// are only registered after the element's props are applied. The ref can fire
|
|
91
|
-
// before that, so focusing there would no-op.
|
|
92
|
-
createEffect(
|
|
93
|
-
() => props.autoFocus,
|
|
94
|
-
(autoFocus) => {
|
|
95
|
-
if (autoFocus && node) setFocus(node.id)
|
|
96
|
-
},
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
let handlePointerDown = () => {
|
|
100
|
-
if (props.disabled) return
|
|
101
|
-
if (node) setFocus(node.id)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
let handleFocus = () => {
|
|
105
|
-
setCaretOn(true)
|
|
106
|
-
if (blinkId == null) {
|
|
107
|
-
blinkId = setInterval(() => setCaretOn((v) => !v), 500)
|
|
108
|
-
}
|
|
109
|
-
props.onFocus?.()
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
let handleBlur = () => {
|
|
113
|
-
if (blinkId != null) {
|
|
114
|
-
clearInterval(blinkId)
|
|
115
|
-
blinkId = null
|
|
116
|
-
}
|
|
117
|
-
props.onBlur?.()
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Keys the input consumes stop propagating: an ancestor (or an app-global
|
|
121
|
-
// shortcut on the window) must not also act on an ArrowLeft that moved the
|
|
122
|
-
// caret. Anything else (e.g. ctrl+s) bubbles on.
|
|
123
|
-
let handleKeyDown = (e: KeyEvent) => {
|
|
124
|
-
if (props.disabled) return
|
|
125
|
-
let consumed = true
|
|
126
|
-
if (e.key === "Backspace") {
|
|
127
|
-
buffer.deleteBackward()
|
|
128
|
-
setCaretOn(true)
|
|
129
|
-
} else if (e.key === "Delete") {
|
|
130
|
-
buffer.deleteForward()
|
|
131
|
-
setCaretOn(true)
|
|
132
|
-
} else if (e.key === "ArrowLeft") {
|
|
133
|
-
buffer.move("left")
|
|
134
|
-
setCaretOn(true)
|
|
135
|
-
} else if (e.key === "ArrowRight") {
|
|
136
|
-
buffer.move("right")
|
|
137
|
-
setCaretOn(true)
|
|
138
|
-
} else if (e.key === "Home") {
|
|
139
|
-
buffer.move("start")
|
|
140
|
-
setCaretOn(true)
|
|
141
|
-
} else if (e.key === "End") {
|
|
142
|
-
buffer.move("end")
|
|
143
|
-
setCaretOn(true)
|
|
144
|
-
} else if (e.key === "Enter" || e.code === "Select") {
|
|
145
|
-
// The remote center key's `key` is "Unidentified"; match its code.
|
|
146
|
-
activateField()
|
|
147
|
-
} else if (e.key === "Escape") {
|
|
148
|
-
if (node) setFocus(null)
|
|
149
|
-
} else {
|
|
150
|
-
consumed = false
|
|
151
|
-
}
|
|
152
|
-
if (consumed) e.stopPropagation()
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
let handleTextInput = (e: any) => {
|
|
156
|
-
if (props.disabled) return
|
|
157
|
-
buffer.insertText(e.text ?? "")
|
|
158
|
-
setCaretOn(true)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Select on the focused field: focused and editing are distinct states. A
|
|
162
|
-
// field reached by navigation is focused but has no text session yet -
|
|
163
|
-
// select begins one (raising the on-screen keyboard where used, e.g. a TV
|
|
164
|
-
// with no keyboard attached); while editing, it submits. On platforms
|
|
165
|
-
// where the session starts invisibly at focus (desktop, physical
|
|
166
|
-
// keyboard) the first branch never runs and Enter submits as always.
|
|
167
|
-
// Registered as the nav action too, for a controller's south button.
|
|
168
|
-
let activateField = () => {
|
|
169
|
-
if (props.disabled) return
|
|
170
|
-
if (!textInputActive()) {
|
|
171
|
-
startTextInput()
|
|
172
|
-
} else {
|
|
173
|
-
props.onSubmit?.(value())
|
|
174
|
-
setFocus(null)
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
let unregisterNav: (() => void) | null = null
|
|
179
|
-
|
|
180
|
-
onCleanup(() => {
|
|
181
|
-
if (blinkId != null) clearInterval(blinkId)
|
|
182
|
-
unregisterNav?.()
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
// Style overrides fall back to theme defaults. The border doubles as the
|
|
186
|
-
// focus ring: primary while focused, when the focus-ring policy asks for a
|
|
187
|
-
// visible indicator.
|
|
188
|
-
let textColor = () => props.style?.color ?? theme.color.text
|
|
189
|
-
let surfaceColor = () => props.style?.backgroundColor ?? theme.color.surface
|
|
190
|
-
let borderColor = () =>
|
|
191
|
-
props.style?.borderColor ?? (focused() && policy.focusRing ? theme.color.primary : theme.color.border)
|
|
192
|
-
let borderWidth = () => props.style?.borderWidth ?? theme.borderWidth.sm
|
|
193
|
-
let borderRadius = () => props.style?.borderRadius ?? theme.radius.sm
|
|
194
|
-
|
|
195
|
-
let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
|
|
196
|
-
let showCaret = () => focused() && caretOn() && !showPlaceholder()
|
|
197
|
-
|
|
198
|
-
// Everything inside the viewport is detached: the value is one d-text shaped
|
|
199
|
-
// at an unbounded width and the caret a d-rect at the measured before-caret
|
|
200
|
-
// width, so typing, caret movement, blink and scroll never touch layout. The
|
|
201
|
-
// viewport carries an explicit height (detached content takes no layout
|
|
202
|
-
// slot) equal to the one-line paragraph height, which keeps the text where
|
|
203
|
-
// the old centered attached row sat. createCaretScroll keeps the caret in
|
|
204
|
-
// view and flushes the offset before paint; scrollX is a paint-time
|
|
205
|
-
// translate that also applies to detached children.
|
|
206
|
-
// All one-line metrics derive from the scaled body size, so the field, the
|
|
207
|
-
// caret, and the scroll math grow together under policy.textScale.
|
|
208
|
-
let fontSize = () => theme.text.body.size * policy.textScale
|
|
209
|
-
let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
|
|
210
|
-
let caretX = () => measureText(value().slice(0, buffer.caret()), { fontSize: fontSize() }).width
|
|
211
|
-
let scrollX = createCaretScroll(
|
|
212
|
-
() => viewport,
|
|
213
|
-
() => ({
|
|
214
|
-
text: value(),
|
|
215
|
-
fontSize: fontSize(),
|
|
216
|
-
caret: buffer.caret(),
|
|
217
|
-
// Constant, not tied to caret visibility: the caret's footprint does not
|
|
218
|
-
// change as it blinks, so reserving the column only when shown would swing
|
|
219
|
-
// the scroll offset every blink and shift text that exactly fills the box.
|
|
220
|
-
caretWidth: CARET_WIDTH,
|
|
221
|
-
}),
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
let textStyle = (color: Color | Gradient) => ({
|
|
225
|
-
w: TEXT_SHAPE_WIDTH,
|
|
226
|
-
fontSize: fontSize(),
|
|
227
|
-
lineHeight: theme.text.body.lineHeight,
|
|
228
|
-
color,
|
|
229
|
-
maxLines: 1,
|
|
230
|
-
})
|
|
231
|
-
|
|
46
|
+
let value = (): string => ""
|
|
232
47
|
return (
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
48
|
+
<EditorField
|
|
49
|
+
buffer={(step) => {
|
|
50
|
+
let buffer = createTextBuffer({
|
|
51
|
+
value: () => props.value,
|
|
52
|
+
// A one-shot initial value by contract (see createTextBuffer): read
|
|
53
|
+
// once, deliberately untracked.
|
|
54
|
+
defaultValue: untrack(() => props.defaultValue),
|
|
55
|
+
onInput: (v) => props.onInput?.(v),
|
|
56
|
+
maxLength: () => props.maxLength,
|
|
57
|
+
step,
|
|
58
|
+
})
|
|
59
|
+
value = buffer.value
|
|
60
|
+
return buffer
|
|
239
61
|
}}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
{
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
onTextInput={handleTextInput}
|
|
259
|
-
>
|
|
260
|
-
<d-rect color={surfaceColor()} radius={borderRadius()} />
|
|
261
|
-
<d-rect
|
|
262
|
-
drawStyle="stroke"
|
|
263
|
-
color={borderColor()}
|
|
264
|
-
strokeWidth={borderWidth()}
|
|
265
|
-
radius={borderRadius()}
|
|
266
|
-
/>
|
|
267
|
-
<view
|
|
268
|
-
ref={(n: { id: number }) => (viewport = n)}
|
|
269
|
-
flex={1}
|
|
270
|
-
height={rowHeight()}
|
|
271
|
-
overflow="hidden"
|
|
272
|
-
scrollX={scrollX()}
|
|
273
|
-
>
|
|
274
|
-
{showPlaceholder() ? (
|
|
275
|
-
<d-text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</d-text>
|
|
276
|
-
) : (
|
|
277
|
-
<>
|
|
278
|
-
<d-text {...textStyle(textColor())}>{value()}</d-text>
|
|
279
|
-
{showCaret() ? (
|
|
280
|
-
<d-rect
|
|
281
|
-
color={textColor()}
|
|
282
|
-
x={caretX()}
|
|
283
|
-
y={(rowHeight() - fontSize()) / 2}
|
|
284
|
-
w={CARET_WIDTH}
|
|
285
|
-
h={fontSize()}
|
|
286
|
-
/>
|
|
287
|
-
) : null}
|
|
288
|
-
</>
|
|
289
|
-
)}
|
|
290
|
-
</view>
|
|
291
|
-
</view>
|
|
62
|
+
renderLine={({ line, font, color }) => (
|
|
63
|
+
<d-text y={line().y} w={line().width + 1} {...font()} color={color()} maxLines={1}>
|
|
64
|
+
{value().slice(line().start, line().end)}
|
|
65
|
+
</d-text>
|
|
66
|
+
)}
|
|
67
|
+
onSubmit={props.onSubmit}
|
|
68
|
+
onFocus={props.onFocus}
|
|
69
|
+
onBlur={props.onBlur}
|
|
70
|
+
placeholder={props.placeholder}
|
|
71
|
+
disabled={props.disabled}
|
|
72
|
+
autoFocus={props.autoFocus}
|
|
73
|
+
multiline={props.multiline}
|
|
74
|
+
maxRows={props.maxRows}
|
|
75
|
+
hints={props.hints}
|
|
76
|
+
ref={props.ref}
|
|
77
|
+
layout={props.layout}
|
|
78
|
+
style={{ ...theme.components.textInput, ...props.style }}
|
|
79
|
+
/>
|
|
292
80
|
)
|
|
293
|
-
}
|
|
81
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createStore
|
|
1
|
+
import { createStore } from "@solidjs/signals"
|
|
2
|
+
import type { StyleProps } from "./types"
|
|
2
3
|
|
|
3
4
|
export type TextStyle = {
|
|
4
5
|
size: number
|
|
@@ -9,6 +10,30 @@ export type TextStyle = {
|
|
|
9
10
|
// The type scale's role names. <Text variant> and theme.text are keyed by these.
|
|
10
11
|
export type TextVariant = "caption" | "label" | "body" | "title" | "heading"
|
|
11
12
|
|
|
13
|
+
// The components whose chrome accepts a theme-level paint override (see
|
|
14
|
+
// Theme["components"]). Plain containers (View, Pressable, ...) are not
|
|
15
|
+
// themed, so they take no override either.
|
|
16
|
+
export type ThemedComponent =
|
|
17
|
+
| "button"
|
|
18
|
+
| "card"
|
|
19
|
+
| "badge"
|
|
20
|
+
| "switch"
|
|
21
|
+
| "checkbox"
|
|
22
|
+
| "radio"
|
|
23
|
+
| "item"
|
|
24
|
+
| "select"
|
|
25
|
+
| "segmentedControl"
|
|
26
|
+
| "textInput"
|
|
27
|
+
| "richTextEditor"
|
|
28
|
+
| "tooltip"
|
|
29
|
+
| "divider"
|
|
30
|
+
| "progressBar"
|
|
31
|
+
| "spinner"
|
|
32
|
+
|
|
33
|
+
// A resolved theme: every value is a plain string/number ready to be read by
|
|
34
|
+
// a component. Authoring happens through defineTheme, which is where
|
|
35
|
+
// [light, dark] pairs and the type-scale expansion live; a Theme itself has
|
|
36
|
+
// no notion of modes.
|
|
12
37
|
export type Theme = {
|
|
13
38
|
text: {
|
|
14
39
|
// Passed through to the core font stack: "sans" | "mono" | a family name.
|
|
@@ -26,117 +51,179 @@ export type Theme = {
|
|
|
26
51
|
surface: string
|
|
27
52
|
// Subtle raised/track fill (switch off-state, slider track, ...).
|
|
28
53
|
surfaceAlt: string
|
|
29
|
-
// Hover tint for surface-colored controls (non-touch interaction policies).
|
|
30
|
-
surfaceHover: string
|
|
31
54
|
text: string
|
|
32
55
|
textMuted: string
|
|
33
56
|
border: string
|
|
34
57
|
primary: string
|
|
35
|
-
// Hover tint for primary-colored controls.
|
|
36
|
-
primaryHover: string
|
|
37
58
|
onPrimary: string
|
|
38
59
|
// Lower-emphasis accent: the puzzle mark's darker blue.
|
|
39
60
|
secondary: string
|
|
40
|
-
// Hover tint for secondary-colored controls.
|
|
41
|
-
secondaryHover: string
|
|
42
61
|
onSecondary: string
|
|
43
62
|
// Validation / destructive.
|
|
44
63
|
danger: string
|
|
45
|
-
// Hover tint for danger-colored controls.
|
|
46
|
-
dangerHover: string
|
|
47
64
|
// Overlay dim behind modals.
|
|
48
65
|
scrim: string
|
|
66
|
+
// Hover/pressed feedback tints: translucent colors DRAWN OVER a control's
|
|
67
|
+
// own fill (one token pair for every control, instead of a hover variant
|
|
68
|
+
// per fill color), so feedback works over any background, including a
|
|
69
|
+
// caller-set style.backgroundColor. Non-touch interaction policies only.
|
|
70
|
+
overlayHover: string
|
|
71
|
+
overlayPressed: string
|
|
49
72
|
}
|
|
50
73
|
spacing: { sm: number; md: number; lg: number; xl: number }
|
|
51
74
|
radius: { sm: number; md: number; lg: number }
|
|
52
75
|
borderWidth: { sm: number }
|
|
76
|
+
// Semantic control glyphs, as SVG document strings (the Icon currency).
|
|
77
|
+
// Components draw their built-in vector paths by default; a theme that sets
|
|
78
|
+
// a slot swaps that glyph everywhere it appears. The package still bundles
|
|
79
|
+
// no icon set.
|
|
80
|
+
icons: { chevronDown?: string; check?: string }
|
|
81
|
+
// Per-component paint overrides: merged between a component's themed
|
|
82
|
+
// defaults and the instance's style prop, so a theme can restyle every
|
|
83
|
+
// Button (say, pill corners) without wrapping the component. Instance
|
|
84
|
+
// style still wins.
|
|
85
|
+
components: { [K in ThemedComponent]?: StyleProps }
|
|
53
86
|
}
|
|
54
87
|
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
// -- Authoring ---------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
// A color in a theme definition: one value, or a [light, dark] pair resolved
|
|
91
|
+
// by defineTheme's scheme argument. Pairs are opt-in per token; a definition
|
|
92
|
+
// without any needs no scheme at all (a game ships one look, not two).
|
|
93
|
+
export type ThemeColor = string | [light: string, dark: string]
|
|
94
|
+
|
|
95
|
+
export type ThemeDefinition = {
|
|
96
|
+
color: { [K in keyof Theme["color"]]: ThemeColor }
|
|
97
|
+
text?: {
|
|
98
|
+
fontFamily?: string
|
|
99
|
+
// The body font size; the other roles derive from it. Default 14.
|
|
100
|
+
base?: number
|
|
101
|
+
// The step between adjacent roles (caption, label, body, title, heading
|
|
102
|
+
// sit at base * ratio^(-2..2), rounded to whole px). Default 1.26.
|
|
103
|
+
ratio?: number
|
|
104
|
+
// Per-role overrides of the derived size and the default line heights
|
|
105
|
+
// and weights.
|
|
106
|
+
roles?: { [K in TextVariant]?: Partial<TextStyle> }
|
|
107
|
+
}
|
|
108
|
+
spacing?: Partial<Theme["spacing"]>
|
|
109
|
+
radius?: Partial<Theme["radius"]>
|
|
110
|
+
borderWidth?: Partial<Theme["borderWidth"]>
|
|
111
|
+
icons?: Theme["icons"]
|
|
112
|
+
components?: Theme["components"]
|
|
65
113
|
}
|
|
114
|
+
|
|
66
115
|
const SPACING = { sm: 4, md: 8, lg: 16, xl: 20 }
|
|
67
116
|
const RADIUS = { sm: 4, md: 8, lg: 12 }
|
|
68
117
|
const BORDER_WIDTH = { sm: 1 }
|
|
69
118
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
119
|
+
// Line height and weight per role; body is the base text, caption and label
|
|
120
|
+
// sit under it (secondary and emphasized small text), title and heading
|
|
121
|
+
// above it (card and page headings).
|
|
122
|
+
const ROLE_DEFAULTS: { [K in TextVariant]: { step: number; lineHeight: number; weight: TextStyle["weight"] } } = {
|
|
123
|
+
caption: { step: -2, lineHeight: 1.3, weight: 400 },
|
|
124
|
+
label: { step: -1, lineHeight: 1.3, weight: 600 },
|
|
125
|
+
body: { step: 0, lineHeight: 1.5, weight: 400 },
|
|
126
|
+
title: { step: 1, lineHeight: 1.4, weight: 700 },
|
|
127
|
+
heading: { step: 2, lineHeight: 1.3, weight: 700 },
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolves a theme definition into a Theme. `scheme` picks the side of every
|
|
132
|
+
* [light, dark] color pair; a definition without pairs needs no scheme
|
|
133
|
+
* (modes are a per-theme choice, not a framework requirement). The type
|
|
134
|
+
* scale expands from text.base and text.ratio, with text.roles overriding
|
|
135
|
+
* per role. Throws on a pair without a scheme (throw-in-dev policy).
|
|
136
|
+
*/
|
|
137
|
+
export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Theme {
|
|
138
|
+
let color = {} as Theme["color"]
|
|
139
|
+
for (let key in def.color) {
|
|
140
|
+
let k = key as keyof Theme["color"]
|
|
141
|
+
let value = def.color[k]
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
if (!scheme) throw new Error(`Theme color "${key}" is a [light, dark] pair; pass a scheme to defineTheme`)
|
|
144
|
+
color[k] = value[scheme === "light" ? 0 : 1]
|
|
145
|
+
} else color[k] = value
|
|
146
|
+
}
|
|
147
|
+
let base = def.text?.base ?? 14
|
|
148
|
+
let ratio = def.text?.ratio ?? 1.26
|
|
149
|
+
let role = (name: TextVariant): TextStyle => {
|
|
150
|
+
let d = ROLE_DEFAULTS[name]
|
|
151
|
+
return {
|
|
152
|
+
size: Math.round(base * ratio ** d.step),
|
|
153
|
+
lineHeight: d.lineHeight,
|
|
154
|
+
weight: d.weight,
|
|
155
|
+
...def.text?.roles?.[name],
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
text: {
|
|
160
|
+
fontFamily: def.text?.fontFamily ?? "sans",
|
|
161
|
+
caption: role("caption"),
|
|
162
|
+
label: role("label"),
|
|
163
|
+
body: role("body"),
|
|
164
|
+
title: role("title"),
|
|
165
|
+
heading: role("heading"),
|
|
166
|
+
},
|
|
167
|
+
color,
|
|
168
|
+
spacing: { ...SPACING, ...def.spacing },
|
|
169
|
+
radius: { ...RADIUS, ...def.radius },
|
|
170
|
+
borderWidth: { ...BORDER_WIDTH, ...def.borderWidth },
|
|
171
|
+
icons: def.icons ?? {},
|
|
172
|
+
components: def.components ?? {},
|
|
173
|
+
}
|
|
99
174
|
}
|
|
100
175
|
|
|
101
|
-
|
|
102
|
-
|
|
176
|
+
// -- The built-in presets: one definition, resolved twice ---------------------
|
|
177
|
+
|
|
178
|
+
const DEFAULT: ThemeDefinition = {
|
|
103
179
|
color: {
|
|
104
|
-
background: "#ffffff",
|
|
105
|
-
surface: "#f6f8fa",
|
|
106
|
-
surfaceAlt: "#eaeef2",
|
|
107
|
-
|
|
108
|
-
text
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
180
|
+
background: ["#ffffff", "#0b0f17"],
|
|
181
|
+
surface: ["#f6f8fa", "#161b22"],
|
|
182
|
+
surfaceAlt: ["#eaeef2", "#21262d"],
|
|
183
|
+
text: ["#1f2328", "#e6edf3"],
|
|
184
|
+
// Muted is an opaque tone between text and background, mixed in oklab
|
|
185
|
+
// (like Material 3's tonal colors, not an alpha overlay): alpha text
|
|
186
|
+
// renders thin on low-DPI and its contrast depends on what sits behind
|
|
187
|
+
// it. Precomputed - core's mixColors delegates to flux:rendertree, and a
|
|
188
|
+
// preset is data that must not need the render engine at import time
|
|
189
|
+
// (the website token build imports this module headless). If text or
|
|
190
|
+
// background changes, recompute: mixColors(text, background, 0.4).
|
|
191
|
+
textMuted: ["#707376", "#848b92"],
|
|
192
|
+
border: ["rgba(0,0,0,0.15)", "rgba(255,255,255,0.14)"],
|
|
193
|
+
// Accent tuned to the puzzle mark's mid blue.
|
|
113
194
|
primary: "#547ebf",
|
|
114
|
-
primaryHover: "#3f5494",
|
|
115
195
|
onPrimary: "#ffffff",
|
|
116
|
-
// The darker shade of the same puzzle segment
|
|
196
|
+
// The darker shade of the same puzzle segment.
|
|
117
197
|
secondary: "#2b5696",
|
|
118
|
-
secondaryHover: "#1f4176",
|
|
119
198
|
onSecondary: "#ffffff",
|
|
120
|
-
danger: "#cf222e",
|
|
121
|
-
|
|
122
|
-
|
|
199
|
+
danger: ["#cf222e", "#f85149"],
|
|
200
|
+
scrim: ["rgba(0,0,0,0.4)", "rgba(0,0,0,0.6)"],
|
|
201
|
+
// Feedback darkens on a light scheme and lightens on a dark one.
|
|
202
|
+
overlayHover: ["rgba(0,0,0,0.08)", "rgba(255,255,255,0.08)"],
|
|
203
|
+
overlayPressed: ["rgba(0,0,0,0.14)", "rgba(255,255,255,0.14)"],
|
|
123
204
|
},
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
205
|
+
// base 14 and ratio 1.26 derive title 18 and heading 22; the two small
|
|
206
|
+
// roles sit tighter than the ratio, so they are pinned.
|
|
207
|
+
text: { roles: { caption: { size: 11 }, label: { size: 12 } } },
|
|
127
208
|
}
|
|
128
209
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
let [
|
|
133
|
-
|
|
210
|
+
export let darkTheme: Theme = defineTheme(DEFAULT, "dark")
|
|
211
|
+
export let lightTheme: Theme = defineTheme(DEFAULT, "light")
|
|
212
|
+
|
|
213
|
+
let [themeStore, setThemeStore] = createStore<Theme>({ ...darkTheme })
|
|
214
|
+
|
|
215
|
+
// The shared theme, backed by a Solid store so reads are tracked: calling
|
|
216
|
+
// setTheme at runtime recolors the live UI without remounting. Components
|
|
217
|
+
// read theme.* through thunks/JSX expressions, so they pick this up with no
|
|
218
|
+
// call-site changes.
|
|
219
|
+
export let theme: Theme = themeStore
|
|
134
220
|
|
|
135
221
|
type ThemePartial = { [K in keyof Theme]?: Partial<Theme[K]> }
|
|
136
222
|
|
|
137
|
-
// Switch themes with a full preset (setTheme(lightTheme))
|
|
138
|
-
//
|
|
139
|
-
//
|
|
223
|
+
// Switch themes with a full preset (setTheme(lightTheme)), a resolved
|
|
224
|
+
// definition (setTheme(defineTheme({...}))), or apply a targeted override
|
|
225
|
+
// (setTheme({ color: { primary: "#f00" } })). Merges one level deep per
|
|
226
|
+
// category (for components, that level is the component name).
|
|
140
227
|
export function setTheme(partial: ThemePartial) {
|
|
141
228
|
setThemeStore((s) => {
|
|
142
229
|
for (let key in partial) {
|