@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 +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/types.ts +8 -0
package/src/press.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createSignal, onSettled, getBoundingBoxViewport } from "@solidrt/core"
|
|
2
|
+
import type { PointerEvent } from "@solidrt/core"
|
|
3
|
+
import { claim, release } from "./arena"
|
|
4
|
+
|
|
5
|
+
export type PressState = { pressed: boolean; hovered: boolean }
|
|
6
|
+
|
|
7
|
+
export interface PressOptions {
|
|
8
|
+
onPress?: () => void
|
|
9
|
+
onPointerDown?: (e: PointerEvent) => void
|
|
10
|
+
onPointerUp?: (e: PointerEvent) => void
|
|
11
|
+
onPointerMove?: (e: PointerEvent) => void
|
|
12
|
+
onPointerEnter?: (e: PointerEvent) => void
|
|
13
|
+
onPointerLeave?: (e: PointerEvent) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The press state machine shared by the pressable components. onPress fires on
|
|
17
|
+
// a primary-button down followed by an up over the node. The down provisionally
|
|
18
|
+
// claims the pointer in the arena; pointer events dispatch leaf to root, so the
|
|
19
|
+
// innermost recognizer claims first and recognizers further up the same bubble
|
|
20
|
+
// path find the pointer taken and fail silently (no pressed state, no onPress -
|
|
21
|
+
// ancestors keep hover only). The claim is stealable: a pan recognizer crossing
|
|
22
|
+
// its slop takes the pointer and this press is cancelled through the arena.
|
|
23
|
+
//
|
|
24
|
+
// Moves and the up arrive on the frozen down path, so the press survives
|
|
25
|
+
// leaving the node: while outside its window-relative bounds the pressed state
|
|
26
|
+
// clears (visual feedback retracts), wandering back in restores it (press
|
|
27
|
+
// retention), and only an up inside fires onPress. Enter/leave drive hover
|
|
28
|
+
// alone. Non-primary buttons (right/middle) do not start a press. cancel() is
|
|
29
|
+
// the external-cancel hook; it ends the press without firing. Options are read
|
|
30
|
+
// at event time, so passing a component's reactive props object keeps handler
|
|
31
|
+
// changes live. The host view must attach `ref` for retention bounds; without
|
|
32
|
+
// it every position counts as inside (the up always fires).
|
|
33
|
+
// Deliberately framework-agnostic (no theme, no styling): a candidate for
|
|
34
|
+
// promotion into core once the recognizer family grows
|
|
35
|
+
// (okf/plans/component-gestures.md).
|
|
36
|
+
export function createPress(options: PressOptions) {
|
|
37
|
+
let [pressed, setPressed] = createSignal(false)
|
|
38
|
+
let [hovered, setHovered] = createSignal(false)
|
|
39
|
+
let node: { id: number } | null = null
|
|
40
|
+
// The pointer this recognizer is tracking while a press is in flight, and
|
|
41
|
+
// the retention state at the last move (read on up; the signal itself is
|
|
42
|
+
// not readable same-dispatch because writes flush on the microtask).
|
|
43
|
+
let active: number | null = null
|
|
44
|
+
let inside = false
|
|
45
|
+
|
|
46
|
+
let state = (): PressState => ({ pressed: pressed(), hovered: hovered() })
|
|
47
|
+
let ref = (n: { id: number }) => {
|
|
48
|
+
node = n
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let within = (e: PointerEvent) => {
|
|
52
|
+
let b = node && getBoundingBoxViewport(node)
|
|
53
|
+
if (!b) return true
|
|
54
|
+
return e.clientX >= b.x && e.clientX < b.x + b.width && e.clientY >= b.y && e.clientY < b.y + b.height
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let disengage = () => {
|
|
58
|
+
if (active != null) {
|
|
59
|
+
release(active, owner)
|
|
60
|
+
active = null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let cancel = () => {
|
|
64
|
+
disengage()
|
|
65
|
+
setPressed(false)
|
|
66
|
+
}
|
|
67
|
+
let owner = { cancel }
|
|
68
|
+
|
|
69
|
+
// A press abandoned mid-flight (unmount during a drag) must not leave its
|
|
70
|
+
// claim behind, or that pointer id could never press anything again.
|
|
71
|
+
onSettled(() => disengage)
|
|
72
|
+
|
|
73
|
+
let handlers = {
|
|
74
|
+
onPointerDown: (e: PointerEvent) => {
|
|
75
|
+
if (e.button != null && e.button !== 0) return
|
|
76
|
+
if (active == null && claim(e.pointerId, owner)) {
|
|
77
|
+
active = e.pointerId
|
|
78
|
+
inside = true
|
|
79
|
+
setPressed(true)
|
|
80
|
+
}
|
|
81
|
+
options.onPointerDown?.(e)
|
|
82
|
+
},
|
|
83
|
+
onPointerMove: (e: PointerEvent) => {
|
|
84
|
+
if (active === e.pointerId) {
|
|
85
|
+
inside = within(e)
|
|
86
|
+
setPressed(inside)
|
|
87
|
+
}
|
|
88
|
+
options.onPointerMove?.(e)
|
|
89
|
+
},
|
|
90
|
+
onPointerUp: (e: PointerEvent) => {
|
|
91
|
+
if (active === e.pointerId) {
|
|
92
|
+
let fire = inside
|
|
93
|
+
cancel()
|
|
94
|
+
if (fire) options.onPress?.()
|
|
95
|
+
}
|
|
96
|
+
options.onPointerUp?.(e)
|
|
97
|
+
},
|
|
98
|
+
onPointerEnter: (e: PointerEvent) => {
|
|
99
|
+
setHovered(true)
|
|
100
|
+
options.onPointerEnter?.(e)
|
|
101
|
+
},
|
|
102
|
+
onPointerLeave: (e: PointerEvent) => {
|
|
103
|
+
setHovered(false)
|
|
104
|
+
options.onPointerLeave?.(e)
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { pressed, hovered, state, ref, handlers, cancel }
|
|
109
|
+
}
|
package/src/pressable.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { LayoutProps,
|
|
1
|
+
import { children } from "@solidrt/core"
|
|
2
|
+
import type { LayoutProps, PointerProps } from "@solidrt/core"
|
|
3
3
|
import type { StyleProps } from "./types"
|
|
4
|
+
import { createPress, type PressState } from "./press"
|
|
4
5
|
|
|
5
|
-
export type
|
|
6
|
+
export type { PressState } from "./press"
|
|
6
7
|
|
|
7
8
|
export interface PressableProps extends PointerProps {
|
|
8
9
|
// children and style may be functions of the press state, so a caller can
|
|
@@ -15,17 +16,12 @@ export interface PressableProps extends PointerProps {
|
|
|
15
16
|
disabled?: boolean
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
// A pressable box
|
|
19
|
-
//
|
|
20
|
-
// fires onPointerLeave, which cancels the press (no onPress) and clears hover --
|
|
21
|
-
// this also covers the no-up-outside case. Non-primary buttons (right/middle) do
|
|
22
|
-
// not start a press. When disabled, it takes no pointer events at all.
|
|
19
|
+
// A pressable box: the createPress semantics (see press.ts) on a styled view.
|
|
20
|
+
// When disabled, it takes no pointer events at all.
|
|
23
21
|
export function Pressable(props: PressableProps) {
|
|
24
|
-
let
|
|
25
|
-
let [hovered, setHovered] = createSignal(false)
|
|
22
|
+
let press = createPress(props)
|
|
26
23
|
|
|
27
|
-
let
|
|
28
|
-
let style = () => (typeof props.style === "function" ? props.style(state()) : props.style)
|
|
24
|
+
let style = () => (typeof props.style === "function" ? props.style(press.state()) : props.style)
|
|
29
25
|
// Element-valued props build a fresh native subtree on every read, and a
|
|
30
26
|
// subtree that is never inserted is never destroyed - probing the raw getter
|
|
31
27
|
// with typeof would orphan one full copy per evaluation. children() memoizes
|
|
@@ -35,27 +31,7 @@ export function Pressable(props: PressableProps) {
|
|
|
35
31
|
let resolved = children(() => props.children)
|
|
36
32
|
let kids = () => {
|
|
37
33
|
let c = resolved()
|
|
38
|
-
return typeof c === "function" ? c(state()) : c
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let handleDown = (e: PointerEvent) => {
|
|
42
|
-
if (e.button != null && e.button !== 0) return
|
|
43
|
-
setPressed(true)
|
|
44
|
-
props.onPointerDown?.(e)
|
|
45
|
-
}
|
|
46
|
-
let handleUp = (e: PointerEvent) => {
|
|
47
|
-
if (pressed()) props.onPress?.()
|
|
48
|
-
setPressed(false)
|
|
49
|
-
props.onPointerUp?.(e)
|
|
50
|
-
}
|
|
51
|
-
let handleEnter = (e: PointerEvent) => {
|
|
52
|
-
setHovered(true)
|
|
53
|
-
props.onPointerEnter?.(e)
|
|
54
|
-
}
|
|
55
|
-
let handleLeave = (e: PointerEvent) => {
|
|
56
|
-
setHovered(false)
|
|
57
|
-
setPressed(false)
|
|
58
|
-
props.onPointerLeave?.(e)
|
|
34
|
+
return typeof c === "function" ? c(press.state()) : c
|
|
59
35
|
}
|
|
60
36
|
|
|
61
37
|
let hasBackground = () => style()?.backgroundColor != null || style()?.borderRadius != null
|
|
@@ -63,7 +39,10 @@ export function Pressable(props: PressableProps) {
|
|
|
63
39
|
|
|
64
40
|
return (
|
|
65
41
|
<view
|
|
66
|
-
ref={
|
|
42
|
+
ref={(n: { id: number }) => {
|
|
43
|
+
press.ref(n)
|
|
44
|
+
props.ref?.(n)
|
|
45
|
+
}}
|
|
67
46
|
repaintBoundary
|
|
68
47
|
{...props.layout}
|
|
69
48
|
x={style()?.x}
|
|
@@ -71,11 +50,11 @@ export function Pressable(props: PressableProps) {
|
|
|
71
50
|
scale={style()?.scale}
|
|
72
51
|
rotate={style()?.rotate}
|
|
73
52
|
opacity={style()?.opacity}
|
|
74
|
-
onPointerEnter={
|
|
75
|
-
onPointerLeave={
|
|
76
|
-
onPointerDown={
|
|
77
|
-
onPointerUp={
|
|
78
|
-
onPointerMove={
|
|
53
|
+
onPointerEnter={press.handlers.onPointerEnter}
|
|
54
|
+
onPointerLeave={press.handlers.onPointerLeave}
|
|
55
|
+
onPointerDown={press.handlers.onPointerDown}
|
|
56
|
+
onPointerUp={press.handlers.onPointerUp}
|
|
57
|
+
onPointerMove={press.handlers.onPointerMove}
|
|
79
58
|
onWheel={props.onWheel}
|
|
80
59
|
onFocus={props.onFocus}
|
|
81
60
|
onBlur={props.onBlur}
|
|
@@ -101,4 +80,4 @@ export function Pressable(props: PressableProps) {
|
|
|
101
80
|
) : null}
|
|
102
81
|
</view>
|
|
103
82
|
)
|
|
104
|
-
}
|
|
83
|
+
}
|
package/src/radio.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSignal, createContext, useContext, Show, children } 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 { typeStyle } from "./typography"
|
|
@@ -81,13 +81,27 @@ export function Radio(props: RadioProps) {
|
|
|
81
81
|
// Inner dot inset as a fraction of the ring, so it scales with the density.
|
|
82
82
|
let inset = () => ring() * 0.3
|
|
83
83
|
|
|
84
|
+
let press = createPress({ onPress: () => ctx.select(props.value) })
|
|
85
|
+
|
|
84
86
|
return (
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
<view
|
|
88
|
+
ref={press.ref}
|
|
89
|
+
repaintBoundary
|
|
90
|
+
flexDirection="row"
|
|
91
|
+
alignItems="center"
|
|
92
|
+
gap={theme.spacing.md}
|
|
93
|
+
{...props.layout}
|
|
94
|
+
x={props.style?.x}
|
|
95
|
+
y={props.style?.y}
|
|
96
|
+
scale={props.style?.scale}
|
|
97
|
+
rotate={props.style?.rotate}
|
|
98
|
+
opacity={props.style?.opacity}
|
|
99
|
+
{...press.handlers}
|
|
100
|
+
pointerEvents={disabled() ? "none" : undefined}
|
|
90
101
|
>
|
|
102
|
+
<Show when={props.style?.backgroundColor != null || props.style?.borderRadius != null}>
|
|
103
|
+
<d-rect color={props.style?.backgroundColor ?? "transparent"} radius={props.style?.borderRadius} />
|
|
104
|
+
</Show>
|
|
91
105
|
<view width={ring()} height={ring()}>
|
|
92
106
|
<d-oval x={1} y={1} w={ring() - 2} h={ring() - 2} drawStyle="stroke" color={ringColor()} strokeWidth={2} />
|
|
93
107
|
<Show when={selected()}>
|
|
@@ -99,6 +113,14 @@ export function Radio(props: RadioProps) {
|
|
|
99
113
|
{resolved()}
|
|
100
114
|
</text>
|
|
101
115
|
</Show>
|
|
102
|
-
|
|
116
|
+
<Show when={(props.style?.borderWidth ?? 0) > 0}>
|
|
117
|
+
<d-rect
|
|
118
|
+
drawStyle="stroke"
|
|
119
|
+
color={props.style?.borderColor ?? "transparent"}
|
|
120
|
+
strokeWidth={props.style?.borderWidth}
|
|
121
|
+
radius={props.style?.borderRadius}
|
|
122
|
+
/>
|
|
123
|
+
</Show>
|
|
124
|
+
</view>
|
|
103
125
|
)
|
|
104
126
|
}
|
package/src/scroll-view.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createScroll } from "@solidrt/core"
|
|
2
|
-
import type { LayoutProps,
|
|
2
|
+
import type { LayoutProps, PointerProps, WheelEvent } from "@solidrt/core"
|
|
3
|
+
import { createPan } from "./pan"
|
|
3
4
|
import type { StyleProps } from "./types"
|
|
4
5
|
|
|
5
6
|
export interface ScrollViewProps extends PointerProps {
|
|
@@ -14,11 +15,13 @@ export interface ScrollViewProps extends PointerProps {
|
|
|
14
15
|
// A scrollable region. The outer box carries layout/style/transform and the
|
|
15
16
|
// optional background and border; inside it a clipping viewport (overflow
|
|
16
17
|
// hidden) holds a content wrapper that takes the children's natural size. The
|
|
17
|
-
// offset from createScroll translates the content via scrollX/scrollY. Wheel
|
|
18
|
-
// drag map to scroll deltas: positive moves the content up/left, so
|
|
19
|
-
// finger up reveals content below (natural scrolling).
|
|
20
|
-
// a
|
|
21
|
-
//
|
|
18
|
+
// offset from createScroll translates the content via scrollX/scrollY. Wheel
|
|
19
|
+
// and drag map to scroll deltas: positive moves the content up/left, so
|
|
20
|
+
// dragging a finger up reveals content below (natural scrolling). The drag is
|
|
21
|
+
// a pan recognizer: it activates on movement slop along the scroll axis,
|
|
22
|
+
// stealing the pointer from a pressable the drag started on (its press
|
|
23
|
+
// feedback retracts), and keeps scrolling when the pointer leaves the box.
|
|
24
|
+
// There is no momentum yet; a fling stops when the finger lifts.
|
|
22
25
|
export function ScrollView(props: ScrollViewProps) {
|
|
23
26
|
let viewport: { id: number } | undefined
|
|
24
27
|
let content: { id: number } | undefined
|
|
@@ -29,21 +32,13 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
29
32
|
{ axis: props.horizontal ? "horizontal" : "vertical" },
|
|
30
33
|
)
|
|
31
34
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
let
|
|
35
|
+
// Content follows the finger: it moves opposite to scroll offsets, which
|
|
36
|
+
// grow toward the bottom/right.
|
|
37
|
+
let pan = createPan({
|
|
38
|
+
axis: props.horizontal ? "horizontal" : "vertical",
|
|
39
|
+
onPanMove: (dx, dy) => scroll.scrollBy(-dx, -dy),
|
|
40
|
+
})
|
|
35
41
|
|
|
36
|
-
let onPointerDown = (e: PointerEvent) => {
|
|
37
|
-
last = { x: e.clientX, y: e.clientY }
|
|
38
|
-
}
|
|
39
|
-
let onPointerMove = (e: PointerEvent) => {
|
|
40
|
-
if (!last) return
|
|
41
|
-
scroll.scrollBy(last.x - e.clientX, last.y - e.clientY)
|
|
42
|
-
last = { x: e.clientX, y: e.clientY }
|
|
43
|
-
}
|
|
44
|
-
let endDrag = () => {
|
|
45
|
-
last = null
|
|
46
|
-
}
|
|
47
42
|
let onWheel = (e: WheelEvent) => {
|
|
48
43
|
// A plain mouse wheel only emits deltaY. On a horizontal scroller, route that
|
|
49
44
|
// vertical delta to the x axis so the wheel still scrolls it (trackpads that
|
|
@@ -88,10 +83,7 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
88
83
|
flexDirection={direction()}
|
|
89
84
|
scrollX={scroll.offset().x}
|
|
90
85
|
scrollY={scroll.offset().y}
|
|
91
|
-
|
|
92
|
-
onPointerMove={onPointerMove}
|
|
93
|
-
onPointerUp={endDrag}
|
|
94
|
-
onPointerLeave={endDrag}
|
|
86
|
+
{...pan.handlers}
|
|
95
87
|
onWheel={onWheel}
|
|
96
88
|
>
|
|
97
89
|
<view ref={(n: { id: number }) => (content = n)} flexShrink={0} flexDirection={direction()}>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createSignal, For } from "@solidrt/core"
|
|
2
|
+
import type { LayoutProps } from "@solidrt/core"
|
|
3
|
+
import { createPress } from "./press"
|
|
4
|
+
import { theme } from "./theme"
|
|
5
|
+
import { policy } from "./policy"
|
|
6
|
+
import { space } from "./spacing"
|
|
7
|
+
import { typeStyle, lightOnDark } from "./typography"
|
|
8
|
+
import type { Option, StyleProps } from "./types"
|
|
9
|
+
|
|
10
|
+
export interface SegmentedControlProps {
|
|
11
|
+
options: Option[]
|
|
12
|
+
// Controlled selected value. If omitted, the control is uncontrolled.
|
|
13
|
+
value?: unknown
|
|
14
|
+
defaultValue?: unknown
|
|
15
|
+
onChange?: (value: unknown) => void
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
layout?: LayoutProps
|
|
18
|
+
style?: StyleProps
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Width of the hairline divider between segments (logical px). The dividers
|
|
22
|
+
// are the backing rect showing through the row gap, so this is a gap, not a
|
|
23
|
+
// stroke.
|
|
24
|
+
const DIVIDER = 0
|
|
25
|
+
|
|
26
|
+
// A single-choice row of equal-width segments, joined flush (the Material
|
|
27
|
+
// style): only the control's outermost corners are rounded, interior segments
|
|
28
|
+
// are square, and hairline dividers separate them. The active segment fills
|
|
29
|
+
// with the primary color. Controlled via value/onChange, or uncontrolled via
|
|
30
|
+
// defaultValue. Hover tints inactive segments (non-touch interaction policies
|
|
31
|
+
// only). Override the inactive fill via style, spacing/sizing via layout.
|
|
32
|
+
export function SegmentedControl(props: SegmentedControlProps) {
|
|
33
|
+
let [internal, setInternal] = createSignal(props.defaultValue)
|
|
34
|
+
let value = () => (props.value !== undefined ? props.value : internal())
|
|
35
|
+
let select = (v: unknown) => {
|
|
36
|
+
if (props.value === undefined) setInternal(() => v)
|
|
37
|
+
props.onChange?.(v)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let radius = () =>
|
|
41
|
+
typeof props.style?.borderRadius === "number" ? props.style.borderRadius : theme.radius.md
|
|
42
|
+
// Per-corner radii: round only the corners on the control's outer edge, so
|
|
43
|
+
// the segments read as one joined control. [tl, tr, br, bl].
|
|
44
|
+
let corners = (i: number): number | [number, number, number, number] => {
|
|
45
|
+
let r = radius()
|
|
46
|
+
let last = props.options.length - 1
|
|
47
|
+
if (last === 0) return r
|
|
48
|
+
if (i === 0) return [r, 0, 0, r]
|
|
49
|
+
if (i === last) return [0, r, r, 0]
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let idleFill = () => props.style?.backgroundColor ?? theme.color.surfaceAlt
|
|
54
|
+
let activeFill = () => (props.disabled ? theme.color.surface : theme.color.primary)
|
|
55
|
+
let label = (active: boolean) =>
|
|
56
|
+
props.disabled ? theme.color.textMuted : active ? theme.color.onPrimary : theme.color.text
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<view
|
|
60
|
+
flexDirection="row"
|
|
61
|
+
gap={DIVIDER}
|
|
62
|
+
{...props.layout}
|
|
63
|
+
x={props.style?.x}
|
|
64
|
+
y={props.style?.y}
|
|
65
|
+
scale={props.style?.scale}
|
|
66
|
+
rotate={props.style?.rotate}
|
|
67
|
+
opacity={props.style?.opacity}
|
|
68
|
+
>
|
|
69
|
+
<d-rect color={theme.color.border} radius={radius()} />
|
|
70
|
+
<For each={props.options}>
|
|
71
|
+
{(opt, i) => {
|
|
72
|
+
let active = () => value() === opt.value
|
|
73
|
+
let press = createPress({ onPress: () => select(opt.value) })
|
|
74
|
+
let fill = () =>
|
|
75
|
+
active()
|
|
76
|
+
? activeFill()
|
|
77
|
+
: press.hovered() && !props.disabled && policy.interaction !== "touch"
|
|
78
|
+
? theme.color.surfaceHover
|
|
79
|
+
: idleFill()
|
|
80
|
+
return (
|
|
81
|
+
<view
|
|
82
|
+
ref={press.ref}
|
|
83
|
+
repaintBoundary
|
|
84
|
+
flexGrow={1}
|
|
85
|
+
flexBasis={0}
|
|
86
|
+
alignItems="center"
|
|
87
|
+
paddingTop={space("md")}
|
|
88
|
+
paddingBottom={space("md")}
|
|
89
|
+
paddingLeft={space("md")}
|
|
90
|
+
paddingRight={space("md")}
|
|
91
|
+
{...press.handlers}
|
|
92
|
+
pointerEvents={props.disabled ? "none" : undefined}
|
|
93
|
+
>
|
|
94
|
+
<d-rect color={fill()} radius={corners(i())} />
|
|
95
|
+
<text
|
|
96
|
+
color={label(active())}
|
|
97
|
+
{...typeStyle("body", active() ? lightOnDark(label(true), activeFill()) : undefined)}
|
|
98
|
+
>
|
|
99
|
+
{opt.label}
|
|
100
|
+
</text>
|
|
101
|
+
</view>
|
|
102
|
+
)
|
|
103
|
+
}}
|
|
104
|
+
</For>
|
|
105
|
+
</view>
|
|
106
|
+
)
|
|
107
|
+
}
|
package/src/select.tsx
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
import { createSignal, createPortal, onLayout, getBoundingBox, Show, For, env } 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"
|
|
7
7
|
import { typeStyle } from "./typography"
|
|
8
|
-
import type { StyleProps } from "./types"
|
|
9
|
-
|
|
10
|
-
export interface SelectOption {
|
|
11
|
-
value: unknown
|
|
12
|
-
label: string
|
|
13
|
-
}
|
|
8
|
+
import type { Option, StyleProps } from "./types"
|
|
14
9
|
|
|
15
10
|
export interface SelectProps {
|
|
16
|
-
options:
|
|
11
|
+
options: Option[]
|
|
17
12
|
// Controlled selected value. If omitted, the select is uncontrolled.
|
|
18
13
|
value?: unknown
|
|
19
14
|
defaultValue?: unknown
|
|
@@ -53,25 +48,31 @@ export function Select(props: SelectProps) {
|
|
|
53
48
|
|
|
54
49
|
// One option row, shared by both presentations; only the vertical padding
|
|
55
50
|
// differs (the sheet gets taller touch targets).
|
|
56
|
-
let OptionRow = (p: { option:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
51
|
+
let OptionRow = (p: { option: Option; padY: number }) => {
|
|
52
|
+
let press = createPress({ onPress: () => choose(p.option.value) })
|
|
53
|
+
return (
|
|
54
|
+
<view
|
|
55
|
+
ref={press.ref}
|
|
56
|
+
repaintBoundary
|
|
57
|
+
flexDirection="row"
|
|
58
|
+
alignItems="center"
|
|
59
|
+
paddingTop={p.padY}
|
|
60
|
+
paddingBottom={p.padY}
|
|
61
|
+
paddingLeft={space("md")}
|
|
62
|
+
paddingRight={space("md")}
|
|
63
|
+
{...press.handlers}
|
|
64
|
+
>
|
|
65
|
+
<d-rect
|
|
66
|
+
color={
|
|
67
|
+
press.pressed() || (press.hovered() && policy.interaction !== "touch")
|
|
68
|
+
? theme.color.surfaceHover
|
|
69
|
+
: "transparent"
|
|
70
|
+
}
|
|
71
|
+
/>
|
|
72
|
+
<text {...bodyText(p.option.value === value() ? theme.color.primary : theme.color.text)}>{p.option.label}</text>
|
|
73
|
+
</view>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
75
76
|
|
|
76
77
|
// Anchored under the trigger, sized at least as wide as it. Positioned like
|
|
77
78
|
// Tooltip: portal at the window root, pinned at 0,0 and moved with the x/y
|
|
@@ -108,7 +109,7 @@ export function Select(props: SelectProps) {
|
|
|
108
109
|
>
|
|
109
110
|
<d-rect color={theme.color.surface} radius={theme.radius.sm} />
|
|
110
111
|
<For each={props.options}>
|
|
111
|
-
{(o:
|
|
112
|
+
{(o: Option) => <OptionRow option={o} padY={space("sm")} />}
|
|
112
113
|
</For>
|
|
113
114
|
<d-rect
|
|
114
115
|
drawStyle="stroke"
|
|
@@ -140,40 +141,50 @@ export function Select(props: SelectProps) {
|
|
|
140
141
|
>
|
|
141
142
|
<d-rect color={theme.color.surface} radius={theme.radius.sm} />
|
|
142
143
|
<For each={props.options}>
|
|
143
|
-
{(o:
|
|
144
|
+
{(o: Option) => <OptionRow option={o} padY={Math.round(theme.spacing.md * 1.5)} />}
|
|
144
145
|
</For>
|
|
145
146
|
</view>
|
|
146
147
|
</view>,
|
|
147
148
|
)
|
|
148
149
|
|
|
150
|
+
let press = createPress({ onPress: () => setOpen(!open()) })
|
|
151
|
+
let style = () => ({
|
|
152
|
+
borderColor: theme.color.border,
|
|
153
|
+
borderWidth: theme.borderWidth.sm,
|
|
154
|
+
borderRadius: theme.radius.sm,
|
|
155
|
+
...props.style,
|
|
156
|
+
backgroundColor:
|
|
157
|
+
props.style?.backgroundColor ??
|
|
158
|
+
(press.hovered() && !props.disabled && policy.interaction !== "touch"
|
|
159
|
+
? theme.color.surfaceHover
|
|
160
|
+
: theme.color.surface),
|
|
161
|
+
})
|
|
162
|
+
|
|
149
163
|
return (
|
|
150
|
-
<
|
|
151
|
-
ref={(n
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
layout={{
|
|
155
|
-
flexDirection: "row",
|
|
156
|
-
alignItems: "center",
|
|
157
|
-
justifyContent: "space-between",
|
|
158
|
-
gap: theme.spacing.md,
|
|
159
|
-
paddingTop: space("sm"),
|
|
160
|
-
paddingBottom: space("sm"),
|
|
161
|
-
paddingLeft: space("md"),
|
|
162
|
-
paddingRight: space("md"),
|
|
163
|
-
...props.layout,
|
|
164
|
+
<view
|
|
165
|
+
ref={(n: { id: number }) => {
|
|
166
|
+
press.ref(n)
|
|
167
|
+
trigger = n
|
|
164
168
|
}}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
repaintBoundary
|
|
170
|
+
flexDirection="row"
|
|
171
|
+
alignItems="center"
|
|
172
|
+
justifyContent="space-between"
|
|
173
|
+
gap={theme.spacing.md}
|
|
174
|
+
paddingTop={space("sm")}
|
|
175
|
+
paddingBottom={space("sm")}
|
|
176
|
+
paddingLeft={space("md")}
|
|
177
|
+
paddingRight={space("md")}
|
|
178
|
+
{...props.layout}
|
|
179
|
+
x={style().x}
|
|
180
|
+
y={style().y}
|
|
181
|
+
scale={style().scale}
|
|
182
|
+
rotate={style().rotate}
|
|
183
|
+
opacity={style().opacity}
|
|
184
|
+
{...press.handlers}
|
|
185
|
+
pointerEvents={props.disabled ? "none" : undefined}
|
|
176
186
|
>
|
|
187
|
+
<d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
|
|
177
188
|
<Show
|
|
178
189
|
when={selected()}
|
|
179
190
|
fallback={<text {...bodyText(theme.color.textMuted)}>{props.placeholder ?? ""}</text>}
|
|
@@ -195,6 +206,14 @@ export function Select(props: SelectProps) {
|
|
|
195
206
|
<Sheet />
|
|
196
207
|
</Show>
|
|
197
208
|
</Show>
|
|
198
|
-
|
|
209
|
+
<Show when={(style().borderWidth ?? 0) > 0}>
|
|
210
|
+
<d-rect
|
|
211
|
+
drawStyle="stroke"
|
|
212
|
+
color={style().borderColor ?? "transparent"}
|
|
213
|
+
strokeWidth={style().borderWidth}
|
|
214
|
+
radius={style().borderRadius}
|
|
215
|
+
/>
|
|
216
|
+
</Show>
|
|
217
|
+
</view>
|
|
199
218
|
)
|
|
200
219
|
}
|