@solidrt/components 0.0.18 → 0.0.19

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/radio.tsx ADDED
@@ -0,0 +1,100 @@
1
+ import { createSignal, createContext, useContext, Show } from "@solidrt/core"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { Pressable } from "./pressable"
4
+ import { theme } from "./theme"
5
+ import { densityScale } from "./policy"
6
+ import type { StyleProps } from "./types"
7
+
8
+ // Shared selection state for a group. Created and consumed within this module, so
9
+ // RadioGroup/Radio are a self-contained pair, not a cross-component dependency.
10
+ type RadioContextValue = {
11
+ value: () => unknown
12
+ select: (value: unknown) => void
13
+ disabled: () => boolean
14
+ }
15
+
16
+ let RadioContext = createContext<RadioContextValue>()
17
+
18
+ export interface RadioGroupProps {
19
+ // Controlled selected value. If omitted, the group is uncontrolled.
20
+ value?: unknown
21
+ defaultValue?: unknown
22
+ onChange?: (value: unknown) => void
23
+ disabled?: boolean
24
+ layout?: LayoutProps
25
+ children?: any
26
+ }
27
+
28
+ // Owns the selection for its Radio children and shares it through context.
29
+ export function RadioGroup(props: RadioGroupProps) {
30
+ let [internal, setInternal] = createSignal(props.defaultValue)
31
+ let value = () => (props.value !== undefined ? props.value : internal())
32
+
33
+ let select = (v: unknown) => {
34
+ if (props.value === undefined) setInternal(() => v)
35
+ props.onChange?.(v)
36
+ }
37
+
38
+ let ctx: RadioContextValue = {
39
+ value,
40
+ select,
41
+ disabled: () => !!props.disabled,
42
+ }
43
+
44
+ return (
45
+ <RadioContext value={ctx}>
46
+ <view flexDirection="column" gap={theme.spacing.md} {...props.layout}>
47
+ {props.children}
48
+ </view>
49
+ </RadioContext>
50
+ )
51
+ }
52
+
53
+ export interface RadioProps {
54
+ // This option's value; selecting it makes it the group's value.
55
+ value: unknown
56
+ disabled?: boolean
57
+ layout?: LayoutProps
58
+ style?: StyleProps
59
+ // A string/number renders as a themed label beside the ring; anything else
60
+ // renders as-is.
61
+ children?: any
62
+ }
63
+
64
+ const RING = 20
65
+
66
+ // A single option in a RadioGroup: a ring with an inner dot when selected.
67
+ export function Radio(props: RadioProps) {
68
+ // useContext throws ContextNotFoundError if a Radio is used outside a
69
+ // RadioGroup (default-less context), so ctx is always present here.
70
+ let ctx = useContext(RadioContext)
71
+ let selected = () => ctx.value() === props.value
72
+ let disabled = () => props.disabled || ctx.disabled()
73
+ let ringColor = () => (selected() ? theme.color.primary : theme.color.border)
74
+ let isText = () => typeof props.children === "string" || typeof props.children === "number"
75
+
76
+ let ring = () => Math.round(RING * densityScale())
77
+ // Inner dot inset as a fraction of the ring, so it scales with the density.
78
+ let inset = () => ring() * 0.3
79
+
80
+ return (
81
+ <Pressable
82
+ onPress={() => ctx.select(props.value)}
83
+ disabled={disabled()}
84
+ layout={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.md, ...props.layout }}
85
+ style={props.style}
86
+ >
87
+ <view width={ring()} height={ring()}>
88
+ <d-oval x={1} y={1} w={ring() - 2} h={ring() - 2} drawStyle="stroke" color={ringColor()} strokeWidth={2} />
89
+ <Show when={selected()}>
90
+ <d-oval x={inset()} y={inset()} w={ring() - inset() * 2} h={ring() - inset() * 2} color={theme.color.primary} />
91
+ </Show>
92
+ </view>
93
+ <Show when={isText()} fallback={props.children}>
94
+ <text color={theme.color.text} fontSize={theme.text.body.size} lineHeight={theme.text.body.lineHeight}>
95
+ {props.children}
96
+ </text>
97
+ </Show>
98
+ </Pressable>
99
+ )
100
+ }
@@ -45,7 +45,11 @@ export function ScrollView(props: ScrollViewProps) {
45
45
  last = null
46
46
  }
47
47
  let onWheel = (e: WheelEvent) => {
48
- scroll.scrollBy(e.deltaX, e.deltaY)
48
+ // A plain mouse wheel only emits deltaY. On a horizontal scroller, route that
49
+ // vertical delta to the x axis so the wheel still scrolls it (trackpads that
50
+ // emit deltaX take precedence).
51
+ if (props.horizontal) scroll.scrollBy(e.deltaX || e.deltaY, 0)
52
+ else scroll.scrollBy(e.deltaX, e.deltaY)
49
53
  }
50
54
 
51
55
  let direction = () => (props.horizontal ? "row" : "column")
package/src/select.tsx ADDED
@@ -0,0 +1,203 @@
1
+ import { createSignal, createPortal, onLayout, getBoundingBox, Show, For, env } from "@solidrt/core"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { Pressable, type PressState } from "./pressable"
4
+ import { theme } from "./theme"
5
+ import { policy, densityScale } from "./policy"
6
+ import type { StyleProps } from "./types"
7
+
8
+ export interface SelectOption {
9
+ value: unknown
10
+ label: string
11
+ }
12
+
13
+ export interface SelectProps {
14
+ options: SelectOption[]
15
+ // Controlled selected value. If omitted, the select is uncontrolled.
16
+ value?: unknown
17
+ defaultValue?: unknown
18
+ onChange?: (value: unknown) => void
19
+ // Shown in the trigger while nothing is selected.
20
+ placeholder?: string
21
+ disabled?: boolean
22
+ layout?: LayoutProps
23
+ style?: StyleProps
24
+ }
25
+
26
+ const GAP = 4
27
+ // Minimum distance kept between the dropdown and the window edges.
28
+ const MARGIN = 4
29
+
30
+ /**
31
+ * A single-choice picker whose presentation forks on the interaction policy:
32
+ * desktop/hybrid opens an anchored dropdown under the trigger (flipping above
33
+ * when there is no room), touch opens a bottom sheet over a scrim. Same
34
+ * value/onChange contract either way; pressing outside closes without a change.
35
+ * The option list is not scrollable yet, so keep it short.
36
+ */
37
+ export function Select(props: SelectProps) {
38
+ let trigger: { id: number } | undefined
39
+ let [open, setOpen] = createSignal(false)
40
+ let [internal, setInternal] = createSignal(props.defaultValue)
41
+ let value = () => (props.value !== undefined ? props.value : internal())
42
+ let selected = () => props.options.find((o) => o.value === value())
43
+
44
+ let choose = (v: unknown) => {
45
+ setOpen(false)
46
+ if (props.value === undefined) setInternal(() => v)
47
+ props.onChange?.(v)
48
+ }
49
+
50
+ let bodyText = (color: string) => ({
51
+ fontSize: theme.text.body.size,
52
+ lineHeight: theme.text.body.lineHeight,
53
+ color,
54
+ maxLines: 1,
55
+ })
56
+
57
+ // One option row, shared by both presentations; only the vertical padding
58
+ // differs (the sheet gets taller touch targets).
59
+ let OptionRow = (p: { option: SelectOption; padY: number }) => (
60
+ <Pressable
61
+ onPress={() => choose(p.option.value)}
62
+ layout={{
63
+ flexDirection: "row",
64
+ alignItems: "center",
65
+ paddingTop: p.padY,
66
+ paddingBottom: p.padY,
67
+ paddingLeft: Math.round(theme.spacing.md * densityScale()),
68
+ paddingRight: Math.round(theme.spacing.md * densityScale()),
69
+ }}
70
+ style={(s: PressState) => ({
71
+ backgroundColor:
72
+ s.pressed || (s.hovered && policy.interaction !== "touch") ? theme.color.surfaceHover : "transparent",
73
+ })}
74
+ >
75
+ <text {...bodyText(p.option.value === value() ? theme.color.primary : theme.color.text)}>{p.option.label}</text>
76
+ </Pressable>
77
+ )
78
+
79
+ // Anchored under the trigger, sized at least as wide as it. Positioned like
80
+ // Tooltip: portal at the window root, pinned at 0,0 and moved with the x/y
81
+ // paint transforms after measuring, so tracking the anchor never reflows.
82
+ let Dropdown = () => {
83
+ let menu: { id: number } | undefined
84
+ let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
85
+ let [minWidth, setMinWidth] = createSignal(0)
86
+ onLayout(() => {
87
+ let a = trigger && getBoundingBox(trigger)
88
+ let b = menu && getBoundingBox(menu)
89
+ if (!a || !b) return
90
+ if (a.width !== minWidth()) setMinWidth(a.width)
91
+ let x = Math.round(Math.min(Math.max(a.x, MARGIN), env.windowSize.width - b.width - MARGIN))
92
+ let below = a.y + a.height + GAP
93
+ let y = Math.round(below + b.height > env.windowSize.height - MARGIN ? a.y - b.height - GAP : below)
94
+ let cur = pos()
95
+ if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
96
+ })
97
+ return createPortal(
98
+ <view position="absolute" top={0} left={0} right={0} bottom={0}>
99
+ <view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)} />
100
+ <view
101
+ ref={(n: { id: number }) => (menu = n)}
102
+ position="absolute"
103
+ top={0}
104
+ left={0}
105
+ x={pos()?.x ?? -10000}
106
+ y={pos()?.y ?? 0}
107
+ minWidth={minWidth() || undefined}
108
+ flexDirection="column"
109
+ paddingTop={theme.spacing.sm}
110
+ paddingBottom={theme.spacing.sm}
111
+ >
112
+ <d-rect color={theme.color.surface} radius={theme.radius.sm} />
113
+ <For each={props.options}>
114
+ {(o: SelectOption) => <OptionRow option={o} padY={Math.round(theme.spacing.sm * densityScale())} />}
115
+ </For>
116
+ <d-rect
117
+ drawStyle="stroke"
118
+ color={theme.color.border}
119
+ strokeWidth={theme.borderWidth.sm}
120
+ radius={theme.radius.sm}
121
+ />
122
+ </view>
123
+ </view>,
124
+ )
125
+ }
126
+
127
+ // Bottom sheet over a scrim; the content is a sibling of the scrim (Modal's
128
+ // trick) so pressing an option never has the scrim on its hit path.
129
+ let Sheet = () =>
130
+ createPortal(
131
+ <view position="absolute" top={0} left={0} right={0} bottom={0}>
132
+ <view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)}>
133
+ <d-rect color={theme.color.scrim} />
134
+ </view>
135
+ <view
136
+ position="absolute"
137
+ left={0}
138
+ right={0}
139
+ bottom={0}
140
+ flexDirection="column"
141
+ paddingTop={theme.spacing.md}
142
+ paddingBottom={theme.spacing.md + env.safeArea.bottom}
143
+ >
144
+ <d-rect color={theme.color.surface} radius={theme.radius.sm} />
145
+ <For each={props.options}>
146
+ {(o: SelectOption) => <OptionRow option={o} padY={Math.round(theme.spacing.md * 1.5)} />}
147
+ </For>
148
+ </view>
149
+ </view>,
150
+ )
151
+
152
+ return (
153
+ <Pressable
154
+ ref={(n) => (trigger = n)}
155
+ onPress={() => setOpen(!open())}
156
+ disabled={props.disabled}
157
+ layout={{
158
+ flexDirection: "row",
159
+ alignItems: "center",
160
+ justifyContent: "space-between",
161
+ gap: theme.spacing.md,
162
+ paddingTop: Math.round(theme.spacing.sm * densityScale()),
163
+ paddingBottom: Math.round(theme.spacing.sm * densityScale()),
164
+ paddingLeft: Math.round(theme.spacing.md * densityScale()),
165
+ paddingRight: Math.round(theme.spacing.md * densityScale()),
166
+ ...props.layout,
167
+ }}
168
+ style={(s: PressState) => ({
169
+ borderColor: theme.color.border,
170
+ borderWidth: theme.borderWidth.sm,
171
+ borderRadius: theme.radius.sm,
172
+ ...props.style,
173
+ backgroundColor:
174
+ props.style?.backgroundColor ??
175
+ (s.hovered && !props.disabled && policy.interaction !== "touch"
176
+ ? theme.color.surfaceHover
177
+ : theme.color.surface),
178
+ })}
179
+ >
180
+ <Show
181
+ when={selected()}
182
+ fallback={<text {...bodyText(theme.color.textMuted)}>{props.placeholder ?? ""}</text>}
183
+ >
184
+ <text {...bodyText(props.disabled ? theme.color.textMuted : theme.color.text)}>{selected()!.label}</text>
185
+ </Show>
186
+ <view width={12} height={8}>
187
+ <d-path
188
+ d="M 2 2 L 6 6 L 10 2"
189
+ drawStyle="stroke"
190
+ color={theme.color.textMuted}
191
+ strokeWidth={2}
192
+ strokeCap="round"
193
+ strokeJoin="round"
194
+ />
195
+ </view>
196
+ <Show when={open()}>
197
+ <Show when={policy.interaction === "touch"} fallback={<Dropdown />}>
198
+ <Sheet />
199
+ </Show>
200
+ </Show>
201
+ </Pressable>
202
+ )
203
+ }
package/src/slider.tsx ADDED
@@ -0,0 +1,116 @@
1
+ import { createSignal } from "@solidjs/signals"
2
+ import { getBoundingBox, onLayout, setPointerCapture } from "@solidrt/core"
3
+ import type { LayoutProps, PointerEvent } from "@solidrt/core"
4
+ import { theme } from "./theme"
5
+ import { densityScale } from "./policy"
6
+ import type { StyleProps } from "./types"
7
+
8
+ export interface SliderProps {
9
+ // Controlled value. If omitted, the slider is uncontrolled.
10
+ value?: number
11
+ defaultValue?: number
12
+ min?: number
13
+ max?: number
14
+ // Snap increment. Omit for continuous.
15
+ step?: number
16
+ onChange?: (value: number) => void
17
+ disabled?: boolean
18
+ layout?: LayoutProps
19
+ style?: StyleProps
20
+ }
21
+
22
+ const HEIGHT = 24
23
+ const GROOVE = 4
24
+ const THUMB = 20
25
+
26
+ let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi : x)
27
+
28
+ // A horizontal slider. The groove fills up to the thumb; dragging or pressing
29
+ // the track sets the value from the pointer x. No pointer capture, so a drag
30
+ // that leaves the track stops updating. Controlled via value/onChange, or
31
+ // uncontrolled via defaultValue.
32
+ export function Slider(props: SliderProps) {
33
+ let min = () => props.min ?? 0
34
+ let max = () => props.max ?? 100
35
+ let [internal, setInternal] = createSignal(props.defaultValue ?? props.min ?? 0)
36
+ let value = () => (props.value !== undefined ? props.value : internal())
37
+
38
+ let track: { id: number } | undefined
39
+ let dragging = false
40
+
41
+ let height = () => Math.round(HEIGHT * densityScale())
42
+ let thumb = () => Math.round(THUMB * densityScale())
43
+
44
+ let pct = () => clamp(((value() - min()) / (max() - min())) * 100, 0, 100)
45
+
46
+ // Measured groove width in pixels. The fill and thumb are driven off this
47
+ // rather than a percentage `width`/`left`, so dragging repaints (d-rect `w`,
48
+ // thumb `x` transform) instead of reflowing taffy every move. Refreshed each
49
+ // layout so it tracks resizes.
50
+ let groove: { id: number } | undefined
51
+ let [grooveWidth, setGrooveWidth] = createSignal(0)
52
+ onLayout(() => {
53
+ if (groove) setGrooveWidth(getBoundingBox(groove)?.width ?? 0)
54
+ })
55
+ let fillPx = () => (pct() / 100) * grooveWidth()
56
+
57
+ let commit = (v: number) => {
58
+ if (props.value === undefined) setInternal(v)
59
+ props.onChange?.(v)
60
+ }
61
+
62
+ let setFromClientX = (clientX: number) => {
63
+ if (!track) return
64
+ let box = getBoundingBox(track)
65
+ if (!box || box.width === 0) return
66
+ let f = clamp((clientX - box.x) / box.width, 0, 1)
67
+ let raw = min() + f * (max() - min())
68
+ if (props.step) raw = Math.round(raw / props.step) * props.step
69
+ commit(clamp(raw, min(), max()))
70
+ }
71
+
72
+ let handleDown = (e: PointerEvent) => {
73
+ if (props.disabled) return
74
+ // Claim the drag: stopPropagation keeps an ancestor scroller from starting a
75
+ // scroll, and pointer capture routes moves/up here even when the pointer
76
+ // drifts off the track.
77
+ e.stopPropagation()
78
+ if (track) setPointerCapture(track.id, e.pointerId)
79
+ dragging = true
80
+ setFromClientX(e.clientX)
81
+ }
82
+ let handleMove = (e: PointerEvent) => {
83
+ if (!dragging) return
84
+ setFromClientX(e.clientX)
85
+ }
86
+ let handleUp = () => {
87
+ dragging = false
88
+ }
89
+
90
+ return (
91
+ <view
92
+ ref={(n: { id: number }) => (track = n)}
93
+ flexDirection="row"
94
+ alignItems="center"
95
+ height={height()}
96
+ width={200}
97
+ {...props.layout}
98
+ x={props.style?.x}
99
+ y={props.style?.y}
100
+ scale={props.style?.scale}
101
+ rotate={props.style?.rotate}
102
+ pointerEvents={props.disabled ? "none" : "auto"}
103
+ onPointerDown={handleDown}
104
+ onPointerMove={handleMove}
105
+ onPointerUp={handleUp}
106
+ >
107
+ <view ref={(n: { id: number }) => (groove = n)} position="relative" flex={1} height={GROOVE}>
108
+ <d-rect color={theme.color.surfaceAlt} radius={GROOVE / 2} />
109
+ <d-rect color={theme.color.primary} w={fillPx()} h={GROOVE} radius={GROOVE / 2} />
110
+ <view position="absolute" left={0} top={(GROOVE - thumb()) / 2} x={fillPx() - thumb() / 2}>
111
+ <d-oval w={thumb()} h={thumb()} color={theme.color.primary} />
112
+ </view>
113
+ </view>
114
+ </view>
115
+ )
116
+ }
@@ -0,0 +1,74 @@
1
+ import { createSignal } from "@solidjs/signals"
2
+ import { onFrame, Show } from "@solidrt/core"
3
+ import type { LayoutProps } from "@solidrt/core"
4
+ import { theme } from "./theme"
5
+ import { policy } from "./policy"
6
+ import type { StyleProps } from "./types"
7
+
8
+ export interface SpinnerProps {
9
+ // Overall diameter in pixels.
10
+ size?: number
11
+ // Arc stroke width in pixels.
12
+ thickness?: number
13
+ // Revolutions per second.
14
+ speed?: number
15
+ layout?: LayoutProps
16
+ style?: StyleProps
17
+ }
18
+
19
+ const SIZE = 24
20
+ const THICKNESS = 3
21
+
22
+ // An indeterminate spinner: a 270-degree arc that rotates continuously, driven
23
+ // by core onFrame (so it participates in demand-driven rendering and stops when
24
+ // unmounted). Color comes from the theme; override via style.color.
25
+ export function Spinner(props: SpinnerProps) {
26
+ let size = () => props.size ?? SIZE
27
+ let thickness = () => props.thickness ?? THICKNESS
28
+ let color = () => props.style?.color ?? theme.color.primary
29
+ let speed = () => props.speed ?? 1
30
+
31
+ // tick is in milliseconds (like performance.now()). The frame loop is mounted
32
+ // through the <Show> below so the motion policy can unmount it: onFrame holds
33
+ // a standing frame request while registered, so a check inside the callback
34
+ // would still keep the renderer free-running. Under "none" the arc freezes;
35
+ // "reduced" halves the spin.
36
+ let [angle, setAngle] = createSignal(0)
37
+ let Animate = () => {
38
+ onFrame((tick) =>
39
+ setAngle((tick / 1000) * speed() * (policy.motion === "reduced" ? 0.5 : 1) * Math.PI * 2),
40
+ )
41
+ return null
42
+ }
43
+
44
+ // A 270-degree arc starting at the top, sweeping clockwise. Coordinates are in
45
+ // the parent box space, so the wrapping view is sized to match.
46
+ let path = () => {
47
+ let s = size()
48
+ let r = (s - thickness()) / 2
49
+ let c = s / 2
50
+ return `M ${c} ${c - r} A ${r} ${r} 0 1 1 ${c - r} ${c}`
51
+ }
52
+
53
+ return (
54
+ <view
55
+ width={size()}
56
+ height={size()}
57
+ {...props.layout}
58
+ rotate={angle()}
59
+ x={props.style?.x}
60
+ y={props.style?.y}
61
+ >
62
+ <Show when={policy.motion !== "none"}>
63
+ <Animate />
64
+ </Show>
65
+ <d-path
66
+ d={path()}
67
+ drawStyle="stroke"
68
+ color={color()}
69
+ strokeWidth={thickness()}
70
+ strokeCap="round"
71
+ />
72
+ </view>
73
+ )
74
+ }
@@ -0,0 +1,54 @@
1
+ import { Show } from "@solidrt/core"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import { policy } from "./policy"
5
+
6
+ export interface SplitViewProps {
7
+ // The list (or primary) pane.
8
+ list?: any
9
+ // The detail (or secondary) pane.
10
+ detail?: any
11
+ // Single-pane mode only: show the detail instead of the list. The app owns
12
+ // this navigation state; two-pane mode ignores it.
13
+ showDetail?: boolean
14
+ // Width of the list pane in two-pane mode.
15
+ listWidth?: number
16
+ layout?: LayoutProps
17
+ }
18
+
19
+ const LIST_WIDTH = 320
20
+
21
+ /**
22
+ * A list-detail container driven by the layout policy: two-pane shows the list
23
+ * beside the detail with a hairline between, single-pane shows one pane at a
24
+ * time per `showDetail`. Keep pane state (selection, scroll) in the app, not
25
+ * in the panes: crossing a breakpoint re-arranges and can remount them.
26
+ * SplitView draws no chrome; a back affordance in the single-pane detail is
27
+ * the app's to render (fork on policy.layout, as the shell example does).
28
+ */
29
+ export function SplitView(props: SplitViewProps) {
30
+ return (
31
+ <Show
32
+ when={policy.layout === "twoPane"}
33
+ fallback={
34
+ <view flexDirection="column" {...props.layout}>
35
+ <Show when={props.showDetail} fallback={props.list}>
36
+ {props.detail}
37
+ </Show>
38
+ </view>
39
+ }
40
+ >
41
+ <view flexDirection="row" {...props.layout}>
42
+ <view width={props.listWidth ?? LIST_WIDTH} flexDirection="column">
43
+ {props.list}
44
+ </view>
45
+ <view width={1}>
46
+ <d-rect color={theme.color.border} />
47
+ </view>
48
+ <view flex={1} flexDirection="column">
49
+ {props.detail}
50
+ </view>
51
+ </view>
52
+ </Show>
53
+ )
54
+ }
package/src/switch.tsx ADDED
@@ -0,0 +1,58 @@
1
+ import { createSignal } from "@solidjs/signals"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { Pressable } from "./pressable"
4
+ import { theme } from "./theme"
5
+ import { densityScale } from "./policy"
6
+ import type { StyleProps } from "./types"
7
+
8
+ export interface SwitchProps {
9
+ // Controlled on/off. If omitted, the switch is uncontrolled.
10
+ value?: boolean
11
+ // Initial value for uncontrolled use.
12
+ defaultValue?: boolean
13
+ onChange?: (value: boolean) => void
14
+ disabled?: boolean
15
+ layout?: LayoutProps
16
+ style?: StyleProps
17
+ }
18
+
19
+ // Designed (comfortable-density) metrics; w/h below scale them by the density
20
+ // policy.
21
+ const W = 44
22
+ const H = 24
23
+ const PAD = 2
24
+
25
+ // A toggle. Track fills with primary when on, surfaceAlt when off; the thumb
26
+ // slides across. Controlled via value/onChange, or uncontrolled via
27
+ // defaultValue. Built on Pressable, so disabled takes no pointer events.
28
+ export function Switch(props: SwitchProps) {
29
+ let [internal, setInternal] = createSignal(props.defaultValue ?? false)
30
+ let on = () => (props.value !== undefined ? props.value : internal())
31
+
32
+ let toggle = () => {
33
+ let next = !on()
34
+ if (props.value === undefined) setInternal(next)
35
+ props.onChange?.(next)
36
+ }
37
+
38
+ let w = () => Math.round(W * densityScale())
39
+ let h = () => Math.round(H * densityScale())
40
+ let thumb = () => h() - PAD * 2
41
+
42
+ return (
43
+ <Pressable
44
+ onPress={toggle}
45
+ disabled={props.disabled}
46
+ layout={{ width: w(), height: h(), ...props.layout }}
47
+ style={{
48
+ backgroundColor: on() ? theme.color.primary : theme.color.surfaceAlt,
49
+ borderRadius: h() / 2,
50
+ ...props.style,
51
+ }}
52
+ >
53
+ <view position="absolute" top={PAD} left={PAD} x={on() ? w() - thumb() - PAD * 2 : 0}>
54
+ <d-oval w={thumb()} h={thumb()} color={theme.color.onPrimary} />
55
+ </view>
56
+ </Pressable>
57
+ )
58
+ }