@solidrt/components 0.0.35 → 0.0.36

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 CHANGED
@@ -36,11 +36,15 @@ Most components group props into two objects, plus top-level event handlers:
36
36
  - `TextInput` - single-line input; `value`/`onInput`/`onSubmit`, controlled or
37
37
  uncontrolled, plus `placeholder`, `maxLength`, `autoFocus`, `disabled`.
38
38
  - `ScrollView` - scrollable region; vertical by default, `horizontal` to flip.
39
- Wheel + drag, no momentum yet. Backed by `createScroll` from
40
- `@solidrt/core` (headless offset+clamp geometry).
39
+ Wheel + drag (a pan recognizer: activates on slop along the scroll axis and
40
+ steals the pointer from a pressable the drag started on), no momentum yet.
41
+ Backed by `createScroll` from `@solidrt/core` (headless offset+clamp
42
+ geometry) and the internal `createPan`/arena (arena.ts, pan.ts, press.ts:
43
+ per-pointer claims, innermost press wins, pan steals on slop).
41
44
  - `Pressable` - pressable box; `onPress` on a primary press released inside,
42
45
  `disabled` opts out. `children`/`style` can be functions of `{ pressed,
43
- hovered }`. No pointer capture: a drag out cancels via onPointerLeave.
46
+ hovered }`. Press retention: a drag out retracts the pressed state, a drag
47
+ back in restores it; releasing outside does not fire.
44
48
  - `Button` - themed Pressable: accent box + label (string/number child), scales
45
49
  on press (via reactive style); colors from `theme.color.primary`/`onPrimary`.
46
50
  - `Switch` - on/off toggle; `value`/`onChange`, controlled or uncontrolled
@@ -52,8 +56,9 @@ Most components group props into two objects, plus top-level event handlers:
52
56
  module-local context (not a cross-component dependency). `Radio value=...`;
53
57
  string/number child renders as a themed label.
54
58
  - `Slider` - horizontal; `value`/`onChange` (or `defaultValue`), `min`/`max`/
55
- `step`. Pointer x mapped to value via `getBoundingBox`; uses core
56
- `setPointerCapture` so a drag keeps tracking off the track (within the window).
59
+ `step`. Pointer x mapped to value via `getBoundingBox`; a down resolves the
60
+ gesture arena outright (no ancestor scroller takeover) and moves follow the
61
+ frozen down path, so a drag keeps tracking off the track.
57
62
  - `Card` - themed surface container: padded column box, `surface` fill, `border`
58
63
  stroke, rounded. Optional `title` heading; override paint via `style`.
59
64
  - `Divider` - thin rule in `border` color; `orientation` (default horizontal),
package/README.md CHANGED
@@ -283,7 +283,7 @@ Top and bottom insets are applied by default. Pass `false` to opt out of an edge
283
283
 
284
284
  ### ScrollView
285
285
 
286
- A scrollable region. Scrolls vertically by default; pass `horizontal` to scroll the other axis instead. Both the wheel and dragging scroll the content. There is no momentum/fling yet, and (with no pointer capture) a drag that leaves the box ends the gesture.
286
+ A scrollable region. Scrolls vertically by default; pass `horizontal` to scroll the other axis instead. Both the wheel and dragging scroll the content. The drag activates after a small movement threshold along the scroll axis, also when it starts on a pressable (the press is cancelled and its feedback retracts), and keeps scrolling when the pointer leaves the box. There is no momentum/fling yet.
287
287
 
288
288
  ```jsx
289
289
  import { ScrollView, Text } from "@solidrt/components"
@@ -440,7 +440,7 @@ import { RadioGroup, Radio } from "@solidrt/components"
440
440
 
441
441
  ### Slider
442
442
 
443
- A horizontal slider. The groove fills up to the thumb; pressing or dragging the track sets the value from the pointer position. Controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. The drag uses pointer capture, so it keeps tracking when the pointer drifts off the track (anywhere within the window).
443
+ A horizontal slider. The groove fills up to the thumb; pressing or dragging the track sets the value from the pointer position. Controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. The drag keeps tracking when the pointer drifts off the track, and an enclosing ScrollView never takes it over.
444
444
 
445
445
  ```jsx
446
446
  import { Slider } from "@solidrt/components"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.35",
3
+ "version": "0.0.36",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -18,6 +18,6 @@
18
18
  },
19
19
  "peerDependencies": {
20
20
  "@solidjs/signals": "2.0.0-beta.20",
21
- "@solidrt/core": "0.0.35"
21
+ "@solidrt/core": "0.0.36"
22
22
  }
23
23
  }
package/src/arena.ts ADDED
@@ -0,0 +1,58 @@
1
+ // The gesture arena: per-pointer ownership arbitration between recognizers.
2
+ // Raw pointer events keep bubbling along the frozen down path regardless; the
3
+ // arena only decides which recognizer's gesture a pointer belongs to.
4
+ //
5
+ // Two claim strengths mirror how gestures resolve. A press claims its pointer
6
+ // provisionally on the down: it is the presumed winner (innermost-wins falls
7
+ // out of leaf-to-root dispatch, first claim sticks) but a later recognizer
8
+ // with positive evidence of a different gesture (a pan crossing its movement
9
+ // slop) may steal the pointer, cancelling the press. A steal resolves the
10
+ // arena: the pointer is won outright and cannot be stolen again, so e.g. the
11
+ // outer axis of two nested scrollers cannot take a drag the inner one already
12
+ // owns. Plain state on purpose: claims must be visible across recognizers
13
+ // within one synchronous bubble dispatch, before any signal flush.
14
+ //
15
+ // Deliberately framework-agnostic (no theme, no styling, no components): a
16
+ // candidate for promotion into core along with the recognizers
17
+ // (okf/plans/component-gestures.md).
18
+
19
+ export type ArenaOwner = {
20
+ /** Retract the gesture without firing; invoked when the pointer is stolen. */
21
+ cancel(): void
22
+ }
23
+
24
+ type Claim = { owner: ArenaOwner; resolved: boolean }
25
+
26
+ let claims = new Map<number, Claim>()
27
+
28
+ /**
29
+ * Provisionally claim an unowned pointer. Returns false (claim refused) when
30
+ * any recognizer already owns it. The claim is stealable until the owner
31
+ * releases it or a steal resolves the arena.
32
+ */
33
+ export function claim(pointerId: number, owner: ArenaOwner): boolean {
34
+ if (claims.has(pointerId)) return false
35
+ claims.set(pointerId, { owner, resolved: false })
36
+ return true
37
+ }
38
+
39
+ /**
40
+ * Take the pointer on positive evidence of a gesture, cancelling the current
41
+ * provisional owner (if any), and resolve the arena: the resulting claim
42
+ * cannot be stolen. Returns false when the arena is already resolved, in
43
+ * which case the caller lost and must stand down.
44
+ */
45
+ export function steal(pointerId: number, owner: ArenaOwner): boolean {
46
+ let current = claims.get(pointerId)
47
+ if (current) {
48
+ if (current.resolved) return false
49
+ current.owner.cancel()
50
+ }
51
+ claims.set(pointerId, { owner, resolved: true })
52
+ return true
53
+ }
54
+
55
+ /** Release the claim on a pointer, if `owner` still holds it. */
56
+ export function release(pointerId: number, owner: ArenaOwner): void {
57
+ if (claims.get(pointerId)?.owner === owner) claims.delete(pointerId)
58
+ }
package/src/button.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Show, children } from "@solidrt/core"
2
- import { Pressable, type PressState } from "./pressable"
2
+ import { createPress, type PressState } from "./press"
3
3
  import { theme } from "./theme"
4
4
  import { policy } from "./policy"
5
5
  import { space } from "./spacing"
@@ -32,12 +32,12 @@ export interface ButtonProps {
32
32
  // labels wider than the preset never clip.
33
33
  const SIZE_WIDTH: Record<ButtonSize, number> = { sm: 88, md: 120, lg: 160 }
34
34
 
35
- // Themed convenience over Pressable: a padded, centered, accent-colored box with
36
- // a label. Press feedback is a slight scale, hover feedback a tint (non-touch
37
- // interaction policies only), both driven through Pressable's reactive style so
38
- // no nodes are recreated. Override the box via style and the padding/sizing via
35
+ // A themed press target: a padded, centered, accent-colored box with a label.
36
+ // Press feedback is a slight scale, hover feedback a tint (non-touch
37
+ // interaction policies only), both reactive reads of the press state so no
38
+ // nodes are recreated. Override the box via style and the padding/sizing via
39
39
  // layout. A caller-set backgroundColor disables the hover tint: we cannot know
40
- // its hover variant.
40
+ // its hover variant. When disabled, it takes no pointer events at all.
41
41
  export function Button(props: ButtonProps) {
42
42
  // Fill, hover fill, and label color per variant, read reactively from the
43
43
  // theme. No variant draws a border.
@@ -79,36 +79,53 @@ export function Button(props: ButtonProps) {
79
79
  // compensation there too.
80
80
  let labelOnDark = () => lightOnDark(label(), props.style?.backgroundColor ?? idleFill())
81
81
 
82
+ // props (not a literal) so a swapped-in onPress is read at event time.
83
+ let press = createPress(props)
84
+ let style = () => ({
85
+ ...props.style,
86
+ backgroundColor: bg(press.state()),
87
+ borderRadius: radius(),
88
+ // Always a number: a scale that flips from a number back to undefined
89
+ // hits the transform decoder, which rejects null. Multiply so a
90
+ // caller-set scale is preserved under the press feedback.
91
+ scale: (props.style?.scale ?? 1) * (press.pressed() && policy.motion !== "none" ? 0.97 : 1),
92
+ })
93
+
82
94
  return (
83
- <Pressable
84
- onPress={props.onPress}
85
- disabled={props.disabled}
86
- layout={{
87
- flexDirection: "row",
88
- alignItems: "center",
89
- justifyContent: "center",
90
- paddingTop: space("md"),
91
- paddingBottom: space("md"),
92
- paddingLeft: space("lg"),
93
- paddingRight: space("lg"),
94
- ...(props.size ? { minWidth: SIZE_WIDTH[props.size] } : { width: "100%" }),
95
- ...props.layout,
96
- }}
97
- style={(s: PressState) => ({
98
- ...props.style,
99
- backgroundColor: bg(s),
100
- borderRadius: radius(),
101
- // Always a number: a scale that flips from a number back to undefined
102
- // hits the transform decoder, which rejects null. Multiply so a
103
- // caller-set scale is preserved under the press feedback.
104
- scale: (props.style?.scale ?? 1) * (s.pressed && policy.motion !== "none" ? 0.97 : 1),
105
- })}
95
+ <view
96
+ ref={press.ref}
97
+ repaintBoundary
98
+ flexDirection="row"
99
+ alignItems="center"
100
+ justifyContent="center"
101
+ paddingTop={space("md")}
102
+ paddingBottom={space("md")}
103
+ paddingLeft={space("lg")}
104
+ paddingRight={space("lg")}
105
+ {...(props.size ? { minWidth: SIZE_WIDTH[props.size] } : { width: "100%" })}
106
+ {...props.layout}
107
+ x={style().x}
108
+ y={style().y}
109
+ scale={style().scale}
110
+ rotate={style().rotate}
111
+ opacity={style().opacity}
112
+ {...press.handlers}
113
+ pointerEvents={props.disabled ? "none" : undefined}
106
114
  >
115
+ <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
107
116
  <Show when={isText()} fallback={resolved()}>
108
117
  <text color={label()} {...typeStyle("body", labelOnDark())}>
109
118
  {resolved()}
110
119
  </text>
111
120
  </Show>
112
- </Pressable>
121
+ <Show when={(style().borderWidth ?? 0) > 0}>
122
+ <d-rect
123
+ drawStyle="stroke"
124
+ color={style().borderColor ?? "transparent"}
125
+ strokeWidth={style().borderWidth}
126
+ radius={style().borderRadius}
127
+ />
128
+ </Show>
129
+ </view>
113
130
  )
114
131
  }
package/src/checkbox.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createSignal, Show } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
- import { Pressable } from "./pressable"
3
+ import { createPress } from "./press"
4
4
  import { theme } from "./theme"
5
5
  import { densityScale } from "./policy"
6
6
  import type { StyleProps } from "./types"
@@ -19,7 +19,7 @@ const SIZE = 20
19
19
 
20
20
  // A checkbox. When checked, fills with primary and draws a checkmark; otherwise
21
21
  // shows an empty bordered box. Controlled via checked/onChange, or uncontrolled
22
- // via defaultChecked. Built on Pressable.
22
+ // via defaultChecked. When disabled, it takes no pointer events at all.
23
23
  export function Checkbox(props: CheckboxProps) {
24
24
  let [internal, setInternal] = createSignal(props.defaultChecked ?? false)
25
25
  let checked = () => (props.checked !== undefined ? props.checked : internal())
@@ -29,6 +29,7 @@ export function Checkbox(props: CheckboxProps) {
29
29
  if (props.checked === undefined) setInternal(next)
30
30
  props.onChange?.(next)
31
31
  }
32
+ let press = createPress({ onPress: toggle })
32
33
 
33
34
  let size = () => Math.round(SIZE * densityScale())
34
35
  // The checkmark in box-relative fractions, so it scales with the density.
@@ -37,19 +38,30 @@ export function Checkbox(props: CheckboxProps) {
37
38
  return `M ${0.25 * s} ${0.5 * s} L ${0.45 * s} ${0.7 * s} L ${0.75 * s} ${0.3 * s}`
38
39
  }
39
40
 
41
+ let style = () => ({
42
+ backgroundColor: checked() ? theme.color.primary : theme.color.surface,
43
+ borderColor: theme.color.border,
44
+ borderWidth: theme.borderWidth.sm,
45
+ borderRadius: theme.radius.sm,
46
+ ...props.style,
47
+ })
48
+
40
49
  return (
41
- <Pressable
42
- onPress={toggle}
43
- disabled={props.disabled}
44
- layout={{ width: size(), height: size(), ...props.layout }}
45
- style={{
46
- backgroundColor: checked() ? theme.color.primary : theme.color.surface,
47
- borderColor: theme.color.border,
48
- borderWidth: theme.borderWidth.sm,
49
- borderRadius: theme.radius.sm,
50
- ...props.style,
51
- }}
50
+ <view
51
+ ref={press.ref}
52
+ repaintBoundary
53
+ width={size()}
54
+ height={size()}
55
+ {...props.layout}
56
+ x={style().x}
57
+ y={style().y}
58
+ scale={style().scale}
59
+ rotate={style().rotate}
60
+ opacity={style().opacity}
61
+ {...press.handlers}
62
+ pointerEvents={props.disabled ? "none" : undefined}
52
63
  >
64
+ <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
53
65
  <Show when={checked()}>
54
66
  <d-path
55
67
  d={check()}
@@ -60,6 +72,14 @@ export function Checkbox(props: CheckboxProps) {
60
72
  strokeJoin="round"
61
73
  />
62
74
  </Show>
63
- </Pressable>
75
+ <Show when={(style().borderWidth ?? 0) > 0}>
76
+ <d-rect
77
+ drawStyle="stroke"
78
+ color={style().borderColor ?? "transparent"}
79
+ strokeWidth={style().borderWidth}
80
+ radius={style().borderRadius}
81
+ />
82
+ </Show>
83
+ </view>
64
84
  )
65
85
  }
@@ -1,6 +1,6 @@
1
1
  import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, For, env } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
- import { Pressable, type PressState } from "./pressable"
3
+ import { createPress } from "./press"
4
4
  import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { space } from "./spacing"
@@ -74,26 +74,32 @@ export function ContextMenu(props: ContextMenuProps) {
74
74
 
75
75
  let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 })
76
76
 
77
- let ItemRow = (p: { item: ContextMenuItem; padY: number }) => (
78
- <Pressable
79
- onPress={() => choose(p.item)}
80
- disabled={p.item.disabled}
81
- layout={{
82
- flexDirection: "row",
83
- alignItems: "center",
84
- paddingTop: p.padY,
85
- paddingBottom: p.padY,
86
- paddingLeft: space("md"),
87
- paddingRight: space("md"),
88
- }}
89
- style={(s: PressState) => ({
90
- backgroundColor:
91
- s.pressed || (s.hovered && policy.interaction !== "touch") ? theme.color.surfaceHover : "transparent",
92
- })}
93
- >
94
- <text {...bodyText(p.item.disabled ? theme.color.textMuted : theme.color.text)}>{p.item.label}</text>
95
- </Pressable>
96
- )
77
+ let ItemRow = (p: { item: ContextMenuItem; padY: number }) => {
78
+ let press = createPress({ onPress: () => choose(p.item) })
79
+ return (
80
+ <view
81
+ ref={press.ref}
82
+ repaintBoundary
83
+ flexDirection="row"
84
+ alignItems="center"
85
+ paddingTop={p.padY}
86
+ paddingBottom={p.padY}
87
+ paddingLeft={space("md")}
88
+ paddingRight={space("md")}
89
+ {...press.handlers}
90
+ pointerEvents={p.item.disabled ? "none" : undefined}
91
+ >
92
+ <d-rect
93
+ color={
94
+ press.pressed() || (press.hovered() && policy.interaction !== "touch")
95
+ ? theme.color.surfaceHover
96
+ : "transparent"
97
+ }
98
+ />
99
+ <text {...bodyText(p.item.disabled ? theme.color.textMuted : theme.color.text)}>{p.item.label}</text>
100
+ </view>
101
+ )
102
+ }
97
103
 
98
104
  // Anchored at the opening pointer position, flipping up when it would run
99
105
  // off the bottom. Same reflow-free placement as Tooltip/Select: portal at
package/src/index.ts CHANGED
@@ -19,7 +19,8 @@ export { ProgressBar, type ProgressBarProps } from "./progress-bar"
19
19
  export { Portal, type PortalProps } from "./portal"
20
20
  export { Modal, type ModalProps } from "./modal"
21
21
  export { Tooltip, type TooltipProps } from "./tooltip"
22
- export { Select, type SelectProps, type SelectOption } from "./select"
22
+ export { Select, type SelectProps } from "./select"
23
+ export { SegmentedControl, type SegmentedControlProps } from "./segmented-control"
23
24
  export { ContextMenu, type ContextMenuProps, type ContextMenuItem } from "./context-menu"
24
25
  export { NavShell, type NavShellProps, type NavItem } from "./nav-shell"
25
26
  export { SplitView, type SplitViewProps } from "./split-view"
@@ -50,4 +51,4 @@ export {
50
51
  } from "./policy"
51
52
  export { typeStyle, typeWeight, lightOnDark } from "./typography"
52
53
  export { space } from "./spacing"
53
- export type { StyleProps, TextLayoutProps } from "./types"
54
+ export type { StyleProps, TextLayoutProps, Option } from "./types"
package/src/nav-shell.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createSignal, Switch, Match, For } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
- import { Pressable, type PressState } from "./pressable"
3
+ import { createPress } from "./press"
4
4
  import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { space } from "./spacing"
@@ -45,34 +45,37 @@ export function NavShell(props: NavShellProps) {
45
45
  }
46
46
 
47
47
  let labelColor = (item: NavItem) => (item.value === value() ? theme.color.primary : theme.color.textMuted)
48
- let itemBg = (item: NavItem, s: PressState) =>
48
+ let itemBg = (item: NavItem, hovered: boolean) =>
49
49
  item.value === value()
50
50
  ? theme.color.surfaceAlt
51
- : s.hovered && policy.interaction !== "touch"
51
+ : hovered && policy.interaction !== "touch"
52
52
  ? theme.color.surfaceHover
53
53
  : "transparent"
54
54
 
55
55
  // Icon over a small label, centered; shared by the tab bar and the rail.
56
- let StackedItem = (p: { item: NavItem; padY: number; layout?: LayoutProps }) => (
57
- <Pressable
58
- onPress={() => select(p.item.value)}
59
- layout={{
60
- flexDirection: "column",
61
- alignItems: "center",
62
- justifyContent: "center",
63
- gap: theme.spacing.sm,
64
- paddingTop: p.padY,
65
- paddingBottom: p.padY,
66
- ...p.layout,
67
- }}
68
- style={(s: PressState) => ({ backgroundColor: itemBg(p.item, s), borderRadius: theme.radius.sm })}
69
- >
70
- {p.item.icon}
71
- <text color={labelColor(p.item)} {...typeStyle("caption")}>
72
- {p.item.label}
73
- </text>
74
- </Pressable>
75
- )
56
+ let StackedItem = (p: { item: NavItem; padY: number; layout?: LayoutProps }) => {
57
+ let press = createPress({ onPress: () => select(p.item.value) })
58
+ return (
59
+ <view
60
+ ref={press.ref}
61
+ repaintBoundary
62
+ flexDirection="column"
63
+ alignItems="center"
64
+ justifyContent="center"
65
+ gap={theme.spacing.sm}
66
+ paddingTop={p.padY}
67
+ paddingBottom={p.padY}
68
+ {...p.layout}
69
+ {...press.handlers}
70
+ >
71
+ <d-rect color={itemBg(p.item, press.hovered())} radius={theme.radius.sm} />
72
+ {p.item.icon}
73
+ <text color={labelColor(p.item)} {...typeStyle("caption")}>
74
+ {p.item.label}
75
+ </text>
76
+ </view>
77
+ )
78
+ }
76
79
 
77
80
  let Hairline = (p: { vertical?: boolean }) => (
78
81
  <view width={p.vertical ? 1 : undefined} height={p.vertical ? undefined : 1}>
@@ -107,31 +110,34 @@ export function NavShell(props: NavShellProps) {
107
110
  <view flexDirection="column" width={SIDEBAR_WIDTH} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
108
111
  <d-rect color={theme.color.surface} />
109
112
  <For each={props.items}>
110
- {(item: NavItem) => (
111
- <Pressable
112
- onPress={() => select(item.value)}
113
- layout={{
114
- flexDirection: "row",
115
- alignItems: "center",
116
- gap: theme.spacing.md,
117
- paddingTop: space("sm") + 2,
118
- paddingBottom: space("sm") + 2,
119
- paddingLeft: theme.spacing.md,
120
- paddingRight: theme.spacing.md,
121
- marginLeft: theme.spacing.sm,
122
- marginRight: theme.spacing.sm,
123
- }}
124
- style={(s: PressState) => ({ backgroundColor: itemBg(item, s), borderRadius: theme.radius.sm })}
125
- >
126
- {item.icon}
127
- <text
128
- color={item.value === value() ? theme.color.primary : theme.color.text}
129
- {...typeStyle("body")}
113
+ {(item: NavItem) => {
114
+ let press = createPress({ onPress: () => select(item.value) })
115
+ return (
116
+ <view
117
+ ref={press.ref}
118
+ repaintBoundary
119
+ flexDirection="row"
120
+ alignItems="center"
121
+ gap={theme.spacing.md}
122
+ paddingTop={space("sm") + 2}
123
+ paddingBottom={space("sm") + 2}
124
+ paddingLeft={theme.spacing.md}
125
+ paddingRight={theme.spacing.md}
126
+ marginLeft={theme.spacing.sm}
127
+ marginRight={theme.spacing.sm}
128
+ {...press.handlers}
130
129
  >
131
- {item.label}
132
- </text>
133
- </Pressable>
134
- )}
130
+ <d-rect color={itemBg(item, press.hovered())} radius={theme.radius.sm} />
131
+ {item.icon}
132
+ <text
133
+ color={item.value === value() ? theme.color.primary : theme.color.text}
134
+ {...typeStyle("body")}
135
+ >
136
+ {item.label}
137
+ </text>
138
+ </view>
139
+ )
140
+ }}
135
141
  </For>
136
142
  </view>
137
143
  <Hairline vertical />
package/src/pan.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { onSettled } from "@solidrt/core"
2
+ import type { PointerEvent } from "@solidrt/core"
3
+ import { release, steal } from "./arena"
4
+
5
+ // Movement in logical pixels before a pan activates. Below this a drag still
6
+ // reads as a press (tap wiggle); crossing it is the positive evidence that the
7
+ // gesture is a pan, at which point the pan steals the pointer in the arena.
8
+ const PAN_SLOP = 8
9
+
10
+ export type PanAxis = "vertical" | "horizontal" | "both"
11
+
12
+ export interface PanOptions {
13
+ /**
14
+ * Which movement direction activates the pan. Slop is axis-aware: a
15
+ * "vertical" pan only activates once vertical travel crosses the threshold,
16
+ * so nested cross-axis scrollers each take only drags along their own axis.
17
+ * Default "both" (straight-line distance).
18
+ */
19
+ axis?: PanAxis
20
+ onPanStart?: () => void
21
+ /** Pointer movement since the previous event; positive dx is rightward, positive dy downward. */
22
+ onPanMove?: (dx: number, dy: number) => void
23
+ onPanEnd?: () => void
24
+ }
25
+
26
+ // The pan recognizer: turns a drag into a movement-delta stream. On a down it
27
+ // arms and starts measuring; when travel from the down point crosses the slop
28
+ // along the enabled axis it activates, stealing the pointer in the arena (a
29
+ // press that provisionally owned it is cancelled - its feedback retracts) and
30
+ // resolving it so no other recognizer can take the drag over. If the arena is
31
+ // already resolved (an inner pan won first) the recognizer disarms and stays
32
+ // out. The slop distance itself is swallowed: deltas stream from the
33
+ // activation point on. Moves and the up arrive on the frozen down path, so an
34
+ // active pan keeps streaming when the pointer leaves the node or the window.
35
+ // cancel() is the external-cancel hook; it ends an active pan without
36
+ // onPanEnd. Options are read at event time. Deliberately framework-agnostic:
37
+ // a candidate for promotion into core (okf/plans/component-gestures.md).
38
+ export function createPan(options: PanOptions) {
39
+ // Down position while armed; last delivered position while active.
40
+ let origin: { x: number; y: number } | null = null
41
+ let active: number | null = null
42
+ let armed: number | null = null
43
+
44
+ let past = (e: PointerEvent) => {
45
+ if (!origin) return false
46
+ let dx = Math.abs(e.clientX - origin.x)
47
+ let dy = Math.abs(e.clientY - origin.y)
48
+ let axis = options.axis ?? "both"
49
+ if (axis === "vertical") return dy >= PAN_SLOP
50
+ if (axis === "horizontal") return dx >= PAN_SLOP
51
+ return dx * dx + dy * dy >= PAN_SLOP * PAN_SLOP
52
+ }
53
+
54
+ let reset = () => {
55
+ if (active != null) {
56
+ release(active, owner)
57
+ active = null
58
+ }
59
+ armed = null
60
+ origin = null
61
+ }
62
+ let cancel = reset
63
+ let owner = { cancel }
64
+
65
+ // An unmount mid-drag must not leave a resolved claim behind.
66
+ onSettled(() => reset)
67
+
68
+ let handlers = {
69
+ onPointerDown: (e: PointerEvent) => {
70
+ if (e.button != null && e.button !== 0) return
71
+ if (armed == null && active == null) {
72
+ armed = e.pointerId
73
+ origin = { x: e.clientX, y: e.clientY }
74
+ }
75
+ },
76
+ onPointerMove: (e: PointerEvent) => {
77
+ if (armed === e.pointerId && past(e)) {
78
+ if (steal(e.pointerId, owner)) {
79
+ active = e.pointerId
80
+ armed = null
81
+ origin = { x: e.clientX, y: e.clientY }
82
+ options.onPanStart?.()
83
+ } else {
84
+ // The arena is resolved against us; the drag belongs elsewhere.
85
+ reset()
86
+ }
87
+ return
88
+ }
89
+ if (active === e.pointerId && origin) {
90
+ options.onPanMove?.(e.clientX - origin.x, e.clientY - origin.y)
91
+ origin = { x: e.clientX, y: e.clientY }
92
+ }
93
+ },
94
+ onPointerUp: (e: PointerEvent) => {
95
+ if (active === e.pointerId) {
96
+ reset()
97
+ options.onPanEnd?.()
98
+ } else if (armed === e.pointerId) {
99
+ reset()
100
+ }
101
+ },
102
+ }
103
+
104
+ return { handlers, cancel }
105
+ }