@solidrt/components 0.0.55 → 0.0.57

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/pressable.tsx CHANGED
@@ -1,8 +1,9 @@
1
1
  import { children } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerProps } from "@solidrt/core"
3
3
  import type { StyleProps, TransitionProps } from "./types"
4
- import { splitTransition, transitionEndFor } from "./types"
4
+ import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
5
5
  import { createPress, type PressState } from "./press"
6
+ import { colorFade, scaleFeedback } from "./motion"
6
7
 
7
8
  export type { PressState } from "./press"
8
9
 
@@ -55,7 +56,9 @@ export function Pressable(props: PressableProps) {
55
56
 
56
57
  return (
57
58
  <view
58
- transition={split().root}
59
+ // The scale default animates a style-function's press scale, so a
60
+ // custom control built on Pressable presses like Button by default.
61
+ transition={withTransitionDefaults(split().root, scaleFeedback())}
59
62
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
60
63
  ref={(n: { id: number }) => {
61
64
  press.ref(n)
@@ -84,7 +87,7 @@ export function Pressable(props: PressableProps) {
84
87
  >
85
88
  {hasBackground() ? (
86
89
  <d-rect
87
- transition={split().background}
90
+ transition={withTransitionDefaults(split().background, colorFade())}
88
91
  onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
89
92
  color={style()?.backgroundColor ?? "transparent"}
90
93
  radius={style()?.borderRadius}
@@ -94,7 +97,7 @@ export function Pressable(props: PressableProps) {
94
97
  {hasBorder() ? (
95
98
  <d-rect
96
99
  drawStyle="stroke"
97
- transition={split().border}
100
+ transition={withTransitionDefaults(split().border, colorFade())}
98
101
  onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
99
102
  color={style()?.borderColor ?? "transparent"}
100
103
  strokeWidth={style()?.borderWidth}
@@ -2,10 +2,11 @@ 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, TransitionProps } from "./types"
6
- import { splitTransition, transitionEndFor } from "./types"
5
+ import type { StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
6
+ import { partTransition, partTransitionEnd, splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
7
+ import { colorFade, travelMotion } from "./motion"
7
8
 
8
- export interface ProgressBarProps extends TransitionProps {
9
+ export interface ProgressBarProps extends TransitionProps<TransitionViewProp | TransitionStyleProp | "fill"> {
9
10
  // Progress from 0 to 1. Omit (or leave undefined) for an indeterminate bar: a
10
11
  // segment that slides back and forth.
11
12
  value?: number
@@ -71,13 +72,25 @@ export function ProgressBar(props: ProgressBarProps) {
71
72
  let effectivePhase = () => (policy.motion === "none" ? 0.5 : phase())
72
73
  let offset = () => effectivePhase() * trackWidth() * (1 - SEGMENT)
73
74
 
75
+ let split = () => splitTransition(props.transition, ["fill"])
76
+ // Determinate only: a value write glides the fill (the `fill` transition
77
+ // entry retimes it; bounce 0, progress must not overshoot). Indeterminate
78
+ // writes w/x per frame, which a transition would lag behind.
79
+ let fillTransition = () => {
80
+ if (props.transition === null) return null
81
+ let travel = indeterminate() ? undefined : partTransition(props.transition, "fill", "w", travelMotion(0))
82
+ let fade = colorFade()
83
+ if (!travel && !fade) return undefined
84
+ return { ...fade, ...(travel as object | undefined) }
85
+ }
86
+
74
87
  return (
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}>
88
+ <view ref={(n: { id: number }) => (trackNode = n)} position="relative" width="100%" height={h()} transition={split().root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
76
89
  <Show when={animating()}>
77
90
  <Animate />
78
91
  </Show>
79
- <d-rect color={track()} radius={radius()} />
80
- <d-rect color={fill()} radius={radius()} w={fillWidth()} h={h()} x={indeterminate() ? offset() : 0} />
92
+ <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={track()} radius={radius()} />
93
+ <d-rect transition={fillTransition()} onTransitionEnd={partTransitionEnd("fill", "w", props.onTransitionEnd)} color={fill()} radius={radius()} w={fillWidth()} h={h()} x={indeterminate() ? offset() : 0} />
81
94
  </view>
82
95
  )
83
96
  }
package/src/radio.tsx CHANGED
@@ -6,7 +6,8 @@ import { policy } from "./policy"
6
6
  import { densityScale } from "./density"
7
7
  import { typeStyle } from "./typography"
8
8
  import type { StyleProps, TransitionProps } from "./types"
9
- import { splitTransition, transitionEndFor } from "./types"
9
+ import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
10
+ import { colorFade, markMotion, pressScale, scaleFeedback } from "./motion"
10
11
 
11
12
  // Shared selection state for a group. Created and consumed within this module, so
12
13
  // RadioGroup/Radio are a self-contained pair, not a cross-component dependency.
@@ -67,6 +68,8 @@ export interface RadioProps extends TransitionProps {
67
68
  const RING = 20
68
69
 
69
70
  // A single option in a RadioGroup: a ring with an inner dot when selected.
71
+ // The ring color fades, the dot pops in/out (markMotion), and a press
72
+ // shrinks the ring - not the whole row, so a long label never wobbles.
70
73
  export function Radio(props: RadioProps) {
71
74
  // useContext throws ContextNotFoundError if a Radio is used outside a
72
75
  // RadioGroup (default-less context), so ctx is always present here.
@@ -90,9 +93,11 @@ export function Radio(props: RadioProps) {
90
93
  let ringColor = () => (focusRing() ? theme.color.ring : selected() ? theme.color.primary : theme.color.border)
91
94
  let ringWidth = () => (focusRing() ? theme.borderWidth.focus : 2)
92
95
 
96
+ let split = () => splitTransition(props.transition)
97
+
93
98
  return (
94
99
  <view
95
- transition={splitTransition(props.transition).root}
100
+ transition={split().root}
96
101
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
97
102
  ref={press.ref}
98
103
  repaintBoundary
@@ -110,22 +115,46 @@ export function Radio(props: RadioProps) {
110
115
  pointerEvents={disabled() ? "none" : undefined}
111
116
  >
112
117
  <Show when={styled().backgroundColor != null || styled().borderRadius != null}>
113
- <d-rect color={styled().backgroundColor ?? "transparent"} radius={styled().borderRadius} />
118
+ <d-rect
119
+ transition={withTransitionDefaults(split().background, colorFade())}
120
+ onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
121
+ color={styled().backgroundColor ?? "transparent"}
122
+ radius={styled().borderRadius}
123
+ />
114
124
  </Show>
115
- <view width={ring()} height={ring()}>
116
- <d-oval drawStyle="stroke" color={ringColor()} strokeWidth={ringWidth()} />
125
+ <view
126
+ width={ring()}
127
+ height={ring()}
128
+ position="relative"
129
+ scale={pressScale(press.pressed())}
130
+ transition={scaleFeedback()}
131
+ >
132
+ <d-oval drawStyle="stroke" transition={colorFade()} color={ringColor()} strokeWidth={ringWidth()} />
117
133
  <Show when={selected()}>
118
- <d-oval x={inset()} y={inset()} w={ring() - inset() * 2} h={ring() - inset() * 2} color={theme.color.primary} />
134
+ <view
135
+ position="absolute"
136
+ top={0}
137
+ bottom={0}
138
+ left={0}
139
+ right={0}
140
+ opacity={1}
141
+ scale={1}
142
+ transition={markMotion()}
143
+ >
144
+ <d-oval x={inset()} y={inset()} w={ring() - inset() * 2} h={ring() - inset() * 2} transition={colorFade()} color={theme.color.primary} />
145
+ </view>
119
146
  </Show>
120
147
  </view>
121
148
  <Show when={isText()} fallback={resolved()}>
122
- <text color={theme.color.text} {...typeStyle("body")}>
149
+ <text transition={colorFade()} color={theme.color.text} {...typeStyle("body")}>
123
150
  {resolved()}
124
151
  </text>
125
152
  </Show>
126
153
  <Show when={(styled().borderWidth ?? 0) > 0}>
127
154
  <d-rect
128
155
  drawStyle="stroke"
156
+ transition={withTransitionDefaults(split().border, colorFade())}
157
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
129
158
  color={styled().borderColor ?? "transparent"}
130
159
  strokeWidth={styled().borderWidth}
131
160
  radius={styled().borderRadius}
@@ -1,14 +1,16 @@
1
- import { createSignal, For, Show } from "@solidrt/core"
1
+ import { createSignal, For, Show, getBoundingBox, onLayout, withAlpha } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { createPress } from "./press"
4
4
  import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { space } from "./spacing"
7
7
  import { typeStyle, lightOnDark } from "./typography"
8
- import type { Option, StyleProps, TransitionProps } from "./types"
9
- import { splitTransition, transitionEndFor } from "./types"
8
+ import type { Option, StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
9
+ import { partTransition, partTransitionEnd, splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
10
+ import { colorFade, PressFeedback, travelMotion } from "./motion"
10
11
 
11
- export interface SegmentedControlProps extends TransitionProps {
12
+ export interface SegmentedControlProps
13
+ extends TransitionProps<TransitionViewProp | TransitionStyleProp | "indicator"> {
12
14
  options: Option[]
13
15
  // Controlled selected value. If omitted, the control is uncontrolled.
14
16
  value?: unknown
@@ -26,10 +28,12 @@ const DIVIDER = 0
26
28
 
27
29
  // A single-choice row of equal-width segments, joined flush (the Material
28
30
  // style): only the control's outermost corners are rounded, interior segments
29
- // are square, and hairline dividers separate them. The active segment fills
30
- // with the primary color. Controlled via value/onChange, or uncontrolled via
31
- // defaultValue. Hover tints inactive segments (non-touch interaction policies
32
- // only). Override the inactive fill via style, spacing/sizing via layout.
31
+ // are square, and hairline dividers separate them. The active segment is one
32
+ // indicator rect drawn under the labels that springs between segments - the
33
+ // `indicator` transition entry retimes it. Controlled via value/onChange, or
34
+ // uncontrolled via defaultValue. Hover tints inactive segments (non-touch
35
+ // interaction policies only). Override the inactive fill via style, the
36
+ // spacing/sizing via layout.
33
37
  export function SegmentedControl(props: SegmentedControlProps) {
34
38
  let [internal, setInternal] = createSignal(props.defaultValue)
35
39
  let value = () => (props.value !== undefined ? props.value : internal())
@@ -60,12 +64,49 @@ export function SegmentedControl(props: SegmentedControlProps) {
60
64
  let label = (active: boolean) =>
61
65
  props.disabled ? theme.color.textMuted : active ? theme.color.onPrimary : theme.color.text
62
66
 
63
- let split = () => splitTransition(props.transition)
67
+ // The indicator is positioned from measured segment boxes (control-relative
68
+ // x and width per segment), so a selection change retargets it without a
69
+ // reflow and unequal rounding never misaligns it. Remeasured each layout.
70
+ let root: { id: number } | undefined
71
+ let segs: ({ id: number } | undefined)[] = []
72
+ let [boxes, setBoxes] = createSignal<{ x: number; w: number }[]>([])
73
+ // The indicator's travel spring is armed only after the first placement:
74
+ // the declaration exists from mount, so without this the first measured
75
+ // write would slide the indicator in from x 0. A timer, not the same
76
+ // flush - the declaration must be committed strictly after that write.
77
+ let [placed, setPlaced] = createSignal(false)
78
+ onLayout(() => {
79
+ if (!root) return
80
+ let r = getBoundingBox(root)
81
+ if (!r) return
82
+ let next: { x: number; w: number }[] = []
83
+ for (let i = 0; i < props.options.length; i++) {
84
+ let s = segs[i]
85
+ let b = s && getBoundingBox(s)
86
+ if (!b) return
87
+ next.push({ x: b.x - r.x, w: b.width })
88
+ }
89
+ let cur = boxes()
90
+ if (cur.length !== next.length || cur.some((c, i) => c.x !== next[i]!.x || c.w !== next[i]!.w)) setBoxes(next)
91
+ if (!placed()) setTimeout(() => setPlaced(true), 0)
92
+ })
93
+ let activeIndex = () => props.options.findIndex((o) => o.value === value())
94
+ let indicator = () => boxes()[activeIndex()]
95
+
96
+ let split = () => splitTransition(props.transition, ["indicator"])
97
+ let indicatorTransition = () => {
98
+ if (props.transition === null) return null
99
+ let travel = placed() ? partTransition(props.transition, "indicator", "x", travelMotion()) : undefined
100
+ let fade = colorFade()
101
+ if (!travel && !fade) return undefined
102
+ return { ...fade, ...(travel as object | undefined) }
103
+ }
64
104
 
65
105
  return (
66
106
  <view
67
107
  transition={split().root}
68
108
  onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
109
+ ref={(n: { id: number }) => (root = n)}
69
110
  flexDirection="row"
70
111
  gap={DIVIDER}
71
112
  {...props.layout}
@@ -75,20 +116,25 @@ export function SegmentedControl(props: SegmentedControlProps) {
75
116
  rotate={styled().rotate}
76
117
  opacity={styled().opacity}
77
118
  >
78
- <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={theme.color.border} radius={radius()} />
119
+ <d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={idleFill()} radius={radius()} />
120
+ <d-rect
121
+ transition={indicatorTransition()}
122
+ onTransitionEnd={partTransitionEnd("indicator", "x", props.onTransitionEnd)}
123
+ color={indicator() ? activeFill() : withAlpha(activeFill(), 0)}
124
+ x={indicator()?.x ?? 0}
125
+ w={indicator()?.w ?? 0}
126
+ radius={corners(activeIndex())}
127
+ />
79
128
  <For each={props.options}>
80
129
  {(opt, i) => {
81
130
  let active = () => value() === opt.value
82
131
  let press = createPress({ onPress: () => select(opt.value) })
83
- let fill = () => (active() ? activeFill() : idleFill())
84
- // Hover feedback: the theme overlay tint drawn over the segment fill.
85
- let overlay = () =>
86
- press.hovered() && !props.disabled && policy.interaction !== "touch"
87
- ? theme.color.overlayHover
88
- : "transparent"
89
- return (
132
+ return (
90
133
  <view
91
- ref={press.ref}
134
+ ref={(n: { id: number }) => {
135
+ segs[i()] = n
136
+ press.ref(n)
137
+ }}
92
138
  repaintBoundary
93
139
  flexGrow={1}
94
140
  flexBasis={0}
@@ -101,12 +147,16 @@ export function SegmentedControl(props: SegmentedControlProps) {
101
147
  focusable={!props.disabled}
102
148
  pointerEvents={props.disabled ? "none" : undefined}
103
149
  >
104
- <d-rect color={fill()} radius={corners(i())} />
105
- <d-rect color={overlay()} radius={corners(i())} />
150
+ <PressFeedback
151
+ pressed={press.pressed()}
152
+ hovered={press.hovered() && !props.disabled && policy.interaction !== "touch"}
153
+ radius={corners(i())}
154
+ />
106
155
  <Show when={press.focused() && policy.focusRing}>
107
156
  <d-rect drawStyle="stroke" color={theme.color.ring} strokeWidth={theme.borderWidth.focus} radius={corners(i())} />
108
157
  </Show>
109
158
  <text
159
+ transition={colorFade()}
110
160
  color={label(active())}
111
161
  {...typeStyle("body", active() ? lightOnDark(label(true), activeFill()) : undefined)}
112
162
  >
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}