@solidrt/components 0.0.54 → 0.0.56

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/select.tsx CHANGED
@@ -6,8 +6,9 @@ import { policy } from "./policy"
6
6
  import { space } from "./spacing"
7
7
  import { typeStyle } from "./typography"
8
8
  import type { Option, StyleProps, TransitionProps } from "./types"
9
- import { splitTransition, transitionEndFor } from "./types"
9
+ import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
10
10
  import { Icon } from "./icon"
11
+ import { colorFade, popupFade, popupFadeOut, PressFeedback, travelMotion } from "./motion"
11
12
 
12
13
  export interface SelectProps extends TransitionProps {
13
14
  options: Option[]
@@ -66,16 +67,11 @@ export function Select(props: SelectProps) {
66
67
  {...press.handlers}
67
68
  focusable
68
69
  >
69
- <d-rect
70
- color={
71
- press.pressed()
72
- ? theme.color.overlayPressed
73
- : (press.hovered() && policy.interaction !== "touch") || (press.focused() && policy.focusRing)
74
- ? theme.color.overlayHover
75
- : "transparent"
76
- }
70
+ <PressFeedback
71
+ pressed={press.pressed()}
72
+ hovered={(press.hovered() && policy.interaction !== "touch") || (press.focused() && policy.focusRing)}
77
73
  />
78
- <text {...bodyText(p.option.value === value() ? theme.color.primary : theme.color.text)}>{p.option.label}</text>
74
+ <text transition={colorFade()} {...bodyText(p.option.value === value() ? theme.color.primary : theme.color.text)}>{p.option.label}</text>
79
75
  </view>
80
76
  )
81
77
  }
@@ -98,8 +94,19 @@ export function Select(props: SelectProps) {
98
94
  let cur = pos()
99
95
  if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
100
96
  })
97
+ // The fade is driven by the position write, not a mount-time `from`: the
98
+ // menu parks offscreen until measured, so a `from` would play unseen.
99
+ // The exit fade lives on this root (the node the close unmounts).
101
100
  return createPortal(
102
- <view position="absolute" top={0} left={0} right={0} bottom={0}>
101
+ <view
102
+ position="absolute"
103
+ top={0}
104
+ left={0}
105
+ right={0}
106
+ bottom={0}
107
+ opacity={pos() ? 1 : 0}
108
+ transition={popupFadeOut()}
109
+ >
103
110
  <view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)} />
104
111
  <view
105
112
  ref={(n: { id: number }) => (menu = n)}
@@ -113,12 +120,13 @@ export function Select(props: SelectProps) {
113
120
  paddingTop={theme.spacing.sm}
114
121
  paddingBottom={theme.spacing.sm}
115
122
  >
116
- <d-rect color={theme.color.surface} radius={theme.radius.sm} />
123
+ <d-rect transition={colorFade()} color={theme.color.surface} radius={theme.radius.sm} />
117
124
  <For each={props.options}>
118
125
  {(o: Option) => <OptionRow option={o} padY={space("sm")} />}
119
126
  </For>
120
127
  <d-rect
121
128
  drawStyle="stroke"
129
+ transition={colorFade()}
122
130
  color={theme.color.border}
123
131
  strokeWidth={theme.borderWidth.sm}
124
132
  radius={theme.radius.sm}
@@ -132,9 +140,9 @@ export function Select(props: SelectProps) {
132
140
  // trick) so pressing an option never has the scrim on its hit path.
133
141
  let Sheet = () =>
134
142
  createPortal(
135
- <view position="absolute" top={0} left={0} right={0} bottom={0}>
143
+ <view position="absolute" top={0} left={0} right={0} bottom={0} opacity={1} transition={popupFade()}>
136
144
  <view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)}>
137
- <d-rect color={theme.color.scrim} />
145
+ <d-rect transition={colorFade()} color={theme.color.scrim} />
138
146
  </view>
139
147
  <view
140
148
  position="absolute"
@@ -145,7 +153,7 @@ export function Select(props: SelectProps) {
145
153
  paddingTop={theme.spacing.md}
146
154
  paddingBottom={theme.spacing.md + env.safeArea.bottom}
147
155
  >
148
- <d-rect color={theme.color.surface} radius={theme.radius.sm} />
156
+ <d-rect transition={colorFade()} color={theme.color.surface} radius={theme.radius.sm} />
149
157
  <For each={props.options}>
150
158
  {(o: Option) => <OptionRow option={o} padY={Math.round(theme.spacing.md * 1.5)} />}
151
159
  </For>
@@ -167,12 +175,12 @@ export function Select(props: SelectProps) {
167
175
  ...props.style,
168
176
  ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
169
177
  })
170
- // Hover feedback: the theme overlay tint drawn over the trigger fill.
171
- let overlay = () =>
172
- press.hovered() && !props.disabled && policy.interaction !== "touch"
173
- ? theme.color.overlayHover
174
- : "transparent"
175
-
178
+ // The chevron flips while the picker is open; travel motion, so it snaps
179
+ // under reduced motion (the flip itself stays).
180
+ let chevronSpin = () => {
181
+ let t = travelMotion()
182
+ return t && { rotate: t }
183
+ }
176
184
 
177
185
  let split = () => splitTransition(props.transition)
178
186
 
@@ -203,31 +211,38 @@ export function Select(props: SelectProps) {
203
211
  focusable={!props.disabled}
204
212
  pointerEvents={props.disabled ? "none" : undefined}
205
213
  >
206
- <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
207
- <d-rect color={overlay()} radius={style().borderRadius} />
214
+ <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
215
+ <PressFeedback
216
+ pressed={press.pressed() && !props.disabled}
217
+ hovered={press.hovered() && !props.disabled && policy.interaction !== "touch"}
218
+ radius={style().borderRadius}
219
+ />
208
220
  <Show
209
221
  when={selected()}
210
- fallback={<text {...bodyText(theme.color.textMuted)}>{props.placeholder ?? ""}</text>}
222
+ fallback={<text transition={colorFade()} {...bodyText(theme.color.textMuted)}>{props.placeholder ?? ""}</text>}
211
223
  >
212
- <text {...bodyText(props.disabled ? theme.color.textMuted : theme.color.text)}>{selected()!.label}</text>
213
- </Show>
214
- <Show
215
- when={theme.icons.chevronDown}
216
- fallback={
217
- <view width={12} height={8}>
218
- <d-path
219
- d="M 2 2 L 6 6 L 10 2"
220
- drawStyle="stroke"
221
- color={theme.color.textMuted}
222
- strokeWidth={2}
223
- strokeCap="round"
224
- strokeJoin="round"
225
- />
226
- </view>
227
- }
228
- >
229
- <Icon src={theme.icons.chevronDown!} size={12} color={theme.color.textMuted} />
224
+ <text transition={colorFade()} {...bodyText(props.disabled ? theme.color.textMuted : theme.color.text)}>{selected()!.label}</text>
230
225
  </Show>
226
+ <view rotate={open() ? Math.PI : 0} transition={chevronSpin()}>
227
+ <Show
228
+ when={theme.icons.chevronDown}
229
+ fallback={
230
+ <view width={12} height={8}>
231
+ <d-path
232
+ d="M 2 2 L 6 6 L 10 2"
233
+ drawStyle="stroke"
234
+ transition={colorFade()}
235
+ color={theme.color.textMuted}
236
+ strokeWidth={2}
237
+ strokeCap="round"
238
+ strokeJoin="round"
239
+ />
240
+ </view>
241
+ }
242
+ >
243
+ <Icon src={theme.icons.chevronDown!} size={12} color={theme.color.textMuted} />
244
+ </Show>
245
+ </view>
231
246
  <Show when={open()}>
232
247
  <Show when={policy.interaction === "touch"} fallback={<Dropdown />}>
233
248
  <Sheet />
@@ -236,7 +251,7 @@ export function Select(props: SelectProps) {
236
251
  <Show when={(style().borderWidth ?? 0) > 0}>
237
252
  <d-rect
238
253
  drawStyle="stroke"
239
- transition={split().border}
254
+ transition={withTransitionDefaults(split().border, colorFade())}
240
255
  onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
241
256
  color={style().borderColor ?? "transparent"}
242
257
  strokeWidth={style().borderWidth}
package/src/slider.tsx CHANGED
@@ -5,6 +5,7 @@ import { policy } from "./policy"
5
5
  import { densityScale } from "./density"
6
6
  import type { StyleProps, TransitionProps } from "./types"
7
7
  import { splitTransition, transitionEndFor } from "./types"
8
+ import { colorFade } from "./motion"
8
9
 
9
10
  export interface SliderProps extends TransitionProps {
10
11
  // Controlled value. If omitted, the slider is uncontrolled.
@@ -137,11 +138,14 @@ export function Slider(props: SliderProps) {
137
138
  onPointerUp={handleUp}
138
139
  onKeyDown={handleKeyDown}
139
140
  >
141
+ {/* Colors fade with the theme; the fill's w and the thumb's x never
142
+ get a transition - they track the drag 1:1, and a spring would
143
+ rubber-band it. */}
140
144
  <view ref={(n: { id: number }) => (groove = n)} position="relative" flex={1} height={GROOVE}>
141
- <d-rect color={theme.color.surfaceAlt} radius={GROOVE / 2} />
142
- <d-rect color={theme.color.primary} w={fillPx()} h={GROOVE} radius={GROOVE / 2} />
145
+ <d-rect transition={colorFade()} color={theme.color.surfaceAlt} radius={GROOVE / 2} />
146
+ <d-rect transition={colorFade()} color={theme.color.primary} w={fillPx()} h={GROOVE} radius={GROOVE / 2} />
143
147
  <view position="absolute" left={0} top={(GROOVE - thumb()) / 2} x={fillPx() - thumb() / 2}>
144
- <d-oval w={thumb()} h={thumb()} color={theme.color.primary} />
148
+ <d-oval transition={colorFade()} w={thumb()} h={thumb()} color={theme.color.primary} />
145
149
  <Show when={focused() && policy.focusRing}>
146
150
  <d-oval drawStyle="stroke" w={thumb()} h={thumb()} color={theme.color.ring} strokeWidth={theme.borderWidth.focus} />
147
151
  </Show>
package/src/spinner.tsx CHANGED
@@ -4,6 +4,7 @@ import { theme } from "./theme"
4
4
  import { policy } from "./policy"
5
5
  import type { StyleProps, TransitionProps } from "./types"
6
6
  import { splitTransition, transitionEndFor } from "./types"
7
+ import { colorFade } from "./motion"
7
8
 
8
9
  export interface SpinnerProps extends TransitionProps {
9
10
  // Overall diameter in pixels.
@@ -70,6 +71,7 @@ export function Spinner(props: SpinnerProps) {
70
71
  <d-path
71
72
  d={path()}
72
73
  drawStyle="stroke"
74
+ transition={colorFade()}
73
75
  color={color()}
74
76
  strokeWidth={thickness()}
75
77
  strokeCap="round"
package/src/switch.tsx CHANGED
@@ -4,10 +4,11 @@ import { createPress } from "./press"
4
4
  import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { densityScale } from "./density"
7
- import type { StyleProps, TransitionProps } from "./types"
8
- import { splitTransition, transitionEndFor } from "./types"
7
+ import type { StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
8
+ import { partTransition, partTransitionEnd, splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
9
+ import { colorFade, pressScale, scaleFeedback, travelMotion } from "./motion"
9
10
 
10
- export interface SwitchProps extends TransitionProps {
11
+ export interface SwitchProps extends TransitionProps<TransitionViewProp | TransitionStyleProp | "knob"> {
11
12
  // Controlled on/off. If omitted, the switch is uncontrolled.
12
13
  value?: boolean
13
14
  // Initial value for uncontrolled use.
@@ -24,9 +25,11 @@ const W = 44
24
25
  const H = 24
25
26
  const PAD = 2
26
27
 
27
- // A toggle. Track fills with primary when on, surfaceAlt when off; the thumb
28
- // slides across. Controlled via value/onChange, or uncontrolled via
29
- // defaultValue. When disabled, it takes no pointer events at all.
28
+ // A toggle. Track fills with primary when on, surfaceAlt when off (a fade);
29
+ // the thumb springs across - the `knob` transition entry retimes it. A press
30
+ // shrinks the control slightly (pressScale). Controlled via value/onChange,
31
+ // or uncontrolled via defaultValue. When disabled, it takes no pointer
32
+ // events at all.
30
33
  export function Switch(props: SwitchProps) {
31
34
  let [internal, setInternal] = createSignal(props.defaultValue ?? false)
32
35
  let on = () => (props.value !== undefined ? props.value : internal())
@@ -50,9 +53,11 @@ export function Switch(props: SwitchProps) {
50
53
  ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
51
54
  })
52
55
 
56
+ let split = () => splitTransition(props.transition, ["knob"])
57
+
53
58
  return (
54
59
  <view
55
- transition={splitTransition(props.transition).root}
60
+ transition={withTransitionDefaults(split().root, scaleFeedback())}
56
61
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
57
62
  ref={press.ref}
58
63
  repaintBoundary
@@ -61,20 +66,34 @@ export function Switch(props: SwitchProps) {
61
66
  {...props.layout}
62
67
  x={style().x}
63
68
  y={style().y}
64
- scale={style().scale}
69
+ scale={(style().scale ?? 1) * pressScale(press.pressed())}
65
70
  rotate={style().rotate}
66
71
  opacity={style().opacity}
67
72
  {...press.handlers}
68
73
  focusable={!props.disabled}
69
74
  pointerEvents={props.disabled ? "none" : undefined}
70
75
  >
71
- <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
72
- <view position="absolute" top={PAD} left={PAD} x={on() ? w() - thumb() - PAD * 2 : 0}>
73
- <d-oval w={thumb()} h={thumb()} color={theme.color.onPrimary} />
76
+ <d-rect
77
+ transition={withTransitionDefaults(split().background, colorFade())}
78
+ onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
79
+ color={style().backgroundColor ?? "transparent"}
80
+ radius={style().borderRadius}
81
+ />
82
+ <view
83
+ position="absolute"
84
+ top={PAD}
85
+ left={PAD}
86
+ x={on() ? w() - thumb() - PAD * 2 : 0}
87
+ transition={partTransition(props.transition, "knob", "x", travelMotion())}
88
+ onTransitionEnd={partTransitionEnd("knob", "x", props.onTransitionEnd)}
89
+ >
90
+ <d-oval transition={colorFade()} w={thumb()} h={thumb()} color={theme.color.onPrimary} />
74
91
  </view>
75
92
  <Show when={(style().borderWidth ?? 0) > 0}>
76
93
  <d-rect
77
94
  drawStyle="stroke"
95
+ transition={withTransitionDefaults(split().border, colorFade())}
96
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
78
97
  color={style().borderColor ?? "transparent"}
79
98
  strokeWidth={style().borderWidth}
80
99
  radius={style().borderRadius}
package/src/text.tsx CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createMemo } from "@solidrt/core"
2
2
  import type { PointerProps } from "@solidrt/core"
3
3
  import type { StyleProps, TextLayoutProps, TransitionProps, TransitionViewProp } from "./types"
4
- import { splitTransition, transitionEndFor } from "./types"
4
+ import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
5
5
  import { theme, type TextVariant } from "./theme"
6
6
  import { policy } from "./policy"
7
7
  import { typeWeight } from "./typography"
8
+ import { colorFade } from "./motion"
8
9
 
9
10
  // Semantic text colors, resolved through the theme. Curated: only tokens that
10
11
  // make sense as a text fill; style.color takes raw values for anything else.
@@ -95,7 +96,7 @@ export function Text(props: TextProps) {
95
96
  pointerEvents={props.pointerEvents}
96
97
  >
97
98
  <text
98
- transition={split().text}
99
+ transition={withTransitionDefaults(split().text, colorFade())}
99
100
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
100
101
  color={color()}
101
102
  fontFamily={props.layout?.fontFamily ?? theme.text.fontFamily}
package/src/theme.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createStore } from "@solidjs/signals"
2
+ import type { FontWeight } from "@solidrt/core"
2
3
  import type { StyleProps } from "./types"
3
4
 
4
5
  export type TextStyle = {
5
6
  size: number
6
7
  lineHeight: number
7
- weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
8
+ weight: FontWeight
8
9
  }
9
10
 
10
11
  // The type scale's role names. <Text variant> and theme.text are keyed by these.
@@ -75,6 +76,10 @@ export type Theme = {
75
76
  // borderWidth.focus by every focusable control. Defaults to text so it
76
77
  // stays visible on primary-filled controls.
77
78
  ring: string
79
+ // The text-selection highlight, drawn by the editable fields behind the
80
+ // selected glyphs; translucent so the text stays readable. Defaults to
81
+ // overlayPressed (a neutral scheme-aware tint that needs no color math).
82
+ selection: string
78
83
  }
79
84
  // Gaps and paddings, multiples of one base unit (sm 1x, md 2x, lg 4x,
80
85
  // xl 5x); read through space() where density should apply.
@@ -84,6 +89,13 @@ export type Theme = {
84
89
  // Tooltip), lg one step over (Card), full is the pill.
85
90
  radius: { sm: number; md: number; lg: number; full: number }
86
91
  borderWidth: { sm: number; focus: number }
92
+ // Motion durations (ms). Every built-in transition the components declare
93
+ // draws its timing here, so one theme edit retimes the whole package:
94
+ // fast is press/hover feedback, base the color/opacity fades (state
95
+ // changes, the theme cross-fade, popup enter/exit), slow the travel of a
96
+ // control's moving parts (switch knob, segmented indicator, progress
97
+ // fill). See motion.tsx; policy.motion gates them.
98
+ motion: { fast: number; base: number; slow: number }
87
99
  // Default extents of the components that have one: the panes and rails an
88
100
  // app lays its screens around, and the smallest sensible popup/track. Each
89
101
  // is a per-instance layout override away (listWidth, layout.width, ...);
@@ -109,7 +121,10 @@ export type Theme = {
109
121
  export type ThemeColor = string | [light: string, dark: string]
110
122
 
111
123
  export type ThemeDefinition = {
112
- color: { [K in Exclude<keyof Theme["color"], "ring">]: ThemeColor } & { ring?: ThemeColor }
124
+ color: { [K in Exclude<keyof Theme["color"], "ring" | "selection">]: ThemeColor } & {
125
+ ring?: ThemeColor
126
+ selection?: ThemeColor
127
+ }
113
128
  text?: {
114
129
  fontFamily?: string
115
130
  monoFamily?: string
@@ -129,6 +144,7 @@ export type ThemeDefinition = {
129
144
  // deriveRadius) or explicit steps. Default 8.
130
145
  radius?: number | Partial<Theme["radius"]>
131
146
  borderWidth?: Partial<Theme["borderWidth"]>
147
+ motion?: Partial<Theme["motion"]>
132
148
  size?: Partial<Theme["size"]>
133
149
  icons?: Theme["icons"]
134
150
  components?: Theme["components"]
@@ -148,6 +164,10 @@ function deriveRadius(base: number): Theme["radius"] {
148
164
  return { sm: Math.round(base / 2), md: base, lg: Math.round(base * 1.5), full: RADIUS_FULL }
149
165
  }
150
166
  const BORDER_WIDTH = { sm: 1, focus: 2 }
167
+ // The motion timing scale (ms): fast enough that feedback reads as
168
+ // immediate, base tuned so a state fade registers without dragging, slow
169
+ // long enough that a part's travel reads as movement rather than a flicker.
170
+ const MOTION = { fast: 100, base: 150, slow: 250 }
151
171
  const SIZE = { navRail: 72, navSidebar: 220, splitViewList: 320, menuMinWidth: 120, slider: 200 }
152
172
 
153
173
  // Line height and weight per role; body is the base text, label is body at
@@ -183,6 +203,7 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
183
203
  } else color[k] = value
184
204
  }
185
205
  if (def.color.ring == null) color.ring = color.text
206
+ if (def.color.selection == null) color.selection = color.overlayPressed
186
207
  let base = def.text?.base ?? 14
187
208
  let ratio = def.text?.ratio ?? 1.26
188
209
  let role = (name: TextVariant): TextStyle => {
@@ -212,6 +233,7 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
212
233
  radius:
213
234
  typeof def.radius === "number" ? deriveRadius(def.radius) : { ...deriveRadius(RADIUS_BASE), ...def.radius },
214
235
  borderWidth: { ...BORDER_WIDTH, ...def.borderWidth },
236
+ motion: { ...MOTION, ...def.motion },
215
237
  size: { ...SIZE, ...def.size },
216
238
  icons: def.icons ?? {},
217
239
  components: def.components ?? {},
@@ -252,6 +274,9 @@ const DEFAULT: ThemeDefinition = {
252
274
  // Feedback darkens on a light scheme and lightens on a dark one.
253
275
  overlayHover: ["rgba(0,0,0,0.08)", "rgba(255,255,255,0.08)"],
254
276
  overlayPressed: ["rgba(0,0,0,0.14)", "rgba(255,255,255,0.14)"],
277
+ // Primary (#547ebf) at a translucent strength: pale enough to read
278
+ // through on light, a touch stronger against the dark ground.
279
+ selection: ["rgba(84,126,191,0.30)", "rgba(84,126,191,0.40)"],
255
280
  },
256
281
  // base 14 and ratio 1.26 derive title 18 and heading 22; caption sits
257
282
  // one step under body but the ratio lands on 11, too small to read on a
package/src/tooltip.tsx CHANGED
@@ -5,7 +5,8 @@ import { policy } from "./policy"
5
5
  import { space } from "./spacing"
6
6
  import { typeStyle } from "./typography"
7
7
  import type { TransitionProps } from "./types"
8
- import { splitTransition, transitionEndFor } from "./types"
8
+ import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
9
+ import { colorFade, popupFadeOut } from "./motion"
9
10
 
10
11
  export interface TooltipProps extends TransitionProps {
11
12
  // The tooltip body. A string/number renders as themed text; anything else
@@ -73,6 +74,9 @@ export function Tooltip(props: TooltipProps) {
73
74
  // share one build - reading the raw getter again would orphan native nodes.
74
75
  let content = children(() => props.content)
75
76
  let isText = () => typeof content() === "string" || typeof content() === "number"
77
+ // The fade is driven by the position write, not a mount-time `from`: the
78
+ // bubble parks offscreen until measured, so a `from` would play unseen;
79
+ // the exit fade plays when the bubble unmounts.
76
80
  return createPortal(
77
81
  <view
78
82
  ref={(n: { id: number }) => (bubble = n)}
@@ -87,21 +91,23 @@ export function Tooltip(props: TooltipProps) {
87
91
  paddingLeft={space("md")}
88
92
  paddingRight={space("md")}
89
93
  pointerEvents="none"
94
+ opacity={pos() ? 1 : 0}
95
+ transition={popupFadeOut()}
90
96
  >
91
97
  <d-rect
92
- transition={split().background}
98
+ transition={withTransitionDefaults(split().background, colorFade())}
93
99
  onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
94
100
  color={theme.components.tooltip?.backgroundColor ?? theme.color.surfaceAlt}
95
101
  radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
96
102
  />
97
103
  <Show when={isText()} fallback={content()}>
98
- <text color={theme.color.text} {...typeStyle("body")}>
104
+ <text transition={colorFade()} color={theme.color.text} {...typeStyle("body")}>
99
105
  {content()}
100
106
  </text>
101
107
  </Show>
102
108
  <d-rect
103
109
  drawStyle="stroke"
104
- transition={split().border}
110
+ transition={withTransitionDefaults(split().border, colorFade())}
105
111
  onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
106
112
  color={theme.components.tooltip?.borderColor ?? theme.color.border}
107
113
  strokeWidth={theme.components.tooltip?.borderWidth ?? theme.borderWidth.sm}
package/src/types.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import type {
2
2
  Color,
3
+ FontFamily,
4
+ FontStyle,
5
+ FontWeight,
3
6
  Gradient,
4
7
  LayoutProps,
8
+ TextAlign,
5
9
  Transition,
6
10
  TransitionEndEvent,
7
11
  TransitionPropName,
@@ -17,9 +21,12 @@ import type {
17
21
  // component they would land on the root view, which has no paint, and be
18
22
  // silently ignored.
19
23
  //
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.
24
+ // Controls with a moving part of their own name it as an extra entry
25
+ // (Switch `knob`, SegmentedControl `indicator`, ProgressBar `fill`), routed
26
+ // by partTransition to the node owning it - with a built-in default from
27
+ // motion.tsx when the caller names nothing. Slider is the deliberate
28
+ // exception: its thumb and fill track the drag 1:1, and a transition would
29
+ // rubber-band it.
23
30
  export type TransitionViewProp =
24
31
  | "opacity"
25
32
  | "x"
@@ -80,9 +87,14 @@ const STYLE_TO_BORDER: Record<string, TransitionPropName> = {
80
87
  * Map a component declaration onto the three nodes a styled component
81
88
  * draws. `all`, a shorthand string and `stagger` apply to every node; named
82
89
  * entries go to the node owning the property. An entry for a node that is
83
- * not mounted (no background set) simply never animates.
90
+ * not mounted (no background set) simply never animates. `parts` lists the
91
+ * component's named moving parts, routed separately via partTransition, so
92
+ * their entries never leak onto the root view.
84
93
  */
85
- export function splitTransition<P extends string>(t: ComponentTransition<P> | undefined): SplitTransition {
94
+ export function splitTransition<P extends string>(
95
+ t: ComponentTransition<P> | undefined,
96
+ parts?: readonly string[],
97
+ ): SplitTransition {
86
98
  if (t == null || typeof t === "string") return { root: t, background: t, border: t }
87
99
  let root: Record<string, unknown> = {}
88
100
  let background: Record<string, unknown> = {}
@@ -95,7 +107,7 @@ export function splitTransition<P extends string>(t: ComponentTransition<P> | un
95
107
  } else if (key in STYLE_TO_BACKGROUND || key in STYLE_TO_BORDER) {
96
108
  if (key in STYLE_TO_BACKGROUND) background[STYLE_TO_BACKGROUND[key]!] = value
97
109
  if (key in STYLE_TO_BORDER) border[STYLE_TO_BORDER[key]!] = value
98
- } else {
110
+ } else if (!parts?.includes(key)) {
99
111
  root[key] = value
100
112
  }
101
113
  }
@@ -103,6 +115,66 @@ export function splitTransition<P extends string>(t: ComponentTransition<P> | un
103
115
  return { root: pick(root), background: pick(background), border: pick(border) }
104
116
  }
105
117
 
118
+ /**
119
+ * Fill a node's split declaration with the component's built-in transitions
120
+ * (motion.tsx) for the properties the caller left uncovered. A caller
121
+ * shorthand or `all` covers everything, so their intent wins wholesale;
122
+ * `transition={null}` suppresses the built-ins too; a named entry overrides
123
+ * the default for that property only.
124
+ */
125
+ export function withTransitionDefaults(
126
+ t: CoreTransitionProps["transition"],
127
+ defaults: Record<string, Transition | undefined> | undefined,
128
+ ): CoreTransitionProps["transition"] {
129
+ if (t === null || typeof t === "string") return t
130
+ if (t?.all !== undefined) return t
131
+ let filled: Record<string, unknown> | undefined
132
+ for (let key in defaults) {
133
+ let spec = defaults[key]
134
+ if (spec === undefined || (t && key in t)) continue
135
+ ;(filled ??= {})[key] = spec
136
+ }
137
+ if (!filled) return t
138
+ return { ...filled, ...t } as CoreTransitionProps["transition"]
139
+ }
140
+
141
+ /**
142
+ * The core declaration for a control's named moving part - a node owning
143
+ * one animatable property (Switch `knob` -> the knob's `x`). The caller's
144
+ * entry for the part (or `all`, or a shorthand string) retimes it, an
145
+ * absent one falls back to the control's built-in spec, and
146
+ * `transition={null}` suppresses that too.
147
+ */
148
+ export function partTransition<P extends string>(
149
+ t: ComponentTransition<P> | undefined,
150
+ part: string,
151
+ coreProp: TransitionPropName,
152
+ fallback: Transition | undefined,
153
+ ): CoreTransitionProps["transition"] {
154
+ let spec: Transition | undefined
155
+ if (t === null) spec = undefined
156
+ else if (typeof t === "string") spec = t
157
+ else if (t) spec = (t as Record<string, Transition | undefined>)[part] ?? t.all ?? fallback
158
+ else spec = fallback
159
+ return spec == null ? undefined : ({ [coreProp]: spec } as CoreTransitionProps["transition"])
160
+ }
161
+
162
+ /**
163
+ * Report a part's settled core property under the part's name. Filtered to
164
+ * the part's own property: the node may also run built-in fades (color),
165
+ * which have no name in the component vocabulary.
166
+ */
167
+ export function partTransitionEnd<P extends string>(
168
+ part: P,
169
+ coreProp: TransitionPropName,
170
+ handler: ((event: { property: P }) => void) | undefined,
171
+ ): ((event: TransitionEndEvent) => void) | undefined {
172
+ if (!handler) return undefined
173
+ return (e) => {
174
+ if (e.property === coreProp) handler({ property: part })
175
+ }
176
+ }
177
+
106
178
  /** Report a settled core property in the component vocabulary for the node it settled on. */
107
179
  export function transitionEndFor<P extends string>(
108
180
  node: "root" | "background" | "border",
@@ -166,11 +238,11 @@ export interface Option {
166
238
  // than style. These end up on the inner <text> node, while the box layout
167
239
  // fields go on the wrapping <view>.
168
240
  export interface TextLayoutProps extends LayoutProps {
169
- fontFamily?: "sans" | "mono" | (string & {})
241
+ fontFamily?: FontFamily
170
242
  fontSize?: number
171
243
  lineHeight?: number
172
- fontStyle?: "normal" | "italic"
173
- fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
174
- textAlign?: "left" | "right" | "center" | "justify"
244
+ fontStyle?: FontStyle
245
+ fontWeight?: FontWeight
246
+ textAlign?: TextAlign
175
247
  maxLines?: number
176
248
  }
package/src/view.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { LayoutProps, PointerProps } from "@solidrt/core"
2
2
  import type { StyleProps, TransitionProps } from "./types"
3
- import { splitTransition, transitionEndFor } from "./types"
3
+ import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
4
+ import { colorFade } from "./motion"
4
5
 
5
6
  export interface ViewProps extends PointerProps, TransitionProps {
6
7
  children?: any
@@ -50,7 +51,7 @@ export function View(props: ViewProps) {
50
51
  >
51
52
  {hasBackground() ? (
52
53
  <d-rect
53
- transition={split().background}
54
+ transition={withTransitionDefaults(split().background, colorFade())}
54
55
  onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
55
56
  color={props.style?.backgroundColor ?? "transparent"}
56
57
  radius={props.style?.borderRadius}
@@ -60,7 +61,7 @@ export function View(props: ViewProps) {
60
61
  {hasBorder() ? (
61
62
  <d-rect
62
63
  drawStyle="stroke"
63
- transition={split().border}
64
+ transition={withTransitionDefaults(split().border, colorFade())}
64
65
  onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
65
66
  color={props.style?.borderColor ?? "transparent"}
66
67
  strokeWidth={props.style?.borderWidth}
package/src/window.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { LayoutProps, PointerProps } from "@solidrt/core"
2
2
  import type { StyleProps } from "./types"
3
+ import { colorFade } from "./motion"
3
4
 
4
5
  export interface WindowProps extends PointerProps {
5
6
  children?: any
@@ -31,7 +32,7 @@ export function Window(props: WindowProps) {
31
32
  pointerEvents={props.pointerEvents}
32
33
  >
33
34
  {props.style?.backgroundColor != null ? (
34
- <d-rect color={props.style.backgroundColor} />
35
+ <d-rect transition={colorFade()} color={props.style.backgroundColor} />
35
36
  ) : null}
36
37
  {props.children}
37
38
  </window>