@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.
@@ -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
+ }
@@ -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 { theme, setTheme, type Theme } from "./theme"
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
+ }