@solidrt/components 0.0.18 → 0.0.20

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/icon.tsx ADDED
@@ -0,0 +1,35 @@
1
+ import type { LayoutProps } from "@solidrt/core"
2
+ import { theme } from "./theme"
3
+
4
+ export interface IconProps {
5
+ // An SVG document as a string: an imported `.svg` asset, a `lucide-static`
6
+ // string export, or an inline template literal. Monochrome icons that stroke/
7
+ // fill with `currentColor` (Lucide, Feather, Heroicons, ...) get recolored by
8
+ // `color`; a multi-color document keeps its own fills.
9
+ src: string
10
+ // Rendered box in pixels, square. Defaults to 24 (the common icon grid).
11
+ size?: number
12
+ // Drives `currentColor` in the document. Defaults to the theme text color.
13
+ color?: string
14
+ layout?: LayoutProps
15
+ }
16
+
17
+ const SIZE = 24
18
+
19
+ // A themed wrapper over the core <svg> document primitive: a square box sized to
20
+ // `size` and colored from the theme by default. This is the only value it adds
21
+ // over `<svg src>` directly, so reach for the primitive when you need a
22
+ // non-square box or want no theme coupling.
23
+ export function Icon(props: IconProps) {
24
+ let size = () => props.size ?? SIZE
25
+
26
+ return (
27
+ <svg
28
+ width={size()}
29
+ height={size()}
30
+ src={props.src}
31
+ color={props.color ?? theme.color.text}
32
+ {...props.layout}
33
+ />
34
+ )
35
+ }
package/src/index.ts CHANGED
@@ -7,7 +7,37 @@ 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 { Icon, type IconProps } from "./icon"
28
+ export { theme, setTheme, darkTheme, lightTheme, type Theme } from "./theme"
29
+ export {
30
+ policy,
31
+ setPolicy,
32
+ setPolicyResolver,
33
+ defaultPolicyResolver,
34
+ densityScale,
35
+ type Policies,
36
+ type PolicyResolver,
37
+ type InteractionPolicy,
38
+ type DensityPolicy,
39
+ type MotionPolicy,
40
+ type NavigationPolicy,
41
+ type LayoutPolicy,
42
+ } from "./policy"
13
43
  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
+ }