@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.
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
@@ -0,0 +1,287 @@
1
+ import { Show, parseColor, withAlpha } from "@solidrt/core"
2
+ import type { Color, Gradient, ShadowProps, TransitionEndEvent, TransitionProps as CoreTransitionProps } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import { policy } from "./policy"
5
+ import { bevelStroke, contactShadow, fadedShadow, keyShadow, sheenFill, themeAxis } from "./light"
6
+ import { glowShadow } from "./glow"
7
+ import { colorFade, elevationMotion, feedbackFade } from "./motion"
8
+ import type { ElevationLevel, Glow, Material, MaterialRole } from "./types"
9
+ import { withTransitionDefaults } from "./types"
10
+
11
+ // The one way a component draws a face. A face is a role's material under
12
+ // the theme light at an elevation, and Surface stacks the layers that make
13
+ // it, in this order:
14
+ //
15
+ // contact shadow tight occlusion where the face meets the ground; stays
16
+ // when a press fades the key shadow out
17
+ // glow the material's emitted light, a zero-offset halo
18
+ // fill the base color, tinted by the light and with the sheen
19
+ // gradient along it when the material has one; carries
20
+ // the key shadow, which the elevation casts along the
21
+ // light, drops to a tenth while pressed and retargets to
22
+ // `liftTo` while lifted (all on the native shadow
23
+ // transition); under a glass material the fill sits in
24
+ // a backdrop-filtered view and the key shadow moves to a
25
+ // caster of its own
26
+ // tint the hover/pressed overlay (PressFeedback), only when
27
+ // the face is pressable (hovered or pressed given)
28
+ // bevel a one-pixel stroke from the lit edge to the shaded one
29
+ // outline the border or the focus ring the component decides on
30
+ //
31
+ // Everything is detached and fills the view Surface is rendered in, so it
32
+ // adds nothing to layout, except the glass view, which is absolutely
33
+ // positioned over the same box: render Surface as the FIRST child of the
34
+ // laid-out view it dresses. Under the stock presets (flat materials, zero
35
+ // elevation) a face is a plain fill, the tint and the outline: the look the
36
+ // package has always had.
37
+ //
38
+ // Shadows cast from opaque casters. The contact shadow and the glow sit on
39
+ // rects in the fill's own color, hidden under the fill; the key shadow on
40
+ // the fill rect itself. A transparent caster's blurred shadow composites in
41
+ // a save layer of its own, which a tiled GPU pays for per shadow - a list of
42
+ // such rows ran at 8-19 fps on every measured device
43
+ // (okf/notes/lit-list-cost.md) - so only a see-through face (glass, a
44
+ // translucent fill) casts from a transparent rect, and those are the few
45
+ // overlays.
46
+
47
+ export interface SurfaceProps {
48
+ role: MaterialRole
49
+ // The level the face stands at; default flat. The theme maps it to a
50
+ // height (theme.elevation), zero under the stock presets.
51
+ elevation?: ElevationLevel
52
+ // The base color: what the light tints and the sheen runs over, and the
53
+ // color a label's polarity is judged against. Defaults per role: surface
54
+ // and overlay to color.surface, control to surfaceAlt, accent to primary.
55
+ fill?: Color | Gradient
56
+ radius?: number | [number, number, number, number]
57
+ // Layers merged over the role's material (a component's theme override
58
+ // and instance style).
59
+ material?: Partial<Material>
60
+ // The glow layer alone: set replaces the material's, null clears it.
61
+ glow?: Glow | null
62
+ // A pressed face sinks: the key shadow fades toward the contact shadow and
63
+ // the lit edge moves to the far side. Giving `pressed` or `hovered` also
64
+ // mounts the tint layer, unless `tint` is false (a knob that sinks under
65
+ // the finger but takes no hover tint); `active` widens the glow on its
66
+ // own, for a face that neither tints nor sinks (a track under a press).
67
+ pressed?: boolean
68
+ hovered?: boolean
69
+ tint?: boolean
70
+ active?: boolean
71
+ // Hover lift: while `lifted`, the key shadow retargets to the one cast
72
+ // from `liftTo`. Without `liftTo` nothing lifts.
73
+ lifted?: boolean
74
+ liftTo?: ElevationLevel
75
+ // A well or a groove (a track, a field): shaded like a pressed face and
76
+ // casting nothing.
77
+ sunken?: boolean
78
+ // False hides fill, sheen, bevel, shadows and glow, keeping only the tint
79
+ // (a ghost button at rest). Default true.
80
+ chrome?: boolean
81
+ // The border or the focus ring, as the component resolves it; null or
82
+ // undefined draws none.
83
+ outline?: { color: Color | Gradient; width: number } | null
84
+ // The component's transition entries for its faces: `fillTransition` is
85
+ // its `background` entry (backgroundColor, borderRadius), `outlineTransition`
86
+ // its `border` entry, each already split from the component's `transition`
87
+ // prop; the fades and the elevation motion are filled in as defaults.
88
+ fillTransition?: CoreTransitionProps["transition"]
89
+ outlineTransition?: CoreTransitionProps["transition"]
90
+ onFillTransitionEnd?: (event: TransitionEndEvent) => void
91
+ onOutlineTransitionEnd?: (event: TransitionEndEvent) => void
92
+ }
93
+
94
+ // Key shadow strength while pressed: nearly gone, so the contact shadow is
95
+ // what remains of the face's lift.
96
+ const PRESSED_KEY_SHADOW = 0.1
97
+ // The saturation a glass face gives what shows through it: a little more
98
+ // than life, so the frosted backdrop reads as glass and not as fog.
99
+ const GLASS_SATURATE = 1.4
100
+ // The alpha of a glass tint that takes the face's own fill.
101
+ const GLASS_ALPHA = 0.6
102
+ // The bevel's stroke width: one logical pixel, an edge and not a band.
103
+ const BEVEL_WIDTH = 1
104
+
105
+ let isOpaque = (c: Color | Gradient) => typeof c !== "string" || ((parseColor(c) >>> 0) & 0xff) === 0xff
106
+
107
+ /** The fill a role's face takes when the component gives none. */
108
+ export function roleFill(role: MaterialRole): string {
109
+ switch (role) {
110
+ case "control":
111
+ return theme.color.surfaceAlt
112
+ case "accent":
113
+ return theme.color.primary
114
+ default:
115
+ return theme.color.surface
116
+ }
117
+ }
118
+
119
+ /** The role's material with the component's layers merged over it. */
120
+ export function roleMaterial(role: MaterialRole, override?: Partial<Material>): Material {
121
+ let m = theme.material[role]
122
+ return override ? { ...m, ...override } : m
123
+ }
124
+
125
+ /**
126
+ * Whether a face at this level gives a press something to sink: a sheen, a
127
+ * bevel or a height. A flat face keeps the press scale instead (see
128
+ * pressScale), since it has no depth to lose.
129
+ */
130
+ export function surfaceSinks(role: MaterialRole, elevation: ElevationLevel = "flat", material?: Partial<Material>): boolean {
131
+ let m = roleMaterial(role, material)
132
+ return (m.sheen ?? 0) > 0 || (m.bevel ?? 0) > 0 || theme.elevation[elevation] > 0
133
+ }
134
+
135
+ /** The paint of a face, for a part a component draws itself (a slider fill, a segmented indicator). */
136
+ export type FacePaint = {
137
+ fill: Color | Gradient
138
+ keyShadow?: ShadowProps
139
+ contactShadow?: ShadowProps
140
+ glow?: ShadowProps
141
+ bevel?: Gradient
142
+ }
143
+
144
+ /**
145
+ * Derives a face's paint from the theme light and the role's material, the
146
+ * same derivation Surface draws with. `active` widens the glow (hovered or
147
+ * pressed); `pressed` and `sunken` invert the sheen and the bevel and, for a
148
+ * press, fade the key shadow. Reads the theme reactively: call it inside the
149
+ * prop expressions that use it.
150
+ */
151
+ export function facePaint(o: {
152
+ role: MaterialRole
153
+ fill?: Color | Gradient
154
+ elevation?: ElevationLevel
155
+ material?: Partial<Material>
156
+ glow?: Glow | null
157
+ pressed?: boolean
158
+ sunken?: boolean
159
+ active?: boolean
160
+ }): FacePaint {
161
+ let light = theme.light
162
+ let axis = themeAxis()
163
+ let material = roleMaterial(o.role, o.material)
164
+ let base = o.fill ?? roleFill(o.role)
165
+ let invert = o.pressed === true || o.sunken === true
166
+ let height = o.sunken ? 0 : theme.elevation[o.elevation ?? "flat"]
167
+ let key = keyShadow(light, axis, height, theme.shadow)
168
+ return {
169
+ fill: sheenFill(base, light, axis, material.sheen, invert),
170
+ keyShadow: key && o.pressed ? fadedShadow(key, PRESSED_KEY_SHADOW) : key,
171
+ contactShadow: contactShadow(light, axis, height, theme.shadow),
172
+ glow: glowShadow(o.glow !== undefined ? o.glow : material.glow, base, o.active),
173
+ bevel: bevelStroke(light, axis, material.bevel, invert),
174
+ }
175
+ }
176
+
177
+ export function Surface(props: SurfaceProps) {
178
+ let chrome = () => props.chrome !== false
179
+ let base = () => props.fill ?? roleFill(props.role)
180
+ let material = () => roleMaterial(props.role, props.material)
181
+ let glass = () => (chrome() ? material().glass : undefined)
182
+ let paint = () =>
183
+ facePaint({
184
+ role: props.role,
185
+ fill: base(),
186
+ elevation: props.elevation,
187
+ material: props.material,
188
+ glow: props.glow,
189
+ pressed: props.pressed,
190
+ sunken: props.sunken,
191
+ active: props.active ?? (props.pressed || props.hovered),
192
+ })
193
+ // The key shadow the face casts now: while lifted, the one from the lift
194
+ // level; the retarget is what the shadow transition animates.
195
+ let key = (): ShadowProps | undefined => {
196
+ if (!chrome()) return undefined
197
+ if (props.lifted && props.liftTo && !props.sunken) {
198
+ let light = theme.light
199
+ return keyShadow(light, themeAxis(), theme.elevation[props.liftTo], theme.shadow)
200
+ }
201
+ return paint().keyShadow
202
+ }
203
+ let contact = () => (chrome() ? paint().contactShadow : undefined)
204
+ let glow = () => (chrome() ? paint().glow : undefined)
205
+ let fill = () => (chrome() ? paint().fill : "transparent")
206
+ let bevel = () => (chrome() ? paint().bevel : undefined)
207
+ // Opaque faces cast from casters in their own color, hidden under the
208
+ // fill; see-through ones from transparent casters (their own layer each).
209
+ let caster = () => (glass() || !isOpaque(base()) ? "transparent" : base())
210
+ let keyOnFill = () => !glass() && caster() !== "transparent"
211
+ let glassFill = () => {
212
+ let g = glass()!
213
+ let tint = g.tint ?? (typeof base() === "string" ? withAlpha(base() as string, GLASS_ALPHA) : base())
214
+ return sheenFill(tint, theme.light, themeAxis(), material().sheen, props.pressed === true || props.sunken === true)
215
+ }
216
+
217
+ let fillTransition = () => withTransitionDefaults(props.fillTransition, { ...colorFade(), ...elevationMotion() })
218
+ let shadowTransition = () => elevationMotion()
219
+ let outlineTransition = () => withTransitionDefaults(props.outlineTransition, colorFade())
220
+ let tinted = () => props.tint !== false && (props.pressed !== undefined || props.hovered !== undefined)
221
+ let tint = () => (props.pressed ? theme.color.overlayPressed : theme.color.overlayHover)
222
+ let tintFade = () => {
223
+ let f = feedbackFade()
224
+ return f === undefined ? undefined : { color: f }
225
+ }
226
+
227
+ return (
228
+ <>
229
+ <Show when={contact()}>
230
+ {(shadow) => <d-rect color={caster()} radius={props.radius} shadow={shadow()} transition={shadowTransition()} />}
231
+ </Show>
232
+ <Show when={glow()}>{(shadow) => <d-rect color={caster()} radius={props.radius} shadow={shadow()} />}</Show>
233
+ <Show when={!keyOnFill() && key()}>
234
+ {(shadow) => <d-rect color="transparent" radius={props.radius} shadow={shadow()} transition={shadowTransition()} />}
235
+ </Show>
236
+ <Show
237
+ when={glass()}
238
+ fallback={
239
+ <d-rect
240
+ transition={fillTransition()}
241
+ onTransitionEnd={props.onFillTransitionEnd}
242
+ color={fill()}
243
+ radius={props.radius}
244
+ shadow={keyOnFill() ? key() : undefined}
245
+ />
246
+ }
247
+ >
248
+ {(g) => (
249
+ <view
250
+ position="absolute"
251
+ top={0}
252
+ left={0}
253
+ right={0}
254
+ bottom={0}
255
+ overflow="hidden"
256
+ clipRadius={props.radius}
257
+ backdropFilter={{ blur: g().blur, saturate: GLASS_SATURATE }}
258
+ >
259
+ <d-rect transition={fillTransition()} onTransitionEnd={props.onFillTransitionEnd} color={glassFill()} radius={props.radius} />
260
+ </view>
261
+ )}
262
+ </Show>
263
+ <Show when={tinted()}>
264
+ <d-rect
265
+ transition={tintFade()}
266
+ color={props.pressed || props.hovered ? tint() : withAlpha(tint(), 0)}
267
+ radius={props.radius}
268
+ />
269
+ </Show>
270
+ <Show when={bevel()}>
271
+ {(b) => <d-rect drawStyle="stroke" strokeWidth={BEVEL_WIDTH} color={b()} radius={props.radius} />}
272
+ </Show>
273
+ <Show when={props.outline}>
274
+ {(o) => (
275
+ <d-rect
276
+ drawStyle="stroke"
277
+ transition={outlineTransition()}
278
+ onTransitionEnd={props.onOutlineTransitionEnd}
279
+ color={o().color}
280
+ strokeWidth={o().width}
281
+ radius={props.radius}
282
+ />
283
+ )}
284
+ </Show>
285
+ </>
286
+ )
287
+ }
package/src/switch.tsx CHANGED
@@ -1,12 +1,13 @@
1
- import { createSignal, Show } from "@solidrt/core"
1
+ import { createSignal } 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
- import type { StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
7
+ import { Surface, surfaceSinks } from "./surface"
8
+ import type { ElevationLevel, StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
8
9
  import { partTransition, partTransitionEnd, splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
9
- import { colorFade, pressScale, scaleFeedback, travelMotion } from "./motion"
10
+ import { pressScale, scaleFeedback, travelMotion } from "./motion"
10
11
 
11
12
  export interface SwitchProps extends TransitionProps<TransitionViewProp | TransitionStyleProp | "knob"> {
12
13
  // Controlled on/off. If omitted, the switch is uncontrolled.
@@ -25,11 +26,15 @@ const W = 44
25
26
  const H = 24
26
27
  const PAD = 2
27
28
 
28
- // A toggle. Track fills with primary when on, surfaceAlt when off (a fade);
29
- // the thumb springs across - the `knob` transition entry retimes it. A press
30
- // shrinks the control slightly (pressScale). Controlled via value/onChange,
31
- // or uncontrolled via defaultValue. When disabled, it takes no pointer
32
- // events at all.
29
+ // A toggle. The track is a sunken face, accent when on (primary, with the
30
+ // accent material's glow), control when off (surfaceAlt), fading between
31
+ // them; the thumb (theme.color.thumb, else onPrimary) is a raised control
32
+ // face that springs across - the `knob` transition entry retimes it - and
33
+ // sinks under the finger where its material has depth. Track and thumb take
34
+ // pill(): round by default, square under radius.full 0. A press shrinks the
35
+ // control slightly (pressScale) while the thumb is flat; style.elevation
36
+ // sets the thumb's level. Controlled via value/onChange, or uncontrolled
37
+ // via defaultValue. When disabled, it takes no pointer events at all.
33
38
  export function Switch(props: SwitchProps) {
34
39
  let [internal, setInternal] = createSignal(props.defaultValue ?? false)
35
40
  let on = () => (props.value !== undefined ? props.value : internal())
@@ -40,6 +45,9 @@ export function Switch(props: SwitchProps) {
40
45
  props.onChange?.(next)
41
46
  }
42
47
  let press = createPress({ onPress: toggle })
48
+ // Widens the glow; hover counts only where a pointer can hover (the
49
+ // tint's rule).
50
+ let active = () => press.pressed() || (press.hovered() && policy.interaction !== "touch")
43
51
 
44
52
  let w = () => Math.round(W * densityScale())
45
53
  let h = () => Math.round(H * densityScale())
@@ -47,11 +55,13 @@ export function Switch(props: SwitchProps) {
47
55
 
48
56
  let style = () => ({
49
57
  backgroundColor: on() ? theme.color.primary : theme.color.surfaceAlt,
50
- borderRadius: h() / 2,
58
+ borderRadius: pill(h()),
51
59
  ...theme.components.switch,
52
60
  ...props.style,
53
61
  ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
54
62
  })
63
+ let thumbElevation = (): ElevationLevel => style().elevation ?? "raised"
64
+ let sinks = () => surfaceSinks("control", thumbElevation(), style().material)
55
65
 
56
66
  let split = () => splitTransition(props.transition, ["knob"])
57
67
 
@@ -66,7 +76,7 @@ export function Switch(props: SwitchProps) {
66
76
  {...props.layout}
67
77
  x={style().x}
68
78
  y={style().y}
69
- scale={(style().scale ?? 1) * pressScale(press.pressed())}
79
+ scale={(style().scale ?? 1) * (sinks() ? 1 : pressScale(press.pressed()))}
70
80
  rotate={style().rotate}
71
81
  opacity={style().opacity}
72
82
  {...press.handlers}
@@ -74,32 +84,44 @@ export function Switch(props: SwitchProps) {
74
84
  focusable={!props.disabled}
75
85
  pointerEvents={props.disabled ? "none" : undefined}
76
86
  >
77
- <d-rect
78
- transition={withTransitionDefaults(split().background, colorFade())}
79
- onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
80
- color={style().backgroundColor ?? "transparent"}
87
+ <Surface
88
+ role={on() ? "accent" : "control"}
89
+ fill={style().backgroundColor ?? "transparent"}
81
90
  radius={style().borderRadius}
91
+ material={style().material}
92
+ glow={on() && !props.disabled ? style().glow : null}
93
+ sunken
94
+ active={active()}
95
+ outline={(style().borderWidth ?? 0) > 0 ? { color: style().borderColor ?? "transparent", width: style().borderWidth! } : null}
96
+ fillTransition={split().background}
97
+ onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
98
+ outlineTransition={split().border}
99
+ onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
82
100
  />
83
101
  <view
84
102
  position="absolute"
85
103
  top={PAD}
86
104
  left={PAD}
105
+ width={thumb()}
106
+ height={thumb()}
87
107
  x={on() ? w() - thumb() - PAD * 2 : 0}
88
108
  transition={partTransition(props.transition, "knob", "x", travelMotion())}
89
109
  onTransitionEnd={partTransitionEnd("knob", "x", props.onTransitionEnd)}
90
110
  >
91
- <d-oval transition={colorFade()} w={thumb()} h={thumb()} color={theme.color.onPrimary} />
92
- </view>
93
- <Show when={(style().borderWidth ?? 0) > 0}>
94
- <d-rect
95
- drawStyle="stroke"
96
- transition={withTransitionDefaults(split().border, colorFade())}
97
- onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
98
- color={style().borderColor ?? "transparent"}
99
- strokeWidth={style().borderWidth}
100
- radius={style().borderRadius}
111
+ {/* The hairline keeps the thumb visible off: onPrimary on the
112
+ light scheme's surfaceAlt track is about 1.2:1 on its own. */}
113
+ <Surface
114
+ role="control"
115
+ elevation={thumbElevation()}
116
+ fill={theme.color.thumb ?? theme.color.onPrimary}
117
+ radius={pill(thumb())}
118
+ material={style().material}
119
+ glow={null}
120
+ pressed={press.pressed()}
121
+ tint={false}
122
+ outline={{ color: theme.color.border, width: theme.borderWidth.sm }}
101
123
  />
102
- </Show>
124
+ </view>
103
125
  </view>
104
126
  )
105
127
  }