@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,19 @@
1
+ # Surface
2
+
3
+ The one way a component draws a face: a role's material (`theme.material`) under the theme light (`theme.light`) at an elevation (`theme.elevation`), stacked as layers that fill the view it is rendered in - a contact shadow, the glow, the fill (tinted by the light, with the sheen gradient along it, carrying the key shadow the elevation casts), the hover/pressed tint, the bevel and the outline. Every themed component draws its chrome through it, so a theme's light, materials and elevation restyle the whole library at once; under the stock presets (flat materials, zero elevation) a face is a plain fill, the tint and the outline, the look the package has always had. A custom control uses it the same way: render it as the first child of the laid-out view it dresses, pick a `role` (never a material), give it the base `fill`, the `radius`, the press state for the tint and the sink, and an `outline` for its border or focus ring.
4
+
5
+ ```jsx
6
+ import { Surface, createPress, theme } from "@solidrt/components"
7
+
8
+ function Tile(props) {
9
+ let press = createPress(props)
10
+ return (
11
+ <view ref={press.ref} {...press.handlers} padding={16} cursor="pointer" focusable>
12
+ <Surface role="surface" elevation="raised" radius={theme.radius.lg} pressed={press.pressed()} hovered={press.hovered()} />
13
+ <text color={theme.color.text}>{props.label}</text>
14
+ </view>
15
+ )
16
+ }
17
+ ```
18
+
19
+ A pressed face sinks (its key shadow fades toward the contact shadow, its lit edge moves to the far side) on the native shadow transition; a `sunken` face is a well or a groove (shaded like a pressed face, casting nothing); `lifted` with `liftTo` retargets the key shadow to a higher level (the Card hover lift); `chrome={false}` keeps only the tint (a ghost button at rest). `material` merges layers over the role's, `glow` overrides that layer alone. Shadows cast from opaque casters hidden under the fill, since a transparent caster's blurred shadow composites in a save layer of its own, which a tiled GPU pays for per shadow; only a glass material (a frosted backdrop, the overlay role's option) casts from a transparent one. `facePaint` gives the same derivation as plain paint values, for a part a control draws itself (a slider fill, a segmented indicator); `roleFill`, `roleMaterial` and `surfaceSinks` are the pieces it is built from.
package/docs/switch.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Switch
2
2
 
3
- An on/off toggle: the track fills with `primary` when on and `surfaceAlt` when off (a fade), and the thumb springs across - the `knob` transition entry retimes that travel. Controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Built on `Pressable`, so `disabled` takes no pointer events. `style` overrides the track colors and radius.
3
+ An on/off toggle: the track fills with `primary` when on and `surfaceAlt` when off (a fade), and the thumb springs across - the `knob` transition entry retimes that travel. Controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Built on `Pressable`, so `disabled` takes no pointer events. The thumb is `theme.color.thumb` (else `onPrimary`), track and thumb follow `radius.full` through `pill` (square in an all-square theme), and the on track glows with the theme's accent glow. `style` overrides the track colors and radius.
4
4
 
5
5
  ```jsx
6
6
  import { Switch } from "@solidrt/components"
package/docs/theme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Theming
2
2
 
3
- Appearance (colors, spacing, border, font roles) comes from one shared, reactive theme backed by a Solid store: reads are tracked, so switching the theme at runtime recolors the live UI without remounting. Two presets ship, `darkTheme` and `lightTheme` (default dark); `setTheme(preset)` switches, `setTheme(partial)` merges an override one level deep per category. Custom themes are authored with `defineTheme`.
3
+ Appearance (colors, spacing, border, font roles) comes from one shared, reactive theme backed by a Solid store: reads are tracked, so switching the theme at runtime recolors the live UI without remounting. Four presets ship, `darkTheme` and `lightTheme` (the flat look, default dark) and `litDarkTheme` and `litLightTheme` (the same palette under a light, see below); `setTheme(preset)` switches, `setTheme(partial)` merges an override one level deep per category. A resolved theme carries every key of every category (unset ones as `undefined`), so switching to one replaces the previous theme outright, per-component overrides and icon slots included; a partial keeps whatever it does not name. Custom themes are authored with `defineTheme`.
4
4
 
5
5
  ```jsx
6
6
  import { setTheme, darkTheme, lightTheme } from "@solidrt/components"
@@ -33,7 +33,7 @@ The type scale derives from `text.base` (the body size, default 14) and `text.ra
33
33
 
34
34
  ## Tokens
35
35
 
36
- The color tokens are `background` (window fill), `surface` (control/card fill), `surfaceAlt` (subtle raised/track fill), `text`, `textMuted`, `border`, `primary`/`onPrimary`, `secondary`/`onSecondary` (lower-emphasis accent), `danger` (validation/destructive), `scrim` (modal dim), `ring` (the focus ring; defaults to `text` so it stays visible on primary fills), and the feedback pair `overlayHover`/`overlayPressed`: translucent tints components draw OVER a control's own fill, so one token pair gives hover/pressed feedback on every fill color, including caller-set ones. Non-color tokens are `spacing`, `radius`, `borderWidth` (`sm` for borders, `focus` for the ring), `size` (app-wide default extents: `navRail` 72, `navSidebar` 220, `splitViewList` 320, `menuMinWidth` 120, `slider` 200; each overridable per instance through its layout or prop), and `text` (the type scale: `caption`/`label`/`body`/`title`/`heading` roles, each `{ size, lineHeight, weight }`, plus `fontFamily` and `monoFamily` for code).
36
+ The color tokens are `background` (window fill), `surface` (control/card fill), `surfaceAlt` (subtle raised/track fill), `text`, `textMuted`, `border`, `primary`/`onPrimary`, `secondary`/`onSecondary` (lower-emphasis accent), `danger` (validation/destructive), `scrim` (modal dim), `ring` (the focus ring; defaults to `text` so it stays visible on primary fills), `thumb` (optional: the cap riding a track, the Switch knob and the Slider thumb; unset they are `onPrimary` and `primary`, so a palette whose `onPrimary` is dark sets a light cap here), and the feedback pair `overlayHover`/`overlayPressed`: translucent tints components draw OVER a control's own fill, so one token pair gives hover/pressed feedback on every fill color, including caller-set ones. Non-color tokens are `spacing`, `radius`, `borderWidth` (`sm` for borders, `focus` for the ring), `size` (app-wide default extents: `navRail` 72, `navSidebar` 220, `splitViewList` 320, `menuMinWidth` 120, `slider` 200; each overridable per instance through its layout or prop), and `text` (the type scale: `caption`/`label`/`body`/`title`/`heading` roles, each `{ size, lineHeight, weight }`, plus `fontFamily` and `monoFamily` for code).
37
37
 
38
38
  ## Spacing
39
39
 
@@ -41,7 +41,7 @@ Spacing is one base unit: `spacing` in a theme definition is a number (default 4
41
41
 
42
42
  ## Radius
43
43
 
44
- Corner radius is set once: `radius` in a theme definition is a single number, the control radius (default 8), and the scale derives from it: `md` is the base (Button, TextInput, RichTextEditor, Select, SegmentedControl, QrCode), `sm` half of it (Checkbox, Item, NavShell items, Select and ContextMenu popups, Tooltip), `lg` one and a half (Card), and `full` the pill (Badge). Set `radius: 0` for a square theme, `radius: 12` for a soft one; buttons and inputs always match. Shapes derived from a control's own height (Switch, Slider, ProgressBar, Radio) are not on the scale. Pass an object (`radius: { sm, md, lg, full }`, any subset) to pin individual steps instead.
44
+ Corner radius is set once: `radius` in a theme definition is a single number, the control radius (default 8), and the scale derives from it: `md` is the base (Button, TextInput, RichTextEditor, Select, SegmentedControl, QrCode), `sm` half of it (Checkbox, Item, NavShell items, Select and ContextMenu popups, Tooltip), `lg` one and a half (Card), and `full` the pill (Badge). Set `radius: 0` for a square theme, `radius: 12` for a soft one; buttons and inputs always match. Shapes derived from a control's own height (Switch, Slider, ProgressBar, Radio) take `full` too, through `pill(size)`: half the size, capped by `radius.full`, so `full: 0` squares every pill and circle in the library (`radius: { sm: 0, md: 0, lg: 0, full: 0 }` is the all-square theme) while the default 9999 leaves them round. `pill` is exported for a custom control with a round part. Pass an object (`radius: { sm, md, lg, full }`, any subset) to pin individual steps instead.
45
45
 
46
46
  ```jsx
47
47
  setTheme({ radius: 4 }) // sm 2, md 4, lg 6
@@ -49,21 +49,49 @@ setTheme({ radius: 4 }) // sm 2, md 4, lg 6
49
49
 
50
50
  ## Motion
51
51
 
52
- `motion` holds the three durations (ms) every built-in component transition draws from, so one theme edit retimes the whole package: `fast` (default 100) is press/hover feedback, `base` (150) the color and opacity fades - state changes, the theme cross-fade (a `setTheme` fades every themed color rather than snapping), popup enter/exit - and `slow` (250) the travel of a control's moving parts (switch knob, segmented indicator, progress fill). `policy.motion` gates whether these play at all; a per-instance `transition` prop overrides them per property.
52
+ `motion` holds the three durations (ms) every built-in component transition draws from, so one theme edit retimes the whole package: `fast` (default 100) is press/hover feedback, `base` (150) the color and opacity fades - state changes, the theme cross-fade (a `setTheme` fades every themed color rather than snapping), popup enter/exit - and `slow` (250) the travel of a control's moving parts (switch knob, segmented indicator, progress fill) and of a face's shadows as it sinks or lifts. `policy.motion` gates whether these play at all; a per-instance `transition` prop overrides them per property.
53
53
 
54
54
  ```jsx
55
55
  setTheme({ motion: { base: 250, slow: 400 } }) // a slower, calmer app
56
56
  ```
57
57
 
58
+ ## Light, materials and elevation
59
+
60
+ Every component draws its faces through `Surface`, and three theme sections decide what a face looks like. `light` is the theme's one light in screen space (`direction` [x right, y down, z into the screen], a `color`, and `ambient`, the fill on the shaded side): shadows fall along it, the sheen and the bevel run from its lit edge to the shaded one, and faces take on its color (a white light leaves every base color as it is). `material` assigns each role its layers: `surface` (Card, Item, the NavShell strips), `control` (a secondary Button, the fields, the Select trigger, the tracks and grooves, the Switch knob and the Slider thumb), `accent` (a primary or danger Button, Switch on, a checked Checkbox, the Radio dot, the Slider and ProgressBar fills, the SegmentedControl indicator, a Badge) and `overlay` (the Tooltip bubble, the Select dropdown, the ContextMenu menu, the sheets). A `Material` is `{ sheen?, bevel?, glass?, glow? }`: `sheen` (0..1) a fill gradient along the light, `bevel` (0..1) a one-pixel stroke from the light color to black, `glass` a frosted backdrop (`{ blur, tint? }`, a deliberate overlay, never a surface material: every glass face captures the pixels under it each frame), `glow` a halo (`{ radius, color? }`; without a color a part glows in its own fill, so a danger button glows red, and a fill with no color to take casts nothing; a pressable face widens it while hovered or pressed). `elevation` maps the four levels (`flat`, `raised`, `floating`, `overlay`) to heights: a face at height h casts a key shadow h px along the light and blurred 2.5 h px, plus a tight contact shadow; a press sinks the face (the key shadow fades toward the contact shadow) on the native shadow transition, and `shadow` (`{ color, strength }`) is what they are cast in.
61
+
62
+ The stock presets are flat: every material `{}` and every height 0, so nothing casts or shines and the components look as they always have. `litDarkTheme` and `litLightTheme` are the same palette under the light: a sheen and a bevel per role, `raised` 2, `floating` 6, `overlay` 14, a light cap for the Switch knob and Slider thumb, and shadows 1.7 times stronger on dark, where they otherwise vanish. Card and Button stand raised by default (a ghost or disabled button flat), the Switch knob, the Slider thumb and the SegmentedControl indicator too; Item, Badge and the Checkbox sit flat whatever the theme, since a screen holds dozens of them and a blurred shadow per row is what a scrolling list cannot afford on a tablet or a TV (measured in the runtime's notes); popups float. Per component, `theme.components.<name>.material` merges layers over the role's, `.elevation` sets the level and `.glow` the halo alone, and an instance's `style` does the same over that.
63
+
64
+ ```jsx
65
+ setTheme(litDarkTheme) // the lit look
66
+ setTheme({ light: { ...theme.light, direction: [-0.4, 1, 0.8] } }) // light from the right
67
+ setTheme({ material: { accent: { sheen: 1, bevel: 0.9, glow: { radius: 14 } } } }) // glowing accents
68
+ setTheme({ components: { item: { elevation: "raised" } } }) // raised rows, deliberately
69
+ ```
70
+
71
+ ## Window finish
72
+
73
+ `finish` is the theme's window finish: a core window shader declaration (`program`, `params`, and the optional `textures`, `previous`, `vertexCount`) that `Window` declares on the window as it is, so one theme can carry a vignette, scanlines or a color grade. `Window` adds one uniform, `uScale`, the display scale, for effects counted in logical pixels. Strength and layer switches are the theme's own `params`; a theme without a `program` has no finish. An app with a slider updates a param by spreading the current ones, because `setTheme` merges one level deep and a partial replaces the whole `params` object it names: `setTheme({ finish: { params: { ...theme.finish.params, uStrength: v } } })`. The theme's module links the program itself, once, at init (the client brings the GPU up before the bundle runs); the package ships no finish, and the stock presets have none.
74
+
75
+ ```jsx
76
+ import { compileShader, linkProgram, destroyShader, glsl } from "@solidrt/core/gpu"
77
+
78
+ let fs = compileShader("fragment", glsl`...`, { header: true }) // reads uSource, uScale and its own params
79
+ let vs = compileShader("vertex", glsl`...`) // the covering triangle
80
+ let program = linkProgram(vs, fs, { label: "crt-finish" })
81
+ destroyShader(vs); destroyShader(fs)
82
+
83
+ export let crtTheme = defineTheme({ color: { /* ... */ }, finish: { program, params: { uStrength: 0.4, uScan: 1 } } })
84
+ ```
85
+
58
86
  ## Per-component overrides
59
87
 
60
- `theme.components` restyles a component everywhere without wrapping it: a `StyleProps` object per component name, merged between the component's themed defaults and each instance's `style` prop (instance style still wins).
88
+ `theme.components` restyles a component everywhere without wrapping it: a `StyleProps` object per component name, merged between the component's themed defaults and each instance's `style` prop (instance style still wins). Every themed component draws the `borderColor`/`borderWidth` it is given (Item rows, the SegmentedControl, Badge, the Slider and ProgressBar grooves included), so one theme boxes the whole library; `material`, `elevation` and `glow` restyle its faces (see Light, materials and elevation).
61
89
 
62
90
  ```jsx
63
91
  setTheme({ components: { button: { borderRadius: 999 } } }) // pill buttons app-wide
64
92
  ```
65
93
 
66
- Keys: `button`, `card`, `badge`, `switch`, `checkbox`, `radio`, `item`, `select`, `segmentedControl`, `textInput`, `richTextEditor`, `tooltip`, `divider`, `progressBar`, `spinner`.
94
+ Keys: `button`, `card`, `badge`, `switch`, `checkbox`, `radio`, `slider`, `item`, `select`, `segmentedControl`, `textInput`, `richTextEditor`, `tooltip`, `divider`, `progressBar`, `spinner`, `contextMenu`, `navShell`.
67
95
 
68
96
  ## Icon slots
69
97
 
package/docs/tooltip.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Tooltip
2
2
 
3
- A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, fades in and out, and hides on leave and on press. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the side (`"top"`, the default, or `"bottom"`).
3
+ A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, fades in and out, hides on leave and on press, and glows with the theme's overlay glow. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the side (`"top"`, the default, or `"bottom"`).
4
4
 
5
5
  ```jsx
6
6
  import { Tooltip, Button } from "@solidrt/components"
package/docs/types.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Layout and style
2
2
 
3
- Most components group their props into two objects, split by one rule: `layout` properties feed the layout engine (flexbox/grid, sizing, padding, margin, position - the core `LayoutProps` set) and changing them triggers a relayout; `style` properties are paint-only and never affect layout: `color`, `backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`, `opacity`, and the transform (`x`, `y`, `scale`, `rotate`, `rotateX`/`rotateY` with `perspective`, `originX`/`originY`, `clipRadius`). Event handlers (`onPointerDown`, `onKeyDown`, ...) are top-level props, never inside `layout` or `style`.
3
+ Most components group their props into two objects, split by one rule: `layout` properties feed the layout engine (flexbox/grid, sizing, padding, margin, position - the core `LayoutProps` set) and changing them triggers a relayout; `style` properties are paint-only and never affect layout: `color`, `backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`, `opacity`, `glow` (a halo on the component's accent part; `null` clears the theme's), `material` (layers merged over the role's material for the component's faces) and `elevation` (the level its raised face stands at), see Theming, and the transform (`x`, `y`, `scale`, `rotate`, `rotateX`/`rotateY` with `perspective`, `originX`/`originY`, `clipRadius`). Event handlers (`onPointerDown`, `onKeyDown`, ...) are top-level props, never inside `layout` or `style`.
4
4
 
5
5
  `StyleProps` is that paint set. `TextLayoutProps` extends `LayoutProps` with the font fields (`fontFamily`, `fontSize`, `lineHeight`, `fontStyle`, `fontWeight`, `textAlign`, `maxLines`) because text shaping affects measurement; note `lineHeight` is a multiplier of `fontSize` (the theme uses 1.3-1.6), not a pixel value. `Option` (`{ value, label }`) is the shared shape of the single-choice controls (`Select`, `SegmentedControl`): shared shapes go through this module so components never import a sibling.
6
6
 
package/docs/window.md CHANGED
@@ -13,3 +13,5 @@ function App() {
13
13
  )
14
14
  }
15
15
  ```
16
+
17
+ The window takes the theme's finish (see Theming) whenever the theme has one; a theme without a finish declares no shader at all. `shader` is the window's own finish, a core window shader declaration forwarded as-is; set, it replaces the theme's, and `shader={null}` opts out of it. A window shader runs past the point snapshots read, so a finish is checked with `srt render`, not `get_snapshot`.
@@ -0,0 +1,182 @@
1
+ // The lit presets: the same components under darkTheme, lightTheme,
2
+ // litDarkTheme and litLightTheme, side by side with a light pad. Under the
3
+ // lit presets every face is a role's material under theme.light - a sheen
4
+ // along the light, a bevel on its edges, shadows cast from its elevation,
5
+ // a press that sinks - and dragging the pad moves the light for the whole
6
+ // screen at once (setTheme({ light })). The flat presets are the same
7
+ // screen with every material empty and every height zero.
8
+ import { render, createSignal, getLayoutBox } from "@solidrt/core"
9
+ import type { PointerEvent } from "@solidrt/core"
10
+ import {
11
+ Badge,
12
+ Button,
13
+ Card,
14
+ Checkbox,
15
+ Item,
16
+ ProgressBar,
17
+ Radio,
18
+ RadioGroup,
19
+ SegmentedControl,
20
+ Select,
21
+ Slider,
22
+ Surface,
23
+ Switch,
24
+ Text,
25
+ TextInput,
26
+ View,
27
+ Window,
28
+ darkTheme,
29
+ lightTheme,
30
+ litDarkTheme,
31
+ litLightTheme,
32
+ lightFrom,
33
+ lightSource,
34
+ setTheme,
35
+ theme,
36
+ } from "@solidrt/components"
37
+
38
+ type Preset = "flat" | "lit"
39
+ type Scheme = "dark" | "light"
40
+
41
+ const PRESETS = [
42
+ { value: "flat" as const, label: "Flat" },
43
+ { value: "lit" as const, label: "Lit" },
44
+ ]
45
+ const SCHEMES = [
46
+ { value: "dark" as const, label: "Dark" },
47
+ { value: "light" as const, label: "Light" },
48
+ ]
49
+ const RANGES = [
50
+ { value: "day", label: "Day" },
51
+ { value: "week", label: "Week" },
52
+ { value: "month", label: "Month" },
53
+ ]
54
+ // The light pad's size, and its marker.
55
+ const PAD = 120
56
+ const MARKER = 14
57
+
58
+ function themeFor(preset: Preset, scheme: Scheme) {
59
+ if (preset === "lit") return scheme === "dark" ? litDarkTheme : litLightTheme
60
+ return scheme === "dark" ? darkTheme : lightTheme
61
+ }
62
+
63
+ // A pad showing where the light comes from; dragging over it moves the
64
+ // light. The direction belongs to the app, so a preset switch keeps it.
65
+ function LightPad() {
66
+ let node: { id: number } | undefined
67
+ let [dragging, setDragging] = createSignal(false)
68
+ let move = (e: PointerEvent) => {
69
+ if (!node) return
70
+ let box = getLayoutBox(node)
71
+ if (!box || box.width === 0) return
72
+ let px = (e.localX / box.width) * 2 - 1
73
+ let py = (e.localY / box.height) * 2 - 1
74
+ setTheme({ light: { ...theme.light, direction: lightFrom(px, py) } })
75
+ }
76
+ let marker = () => {
77
+ let [sx, sy] = lightSource(theme.light)
78
+ return { x: ((sx + 1) / 2) * PAD - MARKER / 2, y: ((sy + 1) / 2) * PAD - MARKER / 2 }
79
+ }
80
+ return (
81
+ <view
82
+ ref={(n: { id: number }) => (node = n)}
83
+ width={PAD}
84
+ height={PAD}
85
+ cursor="pointer"
86
+ onPointerDown={(e: PointerEvent) => {
87
+ setDragging(true)
88
+ move(e)
89
+ }}
90
+ onPointerMove={(e: PointerEvent) => dragging() && move(e)}
91
+ onPointerUp={() => setDragging(false)}
92
+ >
93
+ <Surface role="control" sunken radius={theme.radius.lg} />
94
+ <d-rect x={marker().x} y={marker().y} w={MARKER} h={MARKER} radius={MARKER / 2} color={theme.light.color} />
95
+ </view>
96
+ )
97
+ }
98
+
99
+ // The look, at module scope: the initial setTheme runs at load, outside any
100
+ // component's owned scope (a theme write belongs to an event or to init,
101
+ // never to a component's setup).
102
+ let [preset, setPreset] = createSignal<Preset>("lit")
103
+ let [scheme, setScheme] = createSignal<Scheme>("dark")
104
+ function apply(p: Preset, s: Scheme) {
105
+ // The light's direction is the app's: carry it across the switch.
106
+ let direction = theme.light.direction
107
+ setTheme(themeFor(p, s))
108
+ setTheme({ light: { ...theme.light, direction } })
109
+ }
110
+ apply(preset(), scheme())
111
+
112
+ function App() {
113
+ let [notify, setNotify] = createSignal(true)
114
+ let [volume, setVolume] = createSignal(62)
115
+ let [range, setRange] = createSignal("week")
116
+ let [query, setQuery] = createSignal("")
117
+
118
+ return (
119
+ <Window title="Lit components" style={{ backgroundColor: theme.color.background }} layout={{ padding: 24, gap: 24, flexDirection: "row", alignItems: "flex-start" }}>
120
+ <View layout={{ flexDirection: "column", gap: 16, width: 200 }}>
121
+ <SegmentedControl
122
+ options={PRESETS}
123
+ value={preset()}
124
+ onChange={(v) => {
125
+ setPreset(v)
126
+ apply(v, scheme())
127
+ }}
128
+ />
129
+ <SegmentedControl
130
+ options={SCHEMES}
131
+ value={scheme()}
132
+ onChange={(v) => {
133
+ setScheme(v)
134
+ apply(preset(), v)
135
+ }}
136
+ />
137
+ <Text variant="caption" style={{ color: theme.color.textMuted }}>
138
+ Drag to move the light
139
+ </Text>
140
+ <LightPad />
141
+ </View>
142
+ <Card title="Controls" lift layout={{ width: 360, gap: 16 }}>
143
+ <View layout={{ flexDirection: "row", gap: 8, flexWrap: "wrap" }}>
144
+ <Button>Run</Button>
145
+ <Button variant="secondary">Save</Button>
146
+ <Button variant="ghost">Cancel</Button>
147
+ <Button variant="danger">Delete</Button>
148
+ <Button disabled>Off</Button>
149
+ </View>
150
+ <View layout={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
151
+ <Text>Notifications</Text>
152
+ <Switch value={notify()} onChange={setNotify} />
153
+ </View>
154
+ <View layout={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
155
+ <Checkbox defaultChecked />
156
+ <Checkbox />
157
+ <RadioGroup defaultValue="a" layout={{ flexDirection: "row", gap: 12 }}>
158
+ <Radio value="a">Alpha</Radio>
159
+ <Radio value="b">Beta</Radio>
160
+ </RadioGroup>
161
+ </View>
162
+ <Slider value={volume()} onChange={setVolume} layout={{ width: "100%" }} />
163
+ <ProgressBar value={volume() / 100} />
164
+ <View layout={{ flexDirection: "row", gap: 8 }}>
165
+ <Badge>New</Badge>
166
+ <Badge variant="neutral">12</Badge>
167
+ <Badge variant="danger">Err</Badge>
168
+ </View>
169
+ <SegmentedControl options={RANGES} value={range()} onChange={setRange} />
170
+ <TextInput value={query()} onInput={setQuery} placeholder="Search" />
171
+ <Select options={RANGES} defaultValue="week" />
172
+ </Card>
173
+ <Card title="Inbox" layout={{ width: 320, gap: 0, padding: 8 }}>
174
+ <Item label="Compile" description="12 s" onPress={() => {}} />
175
+ <Item label="Link" description="1 s" onPress={() => {}} selected />
176
+ <Item label="Package" description="4 s" onPress={() => {}} />
177
+ </Card>
178
+ </Window>
179
+ )
180
+ }
181
+
182
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.63",
3
+ "version": "0.0.64",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -21,6 +21,6 @@
21
21
  },
22
22
  "peerDependencies": {
23
23
  "@solidjs/signals": "2.0.0-rc.9",
24
- "@solidrt/core": "0.0.63"
24
+ "@solidrt/core": "0.0.64"
25
25
  }
26
26
  }
package/src/badge.tsx CHANGED
@@ -4,8 +4,9 @@ import { theme } from "./theme"
4
4
  import { space } from "./spacing"
5
5
  import { typeStyle, typeWeight, lightOnDark } from "./typography"
6
6
  import { policy } from "./policy"
7
- import type { StyleProps, TransitionProps } from "./types"
8
- import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
7
+ import { Surface } from "./surface"
8
+ import type { ElevationLevel, MaterialRole, StyleProps, TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
9
10
  import { colorFade } from "./motion"
10
11
 
11
12
  export type BadgeVariant = "primary" | "neutral" | "danger"
@@ -20,9 +21,12 @@ export interface BadgeProps extends TransitionProps {
20
21
  style?: StyleProps
21
22
  }
22
23
 
23
- // A small rounded pill for counts, labels, and status. Accent fill with
24
- // onPrimary text by default; override the fill via style.backgroundColor and the
25
- // label color via style.color.
24
+ // A small rounded pill for counts, labels, and status. An accent face with
25
+ // onPrimary text by default (the neutral variant is a surface tone), drawn
26
+ // by Surface, flat: a badge sits in text and in list rows, so it casts
27
+ // nothing unless style.elevation raises it. Override the fill via
28
+ // style.backgroundColor, the label color via style.color, and box it with
29
+ // borderColor/borderWidth.
26
30
  export function Badge(props: BadgeProps) {
27
31
  let colors = () => {
28
32
  let c = theme.color
@@ -35,11 +39,14 @@ export function Badge(props: BadgeProps) {
35
39
  return { bg: c.primary, fg: c.onPrimary }
36
40
  }
37
41
  }
42
+ let role = (): MaterialRole => ((props.variant ?? "primary") === "neutral" ? "surface" : "accent")
38
43
  // Theme-level per-component overrides merged under the instance style.
39
44
  let styled = () => ({ ...theme.components.badge, ...props.style })
40
45
  let bg = () => styled().backgroundColor ?? colors().bg
41
46
  let fg = () => styled().color ?? colors().fg
42
47
  let radius = () => styled().borderRadius ?? theme.radius.full
48
+ let elevation = (): ElevationLevel => styled().elevation ?? "flat"
49
+ let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
43
50
  // Resolved once via children(): the typeof probe and the mount sites must
44
51
  // share one build - reading the raw getter again would orphan native nodes.
45
52
  let resolved = children(() => props.children)
@@ -66,7 +73,19 @@ export function Badge(props: BadgeProps) {
66
73
  rotate={styled().rotate}
67
74
  opacity={styled().opacity}
68
75
  >
69
- <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
76
+ <Surface
77
+ role={role()}
78
+ elevation={elevation()}
79
+ fill={bg()}
80
+ radius={radius()}
81
+ material={styled().material}
82
+ glow={styled().glow}
83
+ outline={hasBorder() ? { color: styled().borderColor ?? theme.color.border, width: styled().borderWidth ?? theme.borderWidth.sm } : null}
84
+ fillTransition={split().background}
85
+ onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
86
+ outlineTransition={split().border}
87
+ onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
88
+ />
70
89
  <Show when={isText()} fallback={resolved()}>
71
90
  <text
72
91
  transition={colorFade()}
package/src/button.tsx CHANGED
@@ -5,10 +5,11 @@ import { policy } from "./policy"
5
5
  import { space } from "./spacing"
6
6
  import { typeStyle, lightOnDark } from "./typography"
7
7
  import { Spinner } from "./spinner"
8
+ import { Surface, surfaceSinks } from "./surface"
8
9
  import type { LayoutProps } from "@solidrt/core"
9
- import type { StyleProps, TransitionProps } from "./types"
10
+ import type { ElevationLevel, MaterialRole, StyleProps, TransitionProps } from "./types"
10
11
  import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
11
- import { colorFade, scaleFeedback, pressScale, PressFeedback } from "./motion"
12
+ import { colorFade, scaleFeedback, pressScale } from "./motion"
12
13
 
13
14
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
14
15
  export type ButtonSize = "sm" | "md" | "lg"
@@ -42,15 +43,21 @@ export interface ButtonProps extends TransitionProps {
42
43
  // labels wider than the preset never clip.
43
44
  const SIZE_WIDTH: Record<ButtonSize, number> = { sm: 88, md: 120, lg: 160 }
44
45
 
45
- // A themed press target: a padded, centered, accent-colored box with a label.
46
- // Press feedback is a slight sprung scale plus the overlayPressed tint, hover
47
- // feedback the overlayHover tint (non-touch interaction policies only), all
48
- // reactive reads of the press state so no nodes are recreated. Override the
49
- // box via style and the padding/sizing via layout; because hover is an
50
- // overlay, it composes over a caller-set backgroundColor too. When disabled,
51
- // it takes no pointer events at all. Focus (spatial nav) draws a ring under
52
- // the focusRing policy in the theme's ring color; Enter/Space/remote-select
53
- // activates (handled by createPress).
46
+ // A themed press target: a padded, centered box with a label, its face drawn
47
+ // by Surface. The primary and danger variants are accent faces, secondary
48
+ // and ghost control faces; a button stands raised (theme.elevation.raised,
49
+ // zero under the stock presets) unless ghost or disabled, or style.elevation
50
+ // says otherwise. Press feedback is the overlayPressed tint plus, on a face
51
+ // with depth (a sheen, a bevel or a height), the face sinking - its key
52
+ // shadow fading, its lit edge moving to the far side - and on a flat face a
53
+ // slight sprung scale; hover feedback is the overlayHover tint (non-touch
54
+ // interaction policies only), all reactive reads of the press state so no
55
+ // nodes are recreated. Override the box via style and the padding/sizing
56
+ // via layout; because hover is an overlay, it composes over a caller-set
57
+ // backgroundColor too. When disabled, it takes no pointer events at all and
58
+ // never glows. Focus (spatial nav) draws a ring under the focusRing policy
59
+ // in the theme's ring color; Enter/Space/remote-select activates (handled
60
+ // by createPress).
54
61
  export function Button(props: ButtonProps) {
55
62
  // Fill and label color per variant, read reactively from the theme. No
56
63
  // variant draws a border.
@@ -67,16 +74,19 @@ export function Button(props: ButtonProps) {
67
74
  return { fill: c.primary, label: c.onPrimary }
68
75
  }
69
76
  }
77
+ let variant = () => props.variant ?? "primary"
78
+ let role = (): MaterialRole => (variant() === "primary" || variant() === "danger" ? "accent" : "control")
70
79
  // Theme-level per-component overrides merged under the instance style.
71
80
  let styled = (): StyleProps => ({ ...theme.components.button, ...props.style })
72
81
  let idleFill = () =>
73
82
  props.disabled
74
- ? props.variant === "ghost"
83
+ ? variant() === "ghost"
75
84
  ? "transparent"
76
85
  : theme.color.surface
77
86
  : colors().fill
78
87
  let bg = () => styled().backgroundColor ?? idleFill()
79
88
  let radius = () => styled().borderRadius ?? theme.radius.md
89
+ let elevation = (): ElevationLevel => styled().elevation ?? (props.disabled || variant() === "ghost" ? "flat" : "raised")
80
90
  let label = () => (props.disabled ? theme.color.textMuted : colors().label)
81
91
  // Resolved once via children(): reading the raw children getter builds a new
82
92
  // subtree per read, so the typeof probe and the two mount sites below must
@@ -85,11 +95,16 @@ export function Button(props: ButtonProps) {
85
95
  let isText = () => typeof resolved() === "string" || typeof resolved() === "number"
86
96
  // The label's polarity against the idle fill: onPrimary on a saturated fill
87
97
  // is light-on-dark even in a light theme, so it needs the low-DPI weight
88
- // compensation there too.
98
+ // compensation there too. The base fill, not the lit gradient: the
99
+ // compensation reads a solid color.
89
100
  let labelOnDark = () => lightOnDark(label(), bg())
90
101
 
91
102
  // props (not a literal) so a swapped-in onPress is read at event time.
92
103
  let press = createPress(props)
104
+ let pressed = () => !props.disabled && press.pressed()
105
+ let hovered = () => !props.disabled && press.hovered() && policy.interaction !== "touch"
106
+ // A face with depth sinks on press; a flat one shrinks instead.
107
+ let sinks = () => surfaceSinks(role(), elevation(), styled().material)
93
108
  let style = () => ({
94
109
  ...styled(),
95
110
  ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
@@ -98,7 +113,7 @@ export function Button(props: ButtonProps) {
98
113
  // Always a number: a scale that flips from a number back to undefined
99
114
  // hits the transform decoder, which rejects null. Multiply so a
100
115
  // caller-set scale is preserved under the press feedback.
101
- scale: (styled().scale ?? 1) * pressScale(press.pressed()),
116
+ scale: (styled().scale ?? 1) * (sinks() ? 1 : pressScale(press.pressed())),
102
117
  })
103
118
 
104
119
  let split = () => splitTransition(props.transition)
@@ -135,11 +150,21 @@ export function Button(props: ButtonProps) {
135
150
  focusable={(props.focusable ?? true) && props.disabled !== true}
136
151
  pointerEvents={props.disabled ? "none" : undefined}
137
152
  >
138
- <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
139
- <PressFeedback
140
- pressed={press.pressed() && !props.disabled}
141
- hovered={press.hovered() && !props.disabled && policy.interaction !== "touch"}
142
- radius={style().borderRadius}
153
+ <Surface
154
+ role={role()}
155
+ elevation={elevation()}
156
+ fill={bg()}
157
+ radius={radius()}
158
+ material={styled().material}
159
+ glow={props.disabled ? null : styled().glow}
160
+ chrome={variant() !== "ghost" || pressed() || hovered()}
161
+ pressed={pressed()}
162
+ hovered={hovered()}
163
+ outline={(style().borderWidth ?? 0) > 0 ? { color: style().borderColor ?? "transparent", width: style().borderWidth! } : null}
164
+ fillTransition={split().background}
165
+ onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
166
+ outlineTransition={split().border}
167
+ onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
143
168
  />
144
169
  <Show when={isText()} fallback={resolved()}>
145
170
  <text transition={colorFade()} color={press.pending() ? withAlpha(label(), 0) : label()} {...typeStyle("body", labelOnDark())}>
@@ -151,16 +176,6 @@ export function Button(props: ButtonProps) {
151
176
  <Spinner size={16} thickness={2} style={{ color: label() }} />
152
177
  </view>
153
178
  </Show>
154
- <Show when={(style().borderWidth ?? 0) > 0}>
155
- <d-rect
156
- drawStyle="stroke"
157
- transition={withTransitionDefaults(split().border, colorFade())}
158
- onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
159
- color={style().borderColor ?? "transparent"}
160
- strokeWidth={style().borderWidth}
161
- radius={style().borderRadius}
162
- />
163
- </Show>
164
179
  </view>
165
180
  )
166
181
  }