@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/AGENTS.md +33 -2
- package/README.md +244 -1
- package/package.json +5 -2
- package/src/badge.tsx +50 -0
- package/src/button.tsx +19 -10
- package/src/card.tsx +48 -0
- package/src/checkbox.tsx +65 -0
- package/src/context-menu.tsx +189 -0
- package/src/divider.tsx +33 -0
- package/src/index.ts +30 -1
- package/src/nav-shell.tsx +160 -0
- package/src/policy.ts +108 -0
- package/src/progress-bar.tsx +81 -0
- package/src/qrcode.tsx +72 -0
- package/src/radio.tsx +100 -0
- package/src/scroll-view.tsx +5 -1
- package/src/select.tsx +203 -0
- package/src/slider.tsx +116 -0
- package/src/spinner.tsx +74 -0
- package/src/split-view.tsx +54 -0
- package/src/switch.tsx +58 -0
- package/src/text-input.tsx +24 -16
- package/src/theme.ts +72 -17
- package/src/tooltip.tsx +112 -0
- package/src/types.ts +4 -4
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
|
+
}
|
package/src/scroll-view.tsx
CHANGED
|
@@ -45,7 +45,11 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
45
45
|
last = null
|
|
46
46
|
}
|
|
47
47
|
let onWheel = (e: WheelEvent) => {
|
|
48
|
-
|
|
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
|
+
}
|
package/src/spinner.tsx
ADDED
|
@@ -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
|
+
}
|