@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/motion.tsx ADDED
@@ -0,0 +1,122 @@
1
+ import { withAlpha } from "@solidrt/core"
2
+ import type { Transition } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import { policy } from "./policy"
5
+
6
+ // The components' built-in motion. Every default transition in the package
7
+ // is declared through these helpers, so timing comes from theme.motion and
8
+ // the whole set degrades together under policy.motion: "reduced" keeps the
9
+ // fades (color and opacity carry state; a fade is not movement) but snaps
10
+ // everything that travels or scales, "none" snaps it all. Each helper
11
+ // returns undefined while its motion is off. A caller's `transition` prop
12
+ // overrides a default per property (see withTransitionDefaults in types.ts)
13
+ // and `transition={null}` suppresses the defaults outright.
14
+
15
+ // Scale a pressed control shrinks to; the release springs back.
16
+ const PRESS_SCALE = 0.97
17
+ // Overshoot of the travel springs (switch knob, segmented indicator): a
18
+ // touch of bounce reads as physical without wobble.
19
+ const TRAVEL_BOUNCE = 0.15
20
+ // Fraction of its size a state mark (checkmark, radio dot) grows in from.
21
+ const MARK_FROM_SCALE = 0.4
22
+
23
+ /** State-change fade for colors and opacity - also the theme cross-fade. */
24
+ export function fadeMotion(): Transition | undefined {
25
+ if (policy.motion === "none") return undefined
26
+ return { duration: theme.motion.base, curve: "ease-out" } satisfies Transition
27
+ }
28
+
29
+ /** Press/hover feedback fade (the overlay tints): faster than fadeMotion so a press reads as immediate. */
30
+ export function feedbackFade(): Transition | undefined {
31
+ if (policy.motion === "none") return undefined
32
+ return { duration: theme.motion.fast, curve: "ease-out" } satisfies Transition
33
+ }
34
+
35
+ /** Travel spring for a control's moving part. Movement, so "reduced" snaps it too. */
36
+ export function travelMotion(bounce: number = TRAVEL_BOUNCE): Transition | undefined {
37
+ if (policy.motion !== "normal") return undefined
38
+ return { duration: theme.motion.slow, bounce } satisfies Transition
39
+ }
40
+
41
+ /** Default `{ color: fade }` declaration for a themed paint node. */
42
+ export function colorFade(): { color: Transition } | undefined {
43
+ let fade = fadeMotion()
44
+ return fade === undefined ? undefined : { color: fade }
45
+ }
46
+
47
+ /** Default `{ scale: spring }` declaration for a control that shrinks on press. */
48
+ export function scaleFeedback(): { scale: Transition } | undefined {
49
+ if (policy.motion !== "normal") return undefined
50
+ return { scale: { duration: theme.motion.fast } satisfies Transition }
51
+ }
52
+
53
+ /** The scale a pressed control renders at: PRESS_SCALE under full motion, 1 otherwise (a scale snap is worse than none; the overlay tint still marks the press). */
54
+ export function pressScale(pressed: boolean): number {
55
+ return pressed && policy.motion === "normal" ? PRESS_SCALE : 1
56
+ }
57
+
58
+ /**
59
+ * Enter/exit fade for an overlay surface mounted in place (modal, bottom
60
+ * sheet): fades in at mount, fades out on removal. The node must set
61
+ * `opacity` explicitly - only carried properties animate.
62
+ */
63
+ export function popupFade(): { opacity: Transition } | undefined {
64
+ if (policy.motion === "none") return undefined
65
+ return { opacity: { duration: theme.motion.base, curve: "ease-out", from: 0, exit: 0 } satisfies Transition }
66
+ }
67
+
68
+ /**
69
+ * Exit-only fade for popups that park offscreen until measured (tooltip,
70
+ * menus): they fade in by writing opacity 0 -> 1 once positioned - a
71
+ * mount-time `from` would play unseen - and fade out on removal.
72
+ */
73
+ export function popupFadeOut(): { opacity: Transition } | undefined {
74
+ if (policy.motion === "none") return undefined
75
+ return { opacity: { duration: theme.motion.base, curve: "ease-out", exit: 0 } satisfies Transition }
76
+ }
77
+
78
+ /**
79
+ * Enter/exit pop for a control's state mark (checkbox check, radio dot),
80
+ * declared on a wrapper view that sets `opacity={1} scale={1}`: it fades
81
+ * in/out, with a small scale pop under full motion only.
82
+ */
83
+ export function markMotion(): { opacity: Transition; scale?: Transition } | undefined {
84
+ if (policy.motion === "none") return undefined
85
+ let declaration: { opacity: Transition; scale?: Transition } = {
86
+ opacity: { duration: theme.motion.base, curve: "ease-out", from: 0, exit: 0 } satisfies Transition,
87
+ }
88
+ if (policy.motion === "normal")
89
+ declaration.scale = {
90
+ duration: theme.motion.slow,
91
+ bounce: TRAVEL_BOUNCE,
92
+ from: MARK_FROM_SCALE,
93
+ exit: MARK_FROM_SCALE,
94
+ } satisfies Transition
95
+ return declaration
96
+ }
97
+
98
+ /**
99
+ * The hover/pressed tint every pressable control draws over its own fill.
100
+ * One always-mounted rect whose hidden state is the same tint at alpha 0,
101
+ * not "transparent": a fade from transparent black would darken a dark
102
+ * scheme's white tint midway. Pressed swaps in the deeper tint; showing,
103
+ * hiding and the pressed/hover swap all fade at feedback speed.
104
+ */
105
+ export function PressFeedback(props: {
106
+ pressed?: boolean
107
+ hovered?: boolean
108
+ radius?: number | [number, number, number, number]
109
+ }) {
110
+ let tint = () => (props.pressed ? theme.color.overlayPressed : theme.color.overlayHover)
111
+ let fade = () => {
112
+ let f = feedbackFade()
113
+ return f === undefined ? undefined : { color: f }
114
+ }
115
+ return (
116
+ <d-rect
117
+ transition={fade()}
118
+ color={props.pressed || props.hovered ? tint() : withAlpha(tint(), 0)}
119
+ radius={props.radius}
120
+ />
121
+ )
122
+ }
package/src/nav-shell.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal, Switch, Match, For } from "@solidrt/core"
1
+ import { createSignal, Switch, Match, For, withAlpha } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { createPress } from "./press"
4
4
  import { theme } from "./theme"
@@ -7,6 +7,7 @@ import { space } from "./spacing"
7
7
  import { typeStyle } from "./typography"
8
8
  import type { TransitionProps } from "./types"
9
9
  import { splitTransition, transitionEndFor } from "./types"
10
+ import { colorFade, PressFeedback } from "./motion"
10
11
 
11
12
  export interface NavItem {
12
13
  value: unknown
@@ -44,12 +45,11 @@ export function NavShell(props: NavShellProps) {
44
45
  }
45
46
 
46
47
  let labelColor = (item: NavItem) => (item.value === value() ? theme.color.primary : theme.color.textMuted)
47
- let itemBg = (item: NavItem, hovered: boolean) =>
48
- item.value === value()
49
- ? theme.color.surfaceAlt
50
- : hovered && policy.interaction !== "touch"
51
- ? theme.color.overlayHover
52
- : "transparent"
48
+ // The selection fill: the surfaceAlt tint at alpha 0 while unselected, so
49
+ // selecting fades it in (same trap as PressFeedback: a fade from
50
+ // "transparent" would pass through the wrong hue).
51
+ let itemBg = (item: NavItem) =>
52
+ item.value === value() ? theme.color.surfaceAlt : withAlpha(theme.color.surfaceAlt, 0)
53
53
 
54
54
  // Icon over a small label, centered; shared by the tab bar and the rail.
55
55
  let StackedItem = (p: { item: NavItem; padY: number; layout?: LayoutProps }) => {
@@ -67,9 +67,14 @@ export function NavShell(props: NavShellProps) {
67
67
  {...p.layout}
68
68
  {...press.handlers}
69
69
  >
70
- <d-rect color={itemBg(p.item, press.hovered())} radius={theme.radius.sm} />
70
+ <d-rect transition={colorFade()} color={itemBg(p.item)} radius={theme.radius.sm} />
71
+ <PressFeedback
72
+ pressed={press.pressed()}
73
+ hovered={press.hovered() && policy.interaction !== "touch" && p.item.value !== value()}
74
+ radius={theme.radius.sm}
75
+ />
71
76
  {p.item.icon}
72
- <text color={labelColor(p.item)} {...typeStyle("caption")}>
77
+ <text transition={colorFade()} color={labelColor(p.item)} {...typeStyle("caption")}>
73
78
  {p.item.label}
74
79
  </text>
75
80
  </view>
@@ -79,7 +84,7 @@ export function NavShell(props: NavShellProps) {
79
84
  let Tabs = () => (
80
85
  <view flexDirection="column" flexShrink={0}>
81
86
  <view flexDirection="row">
82
- <d-rect color={theme.color.surface} />
87
+ <d-rect transition={colorFade()} color={theme.color.surface} />
83
88
  <For each={props.items}>
84
89
  {(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} layout={{ flex: 1 }} />}
85
90
  </For>
@@ -90,7 +95,7 @@ export function NavShell(props: NavShellProps) {
90
95
  let Rail = () => (
91
96
  <view flexDirection="row" flexShrink={0}>
92
97
  <view flexDirection="column" width={theme.size.navRail} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
93
- <d-rect color={theme.color.surface} />
98
+ <d-rect transition={colorFade()} color={theme.color.surface} />
94
99
  <For each={props.items}>{(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} />}</For>
95
100
  </view>
96
101
  </view>
@@ -99,7 +104,7 @@ export function NavShell(props: NavShellProps) {
99
104
  let Sidebar = () => (
100
105
  <view flexDirection="row" flexShrink={0}>
101
106
  <view flexDirection="column" width={theme.size.navSidebar} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
102
- <d-rect color={theme.color.surface} />
107
+ <d-rect transition={colorFade()} color={theme.color.surface} />
103
108
  <For each={props.items}>
104
109
  {(item: NavItem) => {
105
110
  let press = createPress({ onPress: () => select(item.value) })
@@ -118,9 +123,15 @@ export function NavShell(props: NavShellProps) {
118
123
  marginRight={theme.spacing.sm}
119
124
  {...press.handlers}
120
125
  >
121
- <d-rect color={itemBg(item, press.hovered())} radius={theme.radius.sm} />
126
+ <d-rect transition={colorFade()} color={itemBg(item)} radius={theme.radius.sm} />
127
+ <PressFeedback
128
+ pressed={press.pressed()}
129
+ hovered={press.hovered() && policy.interaction !== "touch" && item.value !== value()}
130
+ radius={theme.radius.sm}
131
+ />
122
132
  {item.icon}
123
133
  <text
134
+ transition={colorFade()}
124
135
  color={item.value === value() ? theme.color.primary : theme.color.text}
125
136
  {...typeStyle("body")}
126
137
  >
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
  >