@solidrt/components 0.0.35 → 0.0.37
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 +10 -5
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/arena.ts +58 -0
- package/src/button.tsx +47 -30
- package/src/checkbox.tsx +34 -14
- package/src/context-menu.tsx +27 -21
- package/src/index.ts +3 -2
- package/src/nav-shell.tsx +53 -47
- package/src/pan.ts +105 -0
- package/src/press.ts +109 -0
- package/src/pressable.tsx +19 -40
- package/src/radio.tsx +29 -7
- package/src/scroll-view.tsx +16 -24
- package/src/segmented-control.tsx +107 -0
- package/src/select.tsx +74 -55
- package/src/slider.tsx +25 -15
- package/src/switch.tsx +33 -13
- package/src/text-input.tsx +3 -3
- package/src/types.ts +8 -0
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
|
|
40
|
-
|
|
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 }`.
|
|
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`;
|
|
56
|
-
|
|
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.
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.0.37",
|
|
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.
|
|
21
|
+
"@solidrt/core": "0.0.37"
|
|
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 {
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// interaction policies only), both
|
|
38
|
-
//
|
|
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
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/context-menu.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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 {
|
|
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,
|
|
48
|
+
let itemBg = (item: NavItem, hovered: boolean) =>
|
|
49
49
|
item.value === value()
|
|
50
50
|
? theme.color.surfaceAlt
|
|
51
|
-
:
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
{p.item.
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|