@solidrt/components 0.0.17 → 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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, For, env } from "@solidrt/core"
|
|
2
|
+
import type { LayoutProps, PointerEvent } from "@solidrt/core"
|
|
3
|
+
import { Pressable, type PressState } from "./pressable"
|
|
4
|
+
import { theme } from "./theme"
|
|
5
|
+
import { policy, densityScale } from "./policy"
|
|
6
|
+
|
|
7
|
+
export interface ContextMenuItem {
|
|
8
|
+
label: string
|
|
9
|
+
onSelect?: () => void
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ContextMenuProps {
|
|
14
|
+
items: ContextMenuItem[]
|
|
15
|
+
// The content the menu attaches to.
|
|
16
|
+
children?: any
|
|
17
|
+
layout?: LayoutProps
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LONG_PRESS_MS = 500
|
|
21
|
+
// Finger travel (window px) that cancels a pending long-press.
|
|
22
|
+
const MOVE_SLOP = 8
|
|
23
|
+
// Minimum distance kept between the menu and the window edges.
|
|
24
|
+
const MARGIN = 4
|
|
25
|
+
const MIN_WIDTH = 120
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Secondary actions on the wrapped content. The opening gesture follows the
|
|
29
|
+
* physical pointer: right-click for a mouse, long-press for touch. The
|
|
30
|
+
* presentation forks on the interaction policy: touch gets a bottom sheet over
|
|
31
|
+
* a scrim, desktop/hybrid an anchored menu at the pointer. Pressing outside
|
|
32
|
+
* closes without selecting.
|
|
33
|
+
*/
|
|
34
|
+
export function ContextMenu(props: ContextMenuProps) {
|
|
35
|
+
let [open, setOpen] = createSignal(false)
|
|
36
|
+
let [point, setPoint] = createSignal({ x: 0, y: 0 })
|
|
37
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
38
|
+
let downAt: { x: number; y: number } | undefined
|
|
39
|
+
|
|
40
|
+
let openAt = (x: number, y: number) => {
|
|
41
|
+
setPoint({ x, y })
|
|
42
|
+
setOpen(true)
|
|
43
|
+
}
|
|
44
|
+
let cancelHold = () => {
|
|
45
|
+
clearTimeout(timer)
|
|
46
|
+
downAt = undefined
|
|
47
|
+
}
|
|
48
|
+
onCleanup(cancelHold)
|
|
49
|
+
|
|
50
|
+
let handleDown = (e: PointerEvent) => {
|
|
51
|
+
if (e.button === 2) {
|
|
52
|
+
cancelHold()
|
|
53
|
+
openAt(e.clientX, e.clientY)
|
|
54
|
+
} else if (e.pointerType === "touch") {
|
|
55
|
+
downAt = { x: e.clientX, y: e.clientY }
|
|
56
|
+
clearTimeout(timer)
|
|
57
|
+
timer = setTimeout(() => {
|
|
58
|
+
if (downAt) openAt(downAt.x, downAt.y)
|
|
59
|
+
downAt = undefined
|
|
60
|
+
}, LONG_PRESS_MS)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let handleMove = (e: PointerEvent) => {
|
|
64
|
+
if (!downAt) return
|
|
65
|
+
if (Math.abs(e.clientX - downAt.x) > MOVE_SLOP || Math.abs(e.clientY - downAt.y) > MOVE_SLOP) cancelHold()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let choose = (item: ContextMenuItem) => {
|
|
69
|
+
setOpen(false)
|
|
70
|
+
item.onSelect?.()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let bodyText = (color: string) => ({
|
|
74
|
+
fontSize: theme.text.body.size,
|
|
75
|
+
lineHeight: theme.text.body.lineHeight,
|
|
76
|
+
color,
|
|
77
|
+
maxLines: 1,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
let ItemRow = (p: { item: ContextMenuItem; padY: number }) => (
|
|
81
|
+
<Pressable
|
|
82
|
+
onPress={() => choose(p.item)}
|
|
83
|
+
disabled={p.item.disabled}
|
|
84
|
+
layout={{
|
|
85
|
+
flexDirection: "row",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
paddingTop: p.padY,
|
|
88
|
+
paddingBottom: p.padY,
|
|
89
|
+
paddingLeft: Math.round(theme.spacing.md * densityScale()),
|
|
90
|
+
paddingRight: Math.round(theme.spacing.md * densityScale()),
|
|
91
|
+
}}
|
|
92
|
+
style={(s: PressState) => ({
|
|
93
|
+
backgroundColor:
|
|
94
|
+
s.pressed || (s.hovered && policy.interaction !== "touch") ? theme.color.surfaceHover : "transparent",
|
|
95
|
+
})}
|
|
96
|
+
>
|
|
97
|
+
<text {...bodyText(p.item.disabled ? theme.color.textMuted : theme.color.text)}>{p.item.label}</text>
|
|
98
|
+
</Pressable>
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// Anchored at the opening pointer position, flipping up when it would run
|
|
102
|
+
// off the bottom. Same reflow-free placement as Tooltip/Select: portal at
|
|
103
|
+
// the window root, measured in onLayout, moved with x/y paint transforms.
|
|
104
|
+
let Menu = () => {
|
|
105
|
+
let menu: { id: number } | undefined
|
|
106
|
+
let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
|
|
107
|
+
onLayout(() => {
|
|
108
|
+
let b = menu && getBoundingBox(menu)
|
|
109
|
+
if (!b) return
|
|
110
|
+
let p = point()
|
|
111
|
+
let x = Math.round(Math.min(Math.max(p.x, MARGIN), env.windowSize.width - b.width - MARGIN))
|
|
112
|
+
let y = Math.round(
|
|
113
|
+
Math.max(p.y + b.height > env.windowSize.height - MARGIN ? p.y - b.height : p.y, MARGIN),
|
|
114
|
+
)
|
|
115
|
+
let cur = pos()
|
|
116
|
+
if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
|
|
117
|
+
})
|
|
118
|
+
return createPortal(
|
|
119
|
+
<view position="absolute" top={0} left={0} right={0} bottom={0}>
|
|
120
|
+
<view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)} />
|
|
121
|
+
<view
|
|
122
|
+
ref={(n: { id: number }) => (menu = n)}
|
|
123
|
+
position="absolute"
|
|
124
|
+
top={0}
|
|
125
|
+
left={0}
|
|
126
|
+
x={pos()?.x ?? -10000}
|
|
127
|
+
y={pos()?.y ?? 0}
|
|
128
|
+
minWidth={MIN_WIDTH}
|
|
129
|
+
flexDirection="column"
|
|
130
|
+
paddingTop={theme.spacing.sm}
|
|
131
|
+
paddingBottom={theme.spacing.sm}
|
|
132
|
+
>
|
|
133
|
+
<d-rect color={theme.color.surface} radius={theme.radius.sm} />
|
|
134
|
+
<For each={props.items}>
|
|
135
|
+
{(item: ContextMenuItem) => <ItemRow item={item} padY={Math.round(theme.spacing.sm * densityScale())} />}
|
|
136
|
+
</For>
|
|
137
|
+
<d-rect
|
|
138
|
+
drawStyle="stroke"
|
|
139
|
+
color={theme.color.border}
|
|
140
|
+
strokeWidth={theme.borderWidth.sm}
|
|
141
|
+
radius={theme.radius.sm}
|
|
142
|
+
/>
|
|
143
|
+
</view>
|
|
144
|
+
</view>,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Bottom sheet over a scrim; content is a sibling of the scrim (Modal's
|
|
149
|
+
// trick) so a row press never has the scrim on its hit path.
|
|
150
|
+
let Sheet = () =>
|
|
151
|
+
createPortal(
|
|
152
|
+
<view position="absolute" top={0} left={0} right={0} bottom={0}>
|
|
153
|
+
<view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)}>
|
|
154
|
+
<d-rect color={theme.color.scrim} />
|
|
155
|
+
</view>
|
|
156
|
+
<view
|
|
157
|
+
position="absolute"
|
|
158
|
+
left={0}
|
|
159
|
+
right={0}
|
|
160
|
+
bottom={0}
|
|
161
|
+
flexDirection="column"
|
|
162
|
+
paddingTop={theme.spacing.md}
|
|
163
|
+
paddingBottom={theme.spacing.md + env.safeArea.bottom}
|
|
164
|
+
>
|
|
165
|
+
<d-rect color={theme.color.surface} radius={theme.radius.sm} />
|
|
166
|
+
<For each={props.items}>
|
|
167
|
+
{(item: ContextMenuItem) => <ItemRow item={item} padY={Math.round(theme.spacing.md * 1.5)} />}
|
|
168
|
+
</For>
|
|
169
|
+
</view>
|
|
170
|
+
</view>,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<view
|
|
175
|
+
onPointerDown={handleDown}
|
|
176
|
+
onPointerMove={handleMove}
|
|
177
|
+
onPointerUp={cancelHold}
|
|
178
|
+
onPointerLeave={cancelHold}
|
|
179
|
+
{...props.layout}
|
|
180
|
+
>
|
|
181
|
+
{props.children}
|
|
182
|
+
<Show when={open()}>
|
|
183
|
+
<Show when={policy.interaction === "touch"} fallback={<Menu />}>
|
|
184
|
+
<Sheet />
|
|
185
|
+
</Show>
|
|
186
|
+
</Show>
|
|
187
|
+
</view>
|
|
188
|
+
)
|
|
189
|
+
}
|
package/src/divider.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { LayoutProps } from "@solidrt/core"
|
|
2
|
+
import { theme } from "./theme"
|
|
3
|
+
import type { StyleProps } from "./types"
|
|
4
|
+
|
|
5
|
+
export interface DividerProps {
|
|
6
|
+
// Line direction. Horizontal (default) is a full-width rule; vertical is a
|
|
7
|
+
// full-height rule for use inside a row.
|
|
8
|
+
orientation?: "horizontal" | "vertical"
|
|
9
|
+
// Line thickness in pixels.
|
|
10
|
+
thickness?: number
|
|
11
|
+
layout?: LayoutProps
|
|
12
|
+
style?: StyleProps
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A thin rule in the theme border color. Stretches across its container on the
|
|
16
|
+
// cross axis (full width in a column, full height in a row); add margin via
|
|
17
|
+
// layout for spacing. Override the color via style.backgroundColor.
|
|
18
|
+
export function Divider(props: DividerProps) {
|
|
19
|
+
let vertical = () => props.orientation === "vertical"
|
|
20
|
+
let thickness = () => props.thickness ?? 1
|
|
21
|
+
let color = () => props.style?.backgroundColor ?? theme.color.border
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<view
|
|
25
|
+
width={vertical() ? thickness() : "auto"}
|
|
26
|
+
height={vertical() ? "auto" : thickness()}
|
|
27
|
+
alignSelf="stretch"
|
|
28
|
+
{...props.layout}
|
|
29
|
+
>
|
|
30
|
+
<d-rect color={color()} />
|
|
31
|
+
</view>
|
|
32
|
+
)
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,36 @@ export { TextInput, type TextInputProps } from "./text-input"
|
|
|
7
7
|
export { ScrollView, type ScrollViewProps } from "./scroll-view"
|
|
8
8
|
export { Pressable, type PressableProps, type PressState } from "./pressable"
|
|
9
9
|
export { Button, type ButtonProps } from "./button"
|
|
10
|
+
export { Switch, type SwitchProps } from "./switch"
|
|
11
|
+
export { Checkbox, type CheckboxProps } from "./checkbox"
|
|
12
|
+
export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
|
|
13
|
+
export { Slider, type SliderProps } from "./slider"
|
|
14
|
+
export { Card, type CardProps } from "./card"
|
|
15
|
+
export { Divider, type DividerProps } from "./divider"
|
|
16
|
+
export { Badge, type BadgeProps } from "./badge"
|
|
17
|
+
export { Spinner, type SpinnerProps } from "./spinner"
|
|
18
|
+
export { ProgressBar, type ProgressBarProps } from "./progress-bar"
|
|
10
19
|
export { Portal, type PortalProps } from "./portal"
|
|
11
20
|
export { Modal, type ModalProps } from "./modal"
|
|
12
|
-
export {
|
|
21
|
+
export { Tooltip, type TooltipProps } from "./tooltip"
|
|
22
|
+
export { Select, type SelectProps, type SelectOption } from "./select"
|
|
23
|
+
export { ContextMenu, type ContextMenuProps, type ContextMenuItem } from "./context-menu"
|
|
24
|
+
export { NavShell, type NavShellProps, type NavItem } from "./nav-shell"
|
|
25
|
+
export { SplitView, type SplitViewProps } from "./split-view"
|
|
26
|
+
export { QrCode, type QrCodeProps } from "./qrcode"
|
|
27
|
+
export { theme, setTheme, darkTheme, lightTheme, type Theme } from "./theme"
|
|
28
|
+
export {
|
|
29
|
+
policy,
|
|
30
|
+
setPolicy,
|
|
31
|
+
setPolicyResolver,
|
|
32
|
+
defaultPolicyResolver,
|
|
33
|
+
densityScale,
|
|
34
|
+
type Policies,
|
|
35
|
+
type PolicyResolver,
|
|
36
|
+
type InteractionPolicy,
|
|
37
|
+
type DensityPolicy,
|
|
38
|
+
type MotionPolicy,
|
|
39
|
+
type NavigationPolicy,
|
|
40
|
+
type LayoutPolicy,
|
|
41
|
+
} from "./policy"
|
|
13
42
|
export type { StyleProps, TextLayoutProps } from "./types"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createSignal, Switch, Match, For } 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
|
+
|
|
7
|
+
export interface NavItem {
|
|
8
|
+
value: unknown
|
|
9
|
+
label: string
|
|
10
|
+
// Optional icon content, rendered as-is above (tabs/rail) or beside
|
|
11
|
+
// (sidebar) the label.
|
|
12
|
+
icon?: any
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NavShellProps {
|
|
16
|
+
items: NavItem[]
|
|
17
|
+
// Controlled selected value. If omitted, the shell is uncontrolled.
|
|
18
|
+
value?: unknown
|
|
19
|
+
defaultValue?: unknown
|
|
20
|
+
onChange?: (value: unknown) => void
|
|
21
|
+
// The page content; keeps its node (and state) when the arrangement changes.
|
|
22
|
+
children?: any
|
|
23
|
+
layout?: LayoutProps
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RAIL_WIDTH = 72
|
|
27
|
+
const SIDEBAR_WIDTH = 220
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An app shell that arranges primary navigation around the content per the
|
|
31
|
+
* navigation policy: bottom tabs under it, a narrow rail or a wide sidebar
|
|
32
|
+
* beside it. The content is a single stable node; switching arrangement only
|
|
33
|
+
* flips the shell's flex direction and remounts the (stateless) nav strip, so
|
|
34
|
+
* page state survives a resize across a breakpoint. Safe areas are the
|
|
35
|
+
* caller's concern: wrap the shell (or the window content) in SafeArea.
|
|
36
|
+
*/
|
|
37
|
+
export function NavShell(props: NavShellProps) {
|
|
38
|
+
let [internal, setInternal] = createSignal(props.defaultValue)
|
|
39
|
+
let value = () => (props.value !== undefined ? props.value : internal())
|
|
40
|
+
let select = (v: unknown) => {
|
|
41
|
+
if (props.value === undefined) setInternal(() => v)
|
|
42
|
+
props.onChange?.(v)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let labelColor = (item: NavItem) => (item.value === value() ? theme.color.primary : theme.color.textMuted)
|
|
46
|
+
let itemBg = (item: NavItem, s: PressState) =>
|
|
47
|
+
item.value === value()
|
|
48
|
+
? theme.color.surfaceAlt
|
|
49
|
+
: s.hovered && policy.interaction !== "touch"
|
|
50
|
+
? theme.color.surfaceHover
|
|
51
|
+
: "transparent"
|
|
52
|
+
|
|
53
|
+
// Icon over a small label, centered; shared by the tab bar and the rail.
|
|
54
|
+
let StackedItem = (p: { item: NavItem; padY: number; layout?: LayoutProps }) => (
|
|
55
|
+
<Pressable
|
|
56
|
+
onPress={() => select(p.item.value)}
|
|
57
|
+
layout={{
|
|
58
|
+
flexDirection: "column",
|
|
59
|
+
alignItems: "center",
|
|
60
|
+
justifyContent: "center",
|
|
61
|
+
gap: theme.spacing.sm,
|
|
62
|
+
paddingTop: p.padY,
|
|
63
|
+
paddingBottom: p.padY,
|
|
64
|
+
...p.layout,
|
|
65
|
+
}}
|
|
66
|
+
style={(s: PressState) => ({ backgroundColor: itemBg(p.item, s), borderRadius: theme.radius.sm })}
|
|
67
|
+
>
|
|
68
|
+
{p.item.icon}
|
|
69
|
+
<text color={labelColor(p.item)} fontSize={11} lineHeight={1.3}>
|
|
70
|
+
{p.item.label}
|
|
71
|
+
</text>
|
|
72
|
+
</Pressable>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
let Hairline = (p: { vertical?: boolean }) => (
|
|
76
|
+
<view width={p.vertical ? 1 : undefined} height={p.vertical ? undefined : 1}>
|
|
77
|
+
<d-rect color={theme.color.border} />
|
|
78
|
+
</view>
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
let Tabs = () => (
|
|
82
|
+
<view flexDirection="column" flexShrink={0}>
|
|
83
|
+
<Hairline />
|
|
84
|
+
<view flexDirection="row">
|
|
85
|
+
<d-rect color={theme.color.surface} />
|
|
86
|
+
<For each={props.items}>
|
|
87
|
+
{(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} layout={{ flex: 1 }} />}
|
|
88
|
+
</For>
|
|
89
|
+
</view>
|
|
90
|
+
</view>
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
let Rail = () => (
|
|
94
|
+
<view flexDirection="row" flexShrink={0}>
|
|
95
|
+
<view flexDirection="column" width={RAIL_WIDTH} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
|
|
96
|
+
<d-rect color={theme.color.surface} />
|
|
97
|
+
<For each={props.items}>{(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} />}</For>
|
|
98
|
+
</view>
|
|
99
|
+
<Hairline vertical />
|
|
100
|
+
</view>
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
let Sidebar = () => (
|
|
104
|
+
<view flexDirection="row" flexShrink={0}>
|
|
105
|
+
<view flexDirection="column" width={SIDEBAR_WIDTH} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
|
|
106
|
+
<d-rect color={theme.color.surface} />
|
|
107
|
+
<For each={props.items}>
|
|
108
|
+
{(item: NavItem) => (
|
|
109
|
+
<Pressable
|
|
110
|
+
onPress={() => select(item.value)}
|
|
111
|
+
layout={{
|
|
112
|
+
flexDirection: "row",
|
|
113
|
+
alignItems: "center",
|
|
114
|
+
gap: theme.spacing.md,
|
|
115
|
+
paddingTop: Math.round(theme.spacing.sm * densityScale()) + 2,
|
|
116
|
+
paddingBottom: Math.round(theme.spacing.sm * densityScale()) + 2,
|
|
117
|
+
paddingLeft: theme.spacing.md,
|
|
118
|
+
paddingRight: theme.spacing.md,
|
|
119
|
+
marginLeft: theme.spacing.sm,
|
|
120
|
+
marginRight: theme.spacing.sm,
|
|
121
|
+
}}
|
|
122
|
+
style={(s: PressState) => ({ backgroundColor: itemBg(item, s), borderRadius: theme.radius.sm })}
|
|
123
|
+
>
|
|
124
|
+
{item.icon}
|
|
125
|
+
<text
|
|
126
|
+
color={item.value === value() ? theme.color.primary : theme.color.text}
|
|
127
|
+
fontSize={theme.text.body.size}
|
|
128
|
+
lineHeight={theme.text.body.lineHeight}
|
|
129
|
+
>
|
|
130
|
+
{item.label}
|
|
131
|
+
</text>
|
|
132
|
+
</Pressable>
|
|
133
|
+
)}
|
|
134
|
+
</For>
|
|
135
|
+
</view>
|
|
136
|
+
<Hairline vertical />
|
|
137
|
+
</view>
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// Children order is (content, nav): "column" puts the nav under the content,
|
|
141
|
+
// "row-reverse" puts it to the left, and the content node never moves.
|
|
142
|
+
return (
|
|
143
|
+
<view flexDirection={policy.navigation === "bottomTabs" ? "column" : "row-reverse"} {...props.layout}>
|
|
144
|
+
<view flex={1} flexDirection="column">
|
|
145
|
+
{props.children}
|
|
146
|
+
</view>
|
|
147
|
+
<Switch>
|
|
148
|
+
<Match when={policy.navigation === "bottomTabs"}>
|
|
149
|
+
<Tabs />
|
|
150
|
+
</Match>
|
|
151
|
+
<Match when={policy.navigation === "rail"}>
|
|
152
|
+
<Rail />
|
|
153
|
+
</Match>
|
|
154
|
+
<Match when={policy.navigation === "sidebar"}>
|
|
155
|
+
<Sidebar />
|
|
156
|
+
</Match>
|
|
157
|
+
</Switch>
|
|
158
|
+
</view>
|
|
159
|
+
)
|
|
160
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { capabilities, createSignal } from "@solidrt/core"
|
|
2
|
+
import type { Capabilities } from "@solidrt/core"
|
|
3
|
+
|
|
4
|
+
// Policies: how components should behave. Derived from capabilities by a
|
|
5
|
+
// replaceable resolver, with per-policy application overrides on top.
|
|
6
|
+
// Components consume policy.* and never ask what platform they are on.
|
|
7
|
+
// Theme answers "how does it look"; policies answer "how does it behave".
|
|
8
|
+
|
|
9
|
+
export type InteractionPolicy = "touch" | "desktop" | "hybrid"
|
|
10
|
+
export type DensityPolicy = "comfortable" | "compact" | "dense"
|
|
11
|
+
export type MotionPolicy = "normal" | "reduced" | "none"
|
|
12
|
+
export type NavigationPolicy = "bottomTabs" | "rail" | "sidebar"
|
|
13
|
+
export type LayoutPolicy = "singlePane" | "twoPane"
|
|
14
|
+
|
|
15
|
+
export type Policies = {
|
|
16
|
+
interaction: InteractionPolicy
|
|
17
|
+
density: DensityPolicy
|
|
18
|
+
motion: MotionPolicy
|
|
19
|
+
// Whether focused controls draw a visible focus indicator. Derived from
|
|
20
|
+
// keyboard presence; the runtime cannot yet tell keyboard focus from pointer
|
|
21
|
+
// focus (no Tab traversal), so this is per-session, not per-focus-source.
|
|
22
|
+
focusRing: boolean
|
|
23
|
+
// Application policies: recommendations derived from the window size class.
|
|
24
|
+
// The application owns the final decision; accept them by consuming
|
|
25
|
+
// policy.navigation / policy.layout, or override via setPolicy.
|
|
26
|
+
navigation: NavigationPolicy
|
|
27
|
+
layout: LayoutPolicy
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PolicyResolver = (caps: Capabilities) => Policies
|
|
31
|
+
|
|
32
|
+
// On runtimes with device enumeration, capabilities reflect connected devices
|
|
33
|
+
// from startup, so interaction settles immediately (and adapts on hotplug).
|
|
34
|
+
// Elsewhere capabilities are inferred from seen input traffic: interaction
|
|
35
|
+
// starts "hybrid" and settles as evidence arrives (the first mouse move flips
|
|
36
|
+
// a mouse-only session to "desktop").
|
|
37
|
+
export function defaultPolicyResolver(caps: Capabilities): Policies {
|
|
38
|
+
let interaction: InteractionPolicy =
|
|
39
|
+
caps.touch && caps.precisePointer
|
|
40
|
+
? "hybrid"
|
|
41
|
+
: caps.touch
|
|
42
|
+
? "touch"
|
|
43
|
+
: caps.precisePointer
|
|
44
|
+
? "desktop"
|
|
45
|
+
: "hybrid"
|
|
46
|
+
return {
|
|
47
|
+
interaction,
|
|
48
|
+
density: interaction === "desktop" ? "compact" : "comfortable",
|
|
49
|
+
motion: "normal",
|
|
50
|
+
focusRing: caps.keyboardNav,
|
|
51
|
+
navigation:
|
|
52
|
+
caps.windowSizeClass === "expanded" ? "sidebar" : caps.windowSizeClass === "medium" ? "rail" : "bottomTabs",
|
|
53
|
+
layout: caps.windowSizeClass === "expanded" ? "twoPane" : "singlePane",
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Boxed: a bare function as the initial signal value would be taken for the
|
|
58
|
+
// writable-memo compute form of createSignal.
|
|
59
|
+
let [resolverBox, setResolverBox] = createSignal({ resolve: defaultPolicyResolver as PolicyResolver })
|
|
60
|
+
let [overrides, setOverrides] = createSignal<Partial<Policies>>({})
|
|
61
|
+
|
|
62
|
+
// Computed, not stored: recomputes per read, tracked through capabilities/env.
|
|
63
|
+
let resolved = () => resolverBox().resolve(capabilities)
|
|
64
|
+
|
|
65
|
+
/** Current policies, as reactive reads. */
|
|
66
|
+
export let policy = {
|
|
67
|
+
get interaction(): InteractionPolicy {
|
|
68
|
+
return overrides().interaction ?? resolved().interaction
|
|
69
|
+
},
|
|
70
|
+
get density(): DensityPolicy {
|
|
71
|
+
return overrides().density ?? resolved().density
|
|
72
|
+
},
|
|
73
|
+
get motion(): MotionPolicy {
|
|
74
|
+
return overrides().motion ?? resolved().motion
|
|
75
|
+
},
|
|
76
|
+
get focusRing(): boolean {
|
|
77
|
+
return overrides().focusRing ?? resolved().focusRing
|
|
78
|
+
},
|
|
79
|
+
get navigation(): NavigationPolicy {
|
|
80
|
+
return overrides().navigation ?? resolved().navigation
|
|
81
|
+
},
|
|
82
|
+
get layout(): LayoutPolicy {
|
|
83
|
+
return overrides().layout ?? resolved().layout
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Replaces how system policies derive from capabilities. */
|
|
88
|
+
export function setPolicyResolver(resolve: PolicyResolver) {
|
|
89
|
+
setResolverBox({ resolve })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Forces individual policies, overriding the resolver:
|
|
94
|
+
* setPolicy({ density: "dense" }). An explicit undefined hands a policy back
|
|
95
|
+
* to the resolver: setPolicy({ density: undefined }).
|
|
96
|
+
*/
|
|
97
|
+
export function setPolicy(partial: Partial<Policies>) {
|
|
98
|
+
setOverrides((prev) => ({ ...prev, ...partial }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// How density maps to component metrics: a multiplier on control sizes,
|
|
102
|
+
// paddings, and hit targets. Comfortable is the components' designed size.
|
|
103
|
+
const DENSITY_SCALE: Record<DensityPolicy, number> = { comfortable: 1, compact: 0.85, dense: 0.7 }
|
|
104
|
+
|
|
105
|
+
/** Reactive density multiplier for control metrics. */
|
|
106
|
+
export function densityScale(): number {
|
|
107
|
+
return DENSITY_SCALE[policy.density]
|
|
108
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createSignal } from "@solidjs/signals"
|
|
2
|
+
import { onFrame, onLayout, getBoundingBox, 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 ProgressBarProps {
|
|
9
|
+
// Progress from 0 to 1. Omit (or leave undefined) for an indeterminate bar: a
|
|
10
|
+
// segment that slides back and forth.
|
|
11
|
+
value?: number
|
|
12
|
+
layout?: LayoutProps
|
|
13
|
+
style?: StyleProps
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const HEIGHT = 6
|
|
17
|
+
// Indeterminate: fraction of the track the sliding segment occupies, and its
|
|
18
|
+
// travel speed in track-widths per second.
|
|
19
|
+
const SEGMENT = 0.3
|
|
20
|
+
const SPEED = 0.8
|
|
21
|
+
|
|
22
|
+
let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi : x)
|
|
23
|
+
|
|
24
|
+
// A horizontal progress bar. Determinate when given a value in [0, 1]: the fill
|
|
25
|
+
// grows from the left. Indeterminate when value is undefined: a short segment
|
|
26
|
+
// slides back and forth (driven by core onFrame). Colors come from the theme;
|
|
27
|
+
// override the track via style.backgroundColor and the fill via style.color.
|
|
28
|
+
export function ProgressBar(props: ProgressBarProps) {
|
|
29
|
+
let h = () => (props.layout?.height as number) ?? HEIGHT
|
|
30
|
+
let radius = () => h() / 2
|
|
31
|
+
let track = () => props.style?.backgroundColor ?? theme.color.surfaceAlt
|
|
32
|
+
let fill = () => props.style?.color ?? theme.color.primary
|
|
33
|
+
let indeterminate = () => props.value === undefined
|
|
34
|
+
|
|
35
|
+
// Measured track width in pixels. Both the determinate fill and the
|
|
36
|
+
// indeterminate segment are drawn as a detached d-rect sized in pixels (paint
|
|
37
|
+
// only) rather than animating a percentage `width`, which would reflow taffy
|
|
38
|
+
// whenever the value changes. Refreshed each layout so it tracks resizes.
|
|
39
|
+
let trackNode: { id: number } | undefined
|
|
40
|
+
let [trackWidth, setTrackWidth] = createSignal(0)
|
|
41
|
+
onLayout(() => {
|
|
42
|
+
if (!trackNode) return
|
|
43
|
+
setTrackWidth(getBoundingBox(trackNode)?.width ?? 0)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// tick is in milliseconds (like performance.now()). The frame loop is mounted
|
|
47
|
+
// through the <Show> below only while indeterminate and the motion policy
|
|
48
|
+
// allows it: onFrame holds a standing frame request while registered, so a
|
|
49
|
+
// check inside the callback would keep the renderer free-running (which is
|
|
50
|
+
// also why a determinate bar must not register it at all). "reduced" halves
|
|
51
|
+
// the travel speed; under "none" the segment parks centered (phase 0.5).
|
|
52
|
+
let [phase, setPhase] = createSignal(0)
|
|
53
|
+
let animating = () => indeterminate() && policy.motion !== "none"
|
|
54
|
+
let Animate = () => {
|
|
55
|
+
onFrame((tick) => {
|
|
56
|
+
// Triangle wave in [0, 1]: the segment travels left edge -> right edge
|
|
57
|
+
// and back.
|
|
58
|
+
let t = ((tick / 1000) * (policy.motion === "reduced" ? SPEED / 2 : SPEED)) % 2
|
|
59
|
+
setPhase(t > 1 ? 2 - t : t)
|
|
60
|
+
})
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fill width in pixels: a fixed segment while indeterminate, the value fraction
|
|
65
|
+
// otherwise. Drawn via the detached d-rect `w` below (paint only), so a
|
|
66
|
+
// changing value never reflows. The indeterminate slide comes from the `x`
|
|
67
|
+
// offset.
|
|
68
|
+
let fillWidth = () => (indeterminate() ? SEGMENT : clamp(props.value ?? 0, 0, 1)) * trackWidth()
|
|
69
|
+
let effectivePhase = () => (policy.motion === "none" ? 0.5 : phase())
|
|
70
|
+
let offset = () => effectivePhase() * trackWidth() * (1 - SEGMENT)
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<view ref={(n: { id: number }) => (trackNode = n)} position="relative" width="100%" height={h()} {...props.layout}>
|
|
74
|
+
<Show when={animating()}>
|
|
75
|
+
<Animate />
|
|
76
|
+
</Show>
|
|
77
|
+
<d-rect color={track()} radius={radius()} />
|
|
78
|
+
<d-rect color={fill()} radius={radius()} w={fillWidth()} h={h()} x={indeterminate() ? offset() : 0} />
|
|
79
|
+
</view>
|
|
80
|
+
)
|
|
81
|
+
}
|
package/src/qrcode.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createMemo } from "@solidjs/signals"
|
|
2
|
+
import type { LayoutProps } from "@solidrt/core"
|
|
3
|
+
import qrcode from "qrcode-generator"
|
|
4
|
+
|
|
5
|
+
export interface QrCodeProps {
|
|
6
|
+
// The string to encode (URL, pairing ticket, text, ...).
|
|
7
|
+
data: string
|
|
8
|
+
// Pixels per QR module (the smallest square). The grid is
|
|
9
|
+
// moduleCount * moduleSize on a side, plus the margin around it.
|
|
10
|
+
moduleSize?: number
|
|
11
|
+
// Quiet-zone padding in pixels around the grid. Scanners need a light border,
|
|
12
|
+
// so keep this non-zero.
|
|
13
|
+
margin?: number
|
|
14
|
+
// Dark/light module colors. Defaults are black on white for reliable scanning
|
|
15
|
+
// regardless of theme; override only if you know the contrast still holds.
|
|
16
|
+
color?: string
|
|
17
|
+
background?: string
|
|
18
|
+
// Error-correction level: higher tolerates more damage but packs denser and
|
|
19
|
+
// caps the data length sooner.
|
|
20
|
+
level?: "L" | "M" | "Q" | "H"
|
|
21
|
+
// Corner radius of the background panel.
|
|
22
|
+
radius?: number
|
|
23
|
+
layout?: LayoutProps
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MODULE_SIZE = 6
|
|
27
|
+
const MARGIN = 16
|
|
28
|
+
const RADIUS = 8
|
|
29
|
+
|
|
30
|
+
// Render a QR for `data` as primitives: merge horizontal runs of same-color
|
|
31
|
+
// modules per row into a single sized box, on a light quiet-zone panel. The
|
|
32
|
+
// module grid recomputes only when the data or error-correction level changes.
|
|
33
|
+
export function QrCode(props: QrCodeProps) {
|
|
34
|
+
let rows = createMemo(() => {
|
|
35
|
+
let qr = qrcode(0, props.level ?? "M")
|
|
36
|
+
qr.addData(props.data)
|
|
37
|
+
qr.make()
|
|
38
|
+
let n = qr.getModuleCount()
|
|
39
|
+
|
|
40
|
+
let out: { dark: boolean; len: number }[][] = []
|
|
41
|
+
for (let y = 0; y < n; y++) {
|
|
42
|
+
let runs: { dark: boolean; len: number }[] = []
|
|
43
|
+
let x = 0
|
|
44
|
+
while (x < n) {
|
|
45
|
+
let dark = qr.isDark(y, x)
|
|
46
|
+
let len = 1
|
|
47
|
+
while (x + len < n && qr.isDark(y, x + len) === dark) len++
|
|
48
|
+
runs.push({ dark, len })
|
|
49
|
+
x += len
|
|
50
|
+
}
|
|
51
|
+
out.push(runs)
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
let size = () => props.moduleSize ?? MODULE_SIZE
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<view flexDirection="column" padding={props.margin ?? MARGIN} {...props.layout}>
|
|
60
|
+
<d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? RADIUS} />
|
|
61
|
+
{rows().map((runs) => (
|
|
62
|
+
<view flexDirection="row">
|
|
63
|
+
{runs.map((run) => (
|
|
64
|
+
<view width={run.len * size()} height={size()}>
|
|
65
|
+
{run.dark ? <d-rect color={props.color ?? "#000000"} /> : null}
|
|
66
|
+
</view>
|
|
67
|
+
))}
|
|
68
|
+
</view>
|
|
69
|
+
))}
|
|
70
|
+
</view>
|
|
71
|
+
)
|
|
72
|
+
}
|