@solidrt/components 0.0.63 → 0.0.64
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 +18 -14
- package/README.md +126 -21
- package/docs/badge.md +1 -1
- package/docs/checkbox.md +1 -1
- package/docs/context-menu.md +1 -1
- package/docs/item.md +1 -1
- package/docs/light.md +12 -0
- package/docs/motion.md +13 -0
- package/docs/press.md +19 -0
- package/docs/progress-bar.md +1 -1
- package/docs/radio.md +1 -1
- package/docs/segmented-control.md +1 -1
- package/docs/select.md +1 -1
- package/docs/slider.md +1 -1
- package/docs/surface.md +19 -0
- package/docs/switch.md +1 -1
- package/docs/theme.md +34 -6
- package/docs/tooltip.md +1 -1
- package/docs/types.md +1 -1
- package/docs/window.md +2 -0
- package/examples/lit.tsx +182 -0
- package/package.json +2 -2
- package/src/badge.tsx +25 -6
- package/src/button.tsx +44 -29
- package/src/card.tsx +76 -20
- package/src/checkbox.tsx +23 -19
- package/src/context-menu.tsx +21 -9
- package/src/editor-field.tsx +12 -7
- package/src/focus-nav.ts +17 -6
- package/src/glow.ts +35 -0
- package/src/index.ts +16 -1
- package/src/item.tsx +35 -13
- package/src/light.ts +274 -0
- package/src/motion.tsx +13 -1
- package/src/nav-shell.tsx +19 -10
- package/src/press.ts +2 -1
- package/src/pressable.tsx +6 -4
- package/src/progress-bar.tsx +25 -7
- package/src/radio.tsx +34 -22
- package/src/segmented-control.tsx +104 -23
- package/src/select.tsx +40 -34
- package/src/slider.tsx +74 -22
- package/src/surface.tsx +287 -0
- package/src/switch.tsx +48 -26
- package/src/theme.ts +201 -40
- package/src/tooltip.tsx +18 -14
- package/src/types.ts +66 -2
- package/src/window.tsx +26 -3
package/src/light.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// The light model: what the theme's one light (theme.light) does to a face.
|
|
2
|
+
// Pure math over colors, no JSX, so Surface, the hand-drawn parts (a slider
|
|
3
|
+
// fill, a segmented indicator) and an app's own lit drawing all read the
|
|
4
|
+
// same answers. The theme's direction is quantized into an interned
|
|
5
|
+
// LightAxis (themeAxis), and every derived paint is interned too, so an
|
|
6
|
+
// unchanged result is the same object and its JSX binding skips the write:
|
|
7
|
+
// a moving light costs each face a re-derivation only when the axis steps.
|
|
8
|
+
import { createLinearGradient, createMemo, createRoot, mixColors, parseColor, runWithOwner, withAlpha } from "@solidrt/core"
|
|
9
|
+
import type { Color, Gradient, ShadowProps } from "@solidrt/core"
|
|
10
|
+
import { theme } from "./theme"
|
|
11
|
+
import type { Light, ShadowTone } from "./theme"
|
|
12
|
+
|
|
13
|
+
// Key shadow offset per elevation unit, in px, for a light at 45 degrees
|
|
14
|
+
// (xy/z = 1); a lower light throws it further, along the light's xy.
|
|
15
|
+
const SHADOW_OFFSET = 1
|
|
16
|
+
// Key shadow blur per elevation unit, px.
|
|
17
|
+
const SHADOW_BLUR = 2.5
|
|
18
|
+
// Key shadow alpha at ambient 0, before ShadowTone.strength.
|
|
19
|
+
const SHADOW_ALPHA = 0.42
|
|
20
|
+
// Contact shadow: the tight occlusion where the face meets the ground,
|
|
21
|
+
// barely offset. It stays when a press fades the key shadow out.
|
|
22
|
+
const CONTACT_OFFSET = 0.5
|
|
23
|
+
const CONTACT_BLUR = 1.5
|
|
24
|
+
const CONTACT_BLUR_PER_UNIT = 0.35
|
|
25
|
+
const CONTACT_ALPHA = 0.3
|
|
26
|
+
// How far ambient light fills shadows and the shaded side in: at ambient 1
|
|
27
|
+
// they keep (1 - AMBIENT_FILL) of their strength.
|
|
28
|
+
const AMBIENT_FILL = 0.6
|
|
29
|
+
// The steepest slant (xy/z) shadows follow, so a grazing light does not
|
|
30
|
+
// throw them across the screen.
|
|
31
|
+
const MAX_SLANT = 2.5
|
|
32
|
+
// Sheen at strength 1: the fraction of the light color mixed into the lit
|
|
33
|
+
// side, and of black into the shaded side (oklab).
|
|
34
|
+
const SHEEN_LIGHT = 0.2
|
|
35
|
+
const SHEEN_SHADE = 0.24
|
|
36
|
+
// How far a lit face moves toward base x light color (a multiply, like
|
|
37
|
+
// diffuse light on a colored surface) at ambient 0: a white card under a
|
|
38
|
+
// warm light reads warm, a blue button under a pink one turns violet.
|
|
39
|
+
// Ambient light is neutral, so it dilutes the tint. A white light leaves
|
|
40
|
+
// every base as it is, which is why the stock presets look the same lit.
|
|
41
|
+
const LIGHT_TINT = 0.3
|
|
42
|
+
// Bevel at strength 1: the alpha of the light color on the lit edge and of
|
|
43
|
+
// black on the shaded edge.
|
|
44
|
+
const BEVEL_LIGHT = 0.55
|
|
45
|
+
const BEVEL_SHADE = 0.45
|
|
46
|
+
// A light traveling out of the screen (z <= 0) is clamped to this, i.e. to
|
|
47
|
+
// grazing, instead of flipping the shadows.
|
|
48
|
+
const MIN_Z = 0.05
|
|
49
|
+
// The quantization of the light's direction: the gradient axis turns in
|
|
50
|
+
// ANGLE_STEPS steps, the slant and the obliquity move in their own; a
|
|
51
|
+
// shadow's offset and blur snap to SHADOW_STEP px. Below these steps the
|
|
52
|
+
// change is invisible, and above them every face re-derives.
|
|
53
|
+
const ANGLE_STEPS = 128
|
|
54
|
+
const SLANT_STEPS = 64
|
|
55
|
+
const OBLIQUITY_STEPS = 64
|
|
56
|
+
const SHADOW_STEP = 0.25
|
|
57
|
+
|
|
58
|
+
/** The light's direction, reduced to what the paint needs, quantized and interned. */
|
|
59
|
+
export type LightAxis = {
|
|
60
|
+
// The light's travel in the screen plane, unit length (down when head-on).
|
|
61
|
+
ax: number
|
|
62
|
+
ay: number
|
|
63
|
+
// Shadow displacement per unit of height: xy/z, clamped.
|
|
64
|
+
slantX: number
|
|
65
|
+
slantY: number
|
|
66
|
+
// 0 for a head-on light (every face lit alike), 1 for a grazing one.
|
|
67
|
+
obliquity: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v)
|
|
71
|
+
|
|
72
|
+
// Color math crosses into the runtime (parse, oklab mix), and a moving light
|
|
73
|
+
// asks every face for its colors each move. While the light only turns, the
|
|
74
|
+
// colors do not change - only the gradient's geometry does - so results are
|
|
75
|
+
// cached by their inputs, with the mix amount quantized so a dragged ambient
|
|
76
|
+
// cannot grow the cache without bound.
|
|
77
|
+
const CACHE_LIMIT = 4096
|
|
78
|
+
const QUANTUM = 512
|
|
79
|
+
let cache = new Map<string, unknown>()
|
|
80
|
+
function cached<T>(key: string, compute: () => T): T {
|
|
81
|
+
let hit = cache.get(key)
|
|
82
|
+
if (hit !== undefined) return hit as T
|
|
83
|
+
if (cache.size >= CACHE_LIMIT) cache.clear()
|
|
84
|
+
let value = compute()
|
|
85
|
+
cache.set(key, value)
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
let quantize = (t: number) => Math.round(t * QUANTUM) / QUANTUM
|
|
89
|
+
|
|
90
|
+
function alpha(color: string, a: number): string {
|
|
91
|
+
let q = quantize(a)
|
|
92
|
+
return cached(`a|${color}|${q}`, () => withAlpha(color, q))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// mixColors, keeping the base's alpha (a glass tint stays translucent).
|
|
96
|
+
function mix(base: string, other: string, t: number): string {
|
|
97
|
+
let q = quantize(t)
|
|
98
|
+
return cached(`m|${base}|${other}|${q}`, () => {
|
|
99
|
+
let a = ((parseColor(base) >>> 0) & 0xff) / 255
|
|
100
|
+
let mixed = mixColors(base, other, q)
|
|
101
|
+
return a < 1 ? withAlpha(mixed, a) : mixed
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// base x light, per channel in linear light (a colored light on a colored
|
|
106
|
+
// surface), back to an sRGB hex.
|
|
107
|
+
function multiply(base: string, light: string): string {
|
|
108
|
+
let a = parseColor(base) >>> 0
|
|
109
|
+
let b = parseColor(light) >>> 0
|
|
110
|
+
let hex = "#"
|
|
111
|
+
for (let shift of [24, 16, 8]) {
|
|
112
|
+
let product = toLinear((a >>> shift) & 0xff) * toLinear((b >>> shift) & 0xff)
|
|
113
|
+
hex += toSrgb(product).toString(16).padStart(2, "0")
|
|
114
|
+
}
|
|
115
|
+
return hex
|
|
116
|
+
}
|
|
117
|
+
function toLinear(channel: number): number {
|
|
118
|
+
let c = channel / 255
|
|
119
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
120
|
+
}
|
|
121
|
+
function toSrgb(linear: number): number {
|
|
122
|
+
let c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055
|
|
123
|
+
return Math.round(clamp(c, 0, 1) * 255)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type LinearGradient = Extract<Gradient, { __gradient: "linear" }>
|
|
127
|
+
|
|
128
|
+
/** The light's direction, quantized and interned: equal steps return the same object. */
|
|
129
|
+
export function lightAxis(direction: Light["direction"]): LightAxis {
|
|
130
|
+
let [x, y, z] = direction
|
|
131
|
+
let length = Math.hypot(x, y, z) || 1
|
|
132
|
+
let planar = Math.hypot(x, y)
|
|
133
|
+
let zz = Math.max(z, MIN_Z)
|
|
134
|
+
let index = planar > 1e-4 ? Math.round((Math.atan2(y, x) / (Math.PI * 2)) * ANGLE_STEPS) : ANGLE_STEPS / 4
|
|
135
|
+
let slantX = Math.round(clamp(x / zz, -MAX_SLANT, MAX_SLANT) * SLANT_STEPS) / SLANT_STEPS
|
|
136
|
+
let slantY = Math.round(clamp(y / zz, -MAX_SLANT, MAX_SLANT) * SLANT_STEPS) / SLANT_STEPS
|
|
137
|
+
let obliquity = Math.round((z <= 0 ? 1 : planar / length) * OBLIQUITY_STEPS) / OBLIQUITY_STEPS
|
|
138
|
+
return cached(`L|${index}|${slantX}|${slantY}|${obliquity}`, () => {
|
|
139
|
+
let angle = (index / ANGLE_STEPS) * Math.PI * 2
|
|
140
|
+
return { ax: Math.cos(angle), ay: Math.sin(angle), slantX, slantY, obliquity }
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let axisMemo: (() => LightAxis) | undefined
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The theme light's axis, one memo for the app's lifetime (a detached root):
|
|
148
|
+
* it re-runs as the light moves but notifies only when the axis steps, so
|
|
149
|
+
* every face reading it recomputes at the step rate, not per write.
|
|
150
|
+
*/
|
|
151
|
+
export function themeAxis(): LightAxis {
|
|
152
|
+
axisMemo ??= runWithOwner(null, () => createRoot(() => createMemo(() => lightAxis(theme.light.direction))))
|
|
153
|
+
return axisMemo()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A two-stop gradient from the lit edge to the shaded one, spanning the box
|
|
158
|
+
* (0..1 coordinates; a diagonal light runs corner to corner). `invert`
|
|
159
|
+
* swaps the ends: a pressed or sunken face catches the light on the far
|
|
160
|
+
* side. Interned per (colors, quantized axis).
|
|
161
|
+
*/
|
|
162
|
+
export function alongLight(axis: LightAxis, lit: string, shaded: string, invert: boolean, mid?: string): Gradient {
|
|
163
|
+
let s = (Math.abs(axis.ax) + Math.abs(axis.ay)) / 2
|
|
164
|
+
let [from, to] = invert ? [shaded, lit] : [lit, shaded]
|
|
165
|
+
let template = cached(`g|${from}|${mid}|${to}`, () =>
|
|
166
|
+
createLinearGradient(0, 0, 0, 1, mid
|
|
167
|
+
? [{ offset: 0, color: from }, { offset: 0.5, color: mid }, { offset: 1, color: to }]
|
|
168
|
+
: [{ offset: 0, color: from }, { offset: 1, color: to }]) as LinearGradient)
|
|
169
|
+
return cached(`G|${from}|${mid}|${to}|${axis.ax}|${axis.ay}`, () => ({
|
|
170
|
+
...template,
|
|
171
|
+
x0: 0.5 - axis.ax * s,
|
|
172
|
+
y0: 0.5 - axis.ay * s,
|
|
173
|
+
x1: 0.5 + axis.ax * s,
|
|
174
|
+
y1: 0.5 + axis.ay * s,
|
|
175
|
+
}))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let snap = (v: number) => Math.round(v / SHADOW_STEP) * SHADOW_STEP
|
|
179
|
+
|
|
180
|
+
// Interned, so an unchanged shadow is the same object and skips its write.
|
|
181
|
+
function shadow(x: number, y: number, blur: number, color: string): ShadowProps {
|
|
182
|
+
let sx = snap(x)
|
|
183
|
+
let sy = snap(y)
|
|
184
|
+
let sb = snap(blur)
|
|
185
|
+
return cached(`s|${sx}|${sy}|${sb}|${color}`, () => ({ x: sx, y: sy, blur: sb, color }))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A base color as the key light colors it (see LIGHT_TINT); keeps the base's alpha. A gradient is left as it is. */
|
|
189
|
+
export function litFace(base: Color | Gradient, light: Light): Color | Gradient {
|
|
190
|
+
if (typeof base !== "string") return base
|
|
191
|
+
let t = LIGHT_TINT * (1 - light.ambient)
|
|
192
|
+
if (t <= 0) return base
|
|
193
|
+
return mix(base, cached(`x|${base}|${light.color}`, () => multiply(base, light.color)), t)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A face's fill: the base color as it is under a flat material, else the
|
|
198
|
+
* base tinted by the light and, from the sheen strength and the light's
|
|
199
|
+
* obliquity, a gradient along the light on top. A gradient base (a caller's
|
|
200
|
+
* own) and a transparent base are left alone.
|
|
201
|
+
*/
|
|
202
|
+
export function sheenFill(
|
|
203
|
+
base: Color | Gradient,
|
|
204
|
+
light: Light,
|
|
205
|
+
axis: LightAxis,
|
|
206
|
+
sheen: number | undefined,
|
|
207
|
+
invert: boolean,
|
|
208
|
+
): Color | Gradient {
|
|
209
|
+
if (!sheen || sheen <= 0 || typeof base !== "string" || base === "transparent") return base
|
|
210
|
+
let face = litFace(base, light) as string
|
|
211
|
+
let k = sheen * axis.obliquity
|
|
212
|
+
if (k <= 0) return face
|
|
213
|
+
let lit = mix(face, light.color, SHEEN_LIGHT * k)
|
|
214
|
+
let shaded = mix(face, "#000000", SHEEN_SHADE * k * (1 - light.ambient * AMBIENT_FILL))
|
|
215
|
+
return alongLight(axis, lit, shaded, invert)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The bevel stroke's gradient, or undefined when the material has none. */
|
|
219
|
+
export function bevelStroke(light: Light, axis: LightAxis, bevel: number | undefined, invert: boolean): Gradient | undefined {
|
|
220
|
+
let k = (bevel ?? 0) * axis.obliquity
|
|
221
|
+
if (k <= 0) return undefined
|
|
222
|
+
let lit = alpha(light.color, BEVEL_LIGHT * k)
|
|
223
|
+
let shaded = alpha("#000000", BEVEL_SHADE * k * (1 - light.ambient * AMBIENT_FILL))
|
|
224
|
+
return alongLight(axis, lit, shaded, invert, alpha(light.color, 0))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The key shadow a face at `height` casts from the light, or undefined at 0. */
|
|
228
|
+
export function keyShadow(light: Light, axis: LightAxis, height: number, tone: ShadowTone): ShadowProps | undefined {
|
|
229
|
+
if (height <= 0 || tone.strength <= 0) return undefined
|
|
230
|
+
return shadow(
|
|
231
|
+
height * SHADOW_OFFSET * axis.slantX,
|
|
232
|
+
height * SHADOW_OFFSET * axis.slantY,
|
|
233
|
+
height * SHADOW_BLUR,
|
|
234
|
+
alpha(tone.color, SHADOW_ALPHA * tone.strength * (1 - light.ambient * AMBIENT_FILL)),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The contact shadow under a face at `height`, or undefined at 0. */
|
|
239
|
+
export function contactShadow(light: Light, axis: LightAxis, height: number, tone: ShadowTone): ShadowProps | undefined {
|
|
240
|
+
if (height <= 0 || tone.strength <= 0) return undefined
|
|
241
|
+
return shadow(
|
|
242
|
+
CONTACT_OFFSET * axis.slantX,
|
|
243
|
+
CONTACT_OFFSET * axis.slantY,
|
|
244
|
+
CONTACT_BLUR + height * CONTACT_BLUR_PER_UNIT,
|
|
245
|
+
alpha(tone.color, CONTACT_ALPHA * tone.strength * (1 - light.ambient * AMBIENT_FILL)),
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A shadow at a fraction of its alpha (a pressed face's key shadow), interned like the rest. */
|
|
250
|
+
export function fadedShadow(s: ShadowProps, fraction: number): ShadowProps {
|
|
251
|
+
let a = ((parseColor(s.color) >>> 0) & 0xff) / 255
|
|
252
|
+
return shadow(s.x ?? 0, s.y ?? 0, s.blur ?? 0, alpha(s.color, a * fraction))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Where the light comes from, as a point on the unit disk (a light pad's
|
|
257
|
+
* coordinates): -direction.xy, normalized. The inverse of lightFrom.
|
|
258
|
+
*/
|
|
259
|
+
export function lightSource(light: Light): [number, number] {
|
|
260
|
+
let [x, y, z] = light.direction
|
|
261
|
+
let length = Math.hypot(x, y, z) || 1
|
|
262
|
+
return [-x / length, -y / length]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A light coming from point (px, py) of the unit disk, in front of the screen: for `setTheme({ light: { direction: lightFrom(px, py) } })`. */
|
|
266
|
+
export function lightFrom(px: number, py: number): Light["direction"] {
|
|
267
|
+
let r = Math.hypot(px, py)
|
|
268
|
+
if (r > 1) {
|
|
269
|
+
px /= r
|
|
270
|
+
py /= r
|
|
271
|
+
r = 1
|
|
272
|
+
}
|
|
273
|
+
return [-px, -py, Math.sqrt(Math.max(MIN_Z * MIN_Z, 1 - r * r))]
|
|
274
|
+
}
|
package/src/motion.tsx
CHANGED
|
@@ -55,6 +55,17 @@ export function pressScale(pressed: boolean): number {
|
|
|
55
55
|
return pressed && policy.motion === "normal" ? PRESS_SCALE : 1
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Elevation motion for a face's shadows: a press sinking (its key shadow
|
|
60
|
+
* fading to the contact shadow), a hover lift (the shadow growing out to
|
|
61
|
+
* the next level), a retargeted level. A travel spring on the shadow
|
|
62
|
+
* property, so "reduced" snaps it as it snaps the other movement.
|
|
63
|
+
*/
|
|
64
|
+
export function elevationMotion(): { shadow: Transition } | undefined {
|
|
65
|
+
if (policy.motion !== "normal") return undefined
|
|
66
|
+
return { shadow: { duration: theme.motion.slow, bounce: TRAVEL_BOUNCE } satisfies Transition }
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
/**
|
|
59
70
|
* Enter/exit fade for an overlay surface mounted in place (modal, bottom
|
|
60
71
|
* sheet): fades in at mount, fades out on removal. The node must set
|
|
@@ -96,7 +107,8 @@ export function markMotion(): { opacity: Transition; scale?: Transition } | unde
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/**
|
|
99
|
-
* The hover/pressed tint every pressable
|
|
110
|
+
* The hover/pressed tint every pressable face draws over its own fill (the
|
|
111
|
+
* tint layer of Surface, exported for a control that draws its own rows).
|
|
100
112
|
* One always-mounted rect whose hidden state is the same tint at alpha 0,
|
|
101
113
|
* not "transparent": a fade from transparent black would darken a dark
|
|
102
114
|
* scheme's white tint midway. Pressed swaps in the deeper tint; showing,
|
package/src/nav-shell.tsx
CHANGED
|
@@ -7,7 +7,8 @@ import { space } from "./spacing"
|
|
|
7
7
|
import { typeStyle } from "./typography"
|
|
8
8
|
import type { TransitionProps } from "./types"
|
|
9
9
|
import { splitTransition, transitionEndFor } from "./types"
|
|
10
|
-
import { colorFade
|
|
10
|
+
import { colorFade } from "./motion"
|
|
11
|
+
import { Surface } from "./surface"
|
|
11
12
|
|
|
12
13
|
export interface NavItem {
|
|
13
14
|
value: unknown
|
|
@@ -44,6 +45,8 @@ export function NavShell(props: NavShellProps) {
|
|
|
44
45
|
props.onChange?.(v)
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// Theme-level overrides for the strips' faces.
|
|
49
|
+
let styled = () => theme.components.navShell ?? {}
|
|
47
50
|
let labelColor = (item: NavItem) => (item.value === value() ? theme.color.primary : theme.color.textMuted)
|
|
48
51
|
// The selection fill: the surfaceAlt tint at alpha 0 while unselected, so
|
|
49
52
|
// selecting fades it in (same trap as PressFeedback: a fade from
|
|
@@ -68,11 +71,14 @@ export function NavShell(props: NavShellProps) {
|
|
|
68
71
|
{...press.handlers}
|
|
69
72
|
cursor="pointer"
|
|
70
73
|
>
|
|
71
|
-
<
|
|
72
|
-
|
|
74
|
+
<Surface
|
|
75
|
+
role="surface"
|
|
76
|
+
fill={itemBg(p.item)}
|
|
77
|
+
radius={theme.radius.sm}
|
|
78
|
+
material={styled().material}
|
|
79
|
+
glow={null}
|
|
73
80
|
pressed={press.pressed()}
|
|
74
81
|
hovered={press.hovered() && policy.interaction !== "touch" && p.item.value !== value()}
|
|
75
|
-
radius={theme.radius.sm}
|
|
76
82
|
/>
|
|
77
83
|
{p.item.icon}
|
|
78
84
|
<text transition={colorFade()} color={labelColor(p.item)} {...typeStyle("caption")}>
|
|
@@ -85,7 +91,7 @@ export function NavShell(props: NavShellProps) {
|
|
|
85
91
|
let Tabs = () => (
|
|
86
92
|
<view flexDirection="column" flexShrink={0}>
|
|
87
93
|
<view flexDirection="row">
|
|
88
|
-
<
|
|
94
|
+
<Surface role="surface" fill={styled().backgroundColor ?? theme.color.surface} material={styled().material} glow={null} />
|
|
89
95
|
<For each={props.items}>
|
|
90
96
|
{(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} layout={{ flex: 1 }} />}
|
|
91
97
|
</For>
|
|
@@ -96,7 +102,7 @@ export function NavShell(props: NavShellProps) {
|
|
|
96
102
|
let Rail = () => (
|
|
97
103
|
<view flexDirection="row" flexShrink={0}>
|
|
98
104
|
<view flexDirection="column" width={theme.size.navRail} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
|
|
99
|
-
<
|
|
105
|
+
<Surface role="surface" fill={styled().backgroundColor ?? theme.color.surface} material={styled().material} glow={null} />
|
|
100
106
|
<For each={props.items}>{(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} />}</For>
|
|
101
107
|
</view>
|
|
102
108
|
</view>
|
|
@@ -105,7 +111,7 @@ export function NavShell(props: NavShellProps) {
|
|
|
105
111
|
let Sidebar = () => (
|
|
106
112
|
<view flexDirection="row" flexShrink={0}>
|
|
107
113
|
<view flexDirection="column" width={theme.size.navSidebar} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
|
|
108
|
-
<
|
|
114
|
+
<Surface role="surface" fill={styled().backgroundColor ?? theme.color.surface} material={styled().material} glow={null} />
|
|
109
115
|
<For each={props.items}>
|
|
110
116
|
{(item: NavItem) => {
|
|
111
117
|
let press = createPress({ onPress: () => select(item.value) })
|
|
@@ -125,11 +131,14 @@ export function NavShell(props: NavShellProps) {
|
|
|
125
131
|
{...press.handlers}
|
|
126
132
|
cursor="pointer"
|
|
127
133
|
>
|
|
128
|
-
<
|
|
129
|
-
|
|
134
|
+
<Surface
|
|
135
|
+
role="surface"
|
|
136
|
+
fill={itemBg(item)}
|
|
137
|
+
radius={theme.radius.sm}
|
|
138
|
+
material={styled().material}
|
|
139
|
+
glow={null}
|
|
130
140
|
pressed={press.pressed()}
|
|
131
141
|
hovered={press.hovered() && policy.interaction !== "touch" && item.value !== value()}
|
|
132
|
-
radius={theme.radius.sm}
|
|
133
142
|
/>
|
|
134
143
|
{item.icon}
|
|
135
144
|
<text
|
package/src/press.ts
CHANGED
|
@@ -56,7 +56,8 @@ export interface PressOptions {
|
|
|
56
56
|
// Key activation shows no pressed state - the focus ring is the feedback.
|
|
57
57
|
// The arena and the movement recognizers (createPan, createTransform) live in
|
|
58
58
|
// core; press stays here because it couples to this package's focus-nav
|
|
59
|
-
// (registerNavAction)
|
|
59
|
+
// (registerNavAction). Exported: a custom control builds its own root on it
|
|
60
|
+
// exactly as Button and Switch do, instead of wrapping Pressable
|
|
60
61
|
// (okf/plans/component-gestures.md).
|
|
61
62
|
export function createPress(options: PressOptions) {
|
|
62
63
|
let [pressed, setPressed] = createSignal(false)
|
package/src/pressable.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { children, untrack } from "@solidrt/core"
|
|
2
|
-
import type { LayoutProps, PointerProps } from "@solidrt/core"
|
|
2
|
+
import type { Element, LayoutProps, PointerProps } from "@solidrt/core"
|
|
3
3
|
import type { StyleProps, TransitionProps } from "./types"
|
|
4
4
|
import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
5
5
|
import { createPress, type PressState } from "./press"
|
|
@@ -12,7 +12,8 @@ export interface PressableProps extends PointerProps, TransitionProps {
|
|
|
12
12
|
// restyle on press/hover without wiring their own signals. The state is live
|
|
13
13
|
// (getters, not a snapshot): read it inside a prop or child expression, never
|
|
14
14
|
// eagerly into a local, or the value is captured once where it was read.
|
|
15
|
-
|
|
15
|
+
// Typed as a union, not `any`, so `{(s) => ...}` gets `s` contextually.
|
|
16
|
+
children?: Element | ((state: PressState) => Element)
|
|
16
17
|
ref?: (node: { id: number }) => void
|
|
17
18
|
layout?: LayoutProps
|
|
18
19
|
style?: StyleProps | ((state: PressState) => StyleProps)
|
|
@@ -37,8 +38,9 @@ export function Pressable(props: PressableProps) {
|
|
|
37
38
|
// with typeof would orphan one full copy per evaluation. children() memoizes
|
|
38
39
|
// the resolve so probe and mount share one build; a render-prop child
|
|
39
40
|
// ((state) => ...) passes through it intact because flatten only unwraps
|
|
40
|
-
// zero-arg functions
|
|
41
|
-
|
|
41
|
+
// zero-arg functions; the cast is for children()'s signature, which does
|
|
42
|
+
// not know the render-prop variant.
|
|
43
|
+
let resolved = children(() => props.children as Element)
|
|
42
44
|
// The render prop runs once: the state it receives is a live object of getters
|
|
43
45
|
// (see press.ts), so a press or hover updates only the props that read it
|
|
44
46
|
// rather than rebuilding this subtree - which is what keeps a nested
|
package/src/progress-bar.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createSignal, onFrame, onLayout, getBoundingBox, Show } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
|
-
import { theme } from "./theme"
|
|
3
|
+
import { pill, theme } from "./theme"
|
|
4
4
|
import { policy } from "./policy"
|
|
5
|
+
import { Surface, facePaint } from "./surface"
|
|
5
6
|
import type { StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
|
|
6
|
-
import { partTransition, partTransitionEnd, splitTransition, transitionEndFor
|
|
7
|
+
import { partTransition, partTransitionEnd, splitTransition, transitionEndFor } from "./types"
|
|
7
8
|
import { colorFade, travelMotion } from "./motion"
|
|
8
9
|
|
|
9
10
|
export interface ProgressBarProps extends TransitionProps<TransitionViewProp | TransitionStyleProp | "fill"> {
|
|
@@ -24,15 +25,20 @@ let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi :
|
|
|
24
25
|
|
|
25
26
|
// A horizontal progress bar. Determinate when given a value in [0, 1]: the fill
|
|
26
27
|
// grows from the left. Indeterminate when value is undefined: a short segment
|
|
27
|
-
// slides back and forth (driven by core onFrame).
|
|
28
|
-
//
|
|
28
|
+
// slides back and forth (driven by core onFrame). The track is a sunken
|
|
29
|
+
// control face, the fill an accent face (with the accent material's glow)
|
|
30
|
+
// drawn as a detached rect; override the track via style.backgroundColor and
|
|
31
|
+
// the fill via style.color, box the track with borderColor/borderWidth. The
|
|
32
|
+
// ends take pill(): round by default, square under radius.full 0.
|
|
29
33
|
export function ProgressBar(props: ProgressBarProps) {
|
|
30
34
|
let h = () => (props.layout?.height as number) ?? HEIGHT
|
|
31
|
-
let radius = () => h() / 2
|
|
32
35
|
// Theme-level per-component overrides merged under the instance style.
|
|
33
36
|
let styled = () => ({ ...theme.components.progressBar, ...props.style })
|
|
37
|
+
let radius = () => styled().borderRadius ?? pill(h())
|
|
34
38
|
let track = () => styled().backgroundColor ?? theme.color.surfaceAlt
|
|
35
39
|
let fill = () => styled().color ?? theme.color.primary
|
|
40
|
+
let fillPaint = () => facePaint({ role: "accent", fill: fill(), material: styled().material, glow: styled().glow })
|
|
41
|
+
let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
|
|
36
42
|
let indeterminate = () => props.value === undefined
|
|
37
43
|
|
|
38
44
|
// Measured track width in pixels. Both the determinate fill and the
|
|
@@ -89,8 +95,20 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
89
95
|
<Show when={animating()}>
|
|
90
96
|
<Animate />
|
|
91
97
|
</Show>
|
|
92
|
-
<
|
|
93
|
-
|
|
98
|
+
<Surface
|
|
99
|
+
role="control"
|
|
100
|
+
fill={track()}
|
|
101
|
+
radius={radius()}
|
|
102
|
+
material={styled().material}
|
|
103
|
+
glow={null}
|
|
104
|
+
sunken
|
|
105
|
+
outline={hasBorder() ? { color: styled().borderColor ?? theme.color.border, width: styled().borderWidth ?? theme.borderWidth.sm } : null}
|
|
106
|
+
fillTransition={split().background}
|
|
107
|
+
onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
108
|
+
outlineTransition={split().border}
|
|
109
|
+
onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
110
|
+
/>
|
|
111
|
+
<d-rect transition={fillTransition()} onTransitionEnd={partTransitionEnd("fill", "w", props.onTransitionEnd)} color={fillPaint().fill} radius={radius()} w={fillWidth()} h={h()} x={indeterminate() ? offset() : 0} shadow={fillPaint().glow} />
|
|
94
112
|
</view>
|
|
95
113
|
)
|
|
96
114
|
}
|
package/src/radio.tsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createSignal, createContext, useContext, Show, children } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { createPress } from "./press"
|
|
4
|
-
import { theme } from "./theme"
|
|
4
|
+
import { pill, theme } from "./theme"
|
|
5
5
|
import { policy } from "./policy"
|
|
6
6
|
import { densityScale } from "./density"
|
|
7
7
|
import { typeStyle } from "./typography"
|
|
8
|
+
import { Surface } from "./surface"
|
|
8
9
|
import type { StyleProps, TransitionProps } from "./types"
|
|
9
|
-
import { splitTransition, transitionEndFor
|
|
10
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
10
11
|
import { colorFade, markMotion, pressScale, scaleFeedback } from "./motion"
|
|
11
12
|
|
|
12
13
|
// Shared selection state for a group. Created and consumed within this module, so
|
|
@@ -67,9 +68,13 @@ export interface RadioProps extends TransitionProps {
|
|
|
67
68
|
|
|
68
69
|
const RING = 20
|
|
69
70
|
|
|
70
|
-
// A single option in a RadioGroup: a ring with an inner dot when selected
|
|
71
|
-
//
|
|
72
|
-
//
|
|
71
|
+
// A single option in a RadioGroup: a ring with an inner dot when selected,
|
|
72
|
+
// both on pill() (round by default, square under radius.full 0); the dot is
|
|
73
|
+
// an accent face (Surface, with the accent material's glow; style.elevation
|
|
74
|
+
// raises it). The ring color fades, the dot pops in/out (markMotion), and a
|
|
75
|
+
// press shrinks the ring - not the whole row, so a long label never
|
|
76
|
+
// wobbles. A style backgroundColor or border boxes the row as a surface
|
|
77
|
+
// face.
|
|
73
78
|
export function Radio(props: RadioProps) {
|
|
74
79
|
// useContext throws ContextNotFoundError if a Radio is used outside a
|
|
75
80
|
// RadioGroup (default-less context), so ctx is always present here.
|
|
@@ -92,6 +97,7 @@ export function Radio(props: RadioProps) {
|
|
|
92
97
|
let focusRing = () => press.focused() && policy.focusRing
|
|
93
98
|
let ringColor = () => (focusRing() ? theme.color.ring : selected() ? theme.color.primary : theme.color.border)
|
|
94
99
|
let ringWidth = () => (focusRing() ? theme.borderWidth.focus : 2)
|
|
100
|
+
let boxed = () => styled().backgroundColor != null || styled().borderRadius != null || (styled().borderWidth ?? 0) > 0
|
|
95
101
|
|
|
96
102
|
let split = () => splitTransition(props.transition)
|
|
97
103
|
|
|
@@ -115,12 +121,19 @@ export function Radio(props: RadioProps) {
|
|
|
115
121
|
focusable={!disabled()}
|
|
116
122
|
pointerEvents={disabled() ? "none" : undefined}
|
|
117
123
|
>
|
|
118
|
-
<Show when={
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
<Show when={boxed()}>
|
|
125
|
+
<Surface
|
|
126
|
+
role="surface"
|
|
127
|
+
elevation={styled().elevation ?? "flat"}
|
|
128
|
+
fill={styled().backgroundColor ?? "transparent"}
|
|
123
129
|
radius={styled().borderRadius}
|
|
130
|
+
material={styled().material}
|
|
131
|
+
glow={null}
|
|
132
|
+
outline={(styled().borderWidth ?? 0) > 0 ? { color: styled().borderColor ?? "transparent", width: styled().borderWidth! } : null}
|
|
133
|
+
fillTransition={split().background}
|
|
134
|
+
onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
135
|
+
outlineTransition={split().border}
|
|
136
|
+
onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
124
137
|
/>
|
|
125
138
|
</Show>
|
|
126
139
|
<view
|
|
@@ -130,7 +143,7 @@ export function Radio(props: RadioProps) {
|
|
|
130
143
|
scale={pressScale(press.pressed())}
|
|
131
144
|
transition={scaleFeedback()}
|
|
132
145
|
>
|
|
133
|
-
<d-
|
|
146
|
+
<d-rect drawStyle="stroke" transition={colorFade()} radius={pill(ring())} color={ringColor()} strokeWidth={ringWidth()} />
|
|
134
147
|
<Show when={selected()}>
|
|
135
148
|
<view
|
|
136
149
|
position="absolute"
|
|
@@ -142,7 +155,16 @@ export function Radio(props: RadioProps) {
|
|
|
142
155
|
scale={1}
|
|
143
156
|
transition={markMotion()}
|
|
144
157
|
>
|
|
145
|
-
<
|
|
158
|
+
<view position="absolute" left={inset()} top={inset()} width={ring() - inset() * 2} height={ring() - inset() * 2}>
|
|
159
|
+
<Surface
|
|
160
|
+
role="accent"
|
|
161
|
+
elevation={styled().elevation ?? "flat"}
|
|
162
|
+
fill={theme.color.primary}
|
|
163
|
+
radius={pill(ring() - inset() * 2)}
|
|
164
|
+
material={styled().material}
|
|
165
|
+
glow={disabled() ? null : styled().glow}
|
|
166
|
+
/>
|
|
167
|
+
</view>
|
|
146
168
|
</view>
|
|
147
169
|
</Show>
|
|
148
170
|
</view>
|
|
@@ -151,16 +173,6 @@ export function Radio(props: RadioProps) {
|
|
|
151
173
|
{resolved()}
|
|
152
174
|
</text>
|
|
153
175
|
</Show>
|
|
154
|
-
<Show when={(styled().borderWidth ?? 0) > 0}>
|
|
155
|
-
<d-rect
|
|
156
|
-
drawStyle="stroke"
|
|
157
|
-
transition={withTransitionDefaults(split().border, colorFade())}
|
|
158
|
-
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
159
|
-
color={styled().borderColor ?? "transparent"}
|
|
160
|
-
strokeWidth={styled().borderWidth}
|
|
161
|
-
radius={styled().borderRadius}
|
|
162
|
-
/>
|
|
163
|
-
</Show>
|
|
164
176
|
</view>
|
|
165
177
|
)
|
|
166
178
|
}
|