@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/AGENTS.md +10 -10
- package/README.md +22 -12
- package/docs/button.md +1 -1
- package/docs/checkbox.md +1 -1
- package/docs/context-menu.md +1 -1
- package/docs/modal.md +1 -1
- package/docs/policy.md +1 -1
- package/docs/progress-bar.md +1 -1
- package/docs/radio.md +1 -1
- package/docs/segmented-control.md +1 -1
- package/docs/select.md +1 -1
- package/docs/switch.md +1 -1
- package/docs/theme.md +8 -0
- package/docs/tooltip.md +1 -1
- package/docs/types.md +3 -1
- package/package.json +3 -3
- package/src/badge.tsx +4 -2
- package/src/button.tsx +16 -15
- package/src/card.tsx +5 -4
- package/src/checkbox.tsx +45 -21
- package/src/context-menu.tsx +22 -14
- package/src/divider.tsx +3 -2
- package/src/editor-field.tsx +112 -19
- package/src/field.tsx +3 -1
- package/src/item.tsx +11 -14
- package/src/modal.tsx +7 -1
- package/src/motion.tsx +122 -0
- package/src/nav-shell.tsx +24 -13
- package/src/pressable.tsx +7 -4
- package/src/progress-bar.tsx +19 -6
- package/src/radio.tsx +36 -7
- package/src/segmented-control.tsx +70 -20
- package/src/select.tsx +58 -43
- package/src/slider.tsx +7 -3
- package/src/spinner.tsx +2 -0
- package/src/switch.tsx +30 -11
- package/src/text.tsx +3 -2
- package/src/theme.ts +27 -2
- package/src/tooltip.tsx +10 -4
- package/src/types.ts +82 -10
- package/src/view.tsx +4 -3
- package/src/window.tsx +2 -1
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
package/src/progress-bar.tsx
CHANGED
|
@@ -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={
|
|
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={
|
|
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
|
|
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
|
|
116
|
-
|
|
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
|
-
<
|
|
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
|
|
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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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={
|
|
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
|
-
<
|
|
105
|
-
|
|
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
|
>
|