@solidrt/components 0.0.62 → 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.
Files changed (48) hide show
  1. package/AGENTS.md +18 -14
  2. package/README.md +126 -21
  3. package/docs/badge.md +1 -1
  4. package/docs/checkbox.md +1 -1
  5. package/docs/context-menu.md +1 -1
  6. package/docs/item.md +1 -1
  7. package/docs/light.md +12 -0
  8. package/docs/motion.md +13 -0
  9. package/docs/press.md +19 -0
  10. package/docs/progress-bar.md +1 -1
  11. package/docs/radio.md +1 -1
  12. package/docs/segmented-control.md +1 -1
  13. package/docs/select.md +1 -1
  14. package/docs/slider.md +1 -1
  15. package/docs/surface.md +19 -0
  16. package/docs/switch.md +1 -1
  17. package/docs/theme.md +34 -6
  18. package/docs/tooltip.md +1 -1
  19. package/docs/types.md +1 -1
  20. package/docs/window.md +2 -0
  21. package/examples/lit.tsx +182 -0
  22. package/package.json +2 -2
  23. package/src/badge.tsx +25 -6
  24. package/src/button.tsx +44 -29
  25. package/src/card.tsx +76 -20
  26. package/src/checkbox.tsx +23 -19
  27. package/src/context-menu.tsx +21 -9
  28. package/src/editor-field.tsx +12 -7
  29. package/src/focus-nav.ts +17 -6
  30. package/src/glow.ts +35 -0
  31. package/src/index.ts +16 -1
  32. package/src/item.tsx +35 -13
  33. package/src/light.ts +274 -0
  34. package/src/motion.tsx +13 -1
  35. package/src/nav-shell.tsx +19 -10
  36. package/src/press.ts +2 -1
  37. package/src/pressable.tsx +6 -4
  38. package/src/progress-bar.tsx +25 -7
  39. package/src/radio.tsx +34 -22
  40. package/src/segmented-control.tsx +104 -23
  41. package/src/select.tsx +40 -34
  42. package/src/slider.tsx +74 -22
  43. package/src/surface.tsx +287 -0
  44. package/src/switch.tsx +48 -26
  45. package/src/theme.ts +201 -40
  46. package/src/tooltip.tsx +18 -14
  47. package/src/types.ts +66 -2
  48. 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 control draws over its own fill.
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, PressFeedback } from "./motion"
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
- <d-rect transition={colorFade()} color={itemBg(p.item)} radius={theme.radius.sm} />
72
- <PressFeedback
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
- <d-rect transition={colorFade()} color={theme.color.surface} />
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
- <d-rect transition={colorFade()} color={theme.color.surface} />
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
- <d-rect transition={colorFade()} color={theme.color.surface} />
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
- <d-rect transition={colorFade()} color={itemBg(item)} radius={theme.radius.sm} />
129
- <PressFeedback
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) and nothing outside components needs it yet
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
- children?: any | ((state: PressState) => any)
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
- let resolved = children(() => props.children)
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
@@ -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, withTransitionDefaults } from "./types"
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). Colors come from the theme;
28
- // override the track via style.backgroundColor and the fill via style.color.
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
- <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={track()} radius={radius()} />
93
- <d-rect transition={fillTransition()} onTransitionEnd={partTransitionEnd("fill", "w", props.onTransitionEnd)} color={fill()} radius={radius()} w={fillWidth()} h={h()} x={indeterminate() ? offset() : 0} />
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, withTransitionDefaults } from "./types"
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
- // The ring color fades, the dot pops in/out (markMotion), and a press
72
- // shrinks the ring - not the whole row, so a long label never wobbles.
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={styled().backgroundColor != null || styled().borderRadius != null}>
119
- <d-rect
120
- transition={withTransitionDefaults(split().background, colorFade())}
121
- onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
122
- color={styled().backgroundColor ?? "transparent"}
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-oval drawStyle="stroke" transition={colorFade()} color={ringColor()} strokeWidth={ringWidth()} />
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
- <d-oval x={inset()} y={inset()} w={ring() - inset() * 2} h={ring() - inset() * 2} transition={colorFade()} color={theme.color.primary} />
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
  }