@solidrt/components 0.0.51 → 0.0.53

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.
package/src/theme.ts CHANGED
@@ -38,6 +38,8 @@ export type Theme = {
38
38
  text: {
39
39
  // Passed through to the core font stack: "sans" | "mono" | a family name.
40
40
  fontFamily: string
41
+ // The monospace family for code (RichTextEditor inline code).
42
+ monoFamily: string
41
43
  caption: TextStyle
42
44
  label: TextStyle
43
45
  body: TextStyle
@@ -69,10 +71,24 @@ export type Theme = {
69
71
  // caller-set style.backgroundColor. Non-touch interaction policies only.
70
72
  overlayHover: string
71
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
72
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.
73
81
  spacing: { sm: number; md: number; lg: number; xl: number }
74
- radius: { sm: number; md: number; lg: number }
75
- 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 }
76
92
  // Semantic control glyphs, as SVG document strings (the Icon currency).
77
93
  // Components draw their built-in vector paths by default; a theme that sets
78
94
  // a slot swaps that glyph everywhere it appears. The package still bundles
@@ -93,9 +109,10 @@ export type Theme = {
93
109
  export type ThemeColor = string | [light: string, dark: string]
94
110
 
95
111
  export type ThemeDefinition = {
96
- color: { [K in keyof Theme["color"]]: ThemeColor }
112
+ color: { [K in Exclude<keyof Theme["color"], "ring">]: ThemeColor } & { ring?: ThemeColor }
97
113
  text?: {
98
114
  fontFamily?: string
115
+ monoFamily?: string
99
116
  // The body font size; the other roles derive from it. Default 14.
100
117
  base?: number
101
118
  // The step between adjacent roles (caption, label, body, title, heading
@@ -105,23 +122,43 @@ export type ThemeDefinition = {
105
122
  // and weights.
106
123
  roles?: { [K in TextVariant]?: Partial<TextStyle> }
107
124
  }
108
- spacing?: Partial<Theme["spacing"]>
109
- radius?: Partial<Theme["radius"]>
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"]>
110
131
  borderWidth?: Partial<Theme["borderWidth"]>
132
+ size?: Partial<Theme["size"]>
111
133
  icons?: Theme["icons"]
112
134
  components?: Theme["components"]
113
135
  }
114
136
 
115
- const SPACING = { sm: 4, md: 8, lg: 16, xl: 20 }
116
- const RADIUS = { sm: 4, md: 8, lg: 12 }
117
- const BORDER_WIDTH = { sm: 1 }
137
+ const SPACING_BASE = 4
118
138
 
119
- // Line height and weight per role; body is the base text, caption and label
120
- // sit under it (secondary and emphasized small text), title and heading
121
- // above it (card and page headings).
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.
122
159
  const ROLE_DEFAULTS: { [K in TextVariant]: { step: number; lineHeight: number; weight: TextStyle["weight"] } } = {
123
- caption: { step: -2, lineHeight: 1.3, weight: 400 },
124
- label: { step: -1, lineHeight: 1.3, weight: 600 },
160
+ caption: { step: -1, lineHeight: 1.3, weight: 400 },
161
+ label: { step: 0, lineHeight: 1.5, weight: 600 },
125
162
  body: { step: 0, lineHeight: 1.5, weight: 400 },
126
163
  title: { step: 1, lineHeight: 1.4, weight: 700 },
127
164
  heading: { step: 2, lineHeight: 1.3, weight: 700 },
@@ -139,11 +176,13 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
139
176
  for (let key in def.color) {
140
177
  let k = key as keyof Theme["color"]
141
178
  let value = def.color[k]
179
+ if (value == null) continue
142
180
  if (Array.isArray(value)) {
143
181
  if (!scheme) throw new Error(`Theme color "${key}" is a [light, dark] pair; pass a scheme to defineTheme`)
144
182
  color[k] = value[scheme === "light" ? 0 : 1]
145
183
  } else color[k] = value
146
184
  }
185
+ if (def.color.ring == null) color.ring = color.text
147
186
  let base = def.text?.base ?? 14
148
187
  let ratio = def.text?.ratio ?? 1.26
149
188
  let role = (name: TextVariant): TextStyle => {
@@ -158,6 +197,7 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
158
197
  return {
159
198
  text: {
160
199
  fontFamily: def.text?.fontFamily ?? "sans",
200
+ monoFamily: def.text?.monoFamily ?? "mono",
161
201
  caption: role("caption"),
162
202
  label: role("label"),
163
203
  body: role("body"),
@@ -165,9 +205,14 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
165
205
  heading: role("heading"),
166
206
  },
167
207
  color,
168
- spacing: { ...SPACING, ...def.spacing },
169
- radius: { ...RADIUS, ...def.radius },
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 },
170
214
  borderWidth: { ...BORDER_WIDTH, ...def.borderWidth },
215
+ size: { ...SIZE, ...def.size },
171
216
  icons: def.icons ?? {},
172
217
  components: def.components ?? {},
173
218
  }
@@ -180,7 +225,10 @@ const DEFAULT: ThemeDefinition = {
180
225
  background: ["#ffffff", "#0b0f17"],
181
226
  surface: ["#f6f8fa", "#161b22"],
182
227
  surfaceAlt: ["#eaeef2", "#21262d"],
183
- text: ["#1f2328", "#e6edf3"],
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"],
184
232
  // Muted is an opaque tone between text and background, mixed in oklab
185
233
  // (like Material 3's tonal colors, not an alpha overlay): alpha text
186
234
  // renders thin on low-DPI and its contrast depends on what sits behind
@@ -188,7 +236,10 @@ const DEFAULT: ThemeDefinition = {
188
236
  // preset is data that must not need the render engine at import time
189
237
  // (the website token build imports this module headless). If text or
190
238
  // background changes, recompute: mixColors(text, background, 0.4).
191
- textMuted: ["#707376", "#848b92"],
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"],
192
243
  border: ["rgba(0,0,0,0.15)", "rgba(255,255,255,0.14)"],
193
244
  // Accent tuned to the puzzle mark's mid blue.
194
245
  primary: "#547ebf",
@@ -202,9 +253,10 @@ const DEFAULT: ThemeDefinition = {
202
253
  overlayHover: ["rgba(0,0,0,0.08)", "rgba(255,255,255,0.08)"],
203
254
  overlayPressed: ["rgba(0,0,0,0.14)", "rgba(255,255,255,0.14)"],
204
255
  },
205
- // base 14 and ratio 1.26 derive title 18 and heading 22; the two small
206
- // roles sit tighter than the ratio, so they are pinned.
207
- text: { roles: { caption: { size: 11 }, label: { size: 12 } } },
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 } } },
208
260
  }
209
261
 
210
262
  export let darkTheme: Theme = defineTheme(DEFAULT, "dark")
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
  })
@@ -86,6 +89,8 @@ export function Tooltip(props: TooltipProps) {
86
89
  pointerEvents="none"
87
90
  >
88
91
  <d-rect
92
+ transition={split().background}
93
+ onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
89
94
  color={theme.components.tooltip?.backgroundColor ?? theme.color.surfaceAlt}
90
95
  radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
91
96
  />
@@ -96,6 +101,8 @@ export function Tooltip(props: TooltipProps) {
96
101
  </Show>
97
102
  <d-rect
98
103
  drawStyle="stroke"
104
+ transition={split().border}
105
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
99
106
  color={theme.components.tooltip?.borderColor ?? theme.color.border}
100
107
  strokeWidth={theme.components.tooltip?.borderWidth ?? theme.borderWidth.sm}
101
108
  radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
@@ -104,8 +111,12 @@ export function Tooltip(props: TooltipProps) {
104
111
  )
105
112
  }
106
113
 
114
+ let split = () => splitTransition(props.transition)
115
+
107
116
  return (
108
117
  <view
118
+ transition={split().root}
119
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
109
120
  ref={(n: { id: number }) => (anchor = n)}
110
121
  onPointerEnter={enter}
111
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}