@solidrt/components 0.0.50 → 0.0.52

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 (85) hide show
  1. package/AGENTS.md +93 -85
  2. package/README.md +421 -311
  3. package/demos/README.md +24 -0
  4. package/demos/assets/icon.png +0 -0
  5. package/demos/assets/icon.svg +23 -0
  6. package/demos/package.json +9 -0
  7. package/demos/src/gallery.tsx +866 -0
  8. package/demos/tsconfig.json +15 -0
  9. package/docs/badge.md +10 -0
  10. package/docs/button.md +21 -0
  11. package/docs/card.md +11 -0
  12. package/docs/checkbox.md +9 -0
  13. package/docs/context-menu.md +17 -0
  14. package/docs/density.md +13 -0
  15. package/docs/divider.md +10 -0
  16. package/docs/field.md +11 -0
  17. package/docs/focus-nav.md +18 -0
  18. package/docs/icon.md +13 -0
  19. package/docs/image.md +21 -0
  20. package/docs/index.md +13 -0
  21. package/docs/item.md +25 -0
  22. package/docs/modal.md +15 -0
  23. package/docs/nav-shell.md +18 -0
  24. package/docs/policy.md +21 -0
  25. package/docs/portal.md +15 -0
  26. package/docs/pressable.md +17 -0
  27. package/docs/progress-bar.md +10 -0
  28. package/docs/qrcode.md +10 -0
  29. package/docs/radio.md +13 -0
  30. package/docs/rich-text-document.md +5 -0
  31. package/docs/rich-text-editor.md +24 -0
  32. package/docs/safe-area.md +12 -0
  33. package/docs/scroll-view.md +35 -0
  34. package/docs/segmented-control.md +13 -0
  35. package/docs/select.md +15 -0
  36. package/docs/slider.md +9 -0
  37. package/docs/spacing.md +7 -0
  38. package/docs/spinner.md +10 -0
  39. package/docs/split-view.md +14 -0
  40. package/docs/switch.md +13 -0
  41. package/docs/text-input.md +23 -0
  42. package/docs/text.md +15 -0
  43. package/docs/theme.md +68 -0
  44. package/docs/tooltip.md +11 -0
  45. package/docs/types.md +9 -0
  46. package/docs/typography.md +5 -0
  47. package/docs/view.md +14 -0
  48. package/docs/window.md +15 -0
  49. package/package.json +8 -4
  50. package/src/badge.tsx +30 -21
  51. package/src/button.tsx +50 -32
  52. package/src/card.tsx +22 -13
  53. package/src/checkbox.tsx +29 -11
  54. package/src/context-menu.tsx +14 -9
  55. package/src/density.tsx +39 -0
  56. package/src/divider.tsx +11 -4
  57. package/src/editor-field.tsx +368 -0
  58. package/src/field.tsx +47 -0
  59. package/src/icon.tsx +8 -4
  60. package/src/image.tsx +10 -3
  61. package/src/index.ts +19 -2
  62. package/src/item.tsx +121 -0
  63. package/src/nav-shell.tsx +7 -17
  64. package/src/policy.ts +9 -12
  65. package/src/press.ts +37 -8
  66. package/src/pressable.tsx +15 -3
  67. package/src/progress-bar.tsx +8 -5
  68. package/src/qrcode.tsx +7 -5
  69. package/src/radio.tsx +26 -16
  70. package/src/rich-text-document.ts +247 -0
  71. package/src/rich-text-editor.tsx +151 -0
  72. package/src/scroll-view.tsx +76 -7
  73. package/src/segmented-control.tsx +33 -19
  74. package/src/select.tsx +59 -30
  75. package/src/slider.tsx +32 -6
  76. package/src/spacing.ts +1 -1
  77. package/src/spinner.tsx +11 -6
  78. package/src/split-view.tsx +3 -4
  79. package/src/switch.tsx +10 -3
  80. package/src/text-input.tsx +54 -264
  81. package/src/text.tsx +22 -2
  82. package/src/theme.ts +220 -81
  83. package/src/tooltip.tsx +23 -9
  84. package/src/types.ts +119 -1
  85. package/src/view.tsx +11 -2
package/docs/theme.md ADDED
@@ -0,0 +1,68 @@
1
+ # Theming
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`.
4
+
5
+ ```jsx
6
+ import { setTheme, darkTheme, lightTheme } from "@solidrt/components"
7
+
8
+ setTheme(lightTheme) // switch to light
9
+ setTheme(darkTheme) // switch to dark
10
+ setTheme({ color: { primary: "#ff2d55" } }) // override one token
11
+ ```
12
+
13
+ ## Authoring with defineTheme
14
+
15
+ `defineTheme(definition, scheme?)` resolves a definition into a theme. Any color may be a single value or a `[light, dark]` pair; the `scheme` argument picks the side. Pairs are opt-in per token, and a definition without any needs no scheme at all - modes are a per-theme choice, not a framework requirement (a game ships one look, not two). The built-in presets are one definition resolved twice, so they cannot drift apart.
16
+
17
+ ```jsx
18
+ import { defineTheme, setTheme } from "@solidrt/components"
19
+
20
+ let def = {
21
+ color: {
22
+ background: ["#ffffff", "#101014"], // [light, dark]
23
+ primary: "#ff2d55", // same in both
24
+ /* ... every color token ... */
25
+ },
26
+ text: { base: 15, ratio: 1.25 },
27
+ }
28
+
29
+ setTheme(defineTheme(def, "dark"))
30
+ ```
31
+
32
+ The type scale derives from `text.base` (the body size, default 14) and `text.ratio` (default 1.26): caption sits one step under body, label is body at an emphasized weight, title and heading sit one and two steps above, rounded to whole pixels, with per-role `text.roles` overrides for sizes, line heights, and weights. De-emphasis is a color (`textMuted`), not a size: caption is for small glanceable text (badges, tab labels, timestamps) and stays in the full text color.
33
+
34
+ ## Tokens
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).
37
+
38
+ ## Spacing
39
+
40
+ Spacing is one base unit: `spacing` in a theme definition is a number (default 4) and the steps are multiples of it (`sm` 1x, `md` 2x, `lg` 4x, `xl` 5x). Components read them through `space()`, which applies the density policy on top, so a theme sets the rhythm and density tightens it. Pass an object (`spacing: { sm, md, lg, xl }`, any subset) to pin individual steps.
41
+
42
+ ## Radius
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.
45
+
46
+ ```jsx
47
+ setTheme({ radius: 4 }) // sm 2, md 4, lg 6
48
+ ```
49
+
50
+ ## Per-component overrides
51
+
52
+ `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).
53
+
54
+ ```jsx
55
+ setTheme({ components: { button: { borderRadius: 999 } } }) // pill buttons app-wide
56
+ ```
57
+
58
+ Keys: `button`, `card`, `badge`, `switch`, `checkbox`, `radio`, `item`, `select`, `segmentedControl`, `textInput`, `richTextEditor`, `tooltip`, `divider`, `progressBar`, `spinner`.
59
+
60
+ ## Icon slots
61
+
62
+ `theme.icons` holds semantic control glyphs as SVG document strings (the same currency as `Icon`): `chevronDown` (the Select trigger) and `check` (the Checkbox mark). Components draw their built-in vector paths by default; a theme that sets a slot swaps that glyph everywhere it appears, and the package still bundles no icon set.
63
+
64
+ ```jsx
65
+ import ChevronDown from "lucide-static/icons/chevron-down.svg"
66
+
67
+ setTheme({ icons: { chevronDown: ChevronDown } })
68
+ ```
@@ -0,0 +1,11 @@
1
+ # Tooltip
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, 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"`).
4
+
5
+ ```jsx
6
+ import { Tooltip, Button } from "@solidrt/components"
7
+
8
+ <Tooltip content="Save (Ctrl+S)">
9
+ <Button onPress={save}>Save</Button>
10
+ </Tooltip>
11
+ ```
package/docs/types.md ADDED
@@ -0,0 +1,9 @@
1
+ # Layout and style
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`.
4
+
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
+
7
+ `TransitionProps` (`transition`, `onTransitionEnd`) is the third top-level group, in the component's own vocabulary rather than core's: a declaration names the view-level properties (`opacity`, `x`, `y`, `scale*`, `rotate*`, `origin*`, `perspective`, `clipRadius`) and the style ones (`backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`), plus `all`, a shorthand string, and `stagger` - `<Button transition={{ backgroundColor: { duration: 300 }, opacity: "200ms ease-out" }}>`. Core's paint names (`color`, `radius`, `strokeWidth`) are rejected by the types: a component is a root view plus the rects it draws for `style`, and `splitTransition` hands each entry to the node that owns it (the background rect gets `backgroundColor`/`borderRadius`, the stroke rect `borderColor`/`borderWidth`/`borderRadius`, the root view the rest). `onTransitionEnd` reports the component name (`backgroundColor`, not `color`). `Text` adds `color` (its text node), `ScrollView` adds `scrollX`/`scrollY` (its viewport).
8
+
9
+ Controls whose paint is their own - `Switch` knob, `Slider` thumb, `Checkbox` mark, `Radio` dot, `ProgressBar` fill, `Spinner`, `Icon`, `QrCode`, and the chrome of `NavShell`, `ContextMenu`, `Field` - animate the view-level entries only for now; their internal parts are not reachable through `transition` yet (okf/backlog/component-transitions-internal-paint.md).
@@ -0,0 +1,5 @@
1
+ # Typography helpers
2
+
3
+ `typeStyle(variant)` resolves a theme type-scale role (`caption`/`label`/`body`/`title`/`heading`) to font props ready to spread onto a `<text>` or `d-text`: `fontSize` carries `policy.textScale`, and `fontWeight` carries the low-DPI weight compensation. Reactive when called inside a tracked scope, like any theme/policy read. `Text` applies it for you; reach for the helpers when building custom text out of core primitives.
4
+
5
+ The compensation exists because the renderer rasterizes glyphs unhinted and composites in nonlinear sRGB, which thins light-on-dark text on low-DPI displays as glyphs shrink. `typeWeight(weight, size, onDark?)` adds `policy.textWeightDelta` (0 on high-DPI displays) plus one extra step below 16px; dark-on-light text passes through untouched. `lightOnDark(text, fill)` computes the polarity for a known pair of colors (Button uses it for its fills); omitted, the theme's own palette polarity is used.
package/docs/view.md ADDED
@@ -0,0 +1,14 @@
1
+ # View
2
+
3
+ A general-purpose box. Spreads `layout` onto the underlying view, applies the transform from `style`, and draws a background and/or border when those style props are set. Takes all pointer event props.
4
+
5
+ ```jsx
6
+ import { View } from "@solidrt/components"
7
+
8
+ <View
9
+ layout={{ padding: 16, flexDirection: "column", gap: 8 }}
10
+ style={{ backgroundColor: "#222", borderRadius: 8 }}
11
+ >
12
+ {/* ... */}
13
+ </View>
14
+ ```
package/docs/window.md ADDED
@@ -0,0 +1,15 @@
1
+ # Window
2
+
3
+ The root surface of an app: renders a core `<window>`, so `render()` accepts it. Applies `layout` and `style.backgroundColor` only (a window cannot be transformed or bordered), plus `title` and `fullscreen`.
4
+
5
+ ```jsx
6
+ import { Window } from "@solidrt/components"
7
+
8
+ function App() {
9
+ return (
10
+ <Window title="My App" style={{ backgroundColor: "#111" }}>
11
+ {/* ... */}
12
+ </Window>
13
+ )
14
+ }
15
+ ```
package/package.json CHANGED
@@ -1,23 +1,27 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.50",
3
+ "version": "0.0.52",
4
4
  "license": "MIT",
5
+ "funding": "https://github.com/sponsors/wellawaretech",
5
6
  "author": "Antoine van Wel",
6
7
  "type": "module",
7
8
  "main": "src/index.ts",
8
9
  "exports": {
9
- ".": "./src/index.ts"
10
+ ".": "./src/index.ts",
11
+ "./theme": "./src/theme.ts"
10
12
  },
11
13
  "files": [
12
14
  "src/",
15
+ "docs/",
13
16
  "examples/",
17
+ "demos/",
14
18
  "AGENTS.md"
15
19
  ],
16
20
  "dependencies": {
17
21
  "qrcode-generator": "^2.0.4"
18
22
  },
19
23
  "peerDependencies": {
20
- "@solidjs/signals": "2.0.0-rc.0",
21
- "@solidrt/core": "0.0.50"
24
+ "@solidjs/signals": "2.0.0-rc.1",
25
+ "@solidrt/core": "0.0.52"
22
26
  }
23
27
  }
package/src/badge.tsx CHANGED
@@ -1,12 +1,15 @@
1
1
  import { Show, children } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
- import { typeStyle, lightOnDark } from "./typography"
5
- import type { StyleProps } from "./types"
4
+ import { space } from "./spacing"
5
+ import { typeStyle, typeWeight, lightOnDark } from "./typography"
6
+ import { policy } from "./policy"
7
+ import type { StyleProps, TransitionProps } from "./types"
8
+ import { splitTransition, transitionEndFor } from "./types"
6
9
 
7
10
  export type BadgeVariant = "primary" | "neutral" | "danger"
8
11
 
9
- export interface BadgeProps {
12
+ export interface BadgeProps extends TransitionProps {
10
13
  // A string/number renders as the themed pill label; anything else is rendered
11
14
  // as-is (an icon, a dot, ...).
12
15
  children?: any
@@ -16,10 +19,6 @@ export interface BadgeProps {
16
19
  style?: StyleProps
17
20
  }
18
21
 
19
- // A rounded radius large enough to fully pill any typical badge height; the
20
- // renderer clamps it to half the box, so both ends stay round.
21
- const RADIUS = 999
22
-
23
22
  // A small rounded pill for counts, labels, and status. Accent fill with
24
23
  // onPrimary text by default; override the fill via style.backgroundColor and the
25
24
  // label color via style.color.
@@ -35,34 +34,44 @@ export function Badge(props: BadgeProps) {
35
34
  return { bg: c.primary, fg: c.onPrimary }
36
35
  }
37
36
  }
38
- let bg = () => props.style?.backgroundColor ?? colors().bg
39
- let fg = () => props.style?.color ?? colors().fg
40
- let radius = () => props.style?.borderRadius ?? RADIUS
37
+ // Theme-level per-component overrides merged under the instance style.
38
+ let styled = () => ({ ...theme.components.badge, ...props.style })
39
+ let bg = () => styled().backgroundColor ?? colors().bg
40
+ let fg = () => styled().color ?? colors().fg
41
+ let radius = () => styled().borderRadius ?? theme.radius.full
41
42
  // Resolved once via children(): the typeof probe and the mount sites must
42
43
  // share one build - reading the raw getter again would orphan native nodes.
43
44
  let resolved = children(() => props.children)
44
45
  let isText = () => typeof resolved() === "string" || typeof resolved() === "number"
45
46
  let labelOnDark = () => lightOnDark(fg(), bg())
46
47
 
48
+ let split = () => splitTransition(props.transition)
49
+
47
50
  return (
48
51
  <view
52
+ transition={split().root}
53
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
49
54
  flexDirection="row"
50
55
  alignItems="center"
51
56
  justifyContent="center"
52
- paddingLeft={8}
53
- paddingRight={8}
54
- paddingTop={2}
55
- paddingBottom={2}
57
+ paddingLeft={space("md")}
58
+ paddingRight={space("md")}
59
+ paddingTop={Math.round(space("sm") / 2)}
60
+ paddingBottom={Math.round(space("sm") / 2)}
56
61
  {...props.layout}
57
- x={props.style?.x}
58
- y={props.style?.y}
59
- scale={props.style?.scale}
60
- rotate={props.style?.rotate}
61
- opacity={props.style?.opacity}
62
+ x={styled().x}
63
+ y={styled().y}
64
+ scale={styled().scale}
65
+ rotate={styled().rotate}
66
+ opacity={styled().opacity}
62
67
  >
63
- <d-rect color={bg()} radius={radius()} />
68
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
64
69
  <Show when={isText()} fallback={resolved()}>
65
- <text color={fg()} {...typeStyle("label", labelOnDark())}>
70
+ <text
71
+ color={fg()}
72
+ {...typeStyle("caption", labelOnDark())}
73
+ fontWeight={typeWeight(600, theme.text.caption.size * policy.textScale, labelOnDark())}
74
+ >
66
75
  {resolved()}
67
76
  </text>
68
77
  </Show>
package/src/button.tsx CHANGED
@@ -4,13 +4,15 @@ import { theme } from "./theme"
4
4
  import { policy } from "./policy"
5
5
  import { space } from "./spacing"
6
6
  import { typeStyle, lightOnDark } from "./typography"
7
+ import { Spinner } from "./spinner"
7
8
  import type { LayoutProps } from "@solidrt/core"
8
- import type { StyleProps } from "./types"
9
+ import type { StyleProps, TransitionProps } from "./types"
10
+ import { splitTransition, transitionEndFor } from "./types"
9
11
 
10
12
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
11
13
  export type ButtonSize = "sm" | "md" | "lg"
12
14
 
13
- export interface ButtonProps {
15
+ export interface ButtonProps extends TransitionProps {
14
16
  // A string/number is rendered as the themed label; anything else is rendered
15
17
  // as-is, so a button can hold custom content (an icon, a row, ...).
16
18
  children?: any
@@ -22,7 +24,10 @@ export interface ButtonProps {
22
24
  // stretches to the container's width (the default). Padding is the same at
23
25
  // every size.
24
26
  size?: ButtonSize
25
- onPress?: () => void
27
+ // A returned promise makes this an async action: the button shows a
28
+ // centered spinner in place of the label (geometry unchanged) and ignores
29
+ // presses until it settles. Non-thenable returns are ignored.
30
+ onPress?: () => unknown
26
31
  disabled?: boolean
27
32
  // Focus-navigation candidacy (spatial nav, TV remotes); on by default.
28
33
  // Disabled buttons are never candidates.
@@ -37,44 +42,44 @@ export interface ButtonProps {
37
42
  const SIZE_WIDTH: Record<ButtonSize, number> = { sm: 88, md: 120, lg: 160 }
38
43
 
39
44
  // A themed press target: a padded, centered, accent-colored box with a label.
40
- // Press feedback is a slight scale, hover feedback a tint (non-touch
41
- // interaction policies only), both reactive reads of the press state so no
42
- // nodes are recreated. Override the box via style and the padding/sizing via
43
- // layout. A caller-set backgroundColor disables the hover tint: we cannot know
44
- // its hover variant. When disabled, it takes no pointer events at all.
45
- // Focus (spatial nav) draws a ring under the focusRing policy, text-colored
46
- // rather than primary so it stays visible on primary-filled buttons; Enter/
47
- // Space/remote-select activates (handled by createPress).
45
+ // Press feedback is a slight scale, hover feedback the theme's overlayHover
46
+ // tint drawn over the fill (non-touch interaction policies only), both
47
+ // reactive reads of the press state so no nodes are recreated. Override the
48
+ // box via style and the padding/sizing via layout; because hover is an
49
+ // overlay, it composes over a caller-set backgroundColor too. When disabled,
50
+ // it takes no pointer events at all. Focus (spatial nav) draws a ring under
51
+ // the focusRing policy in the theme's ring color; Enter/Space/remote-select
52
+ // activates (handled by createPress).
48
53
  export function Button(props: ButtonProps) {
49
- // Fill, hover fill, and label color per variant, read reactively from the
50
- // theme. No variant draws a border.
54
+ // Fill and label color per variant, read reactively from the theme. No
55
+ // variant draws a border.
51
56
  let colors = () => {
52
57
  let c = theme.color
53
58
  switch (props.variant ?? "primary") {
54
59
  case "secondary":
55
- return { fill: c.secondary, hover: c.secondaryHover, label: c.onSecondary }
60
+ return { fill: c.secondary, label: c.onSecondary }
56
61
  case "ghost":
57
- return { fill: "transparent", hover: c.surfaceHover, label: c.text }
62
+ return { fill: "transparent", label: c.text }
58
63
  case "danger":
59
- return { fill: c.danger, hover: c.dangerHover, label: c.onPrimary }
64
+ return { fill: c.danger, label: c.onPrimary }
60
65
  default:
61
- return { fill: c.primary, hover: c.primaryHover, label: c.onPrimary }
66
+ return { fill: c.primary, label: c.onPrimary }
62
67
  }
63
68
  }
69
+ // Theme-level per-component overrides merged under the instance style.
70
+ let styled = (): StyleProps => ({ ...theme.components.button, ...props.style })
64
71
  let idleFill = () =>
65
72
  props.disabled
66
73
  ? props.variant === "ghost"
67
74
  ? "transparent"
68
75
  : theme.color.surface
69
76
  : colors().fill
70
- let bg = (s: PressState) =>
71
- props.style?.backgroundColor ??
72
- (props.disabled
73
- ? idleFill()
74
- : s.hovered && policy.interaction !== "touch"
75
- ? colors().hover
76
- : colors().fill)
77
- let radius = () => props.style?.borderRadius ?? theme.radius.md
77
+ let bg = () => styled().backgroundColor ?? idleFill()
78
+ // The hover feedback: the theme's overlay tint drawn over the fill, so it
79
+ // composes with any backgroundColor (variant, theme override, or caller).
80
+ let overlay = (s: PressState) =>
81
+ s.hovered && !props.disabled && policy.interaction !== "touch" ? theme.color.overlayHover : "transparent"
82
+ let radius = () => styled().borderRadius ?? theme.radius.md
78
83
  let label = () => (props.disabled ? theme.color.textMuted : colors().label)
79
84
  // Resolved once via children(): reading the raw children getter builds a new
80
85
  // subtree per read, so the typeof probe and the two mount sites below must
@@ -84,23 +89,27 @@ export function Button(props: ButtonProps) {
84
89
  // The label's polarity against the idle fill: onPrimary on a saturated fill
85
90
  // is light-on-dark even in a light theme, so it needs the low-DPI weight
86
91
  // compensation there too.
87
- let labelOnDark = () => lightOnDark(label(), props.style?.backgroundColor ?? idleFill())
92
+ let labelOnDark = () => lightOnDark(label(), bg())
88
93
 
89
94
  // props (not a literal) so a swapped-in onPress is read at event time.
90
95
  let press = createPress(props)
91
96
  let style = () => ({
92
- ...props.style,
93
- ...(press.focused() && policy.focusRing ? { borderWidth: 2, borderColor: theme.color.text } : {}),
94
- backgroundColor: bg(press.state()),
97
+ ...styled(),
98
+ ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
99
+ backgroundColor: bg(),
95
100
  borderRadius: radius(),
96
101
  // Always a number: a scale that flips from a number back to undefined
97
102
  // hits the transform decoder, which rejects null. Multiply so a
98
103
  // caller-set scale is preserved under the press feedback.
99
- scale: (props.style?.scale ?? 1) * (press.pressed() && policy.motion !== "none" ? 0.97 : 1),
104
+ scale: (styled().scale ?? 1) * (press.pressed() && policy.motion !== "none" ? 0.97 : 1),
100
105
  })
101
106
 
107
+ let split = () => splitTransition(props.transition)
108
+
102
109
  return (
103
110
  <view
111
+ transition={split().root}
112
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
104
113
  ref={(n: { id: number }) => {
105
114
  press.ref(n)
106
115
  props.ref?.(n)
@@ -109,6 +118,7 @@ export function Button(props: ButtonProps) {
109
118
  flexDirection="row"
110
119
  alignItems="center"
111
120
  justifyContent="center"
121
+ position="relative"
112
122
  paddingTop={space("md")}
113
123
  paddingBottom={space("md")}
114
124
  paddingLeft={space("lg")}
@@ -124,15 +134,23 @@ export function Button(props: ButtonProps) {
124
134
  focusable={(props.focusable ?? true) && props.disabled !== true}
125
135
  pointerEvents={props.disabled ? "none" : undefined}
126
136
  >
127
- <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
137
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
138
+ <d-rect color={overlay(press.state())} radius={style().borderRadius} />
128
139
  <Show when={isText()} fallback={resolved()}>
129
- <text color={label()} {...typeStyle("body", labelOnDark())}>
140
+ <text color={press.pending() ? "transparent" : label()} {...typeStyle("body", labelOnDark())}>
130
141
  {resolved()}
131
142
  </text>
132
143
  </Show>
144
+ <Show when={press.pending()}>
145
+ <view position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
146
+ <Spinner size={16} thickness={2} style={{ color: label() }} />
147
+ </view>
148
+ </Show>
133
149
  <Show when={(style().borderWidth ?? 0) > 0}>
134
150
  <d-rect
135
151
  drawStyle="stroke"
152
+ transition={split().border}
153
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
136
154
  color={style().borderColor ?? "transparent"}
137
155
  strokeWidth={style().borderWidth}
138
156
  radius={style().borderRadius}
package/src/card.tsx CHANGED
@@ -3,9 +3,10 @@ import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { typeStyle } from "./typography"
5
5
  import { space } from "./spacing"
6
- import type { StyleProps } from "./types"
6
+ import type { StyleProps, TransitionProps } from "./types"
7
+ import { splitTransition, transitionEndFor } from "./types"
7
8
 
8
- export interface CardProps {
9
+ export interface CardProps extends TransitionProps {
9
10
  children?: any
10
11
  // Optional heading rendered above the content.
11
12
  title?: string
@@ -19,25 +20,31 @@ export interface CardProps {
19
20
  // style.borderWidth or style.borderColor to draw an outline. Override any paint
20
21
  // via style, spacing/sizing via layout.
21
22
  export function Card(props: CardProps) {
22
- let bg = () => props.style?.backgroundColor ?? theme.color.surface
23
- let radius = () => props.style?.borderRadius ?? theme.radius.lg
24
- let hasBorder = () => props.style?.borderWidth != null || props.style?.borderColor != null
23
+ // Theme-level per-component overrides merged under the instance style.
24
+ let styled = () => ({ ...theme.components.card, ...props.style })
25
+ let bg = () => styled().backgroundColor ?? theme.color.surface
26
+ let radius = () => styled().borderRadius ?? theme.radius.lg
27
+ let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
28
+
29
+ let split = () => splitTransition(props.transition)
25
30
 
26
31
  return (
27
32
  <view
33
+ transition={split().root}
34
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
28
35
  ref={props.ref}
29
36
  repaintBoundary
30
37
  flexDirection="column"
31
38
  gap={space("lg")}
32
39
  padding={space("xl")}
33
40
  {...props.layout}
34
- x={props.style?.x}
35
- y={props.style?.y}
36
- scale={props.style?.scale}
37
- rotate={props.style?.rotate}
38
- opacity={props.style?.opacity}
41
+ x={styled().x}
42
+ y={styled().y}
43
+ scale={styled().scale}
44
+ rotate={styled().rotate}
45
+ opacity={styled().opacity}
39
46
  >
40
- <d-rect color={bg()} radius={radius()} />
47
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
41
48
  <Show when={props.title != null}>
42
49
  <text color={theme.color.text} {...typeStyle("title")}>
43
50
  {props.title}
@@ -47,8 +54,10 @@ export function Card(props: CardProps) {
47
54
  <Show when={hasBorder()}>
48
55
  <d-rect
49
56
  drawStyle="stroke"
50
- color={props.style?.borderColor ?? theme.color.border}
51
- strokeWidth={props.style?.borderWidth ?? theme.borderWidth.sm}
57
+ transition={split().border}
58
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
59
+ color={styled().borderColor ?? theme.color.border}
60
+ strokeWidth={styled().borderWidth ?? theme.borderWidth.sm}
52
61
  radius={radius()}
53
62
  />
54
63
  </Show>
package/src/checkbox.tsx CHANGED
@@ -2,10 +2,13 @@ import { createSignal, Show } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { createPress } from "./press"
4
4
  import { theme } from "./theme"
5
- import { densityScale } from "./policy"
6
- import type { StyleProps } from "./types"
5
+ import { policy } from "./policy"
6
+ import { densityScale } from "./density"
7
+ import { Icon } from "./icon"
8
+ import type { StyleProps, TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
7
10
 
8
- export interface CheckboxProps {
11
+ export interface CheckboxProps extends TransitionProps {
9
12
  // Controlled checked state. If omitted, the checkbox is uncontrolled.
10
13
  checked?: boolean
11
14
  defaultChecked?: boolean
@@ -43,15 +46,20 @@ export function Checkbox(props: CheckboxProps) {
43
46
  borderColor: theme.color.border,
44
47
  borderWidth: theme.borderWidth.sm,
45
48
  borderRadius: theme.radius.sm,
49
+ ...theme.components.checkbox,
46
50
  ...props.style,
51
+ ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
47
52
  })
48
53
 
49
54
  return (
50
55
  <view
56
+ transition={splitTransition(props.transition).root}
57
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
51
58
  ref={press.ref}
52
59
  repaintBoundary
53
60
  width={size()}
54
61
  height={size()}
62
+ position="relative"
55
63
  {...props.layout}
56
64
  x={style().x}
57
65
  y={style().y}
@@ -59,18 +67,28 @@ export function Checkbox(props: CheckboxProps) {
59
67
  rotate={style().rotate}
60
68
  opacity={style().opacity}
61
69
  {...press.handlers}
70
+ focusable={!props.disabled}
62
71
  pointerEvents={props.disabled ? "none" : undefined}
63
72
  >
64
73
  <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
65
74
  <Show when={checked()}>
66
- <d-path
67
- d={check()}
68
- drawStyle="stroke"
69
- color={theme.color.onPrimary}
70
- strokeWidth={2}
71
- strokeCap="round"
72
- strokeJoin="round"
73
- />
75
+ <Show
76
+ when={theme.icons.check}
77
+ fallback={
78
+ <d-path
79
+ d={check()}
80
+ drawStyle="stroke"
81
+ color={theme.color.onPrimary}
82
+ strokeWidth={2}
83
+ strokeCap="round"
84
+ strokeJoin="round"
85
+ />
86
+ }
87
+ >
88
+ <view position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
89
+ <Icon src={theme.icons.check!} size={Math.round(size() * 0.75)} color={theme.color.onPrimary} />
90
+ </view>
91
+ </Show>
74
92
  </Show>
75
93
  <Show when={(style().borderWidth ?? 0) > 0}>
76
94
  <d-rect
@@ -5,6 +5,8 @@ import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { space } from "./spacing"
7
7
  import { typeStyle } from "./typography"
8
+ import type { TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
8
10
 
9
11
  export interface ContextMenuItem {
10
12
  label: string
@@ -12,7 +14,7 @@ export interface ContextMenuItem {
12
14
  disabled?: boolean
13
15
  }
14
16
 
15
- export interface ContextMenuProps {
17
+ export interface ContextMenuProps extends TransitionProps {
16
18
  items: ContextMenuItem[]
17
19
  // The content the menu attaches to.
18
20
  children?: any
@@ -23,8 +25,7 @@ const LONG_PRESS_MS = 500
23
25
  // Finger travel (window px) that cancels a pending long-press.
24
26
  const MOVE_SLOP = 8
25
27
  // Minimum distance kept between the menu and the window edges.
26
- const MARGIN = 4
27
- const MIN_WIDTH = 120
28
+ let margin = () => theme.spacing.sm
28
29
 
29
30
  /**
30
31
  * Secondary actions on the wrapped content. The opening gesture follows the
@@ -91,9 +92,11 @@ export function ContextMenu(props: ContextMenuProps) {
91
92
  >
92
93
  <d-rect
93
94
  color={
94
- press.pressed() || (press.hovered() && policy.interaction !== "touch")
95
- ? theme.color.surfaceHover
96
- : "transparent"
95
+ press.pressed()
96
+ ? theme.color.overlayPressed
97
+ : press.hovered() && policy.interaction !== "touch"
98
+ ? theme.color.overlayHover
99
+ : "transparent"
97
100
  }
98
101
  />
99
102
  <text {...bodyText(p.item.disabled ? theme.color.textMuted : theme.color.text)}>{p.item.label}</text>
@@ -111,9 +114,9 @@ export function ContextMenu(props: ContextMenuProps) {
111
114
  let b = menu && getBoundingBox(menu)
112
115
  if (!b) return
113
116
  let p = point()
114
- let x = Math.round(Math.min(Math.max(p.x, MARGIN), env.windowSize.width - b.width - MARGIN))
117
+ let x = Math.round(Math.min(Math.max(p.x, margin()), env.windowSize.width - b.width - margin()))
115
118
  let y = Math.round(
116
- Math.max(p.y + b.height > env.windowSize.height - MARGIN ? p.y - b.height : p.y, MARGIN),
119
+ Math.max(p.y + b.height > env.windowSize.height - margin() ? p.y - b.height : p.y, margin()),
117
120
  )
118
121
  let cur = pos()
119
122
  if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
@@ -128,7 +131,7 @@ export function ContextMenu(props: ContextMenuProps) {
128
131
  left={0}
129
132
  x={pos()?.x ?? -10000}
130
133
  y={pos()?.y ?? 0}
131
- minWidth={MIN_WIDTH}
134
+ minWidth={theme.size.menuMinWidth}
132
135
  flexDirection="column"
133
136
  paddingTop={theme.spacing.sm}
134
137
  paddingBottom={theme.spacing.sm}
@@ -175,6 +178,8 @@ export function ContextMenu(props: ContextMenuProps) {
175
178
 
176
179
  return (
177
180
  <view
181
+ transition={splitTransition(props.transition).root}
182
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
178
183
  onPointerDown={handleDown}
179
184
  onPointerMove={handleMove}
180
185
  onPointerUp={cancelHold}