@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/theme.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { createStore, mixColors } from "@solidrt/core"
1
+ import { createStore } from "@solidjs/signals"
2
+ import type { StyleProps } from "./types"
2
3
 
3
4
  export type TextStyle = {
4
5
  size: number
@@ -9,10 +10,36 @@ export type TextStyle = {
9
10
  // The type scale's role names. <Text variant> and theme.text are keyed by these.
10
11
  export type TextVariant = "caption" | "label" | "body" | "title" | "heading"
11
12
 
13
+ // The components whose chrome accepts a theme-level paint override (see
14
+ // Theme["components"]). Plain containers (View, Pressable, ...) are not
15
+ // themed, so they take no override either.
16
+ export type ThemedComponent =
17
+ | "button"
18
+ | "card"
19
+ | "badge"
20
+ | "switch"
21
+ | "checkbox"
22
+ | "radio"
23
+ | "item"
24
+ | "select"
25
+ | "segmentedControl"
26
+ | "textInput"
27
+ | "richTextEditor"
28
+ | "tooltip"
29
+ | "divider"
30
+ | "progressBar"
31
+ | "spinner"
32
+
33
+ // A resolved theme: every value is a plain string/number ready to be read by
34
+ // a component. Authoring happens through defineTheme, which is where
35
+ // [light, dark] pairs and the type-scale expansion live; a Theme itself has
36
+ // no notion of modes.
12
37
  export type Theme = {
13
38
  text: {
14
39
  // Passed through to the core font stack: "sans" | "mono" | a family name.
15
40
  fontFamily: string
41
+ // The monospace family for code (RichTextEditor inline code).
42
+ monoFamily: string
16
43
  caption: TextStyle
17
44
  label: TextStyle
18
45
  body: TextStyle
@@ -26,117 +53,229 @@ export type Theme = {
26
53
  surface: string
27
54
  // Subtle raised/track fill (switch off-state, slider track, ...).
28
55
  surfaceAlt: string
29
- // Hover tint for surface-colored controls (non-touch interaction policies).
30
- surfaceHover: string
31
56
  text: string
32
57
  textMuted: string
33
58
  border: string
34
59
  primary: string
35
- // Hover tint for primary-colored controls.
36
- primaryHover: string
37
60
  onPrimary: string
38
61
  // Lower-emphasis accent: the puzzle mark's darker blue.
39
62
  secondary: string
40
- // Hover tint for secondary-colored controls.
41
- secondaryHover: string
42
63
  onSecondary: string
43
64
  // Validation / destructive.
44
65
  danger: string
45
- // Hover tint for danger-colored controls.
46
- dangerHover: string
47
66
  // Overlay dim behind modals.
48
67
  scrim: string
68
+ // Hover/pressed feedback tints: translucent colors DRAWN OVER a control's
69
+ // own fill (one token pair for every control, instead of a hover variant
70
+ // per fill color), so feedback works over any background, including a
71
+ // caller-set style.backgroundColor. Non-touch interaction policies only.
72
+ overlayHover: string
73
+ overlayPressed: string
74
+ // The focus ring (spatial nav under the focusRing policy), drawn at
75
+ // borderWidth.focus by every focusable control. Defaults to text so it
76
+ // stays visible on primary-filled controls.
77
+ ring: string
49
78
  }
79
+ // Gaps and paddings, multiples of one base unit (sm 1x, md 2x, lg 4x,
80
+ // xl 5x); read through space() where density should apply.
50
81
  spacing: { sm: number; md: number; lg: number; xl: number }
51
- radius: { sm: number; md: number; lg: number }
52
- borderWidth: { sm: number }
82
+ // Corner radii. md is THE control radius (Button, TextInput, Select,
83
+ // SegmentedControl); sm is one step under it (Checkbox, Item, menus,
84
+ // Tooltip), lg one step over (Card), full is the pill.
85
+ radius: { sm: number; md: number; lg: number; full: number }
86
+ borderWidth: { sm: number; focus: number }
87
+ // Default extents of the components that have one: the panes and rails an
88
+ // app lays its screens around, and the smallest sensible popup/track. Each
89
+ // is a per-instance layout override away (listWidth, layout.width, ...);
90
+ // the theme sets the app-wide default.
91
+ size: { navRail: number; navSidebar: number; splitViewList: number; menuMinWidth: number; slider: number }
92
+ // Semantic control glyphs, as SVG document strings (the Icon currency).
93
+ // Components draw their built-in vector paths by default; a theme that sets
94
+ // a slot swaps that glyph everywhere it appears. The package still bundles
95
+ // no icon set.
96
+ icons: { chevronDown?: string; check?: string }
97
+ // Per-component paint overrides: merged between a component's themed
98
+ // defaults and the instance's style prop, so a theme can restyle every
99
+ // Button (say, pill corners) without wrapping the component. Instance
100
+ // style still wins.
101
+ components: { [K in ThemedComponent]?: StyleProps }
53
102
  }
54
103
 
55
- // Scheme-independent tokens, shared by both presets. The type scale: body is
56
- // the base text style; caption and label sit under it (secondary and
57
- // emphasized small text), title and heading above it (card and page headings).
58
- const TEXT: Theme["text"] = {
59
- fontFamily: "sans",
60
- caption: { size: 11, lineHeight: 1.3, weight: 400 },
61
- label: { size: 12, lineHeight: 1.3, weight: 600 },
62
- body: { size: 14, lineHeight: 1.5, weight: 400 },
63
- title: { size: 18, lineHeight: 1.4, weight: 700 },
64
- heading: { size: 22, lineHeight: 1.3, weight: 700 },
104
+ // -- Authoring ---------------------------------------------------------------
105
+
106
+ // A color in a theme definition: one value, or a [light, dark] pair resolved
107
+ // by defineTheme's scheme argument. Pairs are opt-in per token; a definition
108
+ // without any needs no scheme at all (a game ships one look, not two).
109
+ export type ThemeColor = string | [light: string, dark: string]
110
+
111
+ export type ThemeDefinition = {
112
+ color: { [K in Exclude<keyof Theme["color"], "ring">]: ThemeColor } & { ring?: ThemeColor }
113
+ text?: {
114
+ fontFamily?: string
115
+ monoFamily?: string
116
+ // The body font size; the other roles derive from it. Default 14.
117
+ base?: number
118
+ // The step between adjacent roles (caption, label, body, title, heading
119
+ // sit at base * ratio^(-2..2), rounded to whole px). Default 1.26.
120
+ ratio?: number
121
+ // Per-role overrides of the derived size and the default line heights
122
+ // and weights.
123
+ roles?: { [K in TextVariant]?: Partial<TextStyle> }
124
+ }
125
+ // One base unit (the steps derive from it, see deriveSpacing) or explicit
126
+ // steps. Default 4.
127
+ spacing?: number | Partial<Theme["spacing"]>
128
+ // One base (the control radius; the steps derive from it, see
129
+ // deriveRadius) or explicit steps. Default 8.
130
+ radius?: number | Partial<Theme["radius"]>
131
+ borderWidth?: Partial<Theme["borderWidth"]>
132
+ size?: Partial<Theme["size"]>
133
+ icons?: Theme["icons"]
134
+ components?: Theme["components"]
65
135
  }
66
- const SPACING = { sm: 4, md: 8, lg: 16, xl: 20 }
67
- const RADIUS = { sm: 4, md: 8, lg: 12 }
68
- const BORDER_WIDTH = { sm: 1 }
69
136
 
70
- export let darkTheme: Theme = {
71
- text: TEXT,
72
- color: {
73
- background: "#0b0f17",
74
- surface: "#161b22",
75
- surfaceAlt: "#21262d",
76
- surfaceHover: "#262c34",
77
- text: "#e6edf3",
78
- // Muted is an opaque tone between text and background, mixed in LAB (like
79
- // Material 3's tonal colors, not an alpha overlay): alpha text renders
80
- // thin on low-DPI and its contrast depends on what sits behind it.
81
- textMuted: mixColors("#e6edf3", "#0b0f17", 0.4),
82
- border: "rgba(255,255,255,0.14)",
83
- // Accent tuned to the puzzle mark's mid blue; hover lifts toward its
84
- // lightest segment tone.
85
- primary: "#547ebf",
86
- primaryHover: "#7ea9ea",
87
- onPrimary: "#ffffff",
88
- // The darker shade of the same puzzle segment; hover lifts toward primary.
89
- secondary: "#2b5696",
90
- secondaryHover: "#3a68ab",
91
- onSecondary: "#ffffff",
92
- danger: "#f85149",
93
- dangerHover: "#ff7b72",
94
- scrim: "rgba(0,0,0,0.6)",
95
- },
96
- spacing: SPACING,
97
- radius: RADIUS,
98
- borderWidth: BORDER_WIDTH,
137
+ const SPACING_BASE = 4
138
+
139
+ // The spacing scale from its base unit.
140
+ function deriveSpacing(base: number): Theme["spacing"] {
141
+ return { sm: base, md: base * 2, lg: base * 4, xl: base * 5 }
142
+ }
143
+ const RADIUS_BASE = 8
144
+ const RADIUS_FULL = 9999
145
+
146
+ // The radius scale from its base: sm half, lg one and a half, full the pill.
147
+ function deriveRadius(base: number): Theme["radius"] {
148
+ return { sm: Math.round(base / 2), md: base, lg: Math.round(base * 1.5), full: RADIUS_FULL }
149
+ }
150
+ const BORDER_WIDTH = { sm: 1, focus: 2 }
151
+ const SIZE = { navRail: 72, navSidebar: 220, splitViewList: 320, menuMinWidth: 120, slider: 200 }
152
+
153
+ // Line height and weight per role; body is the base text, label is body at
154
+ // an emphasized weight (form labels, key/value keys, tags), caption the one
155
+ // small role (badges, tab labels, timestamps), title and heading sit above
156
+ // body (card and page headings). De-emphasis is a color (textMuted), not a
157
+ // size: caption is small text to be glanced at, so keep it in the full text
158
+ // color rather than stacking small and muted.
159
+ const ROLE_DEFAULTS: { [K in TextVariant]: { step: number; lineHeight: number; weight: TextStyle["weight"] } } = {
160
+ caption: { step: -1, lineHeight: 1.3, weight: 400 },
161
+ label: { step: 0, lineHeight: 1.5, weight: 600 },
162
+ body: { step: 0, lineHeight: 1.5, weight: 400 },
163
+ title: { step: 1, lineHeight: 1.4, weight: 700 },
164
+ heading: { step: 2, lineHeight: 1.3, weight: 700 },
165
+ }
166
+
167
+ /**
168
+ * Resolves a theme definition into a Theme. `scheme` picks the side of every
169
+ * [light, dark] color pair; a definition without pairs needs no scheme
170
+ * (modes are a per-theme choice, not a framework requirement). The type
171
+ * scale expands from text.base and text.ratio, with text.roles overriding
172
+ * per role. Throws on a pair without a scheme (throw-in-dev policy).
173
+ */
174
+ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Theme {
175
+ let color = {} as Theme["color"]
176
+ for (let key in def.color) {
177
+ let k = key as keyof Theme["color"]
178
+ let value = def.color[k]
179
+ if (value == null) continue
180
+ if (Array.isArray(value)) {
181
+ if (!scheme) throw new Error(`Theme color "${key}" is a [light, dark] pair; pass a scheme to defineTheme`)
182
+ color[k] = value[scheme === "light" ? 0 : 1]
183
+ } else color[k] = value
184
+ }
185
+ if (def.color.ring == null) color.ring = color.text
186
+ let base = def.text?.base ?? 14
187
+ let ratio = def.text?.ratio ?? 1.26
188
+ let role = (name: TextVariant): TextStyle => {
189
+ let d = ROLE_DEFAULTS[name]
190
+ return {
191
+ size: Math.round(base * ratio ** d.step),
192
+ lineHeight: d.lineHeight,
193
+ weight: d.weight,
194
+ ...def.text?.roles?.[name],
195
+ }
196
+ }
197
+ return {
198
+ text: {
199
+ fontFamily: def.text?.fontFamily ?? "sans",
200
+ monoFamily: def.text?.monoFamily ?? "mono",
201
+ caption: role("caption"),
202
+ label: role("label"),
203
+ body: role("body"),
204
+ title: role("title"),
205
+ heading: role("heading"),
206
+ },
207
+ color,
208
+ spacing:
209
+ typeof def.spacing === "number"
210
+ ? deriveSpacing(def.spacing)
211
+ : { ...deriveSpacing(SPACING_BASE), ...def.spacing },
212
+ radius:
213
+ typeof def.radius === "number" ? deriveRadius(def.radius) : { ...deriveRadius(RADIUS_BASE), ...def.radius },
214
+ borderWidth: { ...BORDER_WIDTH, ...def.borderWidth },
215
+ size: { ...SIZE, ...def.size },
216
+ icons: def.icons ?? {},
217
+ components: def.components ?? {},
218
+ }
99
219
  }
100
220
 
101
- export let lightTheme: Theme = {
102
- text: TEXT,
221
+ // -- The built-in presets: one definition, resolved twice ---------------------
222
+
223
+ const DEFAULT: ThemeDefinition = {
103
224
  color: {
104
- background: "#ffffff",
105
- surface: "#f6f8fa",
106
- surfaceAlt: "#eaeef2",
107
- surfaceHover: "#e0e5eb",
108
- text: "#1f2328",
109
- textMuted: mixColors("#1f2328", "#ffffff", 0.4),
110
- border: "rgba(0,0,0,0.15)",
111
- // Accent tuned to the puzzle mark's mid blue; hover deepens toward its
112
- // darker segment tone.
225
+ background: ["#ffffff", "#0b0f17"],
226
+ surface: ["#f6f8fa", "#161b22"],
227
+ surfaceAlt: ["#eaeef2", "#21262d"],
228
+ // Dark body text sits well under white (about 10:1 on the background,
229
+ // tuned by eye on a low-DPI display): full brightness glares on a dark
230
+ // ground, and the low-DPI weight compensation thickens it further.
231
+ text: ["#1f2328", "#b1bac4"],
232
+ // Muted is an opaque tone between text and background, mixed in oklab
233
+ // (like Material 3's tonal colors, not an alpha overlay): alpha text
234
+ // renders thin on low-DPI and its contrast depends on what sits behind
235
+ // it. Precomputed - core's mixColors delegates to flux:rendertree, and a
236
+ // preset is data that must not need the render engine at import time
237
+ // (the website token build imports this module headless). If text or
238
+ // background changes, recompute: mixColors(text, background, 0.4).
239
+ // The dark tone sits a step above the 4.5:1 AA floor (about 5.4:1)
240
+ // instead of at the mix: the body text already sits low, and the
241
+ // strict mix falls under it.
242
+ textMuted: ["#707376", "#828993"],
243
+ border: ["rgba(0,0,0,0.15)", "rgba(255,255,255,0.14)"],
244
+ // Accent tuned to the puzzle mark's mid blue.
113
245
  primary: "#547ebf",
114
- primaryHover: "#3f5494",
115
246
  onPrimary: "#ffffff",
116
- // The darker shade of the same puzzle segment; hover deepens it further.
247
+ // The darker shade of the same puzzle segment.
117
248
  secondary: "#2b5696",
118
- secondaryHover: "#1f4176",
119
249
  onSecondary: "#ffffff",
120
- danger: "#cf222e",
121
- dangerHover: "#a40e26",
122
- scrim: "rgba(0,0,0,0.4)",
250
+ danger: ["#cf222e", "#f85149"],
251
+ scrim: ["rgba(0,0,0,0.4)", "rgba(0,0,0,0.6)"],
252
+ // Feedback darkens on a light scheme and lightens on a dark one.
253
+ overlayHover: ["rgba(0,0,0,0.08)", "rgba(255,255,255,0.08)"],
254
+ overlayPressed: ["rgba(0,0,0,0.14)", "rgba(255,255,255,0.14)"],
123
255
  },
124
- spacing: SPACING,
125
- radius: RADIUS,
126
- borderWidth: BORDER_WIDTH,
256
+ // base 14 and ratio 1.26 derive title 18 and heading 22; caption sits
257
+ // one step under body but the ratio lands on 11, too small to read on a
258
+ // low-DPI display or at TV distance, so it is pinned.
259
+ text: { roles: { caption: { size: 12 } } },
127
260
  }
128
261
 
129
- // Backed by a Solid store so reads are tracked: calling setTheme at runtime
130
- // recolors the live UI without remounting. Components read theme.* through
131
- // thunks/JSX expressions, so they pick this up with no call-site changes.
132
- let [theme, setThemeStore] = createStore<Theme>({ ...darkTheme })
133
- export { theme }
262
+ export let darkTheme: Theme = defineTheme(DEFAULT, "dark")
263
+ export let lightTheme: Theme = defineTheme(DEFAULT, "light")
264
+
265
+ let [themeStore, setThemeStore] = createStore<Theme>({ ...darkTheme })
266
+
267
+ // The shared theme, backed by a Solid store so reads are tracked: calling
268
+ // setTheme at runtime recolors the live UI without remounting. Components
269
+ // read theme.* through thunks/JSX expressions, so they pick this up with no
270
+ // call-site changes.
271
+ export let theme: Theme = themeStore
134
272
 
135
273
  type ThemePartial = { [K in keyof Theme]?: Partial<Theme[K]> }
136
274
 
137
- // Switch themes with a full preset (setTheme(lightTheme)) or apply a targeted
138
- // override (setTheme({ color: { primary: "#f00" } })). Merges one level deep per
139
- // category, matching the previous Object.assign behavior.
275
+ // Switch themes with a full preset (setTheme(lightTheme)), a resolved
276
+ // definition (setTheme(defineTheme({...}))), or apply a targeted override
277
+ // (setTheme({ color: { primary: "#f00" } })). Merges one level deep per
278
+ // category (for components, that level is the component name).
140
279
  export function setTheme(partial: ThemePartial) {
141
280
  setThemeStore((s) => {
142
281
  for (let key in partial) {
package/src/tooltip.tsx CHANGED
@@ -4,8 +4,10 @@ import { theme } from "./theme"
4
4
  import { policy } from "./policy"
5
5
  import { space } from "./spacing"
6
6
  import { typeStyle } from "./typography"
7
+ import type { TransitionProps } from "./types"
8
+ import { splitTransition, transitionEndFor } from "./types"
7
9
 
8
- export interface TooltipProps {
10
+ export interface TooltipProps extends TransitionProps {
9
11
  // The tooltip body. A string/number renders as themed text; anything else
10
12
  // renders as-is.
11
13
  content?: any
@@ -19,9 +21,10 @@ export interface TooltipProps {
19
21
  }
20
22
 
21
23
  const DELAY = 500
22
- const GAP = 6
24
+ // Distance between the anchor and the bubble.
25
+ let gap = () => theme.spacing.sm
23
26
  // Minimum distance kept between the bubble and the window edges.
24
- const MARGIN = 4
27
+ let margin = () => theme.spacing.sm
25
28
 
26
29
  /**
27
30
  * A hover-only affordance: under desktop/hybrid interaction policies, resting a
@@ -61,8 +64,8 @@ export function Tooltip(props: TooltipProps) {
61
64
  let b = bubble && getBoundingBox(bubble)
62
65
  if (!a || !b) return
63
66
  let x = a.x + a.width / 2 - b.width / 2
64
- x = Math.round(Math.min(Math.max(x, MARGIN), env.windowSize.width - b.width - MARGIN))
65
- let y = Math.round(props.placement === "bottom" ? a.y + a.height + GAP : a.y - b.height - GAP)
67
+ x = Math.round(Math.min(Math.max(x, margin()), env.windowSize.width - b.width - margin()))
68
+ let y = Math.round(props.placement === "bottom" ? a.y + a.height + gap() : a.y - b.height - gap())
66
69
  let cur = pos()
67
70
  if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
68
71
  })
@@ -85,7 +88,12 @@ export function Tooltip(props: TooltipProps) {
85
88
  paddingRight={space("md")}
86
89
  pointerEvents="none"
87
90
  >
88
- <d-rect color={theme.color.surfaceAlt} radius={theme.radius.sm} />
91
+ <d-rect
92
+ transition={split().background}
93
+ onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
94
+ color={theme.components.tooltip?.backgroundColor ?? theme.color.surfaceAlt}
95
+ radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
96
+ />
89
97
  <Show when={isText()} fallback={content()}>
90
98
  <text color={theme.color.text} {...typeStyle("body")}>
91
99
  {content()}
@@ -93,16 +101,22 @@ export function Tooltip(props: TooltipProps) {
93
101
  </Show>
94
102
  <d-rect
95
103
  drawStyle="stroke"
96
- color={theme.color.border}
97
- strokeWidth={theme.borderWidth.sm}
98
- radius={theme.radius.sm}
104
+ transition={split().border}
105
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
106
+ color={theme.components.tooltip?.borderColor ?? theme.color.border}
107
+ strokeWidth={theme.components.tooltip?.borderWidth ?? theme.borderWidth.sm}
108
+ radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
99
109
  />
100
110
  </view>,
101
111
  )
102
112
  }
103
113
 
114
+ let split = () => splitTransition(props.transition)
115
+
104
116
  return (
105
117
  <view
118
+ transition={split().root}
119
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
106
120
  ref={(n: { id: number }) => (anchor = n)}
107
121
  onPointerEnter={enter}
108
122
  onPointerLeave={hide}
package/src/types.ts CHANGED
@@ -1,4 +1,122 @@
1
- import type { Color, Gradient, LayoutProps } from "@solidrt/core"
1
+ import type {
2
+ Color,
3
+ Gradient,
4
+ LayoutProps,
5
+ Transition,
6
+ TransitionEndEvent,
7
+ TransitionPropName,
8
+ TransitionProps as CoreTransitionProps,
9
+ } from "@solidrt/core"
10
+
11
+ // Native transitions through a component. A component is a root view plus
12
+ // the nodes it draws for `style` (a background d-rect, a stroke d-rect for
13
+ // the border), so a declaration names the STYLE properties - backgroundColor,
14
+ // borderColor, borderWidth, borderRadius - next to the view-level ones, and
15
+ // splitTransition hands each entry to the node that owns it. Core's paint
16
+ // names (color, radius, strokeWidth) are deliberately not accepted: on a
17
+ // component they would land on the root view, which has no paint, and be
18
+ // silently ignored.
19
+ //
20
+ // Controls whose paint is their own (Switch knob, Slider thumb, Checkbox
21
+ // mark, ...) take the view-level entries only for now; see
22
+ // okf/backlog/component-transitions-internal-paint.md.
23
+ export type TransitionViewProp =
24
+ | "opacity"
25
+ | "x"
26
+ | "y"
27
+ | "scale"
28
+ | "scaleX"
29
+ | "scaleY"
30
+ | "rotate"
31
+ | "rotateX"
32
+ | "rotateY"
33
+ | "originX"
34
+ | "originY"
35
+ | "perspective"
36
+ | "clipRadius"
37
+ export type TransitionStyleProp = "backgroundColor" | "borderColor" | "borderWidth" | "borderRadius"
38
+ export type TransitionScrollProp = "scrollX" | "scrollY"
39
+
40
+ type TransitionEntries<P extends string> = {
41
+ /** One spec for every property the component animates (no `from`/`exit` here, as in core). */
42
+ all?: Transition
43
+ /** Group stagger (ms) for descendant enter/exit animations; see core. */
44
+ stagger?: number
45
+ } & { [K in P]?: Transition }
46
+
47
+ /** A component's transition declaration: view-level and style names, `all`, or a shorthand string. */
48
+ export type ComponentTransition<P extends string = TransitionViewProp | TransitionStyleProp> =
49
+ | TransitionEntries<P>
50
+ | string
51
+ | null
52
+
53
+ export interface TransitionProps<P extends string = TransitionViewProp | TransitionStyleProp> {
54
+ /**
55
+ * Animate later writes of the listed properties instead of snapping, like
56
+ * the core `transition` prop but in the component's own vocabulary:
57
+ * `transition={{ opacity: "200ms ease-out", backgroundColor: { duration: 300 } }}`.
58
+ * `all` or a bare string covers every property the component animates.
59
+ */
60
+ transition?: ComponentTransition<P>
61
+ /** A transition settled; `property` is in the component's vocabulary (`backgroundColor`, not `color`). */
62
+ onTransitionEnd?: (event: { property: P }) => void
63
+ }
64
+
65
+ /** The core transition declarations for a component's root view and its background/border rects. */
66
+ export type SplitTransition = {
67
+ root: CoreTransitionProps["transition"]
68
+ background: CoreTransitionProps["transition"]
69
+ border: CoreTransitionProps["transition"]
70
+ }
71
+
72
+ const STYLE_TO_BACKGROUND: Record<string, TransitionPropName> = { backgroundColor: "color", borderRadius: "radius" }
73
+ const STYLE_TO_BORDER: Record<string, TransitionPropName> = {
74
+ borderColor: "color",
75
+ borderWidth: "strokeWidth",
76
+ borderRadius: "radius",
77
+ }
78
+
79
+ /**
80
+ * Map a component declaration onto the three nodes a styled component
81
+ * draws. `all`, a shorthand string and `stagger` apply to every node; named
82
+ * entries go to the node owning the property. An entry for a node that is
83
+ * not mounted (no background set) simply never animates.
84
+ */
85
+ export function splitTransition<P extends string>(t: ComponentTransition<P> | undefined): SplitTransition {
86
+ if (t == null || typeof t === "string") return { root: t, background: t, border: t }
87
+ let root: Record<string, unknown> = {}
88
+ let background: Record<string, unknown> = {}
89
+ let border: Record<string, unknown> = {}
90
+ for (let [key, value] of Object.entries(t)) {
91
+ if (key === "all" || key === "stagger") {
92
+ root[key] = value
93
+ background[key] = value
94
+ border[key] = value
95
+ } else if (key in STYLE_TO_BACKGROUND || key in STYLE_TO_BORDER) {
96
+ if (key in STYLE_TO_BACKGROUND) background[STYLE_TO_BACKGROUND[key]!] = value
97
+ if (key in STYLE_TO_BORDER) border[STYLE_TO_BORDER[key]!] = value
98
+ } else {
99
+ root[key] = value
100
+ }
101
+ }
102
+ let pick = (o: Record<string, unknown>) => (Object.keys(o).length ? (o as CoreTransitionProps["transition"]) : undefined)
103
+ return { root: pick(root), background: pick(background), border: pick(border) }
104
+ }
105
+
106
+ /** Report a settled core property in the component vocabulary for the node it settled on. */
107
+ export function transitionEndFor<P extends string>(
108
+ node: "root" | "background" | "border",
109
+ handler: ((event: { property: P }) => void) | undefined,
110
+ ): ((event: TransitionEndEvent) => void) | undefined {
111
+ if (!handler) return undefined
112
+ return (e) => {
113
+ let name: string = e.property
114
+ if (node === "background") name = e.property === "color" ? "backgroundColor" : "borderRadius"
115
+ if (node === "border")
116
+ name = e.property === "color" ? "borderColor" : e.property === "strokeWidth" ? "borderWidth" : "borderRadius"
117
+ handler({ property: name as P })
118
+ }
119
+ }
2
120
 
3
121
  // The split between layout and style follows one rule: layout properties feed
4
122
  // into Taffy and changing them triggers a relayout; style properties are
package/src/view.tsx CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { LayoutProps, PointerProps } from "@solidrt/core"
2
- import type { StyleProps } from "./types"
2
+ import type { StyleProps, TransitionProps } from "./types"
3
+ import { splitTransition, transitionEndFor } from "./types"
3
4
 
4
- export interface ViewProps extends PointerProps {
5
+ export interface ViewProps extends PointerProps, TransitionProps {
5
6
  children?: any
6
7
  ref?: (node: { id: number }) => void
7
8
  layout?: LayoutProps
@@ -13,8 +14,12 @@ export function View(props: ViewProps) {
13
14
  props.style?.backgroundColor != null || props.style?.borderRadius != null
14
15
  let hasBorder = () => (props.style?.borderWidth ?? 0) > 0
15
16
 
17
+ let split = () => splitTransition(props.transition)
18
+
16
19
  return (
17
20
  <view
21
+ transition={split().root}
22
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
18
23
  ref={props.ref}
19
24
  {...props.layout}
20
25
  x={props.style?.x}
@@ -45,6 +50,8 @@ export function View(props: ViewProps) {
45
50
  >
46
51
  {hasBackground() ? (
47
52
  <d-rect
53
+ transition={split().background}
54
+ onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
48
55
  color={props.style?.backgroundColor ?? "transparent"}
49
56
  radius={props.style?.borderRadius}
50
57
  />
@@ -53,6 +60,8 @@ export function View(props: ViewProps) {
53
60
  {hasBorder() ? (
54
61
  <d-rect
55
62
  drawStyle="stroke"
63
+ transition={split().border}
64
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
56
65
  color={props.style?.borderColor ?? "transparent"}
57
66
  strokeWidth={props.style?.borderWidth}
58
67
  radius={props.style?.borderRadius}