@solidrt/components 0.0.63 → 0.0.64
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 +18 -14
- package/README.md +126 -21
- package/docs/badge.md +1 -1
- package/docs/checkbox.md +1 -1
- package/docs/context-menu.md +1 -1
- package/docs/item.md +1 -1
- package/docs/light.md +12 -0
- package/docs/motion.md +13 -0
- package/docs/press.md +19 -0
- 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/slider.md +1 -1
- package/docs/surface.md +19 -0
- package/docs/switch.md +1 -1
- package/docs/theme.md +34 -6
- package/docs/tooltip.md +1 -1
- package/docs/types.md +1 -1
- package/docs/window.md +2 -0
- package/examples/lit.tsx +182 -0
- package/package.json +2 -2
- package/src/badge.tsx +25 -6
- package/src/button.tsx +44 -29
- package/src/card.tsx +76 -20
- package/src/checkbox.tsx +23 -19
- package/src/context-menu.tsx +21 -9
- package/src/editor-field.tsx +12 -7
- package/src/focus-nav.ts +17 -6
- package/src/glow.ts +35 -0
- package/src/index.ts +16 -1
- package/src/item.tsx +35 -13
- package/src/light.ts +274 -0
- package/src/motion.tsx +13 -1
- package/src/nav-shell.tsx +19 -10
- package/src/press.ts +2 -1
- package/src/pressable.tsx +6 -4
- package/src/progress-bar.tsx +25 -7
- package/src/radio.tsx +34 -22
- package/src/segmented-control.tsx +104 -23
- package/src/select.tsx +40 -34
- package/src/slider.tsx +74 -22
- package/src/surface.tsx +287 -0
- package/src/switch.tsx +48 -26
- package/src/theme.ts +201 -40
- package/src/tooltip.tsx +18 -14
- package/src/types.ts +66 -2
- package/src/window.tsx +26 -3
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
import { createSignal, For, Show, getBoundingBox, onLayout, withAlpha } from "@solidrt/core"
|
|
1
|
+
import { createSignal, For, Show, getBoundingBox, onCleanup, 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
|
|
9
|
-
import {
|
|
8
|
+
import { Surface, facePaint } from "./surface"
|
|
9
|
+
import type { ElevationLevel, Option, StyleProps, TransitionProps, TransitionStyleProp, TransitionViewProp } from "./types"
|
|
10
|
+
import { partTransition, partTransitionEnd, splitTransition, transitionEndFor } from "./types"
|
|
10
11
|
import { colorFade, PressFeedback, travelMotion } from "./motion"
|
|
11
12
|
|
|
12
|
-
export interface SegmentedControlProps
|
|
13
|
+
export interface SegmentedControlProps<T = unknown>
|
|
13
14
|
extends TransitionProps<TransitionViewProp | TransitionStyleProp | "indicator"> {
|
|
14
|
-
options: Option[]
|
|
15
|
+
options: Option<T>[]
|
|
15
16
|
// Controlled selected value. If omitted, the control is uncontrolled.
|
|
16
|
-
value?:
|
|
17
|
-
defaultValue?:
|
|
18
|
-
onChange?: (value:
|
|
17
|
+
value?: T
|
|
18
|
+
defaultValue?: T
|
|
19
|
+
onChange?: (value: T) => void
|
|
19
20
|
disabled?: boolean
|
|
20
21
|
layout?: LayoutProps
|
|
21
22
|
style?: StyleProps
|
|
@@ -28,16 +29,21 @@ const DIVIDER = 0
|
|
|
28
29
|
|
|
29
30
|
// A single-choice row of equal-width segments, joined flush (the Material
|
|
30
31
|
// style): only the control's outermost corners are rounded, interior segments
|
|
31
|
-
// are square, and hairline dividers separate them. The
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
// are square, and hairline dividers separate them. The track is a sunken
|
|
33
|
+
// control face; the active segment is an accent face drawn under the labels
|
|
34
|
+
// as detached rects (its fill and, standing raised, its shadows) that spring
|
|
35
|
+
// between segments - the `indicator` transition entry retimes it; a press on
|
|
36
|
+
// its segment sinks it. Controlled via value/onChange, or uncontrolled via
|
|
37
|
+
// defaultValue. Hover tints inactive segments (non-touch interaction
|
|
38
|
+
// policies only). Override the inactive fill via style, box the control
|
|
39
|
+
// with borderColor/borderWidth, the indicator's level with style.elevation,
|
|
40
|
+
// the spacing/sizing via layout.
|
|
41
|
+
export function SegmentedControl<T>(props: SegmentedControlProps<T>) {
|
|
42
|
+
// The value overload: as far as TypeScript knows T could be a function,
|
|
43
|
+
// which createSignal would read as a compute; the cast picks the value form.
|
|
44
|
+
let [internal, setInternal] = createSignal<T | undefined>(props.defaultValue as Exclude<T | undefined, Function>)
|
|
39
45
|
let value = () => (props.value !== undefined ? props.value : internal())
|
|
40
|
-
let select = (v:
|
|
46
|
+
let select = (v: T) => {
|
|
41
47
|
if (props.value === undefined) setInternal(() => v)
|
|
42
48
|
props.onChange?.(v)
|
|
43
49
|
}
|
|
@@ -60,16 +66,24 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
let idleFill = () => styled().backgroundColor ?? theme.color.surfaceAlt
|
|
69
|
+
let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
|
|
63
70
|
let activeFill = () => (props.disabled ? theme.color.surface : theme.color.primary)
|
|
71
|
+
let elevation = (): ElevationLevel => styled().elevation ?? "raised"
|
|
64
72
|
let label = (active: boolean) =>
|
|
65
73
|
props.disabled ? theme.color.textMuted : active ? theme.color.onPrimary : theme.color.text
|
|
66
74
|
|
|
67
75
|
// The indicator is positioned from measured segment boxes (control-relative
|
|
68
76
|
// x and width per segment), so a selection change retargets it without a
|
|
69
77
|
// reflow and unequal rounding never misaligns it. Remeasured each layout.
|
|
78
|
+
// Segment nodes are keyed by option value, not by the For index: a ref
|
|
79
|
+
// callback runs in the element's owned scope, where reading the index
|
|
80
|
+
// signal warns (STRICT_READ_UNTRACKED) and would go stale on a reorder.
|
|
70
81
|
let root: { id: number } | undefined
|
|
71
|
-
let segs
|
|
82
|
+
let segs = new Map<T, { id: number }>()
|
|
72
83
|
let [boxes, setBoxes] = createSignal<{ x: number; w: number }[]>([])
|
|
84
|
+
// Which segment is pressed, so the indicator sinks under the finger on
|
|
85
|
+
// its own segment only.
|
|
86
|
+
let [pressedValue, setPressedValue] = createSignal<T | undefined>(undefined)
|
|
73
87
|
// The indicator's travel spring is armed only after the first placement:
|
|
74
88
|
// the declaration exists from mount, so without this the first measured
|
|
75
89
|
// write would slide the indicator in from x 0. A timer, not the same
|
|
@@ -81,7 +95,7 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
81
95
|
if (!r) return
|
|
82
96
|
let next: { x: number; w: number }[] = []
|
|
83
97
|
for (let i = 0; i < props.options.length; i++) {
|
|
84
|
-
let s = segs[i]
|
|
98
|
+
let s = segs.get(props.options[i]!.value)
|
|
85
99
|
let b = s && getBoundingBox(s)
|
|
86
100
|
if (!b) return
|
|
87
101
|
next.push({ x: b.x - r.x, w: b.width })
|
|
@@ -92,6 +106,15 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
92
106
|
})
|
|
93
107
|
let activeIndex = () => props.options.findIndex((o) => o.value === value())
|
|
94
108
|
let indicator = () => boxes()[activeIndex()]
|
|
109
|
+
let indicatorPaint = () =>
|
|
110
|
+
facePaint({
|
|
111
|
+
role: "accent",
|
|
112
|
+
fill: activeFill(),
|
|
113
|
+
elevation: elevation(),
|
|
114
|
+
material: styled().material,
|
|
115
|
+
glow: indicator() && !props.disabled ? styled().glow : null,
|
|
116
|
+
pressed: pressedValue() !== undefined && pressedValue() === value(),
|
|
117
|
+
})
|
|
95
118
|
|
|
96
119
|
let split = () => splitTransition(props.transition, ["indicator"])
|
|
97
120
|
let indicatorTransition = () => {
|
|
@@ -116,23 +139,81 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
116
139
|
rotate={styled().rotate}
|
|
117
140
|
opacity={styled().opacity}
|
|
118
141
|
>
|
|
119
|
-
<
|
|
142
|
+
<Surface
|
|
143
|
+
role="control"
|
|
144
|
+
fill={idleFill()}
|
|
145
|
+
radius={radius()}
|
|
146
|
+
material={styled().material}
|
|
147
|
+
glow={null}
|
|
148
|
+
sunken
|
|
149
|
+
outline={hasBorder() ? { color: styled().borderColor ?? theme.color.border, width: styled().borderWidth ?? theme.borderWidth.sm } : null}
|
|
150
|
+
fillTransition={split().background}
|
|
151
|
+
onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
152
|
+
outlineTransition={split().border}
|
|
153
|
+
onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
154
|
+
/>
|
|
155
|
+
{/* The indicator's shadows ride two hidden rects in its own color under
|
|
156
|
+
its fill (a rect carries one shadow; opaque casters, see Surface),
|
|
157
|
+
all three on the same x/w so they travel as one. */}
|
|
158
|
+
<Show when={indicatorPaint().contactShadow}>
|
|
159
|
+
{(shadow) => (
|
|
160
|
+
<d-rect
|
|
161
|
+
transition={indicatorTransition()}
|
|
162
|
+
color={indicator() ? activeFill() : withAlpha(activeFill(), 0)}
|
|
163
|
+
x={indicator()?.x ?? 0}
|
|
164
|
+
w={indicator()?.w ?? 0}
|
|
165
|
+
radius={corners(activeIndex())}
|
|
166
|
+
shadow={shadow()}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
</Show>
|
|
170
|
+
<Show when={indicatorPaint().keyShadow}>
|
|
171
|
+
{(shadow) => (
|
|
172
|
+
<d-rect
|
|
173
|
+
transition={indicatorTransition()}
|
|
174
|
+
color={indicator() ? activeFill() : withAlpha(activeFill(), 0)}
|
|
175
|
+
x={indicator()?.x ?? 0}
|
|
176
|
+
w={indicator()?.w ?? 0}
|
|
177
|
+
radius={corners(activeIndex())}
|
|
178
|
+
shadow={shadow()}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</Show>
|
|
120
182
|
<d-rect
|
|
121
183
|
transition={indicatorTransition()}
|
|
122
184
|
onTransitionEnd={partTransitionEnd("indicator", "x", props.onTransitionEnd)}
|
|
123
|
-
color={indicator() ?
|
|
185
|
+
color={indicator() ? indicatorPaint().fill : withAlpha(activeFill(), 0)}
|
|
124
186
|
x={indicator()?.x ?? 0}
|
|
125
187
|
w={indicator()?.w ?? 0}
|
|
126
188
|
radius={corners(activeIndex())}
|
|
189
|
+
shadow={indicatorPaint().glow}
|
|
127
190
|
/>
|
|
191
|
+
<Show when={indicatorPaint().bevel}>
|
|
192
|
+
{(bevel) => (
|
|
193
|
+
<d-rect
|
|
194
|
+
transition={indicatorTransition()}
|
|
195
|
+
drawStyle="stroke"
|
|
196
|
+
strokeWidth={1}
|
|
197
|
+
color={indicator() ? bevel() : withAlpha(activeFill(), 0)}
|
|
198
|
+
x={indicator()?.x ?? 0}
|
|
199
|
+
w={indicator()?.w ?? 0}
|
|
200
|
+
radius={corners(activeIndex())}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</Show>
|
|
128
204
|
<For each={props.options}>
|
|
129
205
|
{(opt, i) => {
|
|
130
206
|
let active = () => value() === opt.value
|
|
131
|
-
let press = createPress({
|
|
207
|
+
let press = createPress({
|
|
208
|
+
onPress: () => select(opt.value),
|
|
209
|
+
onPointerDown: () => setPressedValue(() => opt.value),
|
|
210
|
+
onPointerUp: () => setPressedValue(undefined),
|
|
211
|
+
})
|
|
212
|
+
onCleanup(() => segs.delete(opt.value))
|
|
132
213
|
return (
|
|
133
214
|
<view
|
|
134
215
|
ref={(n: { id: number }) => {
|
|
135
|
-
segs
|
|
216
|
+
segs.set(opt.value, n)
|
|
136
217
|
press.ref(n)
|
|
137
218
|
}}
|
|
138
219
|
repaintBoundary
|
package/src/select.tsx
CHANGED
|
@@ -6,16 +6,17 @@ 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
|
|
9
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
10
10
|
import { Icon } from "./icon"
|
|
11
11
|
import { colorFade, popupFade, popupFadeOut, PressFeedback, travelMotion } from "./motion"
|
|
12
|
+
import { Surface } from "./surface"
|
|
12
13
|
|
|
13
|
-
export interface SelectProps extends TransitionProps {
|
|
14
|
-
options: Option[]
|
|
14
|
+
export interface SelectProps<T = unknown> extends TransitionProps {
|
|
15
|
+
options: Option<T>[]
|
|
15
16
|
// Controlled selected value. If omitted, the select is uncontrolled.
|
|
16
|
-
value?:
|
|
17
|
-
defaultValue?:
|
|
18
|
-
onChange?: (value:
|
|
17
|
+
value?: T
|
|
18
|
+
defaultValue?: T
|
|
19
|
+
onChange?: (value: T) => void
|
|
19
20
|
// Shown in the trigger while nothing is selected.
|
|
20
21
|
placeholder?: string
|
|
21
22
|
disabled?: boolean
|
|
@@ -35,14 +36,16 @@ let margin = () => theme.spacing.sm
|
|
|
35
36
|
* value/onChange contract either way; pressing outside closes without a change.
|
|
36
37
|
* The option list is not scrollable yet, so keep it short.
|
|
37
38
|
*/
|
|
38
|
-
export function Select(props: SelectProps) {
|
|
39
|
+
export function Select<T>(props: SelectProps<T>) {
|
|
39
40
|
let trigger: { id: number } | undefined
|
|
40
41
|
let [open, setOpen] = createSignal(false)
|
|
41
|
-
|
|
42
|
+
// The value overload: as far as TypeScript knows T could be a function,
|
|
43
|
+
// which createSignal would read as a compute; the cast picks the value form.
|
|
44
|
+
let [internal, setInternal] = createSignal<T | undefined>(props.defaultValue as Exclude<T | undefined, Function>)
|
|
42
45
|
let value = () => (props.value !== undefined ? props.value : internal())
|
|
43
46
|
let selected = () => props.options.find((o) => o.value === value())
|
|
44
47
|
|
|
45
|
-
let choose = (v:
|
|
48
|
+
let choose = (v: T) => {
|
|
46
49
|
setOpen(false)
|
|
47
50
|
if (props.value === undefined) setInternal(() => v)
|
|
48
51
|
props.onChange?.(v)
|
|
@@ -52,7 +55,7 @@ export function Select(props: SelectProps) {
|
|
|
52
55
|
|
|
53
56
|
// One option row, shared by both presentations; only the vertical padding
|
|
54
57
|
// differs (the sheet gets taller touch targets).
|
|
55
|
-
let OptionRow = (p: { option: Option
|
|
58
|
+
let OptionRow = (p: { option: Option<T>; padY: number }) => {
|
|
56
59
|
let press = createPress({ onPress: () => choose(p.option.value) })
|
|
57
60
|
return (
|
|
58
61
|
<view
|
|
@@ -80,6 +83,7 @@ export function Select(props: SelectProps) {
|
|
|
80
83
|
// Anchored under the trigger, sized at least as wide as it. Positioned like
|
|
81
84
|
// Tooltip: portal at the window root, pinned at 0,0 and moved with the x/y
|
|
82
85
|
// paint transforms after measuring, so tracking the anchor never reflows.
|
|
86
|
+
// Glows with theme.glow.overlay (the sheet sits on a scrim and does not).
|
|
83
87
|
let Dropdown = () => {
|
|
84
88
|
let menu: { id: number } | undefined
|
|
85
89
|
let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
|
|
@@ -121,17 +125,18 @@ export function Select(props: SelectProps) {
|
|
|
121
125
|
paddingTop={theme.spacing.sm}
|
|
122
126
|
paddingBottom={theme.spacing.sm}
|
|
123
127
|
>
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
{(
|
|
127
|
-
|
|
128
|
-
<d-rect
|
|
129
|
-
drawStyle="stroke"
|
|
130
|
-
transition={colorFade()}
|
|
131
|
-
color={theme.color.border}
|
|
132
|
-
strokeWidth={theme.borderWidth.sm}
|
|
128
|
+
<Surface
|
|
129
|
+
role="overlay"
|
|
130
|
+
elevation={styled().elevation ?? "overlay"}
|
|
131
|
+
fill={theme.color.surface}
|
|
133
132
|
radius={theme.radius.sm}
|
|
133
|
+
material={styled().material}
|
|
134
|
+
glow={styled().glow}
|
|
135
|
+
outline={{ color: theme.color.border, width: theme.borderWidth.sm }}
|
|
134
136
|
/>
|
|
137
|
+
<For each={props.options}>
|
|
138
|
+
{(o: Option<T>) => <OptionRow option={o} padY={space("sm")} />}
|
|
139
|
+
</For>
|
|
135
140
|
</view>
|
|
136
141
|
</view>,
|
|
137
142
|
)
|
|
@@ -154,9 +159,9 @@ export function Select(props: SelectProps) {
|
|
|
154
159
|
paddingTop={theme.spacing.md}
|
|
155
160
|
paddingBottom={theme.spacing.md + env.safeArea.bottom}
|
|
156
161
|
>
|
|
157
|
-
<
|
|
162
|
+
<Surface role="overlay" fill={theme.color.surface} radius={theme.radius.sm} material={styled().material} glow={null} />
|
|
158
163
|
<For each={props.options}>
|
|
159
|
-
{(o: Option) => <OptionRow option={o} padY={Math.round(theme.spacing.md * 1.5)} />}
|
|
164
|
+
{(o: Option<T>) => <OptionRow option={o} padY={Math.round(theme.spacing.md * 1.5)} />}
|
|
160
165
|
</For>
|
|
161
166
|
</view>
|
|
162
167
|
</view>,
|
|
@@ -167,6 +172,8 @@ export function Select(props: SelectProps) {
|
|
|
167
172
|
setOpen(!open())
|
|
168
173
|
},
|
|
169
174
|
})
|
|
175
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
176
|
+
let styled = () => ({ ...theme.components.select, ...props.style })
|
|
170
177
|
let style = () => ({
|
|
171
178
|
borderColor: theme.color.border,
|
|
172
179
|
borderWidth: theme.borderWidth.sm,
|
|
@@ -213,11 +220,20 @@ export function Select(props: SelectProps) {
|
|
|
213
220
|
focusable={!props.disabled}
|
|
214
221
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
215
222
|
>
|
|
216
|
-
<
|
|
217
|
-
|
|
223
|
+
<Surface
|
|
224
|
+
role="control"
|
|
225
|
+
elevation={style().elevation ?? "flat"}
|
|
226
|
+
fill={style().backgroundColor ?? "transparent"}
|
|
227
|
+
radius={style().borderRadius}
|
|
228
|
+
material={style().material}
|
|
229
|
+
glow={style().glow}
|
|
218
230
|
pressed={press.pressed() && !props.disabled}
|
|
219
231
|
hovered={press.hovered() && !props.disabled && policy.interaction !== "touch"}
|
|
220
|
-
|
|
232
|
+
outline={(style().borderWidth ?? 0) > 0 ? { color: style().borderColor ?? "transparent", width: style().borderWidth! } : null}
|
|
233
|
+
fillTransition={split().background}
|
|
234
|
+
onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
235
|
+
outlineTransition={split().border}
|
|
236
|
+
onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
221
237
|
/>
|
|
222
238
|
<Show
|
|
223
239
|
when={selected()}
|
|
@@ -250,16 +266,6 @@ export function Select(props: SelectProps) {
|
|
|
250
266
|
<Sheet />
|
|
251
267
|
</Show>
|
|
252
268
|
</Show>
|
|
253
|
-
<Show when={(style().borderWidth ?? 0) > 0}>
|
|
254
|
-
<d-rect
|
|
255
|
-
drawStyle="stroke"
|
|
256
|
-
transition={withTransitionDefaults(split().border, colorFade())}
|
|
257
|
-
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
258
|
-
color={style().borderColor ?? "transparent"}
|
|
259
|
-
strokeWidth={style().borderWidth}
|
|
260
|
-
radius={style().borderRadius}
|
|
261
|
-
/>
|
|
262
|
-
</Show>
|
|
263
269
|
</view>
|
|
264
270
|
)
|
|
265
271
|
}
|
package/src/slider.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { arena, createMemo, createSignal, focusedNode, getBoundingBox, onLayout, onSettled
|
|
1
|
+
import { arena, createMemo, createSignal, focusedNode, getBoundingBox, onLayout, onSettled } from "@solidrt/core"
|
|
2
2
|
import type { KeyEvent, LayoutProps, PointerEvent } from "@solidrt/core"
|
|
3
|
-
import { theme } from "./theme"
|
|
3
|
+
import { pill, theme } from "./theme"
|
|
4
4
|
import { policy } from "./policy"
|
|
5
5
|
import { densityScale } from "./density"
|
|
6
|
+
import { Surface, facePaint } from "./surface"
|
|
6
7
|
import type { StyleProps, TransitionProps } from "./types"
|
|
7
8
|
import { splitTransition, transitionEndFor } from "./types"
|
|
8
9
|
import { colorFade } from "./motion"
|
|
@@ -32,7 +33,13 @@ let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi :
|
|
|
32
33
|
// path, so a drag keeps updating when the pointer drifts off the track.
|
|
33
34
|
// Focused (spatial nav), arrow keys step the value (by `step`, else 1% of the
|
|
34
35
|
// range) and the thumb draws the focus ring under the focusRing policy.
|
|
35
|
-
// Controlled via value/onChange, or uncontrolled via defaultValue.
|
|
36
|
+
// Controlled via value/onChange, or uncontrolled via defaultValue. The
|
|
37
|
+
// groove is a sunken control face (surfaceAlt), the fill an accent face
|
|
38
|
+
// (primary, with the accent material's glow) drawn as a detached rect so a
|
|
39
|
+
// drag never reflows, the thumb a raised control face (theme.color.thumb,
|
|
40
|
+
// else primary; style.elevation sets its level) that sinks while dragged;
|
|
41
|
+
// groove and thumb take pill(), round by default and square under
|
|
42
|
+
// radius.full 0.
|
|
36
43
|
export function Slider(props: SliderProps) {
|
|
37
44
|
let min = () => props.min ?? 0
|
|
38
45
|
let max = () => props.max ?? 100
|
|
@@ -41,10 +48,22 @@ export function Slider(props: SliderProps) {
|
|
|
41
48
|
|
|
42
49
|
let track: { id: number } | undefined
|
|
43
50
|
let active: number | null = null
|
|
51
|
+
let [dragging, setDragging] = createSignal(false)
|
|
44
52
|
|
|
45
53
|
let height = () => Math.round(HEIGHT * densityScale())
|
|
46
54
|
let thumb = () => Math.round(THUMB * densityScale())
|
|
47
55
|
|
|
56
|
+
// Theme-level per-component overrides merged under the instance style:
|
|
57
|
+
// backgroundColor is the groove, color the fill, borderRadius the groove's
|
|
58
|
+
// (ProgressBar's reading of the same keys), and the border boxes the groove.
|
|
59
|
+
let styled = () => ({ ...theme.components.slider, ...props.style })
|
|
60
|
+
let grooveColor = () => styled().backgroundColor ?? theme.color.surfaceAlt
|
|
61
|
+
let fillColor = () => styled().color ?? theme.color.primary
|
|
62
|
+
let grooveRadius = () => styled().borderRadius ?? pill(GROOVE)
|
|
63
|
+
let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
|
|
64
|
+
let fillPaint = () => facePaint({ role: "accent", fill: fillColor(), material: styled().material, glow: props.disabled ? null : styled().glow })
|
|
65
|
+
let split = () => splitTransition(props.transition)
|
|
66
|
+
|
|
48
67
|
let pct = () => clamp(((value() - min()) / (max() - min())) * 100, 0, 100)
|
|
49
68
|
|
|
50
69
|
// Measured groove width in pixels. The fill and thumb are driven off this
|
|
@@ -63,11 +82,16 @@ export function Slider(props: SliderProps) {
|
|
|
63
82
|
props.onChange?.(v)
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
|
|
85
|
+
// The handlers sit on the track, and localX is exact in the track's own
|
|
86
|
+
// frame even after a drag drifts off it (frozen down path), so no origin
|
|
87
|
+
// lookup is needed. clientX against getBoundingBox would mix frames: the
|
|
88
|
+
// box's x/y are relative to the nearest positioned ancestor, not the
|
|
89
|
+
// window clientX lives in.
|
|
90
|
+
let setFromLocalX = (localX: number) => {
|
|
67
91
|
if (!track) return
|
|
68
|
-
let
|
|
69
|
-
if (
|
|
70
|
-
let f = clamp(
|
|
92
|
+
let width = getBoundingBox(track)?.width ?? 0
|
|
93
|
+
if (width === 0) return
|
|
94
|
+
let f = clamp(localX / width, 0, 1)
|
|
71
95
|
let raw = min() + f * (max() - min())
|
|
72
96
|
if (props.step) raw = Math.round(raw / props.step) * props.step
|
|
73
97
|
commit(clamp(raw, min(), max()))
|
|
@@ -78,6 +102,7 @@ export function Slider(props: SliderProps) {
|
|
|
78
102
|
arena.release(active, owner)
|
|
79
103
|
active = null
|
|
80
104
|
}
|
|
105
|
+
setDragging(false)
|
|
81
106
|
}
|
|
82
107
|
let owner = { cancel: endDrag }
|
|
83
108
|
|
|
@@ -90,11 +115,12 @@ export function Slider(props: SliderProps) {
|
|
|
90
115
|
// outright so an ancestor scroller's pan cannot take the pointer over.
|
|
91
116
|
arena.steal(e.pointerId, owner)
|
|
92
117
|
active = e.pointerId
|
|
93
|
-
|
|
118
|
+
setDragging(true)
|
|
119
|
+
setFromLocalX(e.localX)
|
|
94
120
|
}
|
|
95
121
|
let handleMove = (e: PointerEvent) => {
|
|
96
122
|
if (active !== e.pointerId) return
|
|
97
|
-
|
|
123
|
+
setFromLocalX(e.localX)
|
|
98
124
|
}
|
|
99
125
|
let handleUp = (e: PointerEvent) => {
|
|
100
126
|
if (active === e.pointerId) endDrag()
|
|
@@ -117,7 +143,7 @@ export function Slider(props: SliderProps) {
|
|
|
117
143
|
|
|
118
144
|
return (
|
|
119
145
|
<view
|
|
120
|
-
transition={
|
|
146
|
+
transition={split().root}
|
|
121
147
|
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
122
148
|
ref={(n: { id: number }) => (track = n)}
|
|
123
149
|
repaintBoundary
|
|
@@ -126,11 +152,11 @@ export function Slider(props: SliderProps) {
|
|
|
126
152
|
height={height()}
|
|
127
153
|
width={theme.size.slider}
|
|
128
154
|
{...props.layout}
|
|
129
|
-
x={
|
|
130
|
-
y={
|
|
131
|
-
scale={
|
|
132
|
-
rotate={
|
|
133
|
-
opacity={
|
|
155
|
+
x={styled().x}
|
|
156
|
+
y={styled().y}
|
|
157
|
+
scale={styled().scale}
|
|
158
|
+
rotate={styled().rotate}
|
|
159
|
+
opacity={styled().opacity}
|
|
134
160
|
pointerEvents={props.disabled ? "none" : "auto"}
|
|
135
161
|
focusable={!props.disabled}
|
|
136
162
|
onPointerDown={handleDown}
|
|
@@ -142,13 +168,39 @@ export function Slider(props: SliderProps) {
|
|
|
142
168
|
get a transition - they track the drag 1:1, and a spring would
|
|
143
169
|
rubber-band it. */}
|
|
144
170
|
<view ref={(n: { id: number }) => (groove = n)} position="relative" flex={1} height={GROOVE}>
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
171
|
+
<Surface
|
|
172
|
+
role="control"
|
|
173
|
+
fill={grooveColor()}
|
|
174
|
+
radius={grooveRadius()}
|
|
175
|
+
material={styled().material}
|
|
176
|
+
glow={null}
|
|
177
|
+
sunken
|
|
178
|
+
outline={hasBorder() ? { color: styled().borderColor ?? theme.color.border, width: styled().borderWidth ?? theme.borderWidth.sm } : null}
|
|
179
|
+
fillTransition={split().background}
|
|
180
|
+
onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
181
|
+
outlineTransition={split().border}
|
|
182
|
+
onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
183
|
+
/>
|
|
184
|
+
<d-rect
|
|
185
|
+
transition={colorFade()}
|
|
186
|
+
color={fillPaint().fill}
|
|
187
|
+
w={fillPx()}
|
|
188
|
+
h={GROOVE}
|
|
189
|
+
radius={grooveRadius()}
|
|
190
|
+
shadow={fillPaint().glow}
|
|
191
|
+
/>
|
|
192
|
+
<view position="absolute" left={0} top={(GROOVE - thumb()) / 2} width={thumb()} height={thumb()} x={fillPx() - thumb() / 2}>
|
|
193
|
+
<Surface
|
|
194
|
+
role="control"
|
|
195
|
+
elevation={styled().elevation ?? "raised"}
|
|
196
|
+
fill={theme.color.thumb ?? theme.color.primary}
|
|
197
|
+
radius={pill(thumb())}
|
|
198
|
+
material={styled().material}
|
|
199
|
+
glow={null}
|
|
200
|
+
pressed={dragging()}
|
|
201
|
+
tint={false}
|
|
202
|
+
outline={focused() && policy.focusRing ? { color: theme.color.ring, width: theme.borderWidth.focus } : null}
|
|
203
|
+
/>
|
|
152
204
|
</view>
|
|
153
205
|
</view>
|
|
154
206
|
</view>
|