@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/src/item.tsx ADDED
@@ -0,0 +1,121 @@
1
+ import { Show, children } from "@solidrt/core"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { createPress, type PressState } from "./press"
4
+ import { theme } from "./theme"
5
+ import { policy } from "./policy"
6
+ import { space } from "./spacing"
7
+ import { typeStyle } from "./typography"
8
+ import type { StyleProps, TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
10
+
11
+ export interface ItemProps extends TransitionProps {
12
+ // Leading content: an icon, avatar, checkbox, ...
13
+ startContent?: any
14
+ // Primary text. A string/number renders as themed body text; anything else
15
+ // as-is.
16
+ label: any
17
+ // Secondary line under the label. A string/number renders as themed muted
18
+ // body text; anything else as-is.
19
+ description?: any
20
+ // Trailing content: a badge, timestamp, chevron, action, ...
21
+ endContent?: any
22
+ // Present = the row is interactive: hover/pressed overlay tints, focusable,
23
+ // Enter/remote activation. A returned promise defers further activations
24
+ // until it settles; non-thenable returns are ignored.
25
+ onPress?: () => unknown
26
+ // Fills the row with surfaceAlt to mark the current selection.
27
+ selected?: boolean
28
+ disabled?: boolean
29
+ // Focus-navigation candidacy; defaults to true for interactive rows.
30
+ focusable?: boolean
31
+ ref?: (node: { id: number }) => void
32
+ layout?: LayoutProps
33
+ style?: StyleProps
34
+ }
35
+
36
+ // A list row: leading content, a label with an optional description under it,
37
+ // trailing content pushed to the end. The dense-data workhorse - rows compose
38
+ // with <For> inside a plain column (or a ScrollView); this package ships no
39
+ // List wrapper because a column view IS the list. Paddings and the gap are
40
+ // density-scaled, so a <Density> region compacts rows wholesale. With onPress
41
+ // the row presses like a menu entry: overlay tints for hover/pressed (no
42
+ // scale - rows sit flush in a list), focus ring under the focusRing policy.
43
+ export function Item(props: ItemProps) {
44
+ // Theme-level per-component overrides merged under the instance style.
45
+ let styled = () => ({ ...theme.components.item, ...props.style })
46
+ let interactive = () => props.onPress != null && !props.disabled
47
+ let press = createPress(props)
48
+
49
+ let bg = () => styled().backgroundColor ?? (props.selected ? theme.color.surfaceAlt : "transparent")
50
+ let overlay = (s: PressState) =>
51
+ !interactive()
52
+ ? "transparent"
53
+ : s.pressed
54
+ ? theme.color.overlayPressed
55
+ : s.hovered && policy.interaction !== "touch"
56
+ ? theme.color.overlayHover
57
+ : "transparent"
58
+ let radius = () => styled().borderRadius ?? theme.radius.sm
59
+
60
+ // Resolved once via children(): the typeof probe and the mount site must
61
+ // share one build (see Button).
62
+ let label = children(() => props.label)
63
+ let labelIsText = () => typeof label() === "string" || typeof label() === "number"
64
+ let description = children(() => props.description)
65
+ let descriptionIsText = () => typeof description() === "string" || typeof description() === "number"
66
+
67
+ let split = () => splitTransition(props.transition)
68
+
69
+ return (
70
+ <view
71
+ transition={split().root}
72
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
73
+ ref={(n: { id: number }) => {
74
+ press.ref(n)
75
+ props.ref?.(n)
76
+ }}
77
+ repaintBoundary
78
+ flexDirection="row"
79
+ alignItems="center"
80
+ gap={space("md")}
81
+ paddingTop={space("md")}
82
+ paddingBottom={space("md")}
83
+ paddingLeft={space("lg")}
84
+ paddingRight={space("lg")}
85
+ {...props.layout}
86
+ x={styled().x}
87
+ y={styled().y}
88
+ scale={styled().scale}
89
+ rotate={styled().rotate}
90
+ opacity={props.disabled ? 0.5 : styled().opacity}
91
+ // A passive row attaches no press recognizer: it must not claim
92
+ // pointers from its children (a Switch in a settings row) or from an
93
+ // enclosing pressable. Interactivity is decided at mount.
94
+ {...(props.onPress != null ? press.handlers : {})}
95
+ focusable={(props.focusable ?? true) && interactive()}
96
+ pointerEvents={props.disabled ? "none" : undefined}
97
+ >
98
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
99
+ <d-rect color={overlay(press.state())} radius={radius()} />
100
+ {props.startContent}
101
+ <view flexDirection="column" flexGrow={1} flexShrink={1} gap={Math.round(space("sm") / 2)}>
102
+ <Show when={labelIsText()} fallback={label()}>
103
+ <text color={theme.color.text} {...typeStyle("body")} maxLines={1}>
104
+ {label()}
105
+ </text>
106
+ </Show>
107
+ <Show when={props.description != null}>
108
+ <Show when={descriptionIsText()} fallback={description()}>
109
+ <text color={theme.color.textMuted} {...typeStyle("body")} maxLines={1}>
110
+ {description()}
111
+ </text>
112
+ </Show>
113
+ </Show>
114
+ </view>
115
+ {props.endContent}
116
+ <Show when={press.focused() && policy.focusRing}>
117
+ <d-rect drawStyle="stroke" color={theme.color.ring} strokeWidth={theme.borderWidth.focus} radius={radius()} />
118
+ </Show>
119
+ </view>
120
+ )
121
+ }
package/src/nav-shell.tsx CHANGED
@@ -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 NavItem {
10
12
  value: unknown
@@ -14,7 +16,7 @@ export interface NavItem {
14
16
  icon?: any
15
17
  }
16
18
 
17
- export interface NavShellProps {
19
+ export interface NavShellProps extends TransitionProps {
18
20
  items: NavItem[]
19
21
  // Controlled selected value. If omitted, the shell is uncontrolled.
20
22
  value?: unknown
@@ -25,9 +27,6 @@ export interface NavShellProps {
25
27
  layout?: LayoutProps
26
28
  }
27
29
 
28
- const RAIL_WIDTH = 72
29
- const SIDEBAR_WIDTH = 220
30
-
31
30
  /**
32
31
  * An app shell that arranges primary navigation around the content per the
33
32
  * navigation policy: bottom tabs under it, a narrow rail or a wide sidebar
@@ -49,7 +48,7 @@ export function NavShell(props: NavShellProps) {
49
48
  item.value === value()
50
49
  ? theme.color.surfaceAlt
51
50
  : hovered && policy.interaction !== "touch"
52
- ? theme.color.surfaceHover
51
+ ? theme.color.overlayHover
53
52
  : "transparent"
54
53
 
55
54
  // Icon over a small label, centered; shared by the tab bar and the rail.
@@ -77,15 +76,8 @@ export function NavShell(props: NavShellProps) {
77
76
  )
78
77
  }
79
78
 
80
- let Hairline = (p: { vertical?: boolean }) => (
81
- <view width={p.vertical ? 1 : undefined} height={p.vertical ? undefined : 1}>
82
- <d-rect color={theme.color.border} />
83
- </view>
84
- )
85
-
86
79
  let Tabs = () => (
87
80
  <view flexDirection="column" flexShrink={0}>
88
- <Hairline />
89
81
  <view flexDirection="row">
90
82
  <d-rect color={theme.color.surface} />
91
83
  <For each={props.items}>
@@ -97,17 +89,16 @@ export function NavShell(props: NavShellProps) {
97
89
 
98
90
  let Rail = () => (
99
91
  <view flexDirection="row" flexShrink={0}>
100
- <view flexDirection="column" width={RAIL_WIDTH} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
92
+ <view flexDirection="column" width={theme.size.navRail} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
101
93
  <d-rect color={theme.color.surface} />
102
94
  <For each={props.items}>{(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} />}</For>
103
95
  </view>
104
- <Hairline vertical />
105
96
  </view>
106
97
  )
107
98
 
108
99
  let Sidebar = () => (
109
100
  <view flexDirection="row" flexShrink={0}>
110
- <view flexDirection="column" width={SIDEBAR_WIDTH} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
101
+ <view flexDirection="column" width={theme.size.navSidebar} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
111
102
  <d-rect color={theme.color.surface} />
112
103
  <For each={props.items}>
113
104
  {(item: NavItem) => {
@@ -140,14 +131,13 @@ export function NavShell(props: NavShellProps) {
140
131
  }}
141
132
  </For>
142
133
  </view>
143
- <Hairline vertical />
144
134
  </view>
145
135
  )
146
136
 
147
137
  // Children order is (content, nav): "column" puts the nav under the content,
148
138
  // "row-reverse" puts it to the left, and the content node never moves.
149
139
  return (
150
- <view flexDirection={policy.navigation === "bottomTabs" ? "column" : "row-reverse"} {...props.layout}>
140
+ <view flexDirection={policy.navigation === "bottomTabs" ? "column" : "row-reverse"} transition={splitTransition(props.transition).root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
151
141
  <view flex={1} flexDirection="column">
152
142
  {props.children}
153
143
  </view>
package/src/policy.ts CHANGED
@@ -29,7 +29,8 @@ export type Policies = {
29
29
  // only (per-run polarity, or the theme's palette polarity as the default),
30
30
  // with one extra step for small font sizes; see typography.ts.
31
31
  textWeightDelta: number
32
- // Application policies: recommendations derived from the window size class.
32
+ // Application policies: recommendations derived from the window size class
33
+ // (layout), and from the resulting pane count (navigation).
33
34
  // The application owns the final decision; accept them by consuming
34
35
  // policy.navigation / policy.layout, or override via setPolicy.
35
36
  navigation: NavigationPolicy
@@ -52,6 +53,7 @@ export function defaultPolicyResolver(caps: Capabilities): Policies {
52
53
  : caps.precisePointer
53
54
  ? "desktop"
54
55
  : "hybrid"
56
+ let layout: LayoutPolicy = caps.windowSizeClass === "expanded" ? "twoPane" : "singlePane"
55
57
  return {
56
58
  interaction,
57
59
  density: interaction === "desktop" ? "compact" : "comfortable",
@@ -62,9 +64,12 @@ export function defaultPolicyResolver(caps: Capabilities): Policies {
62
64
  focusRing: caps.keyboardNav || gamepads().some((p) => p != null),
63
65
  textScale: env.textScale,
64
66
  textWeightDelta: env.displayScale < 1.5 ? 100 : 0,
65
- navigation:
66
- caps.windowSizeClass === "expanded" ? "sidebar" : caps.windowSizeClass === "medium" ? "rail" : "bottomTabs",
67
- layout: caps.windowSizeClass === "expanded" ? "twoPane" : "singlePane",
67
+ // Navigation follows the pane count, not its own breakpoint: a side strip
68
+ // spends width, which a single-pane window is short of, so only a
69
+ // two-pane layout earns one. "rail" is never a default output; it is the
70
+ // narrow side nav an app can pick for a content-dense two-pane layout.
71
+ navigation: layout === "twoPane" ? "sidebar" : "bottomTabs",
72
+ layout,
68
73
  }
69
74
  }
70
75
 
@@ -118,11 +123,3 @@ export function setPolicy(partial: Partial<Policies>) {
118
123
  setOverrides((prev) => ({ ...prev, ...partial }))
119
124
  }
120
125
 
121
- // How density maps to component metrics: a multiplier on control sizes,
122
- // paddings, and hit targets. Comfortable is the components' designed size.
123
- const DENSITY_SCALE: Record<DensityPolicy, number> = { comfortable: 1, compact: 0.85, dense: 0.7 }
124
-
125
- /** Reactive density multiplier for control metrics. */
126
- export function densityScale(): number {
127
- return DENSITY_SCALE[policy.density]
128
- }
package/src/press.ts CHANGED
@@ -6,10 +6,16 @@ import { registerNavAction } from "./focus-nav"
6
6
  // so a consumer that reads one inside a JSX prop or child expression tracks that
7
7
  // signal there and nothing else re-runs. Read them in those positions, not
8
8
  // eagerly into a local, or the read lands in whatever scope destructured it.
9
- export type PressState = { pressed: boolean; hovered: boolean; focused: boolean }
9
+ export type PressState = { pressed: boolean; hovered: boolean; focused: boolean; pending: boolean }
10
10
 
11
11
  export interface PressOptions {
12
- onPress?: () => void
12
+ // A returned promise marks the press `pending` until it settles - further
13
+ // activations (pointer, key, remote) are ignored meanwhile, so an async
14
+ // action (save, submit) cannot double-fire. A rejection still clears
15
+ // pending and surfaces as an unhandled rejection. Typed `unknown` (not
16
+ // `void | Promise<void>`) so plain handlers like `() => setOpen(true)`
17
+ // keep compiling; any non-thenable return is ignored.
18
+ onPress?: () => unknown
13
19
  disabled?: boolean
14
20
  onPointerDown?: (e: PointerEvent) => void
15
21
  onPointerUp?: (e: PointerEvent) => void
@@ -55,6 +61,28 @@ export function createPress(options: PressOptions) {
55
61
  let node: { id: number } | null = null
56
62
  let unregisterNav: (() => void) | null = null
57
63
 
64
+ // Async onPress: while a returned promise is unsettled the press is pending
65
+ // and activations are ignored. `inflight` is a plain boolean because signal
66
+ // writes flush on the microtask, so two activations in one dispatch would
67
+ // both read pending() as false; the signal exists for the UI.
68
+ let [pending, setPending] = createSignal(false)
69
+ let inflight = false
70
+ let activate = () => {
71
+ if (options.disabled || inflight) return
72
+ let result = options.onPress?.()
73
+ if (result && typeof (result as Promise<void>).then === "function") {
74
+ inflight = true
75
+ setPending(true)
76
+ // finally, not then(clear, clear): pending clears either way, but a
77
+ // rejection keeps propagating to the unhandled-rejection report
78
+ // instead of being swallowed here.
79
+ ;(result as Promise<void>).finally(() => {
80
+ inflight = false
81
+ setPending(false)
82
+ })
83
+ }
84
+ }
85
+
58
86
  // Focus is derived from core's reactive focus rather than tracked through
59
87
  // the onFocus/onBlur handlers - one source of truth. Memoized so a focus
60
88
  // move propagates into styling only for the two controls whose value flips.
@@ -89,14 +117,15 @@ export function createPress(options: PressOptions) {
89
117
  get focused() {
90
118
  return focused()
91
119
  },
120
+ get pending() {
121
+ return pending()
122
+ },
92
123
  }
93
124
  let state = (): PressState => live
94
125
  let ref = (n: { id: number }) => {
95
126
  node = n
96
127
  unregisterNav?.()
97
- unregisterNav = registerNavAction(n.id, () => {
98
- if (!options.disabled) options.onPress?.()
99
- })
128
+ unregisterNav = registerNavAction(n.id, activate)
100
129
  }
101
130
 
102
131
  let within = (e: PointerEvent) => {
@@ -145,7 +174,7 @@ export function createPress(options: PressOptions) {
145
174
  if (active === e.pointerId) {
146
175
  let fire = inside
147
176
  cancel()
148
- if (fire) options.onPress?.()
177
+ if (fire) activate()
149
178
  }
150
179
  options.onPointerUp?.(e)
151
180
  },
@@ -161,7 +190,7 @@ export function createPress(options: PressOptions) {
161
190
  // The remote center key's `key` is "Unidentified"; match its code.
162
191
  if ((e.key === "Enter" || e.key === " " || e.code === "Select") && !e.repeat && !options.disabled) {
163
192
  e.stopPropagation()
164
- options.onPress?.()
193
+ activate()
165
194
  }
166
195
  options.onKeyDown?.(e)
167
196
  },
@@ -173,5 +202,5 @@ export function createPress(options: PressOptions) {
173
202
  },
174
203
  }
175
204
 
176
- return { pressed, hovered, focused, state, ref, handlers, cancel }
205
+ return { pressed, hovered, focused, pending, state, ref, handlers, cancel }
177
206
  }
package/src/pressable.tsx CHANGED
@@ -1,11 +1,12 @@
1
1
  import { children } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerProps } from "@solidrt/core"
3
- import type { StyleProps } from "./types"
3
+ import type { StyleProps, TransitionProps } from "./types"
4
+ import { splitTransition, transitionEndFor } from "./types"
4
5
  import { createPress, type PressState } from "./press"
5
6
 
6
7
  export type { PressState } from "./press"
7
8
 
8
- export interface PressableProps extends PointerProps {
9
+ export interface PressableProps extends PointerProps, TransitionProps {
9
10
  // children and style may be functions of the press state, so a caller can
10
11
  // restyle on press/hover without wiring their own signals. The state is live
11
12
  // (getters, not a snapshot): read it inside a prop or child expression, never
@@ -14,7 +15,10 @@ export interface PressableProps extends PointerProps {
14
15
  ref?: (node: { id: number }) => void
15
16
  layout?: LayoutProps
16
17
  style?: StyleProps | ((state: PressState) => StyleProps)
17
- onPress?: () => void
18
+ // A returned promise sets `state.pending` until it settles; further
19
+ // presses are ignored meanwhile (no double-fire). Non-thenable returns
20
+ // are ignored.
21
+ onPress?: () => unknown
18
22
  disabled?: boolean
19
23
  }
20
24
 
@@ -47,8 +51,12 @@ export function Pressable(props: PressableProps) {
47
51
  let hasBackground = () => style()?.backgroundColor != null || style()?.borderRadius != null
48
52
  let hasBorder = () => (style()?.borderWidth ?? 0) > 0
49
53
 
54
+ let split = () => splitTransition(props.transition)
55
+
50
56
  return (
51
57
  <view
58
+ transition={split().root}
59
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
52
60
  ref={(n: { id: number }) => {
53
61
  press.ref(n)
54
62
  props.ref?.(n)
@@ -76,6 +84,8 @@ export function Pressable(props: PressableProps) {
76
84
  >
77
85
  {hasBackground() ? (
78
86
  <d-rect
87
+ transition={split().background}
88
+ onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
79
89
  color={style()?.backgroundColor ?? "transparent"}
80
90
  radius={style()?.borderRadius}
81
91
  />
@@ -84,6 +94,8 @@ export function Pressable(props: PressableProps) {
84
94
  {hasBorder() ? (
85
95
  <d-rect
86
96
  drawStyle="stroke"
97
+ transition={split().border}
98
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
87
99
  color={style()?.borderColor ?? "transparent"}
88
100
  strokeWidth={style()?.borderWidth}
89
101
  radius={style()?.borderRadius}
@@ -2,9 +2,10 @@ import { createSignal, onFrame, onLayout, getBoundingBox, Show } from "@solidrt/
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { policy } from "./policy"
5
- import type { StyleProps } from "./types"
5
+ import type { StyleProps, TransitionProps } from "./types"
6
+ import { splitTransition, transitionEndFor } from "./types"
6
7
 
7
- export interface ProgressBarProps {
8
+ export interface ProgressBarProps extends TransitionProps {
8
9
  // Progress from 0 to 1. Omit (or leave undefined) for an indeterminate bar: a
9
10
  // segment that slides back and forth.
10
11
  value?: number
@@ -27,8 +28,10 @@ let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi :
27
28
  export function ProgressBar(props: ProgressBarProps) {
28
29
  let h = () => (props.layout?.height as number) ?? HEIGHT
29
30
  let radius = () => h() / 2
30
- let track = () => props.style?.backgroundColor ?? theme.color.surfaceAlt
31
- let fill = () => props.style?.color ?? theme.color.primary
31
+ // Theme-level per-component overrides merged under the instance style.
32
+ let styled = () => ({ ...theme.components.progressBar, ...props.style })
33
+ let track = () => styled().backgroundColor ?? theme.color.surfaceAlt
34
+ let fill = () => styled().color ?? theme.color.primary
32
35
  let indeterminate = () => props.value === undefined
33
36
 
34
37
  // Measured track width in pixels. Both the determinate fill and the
@@ -69,7 +72,7 @@ export function ProgressBar(props: ProgressBarProps) {
69
72
  let offset = () => effectivePhase() * trackWidth() * (1 - SEGMENT)
70
73
 
71
74
  return (
72
- <view ref={(n: { id: number }) => (trackNode = n)} position="relative" width="100%" height={h()} {...props.layout}>
75
+ <view ref={(n: { id: number }) => (trackNode = n)} position="relative" width="100%" height={h()} transition={splitTransition(props.transition).root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
73
76
  <Show when={animating()}>
74
77
  <Animate />
75
78
  </Show>
package/src/qrcode.tsx CHANGED
@@ -1,8 +1,11 @@
1
1
  import { createMemo } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import qrcode from "qrcode-generator"
4
+ import { theme } from "./theme"
5
+ import type { TransitionProps } from "./types"
6
+ import { splitTransition, transitionEndFor } from "./types"
4
7
 
5
- export interface QrCodeProps {
8
+ export interface QrCodeProps extends TransitionProps {
6
9
  // The string to encode (URL, pairing ticket, text, ...).
7
10
  data: string
8
11
  // Pixels per QR module (the smallest square). The grid is
@@ -18,14 +21,13 @@ export interface QrCodeProps {
18
21
  // Error-correction level: higher tolerates more damage but packs denser and
19
22
  // caps the data length sooner.
20
23
  level?: "L" | "M" | "Q" | "H"
21
- // Corner radius of the background panel.
24
+ // Corner radius of the background panel; defaults to the theme control radius.
22
25
  radius?: number
23
26
  layout?: LayoutProps
24
27
  }
25
28
 
26
29
  const MODULE_SIZE = 6
27
30
  const MARGIN = 16
28
- const RADIUS = 8
29
31
 
30
32
  // Render a QR for `data` as primitives: merge horizontal runs of dark modules
31
33
  // per row into a single d-rect, placed at explicit coordinates on a light
@@ -62,8 +64,8 @@ export function QrCode(props: QrCodeProps) {
62
64
  let side = () => grid().n * size() + 2 * margin()
63
65
 
64
66
  return (
65
- <view repaintBoundary width={side()} height={side()} {...props.layout}>
66
- <d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? RADIUS} />
67
+ <view repaintBoundary width={side()} height={side()} transition={splitTransition(props.transition).root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
68
+ <d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? theme.radius.md} />
67
69
  {grid().runs.map((run) => (
68
70
  <d-rect
69
71
  x={margin() + run.x * size()}
package/src/radio.tsx CHANGED
@@ -2,9 +2,11 @@ import { createSignal, createContext, useContext, Show, children } from "@solidr
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"
5
+ import { policy } from "./policy"
6
+ import { densityScale } from "./density"
6
7
  import { typeStyle } from "./typography"
7
- import type { StyleProps } from "./types"
8
+ import type { StyleProps, TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
8
10
 
9
11
  // Shared selection state for a group. Created and consumed within this module, so
10
12
  // RadioGroup/Radio are a self-contained pair, not a cross-component dependency.
@@ -51,7 +53,7 @@ export function RadioGroup(props: RadioGroupProps) {
51
53
  )
52
54
  }
53
55
 
54
- export interface RadioProps {
56
+ export interface RadioProps extends TransitionProps {
55
57
  // This option's value; selecting it makes it the group's value.
56
58
  value: unknown
57
59
  disabled?: boolean
@@ -71,7 +73,6 @@ export function Radio(props: RadioProps) {
71
73
  let ctx = useContext(RadioContext)
72
74
  let selected = () => ctx.value() === props.value
73
75
  let disabled = () => props.disabled || ctx.disabled()
74
- let ringColor = () => (selected() ? theme.color.primary : theme.color.border)
75
76
  // Resolved once via children(): the typeof probe and the mount sites must
76
77
  // share one build - reading the raw getter again would orphan native nodes.
77
78
  let resolved = children(() => props.children)
@@ -81,29 +82,38 @@ export function Radio(props: RadioProps) {
81
82
  // Inner dot inset as a fraction of the ring, so it scales with the density.
82
83
  let inset = () => ring() * 0.3
83
84
 
85
+ // Theme-level per-component overrides merged under the instance style.
86
+ let styled = () => ({ ...theme.components.radio, ...props.style })
84
87
  let press = createPress({ onPress: () => ctx.select(props.value) })
88
+ // The circle doubles as the focus ring: ring color at the focus width.
89
+ let focusRing = () => press.focused() && policy.focusRing
90
+ let ringColor = () => (focusRing() ? theme.color.ring : selected() ? theme.color.primary : theme.color.border)
91
+ let ringWidth = () => (focusRing() ? theme.borderWidth.focus : 2)
85
92
 
86
93
  return (
87
94
  <view
95
+ transition={splitTransition(props.transition).root}
96
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
88
97
  ref={press.ref}
89
98
  repaintBoundary
90
99
  flexDirection="row"
91
100
  alignItems="center"
92
101
  gap={theme.spacing.md}
93
102
  {...props.layout}
94
- x={props.style?.x}
95
- y={props.style?.y}
96
- scale={props.style?.scale}
97
- rotate={props.style?.rotate}
98
- opacity={props.style?.opacity}
103
+ x={styled().x}
104
+ y={styled().y}
105
+ scale={styled().scale}
106
+ rotate={styled().rotate}
107
+ opacity={styled().opacity}
99
108
  {...press.handlers}
109
+ focusable={!disabled()}
100
110
  pointerEvents={disabled() ? "none" : undefined}
101
111
  >
102
- <Show when={props.style?.backgroundColor != null || props.style?.borderRadius != null}>
103
- <d-rect color={props.style?.backgroundColor ?? "transparent"} radius={props.style?.borderRadius} />
112
+ <Show when={styled().backgroundColor != null || styled().borderRadius != null}>
113
+ <d-rect color={styled().backgroundColor ?? "transparent"} radius={styled().borderRadius} />
104
114
  </Show>
105
115
  <view width={ring()} height={ring()}>
106
- <d-oval drawStyle="stroke" color={ringColor()} strokeWidth={2} />
116
+ <d-oval drawStyle="stroke" color={ringColor()} strokeWidth={ringWidth()} />
107
117
  <Show when={selected()}>
108
118
  <d-oval x={inset()} y={inset()} w={ring() - inset() * 2} h={ring() - inset() * 2} color={theme.color.primary} />
109
119
  </Show>
@@ -113,12 +123,12 @@ export function Radio(props: RadioProps) {
113
123
  {resolved()}
114
124
  </text>
115
125
  </Show>
116
- <Show when={(props.style?.borderWidth ?? 0) > 0}>
126
+ <Show when={(styled().borderWidth ?? 0) > 0}>
117
127
  <d-rect
118
128
  drawStyle="stroke"
119
- color={props.style?.borderColor ?? "transparent"}
120
- strokeWidth={props.style?.borderWidth}
121
- radius={props.style?.borderRadius}
129
+ color={styled().borderColor ?? "transparent"}
130
+ strokeWidth={styled().borderWidth}
131
+ radius={styled().borderRadius}
122
132
  />
123
133
  </Show>
124
134
  </view>