@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/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
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
<
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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={
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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:
|
|
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 } & {
|
|
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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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>(
|
|
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?:
|
|
241
|
+
fontFamily?: FontFamily
|
|
170
242
|
fontSize?: number
|
|
171
243
|
lineHeight?: number
|
|
172
|
-
fontStyle?:
|
|
173
|
-
fontWeight?:
|
|
174
|
-
textAlign?:
|
|
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>
|