@solidrt/components 0.0.51 → 0.0.53
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 +34 -7
- package/README.md +47 -10
- package/docs/button.md +1 -1
- package/docs/focus-nav.md +1 -1
- package/docs/icon.md +1 -1
- package/docs/item.md +1 -1
- package/docs/policy.md +1 -1
- package/docs/scroll-view.md +22 -1
- package/docs/split-view.md +1 -1
- package/docs/theme.md +14 -2
- package/docs/types.md +4 -0
- package/package.json +3 -3
- package/src/badge.tsx +21 -14
- package/src/button.tsx +13 -7
- package/src/card.tsx +10 -3
- package/src/checkbox.tsx +8 -2
- package/src/context-menu.tsx +9 -6
- package/src/divider.tsx +8 -3
- package/src/editor-field.tsx +18 -11
- package/src/field.tsx +4 -2
- package/src/icon.tsx +8 -4
- package/src/image.tsx +10 -3
- package/src/index.ts +10 -1
- package/src/item.tsx +12 -7
- package/src/nav-shell.tsx +6 -16
- package/src/policy.ts +9 -4
- package/src/pressable.tsx +11 -2
- package/src/progress-bar.tsx +4 -3
- package/src/qrcode.tsx +7 -5
- package/src/radio.tsx +12 -4
- package/src/rich-text-editor.tsx +5 -3
- package/src/scroll-view.tsx +76 -7
- package/src/segmented-control.tsx +14 -5
- package/src/select.tsx +23 -12
- package/src/slider.tsx +31 -5
- package/src/spinner.tsx +5 -2
- package/src/split-view.tsx +3 -4
- package/src/switch.tsx +8 -2
- package/src/text-input.tsx +4 -2
- package/src/text.tsx +22 -2
- package/src/theme.ts +72 -20
- package/src/tooltip.tsx +16 -5
- package/src/types.ts +119 -1
- package/src/view.tsx +11 -2
package/src/radio.tsx
CHANGED
|
@@ -2,9 +2,11 @@ import { createSignal, createContext, useContext, Show, children } from "@solidr
|
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { createPress } from "./press"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
|
+
import { policy } from "./policy"
|
|
5
6
|
import { densityScale } from "./density"
|
|
6
7
|
import { typeStyle } from "./typography"
|
|
7
|
-
import type { StyleProps } from "./types"
|
|
8
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
9
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
8
10
|
|
|
9
11
|
// Shared selection state for a group. Created and consumed within this module, so
|
|
10
12
|
// RadioGroup/Radio are a self-contained pair, not a cross-component dependency.
|
|
@@ -51,7 +53,7 @@ export function RadioGroup(props: RadioGroupProps) {
|
|
|
51
53
|
)
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
export interface RadioProps {
|
|
56
|
+
export interface RadioProps extends TransitionProps {
|
|
55
57
|
// This option's value; selecting it makes it the group's value.
|
|
56
58
|
value: unknown
|
|
57
59
|
disabled?: boolean
|
|
@@ -71,7 +73,6 @@ export function Radio(props: RadioProps) {
|
|
|
71
73
|
let ctx = useContext(RadioContext)
|
|
72
74
|
let selected = () => ctx.value() === props.value
|
|
73
75
|
let disabled = () => props.disabled || ctx.disabled()
|
|
74
|
-
let ringColor = () => (selected() ? theme.color.primary : theme.color.border)
|
|
75
76
|
// Resolved once via children(): the typeof probe and the mount sites must
|
|
76
77
|
// share one build - reading the raw getter again would orphan native nodes.
|
|
77
78
|
let resolved = children(() => props.children)
|
|
@@ -84,9 +85,15 @@ export function Radio(props: RadioProps) {
|
|
|
84
85
|
// Theme-level per-component overrides merged under the instance style.
|
|
85
86
|
let styled = () => ({ ...theme.components.radio, ...props.style })
|
|
86
87
|
let press = createPress({ onPress: () => ctx.select(props.value) })
|
|
88
|
+
// The circle doubles as the focus ring: ring color at the focus width.
|
|
89
|
+
let focusRing = () => press.focused() && policy.focusRing
|
|
90
|
+
let ringColor = () => (focusRing() ? theme.color.ring : selected() ? theme.color.primary : theme.color.border)
|
|
91
|
+
let ringWidth = () => (focusRing() ? theme.borderWidth.focus : 2)
|
|
87
92
|
|
|
88
93
|
return (
|
|
89
94
|
<view
|
|
95
|
+
transition={splitTransition(props.transition).root}
|
|
96
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
90
97
|
ref={press.ref}
|
|
91
98
|
repaintBoundary
|
|
92
99
|
flexDirection="row"
|
|
@@ -99,13 +106,14 @@ export function Radio(props: RadioProps) {
|
|
|
99
106
|
rotate={styled().rotate}
|
|
100
107
|
opacity={styled().opacity}
|
|
101
108
|
{...press.handlers}
|
|
109
|
+
focusable={!disabled()}
|
|
102
110
|
pointerEvents={disabled() ? "none" : undefined}
|
|
103
111
|
>
|
|
104
112
|
<Show when={styled().backgroundColor != null || styled().borderRadius != null}>
|
|
105
113
|
<d-rect color={styled().backgroundColor ?? "transparent"} radius={styled().borderRadius} />
|
|
106
114
|
</Show>
|
|
107
115
|
<view width={ring()} height={ring()}>
|
|
108
|
-
<d-oval drawStyle="stroke" color={ringColor()} strokeWidth={
|
|
116
|
+
<d-oval drawStyle="stroke" color={ringColor()} strokeWidth={ringWidth()} />
|
|
109
117
|
<Show when={selected()}>
|
|
110
118
|
<d-oval x={inset()} y={inset()} w={ring() - inset() * 2} h={ring() - inset() * 2} color={theme.color.primary} />
|
|
111
119
|
</Show>
|
package/src/rich-text-editor.tsx
CHANGED
|
@@ -3,11 +3,11 @@ import type { LayoutProps, TextInputHints } from "@solidrt/core"
|
|
|
3
3
|
import type { TextRunRange } from "flux:rendertree"
|
|
4
4
|
import { EditorField } from "./editor-field"
|
|
5
5
|
import { createDocumentBuffer, type Attributes, type Document, type DocumentBuffer } from "./rich-text-document"
|
|
6
|
-
import type { StyleProps } from "./types"
|
|
6
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
7
7
|
import { theme } from "./theme"
|
|
8
8
|
import { policy } from "./policy"
|
|
9
9
|
|
|
10
|
-
export interface RichTextEditorProps {
|
|
10
|
+
export interface RichTextEditorProps extends TransitionProps {
|
|
11
11
|
value?: Document
|
|
12
12
|
defaultValue?: Document
|
|
13
13
|
onInput?: (value: Document) => void
|
|
@@ -48,7 +48,7 @@ function fontOf(inline: Attributes, block: Attributes, base: number): Font {
|
|
|
48
48
|
else if (heading === 3) font.fontSize = base
|
|
49
49
|
if (heading === 1 || heading === 2 || heading === 3 || inline.bold) font.fontWeight = 700
|
|
50
50
|
if (inline.italic) font.fontStyle = "italic"
|
|
51
|
-
if (inline.code) font.fontFamily =
|
|
51
|
+
if (inline.code) font.fontFamily = theme.text.monoFamily
|
|
52
52
|
return font
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -111,6 +111,8 @@ export function RichTextEditor(props: RichTextEditorProps) {
|
|
|
111
111
|
|
|
112
112
|
return (
|
|
113
113
|
<EditorField
|
|
114
|
+
transition={props.transition}
|
|
115
|
+
onTransitionEnd={props.onTransitionEnd}
|
|
114
116
|
buffer={(step) => {
|
|
115
117
|
editor = createDocumentBuffer({
|
|
116
118
|
value: () => props.value,
|
package/src/scroll-view.tsx
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { createPan, createScroll } from "@solidrt/core"
|
|
2
|
-
import type { LayoutProps, PointerProps, WheelEvent } from "@solidrt/core"
|
|
3
|
-
import type { StyleProps } from "./types"
|
|
1
|
+
import { createPan, createScroll, createSignal, onSettled, untrack } from "@solidrt/core"
|
|
2
|
+
import type { LayoutProps, PointerProps, Scroll, WheelEvent } from "@solidrt/core"
|
|
3
|
+
import type { StyleProps, TransitionProps, TransitionScrollProp, TransitionStyleProp, TransitionViewProp } from "./types"
|
|
4
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
4
5
|
|
|
5
|
-
export interface ScrollViewProps
|
|
6
|
+
export interface ScrollViewProps
|
|
7
|
+
extends PointerProps,
|
|
8
|
+
TransitionProps<TransitionViewProp | TransitionStyleProp | TransitionScrollProp> {
|
|
6
9
|
children?: any
|
|
7
10
|
ref?: (node: { id: number }) => void
|
|
8
11
|
layout?: LayoutProps
|
|
9
12
|
style?: StyleProps
|
|
10
13
|
/** Scroll the horizontal axis instead of the vertical one. */
|
|
11
14
|
horizontal?: boolean
|
|
15
|
+
/** Receives the scroll handle (offset, range, scrollTo) for driving the view
|
|
16
|
+
* from app code; scroll policies such as following a growing log are written
|
|
17
|
+
* against it. Called once the component has settled, outside any reactive
|
|
18
|
+
* scope, so a signal setter can be passed directly. */
|
|
19
|
+
scrollRef?: (scroll: Scroll) => void
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
// A scrollable region. The outer box carries layout/style/transform and the
|
|
@@ -21,31 +29,81 @@ export interface ScrollViewProps extends PointerProps {
|
|
|
21
29
|
// stealing the pointer from a pressable the drag started on (its press
|
|
22
30
|
// feedback retracts), and keeps scrolling when the pointer leaves the box.
|
|
23
31
|
// There is no momentum yet; a fling stops when the finger lifts.
|
|
32
|
+
//
|
|
33
|
+
// Motion: the offset is written as a target and the runtime springs to it,
|
|
34
|
+
// so a wheel tick glides instead of jumping and a burst of ticks retargets
|
|
35
|
+
// one continuous motion. While a finger drags, the spring is withdrawn from
|
|
36
|
+
// the viewport declaration so the content tracks the finger exactly; the
|
|
37
|
+
// first drag write cancels any spring still in flight. A `scrollX`/`scrollY`
|
|
38
|
+
// entry in the `transition` prop replaces the default.
|
|
39
|
+
const SCROLL_SPRING = { duration: 250 }
|
|
40
|
+
|
|
24
41
|
export function ScrollView(props: ScrollViewProps) {
|
|
25
42
|
let viewport: { id: number } | undefined
|
|
26
43
|
let content: { id: number } | undefined
|
|
44
|
+
let [dragging, setDragging] = createSignal(false)
|
|
27
45
|
|
|
28
46
|
let scroll = createScroll(
|
|
29
47
|
() => viewport,
|
|
30
48
|
() => content,
|
|
31
49
|
{ axis: props.horizontal ? "horizontal" : "vertical" },
|
|
32
50
|
)
|
|
51
|
+
// Handed out from onSettled rather than the body: the body is an owned
|
|
52
|
+
// scope, where a signal write (an app passing its setter) is refused.
|
|
53
|
+
onSettled(() => {
|
|
54
|
+
untrack(() => props.scrollRef)?.(scroll)
|
|
55
|
+
})
|
|
33
56
|
|
|
34
57
|
// Content follows the finger: it moves opposite to scroll offsets, which
|
|
35
58
|
// grow toward the bottom/right.
|
|
36
59
|
let pan = createPan({
|
|
37
60
|
axis: props.horizontal ? "horizontal" : "vertical",
|
|
38
|
-
|
|
61
|
+
onPanStart: () => setDragging(true),
|
|
62
|
+
onPanMove: (dx, dy) => scroll.scrollBy({ x: -dx, y: -dy }),
|
|
63
|
+
onPanEnd: () => setDragging(false),
|
|
39
64
|
})
|
|
40
65
|
|
|
41
66
|
let onWheel = (e: WheelEvent) => {
|
|
42
67
|
// A plain mouse wheel only emits deltaY. On a horizontal scroller, route that
|
|
43
68
|
// vertical delta to the x axis so the wheel still scrolls it (trackpads that
|
|
44
69
|
// emit deltaX take precedence).
|
|
45
|
-
if (props.horizontal) scroll.scrollBy(e.deltaX || e.deltaY
|
|
46
|
-
else scroll.scrollBy(e.deltaX, e.deltaY)
|
|
70
|
+
if (props.horizontal) scroll.scrollBy({ x: e.deltaX || e.deltaY })
|
|
71
|
+
else scroll.scrollBy({ x: e.deltaX, y: e.deltaY })
|
|
47
72
|
}
|
|
48
73
|
|
|
74
|
+
// The viewport owns the scroll offset, the outer box everything else: a
|
|
75
|
+
// scrollX/scrollY entry is lifted out of the root declaration so that a
|
|
76
|
+
// shared `all` does not animate opacity twice (outer times viewport).
|
|
77
|
+
let split = () => {
|
|
78
|
+
let t = splitTransition(props.transition)
|
|
79
|
+
if (t.root == null || typeof t.root === "string") return { ...t, viewport: t.root }
|
|
80
|
+
let { scrollX, scrollY, ...rest } = t.root as Record<string, unknown>
|
|
81
|
+
let viewport: Record<string, unknown> = {}
|
|
82
|
+
if (scrollX !== undefined) viewport.scrollX = scrollX
|
|
83
|
+
if (scrollY !== undefined) viewport.scrollY = scrollY
|
|
84
|
+
if (rest.all !== undefined) viewport.all = rest.all
|
|
85
|
+
return {
|
|
86
|
+
...t,
|
|
87
|
+
root: Object.keys(rest).length ? (rest as typeof t.root) : undefined,
|
|
88
|
+
viewport: Object.keys(viewport).length ? (viewport as typeof t.root) : undefined,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// The viewport's declaration: the user's scroll entries over the default
|
|
92
|
+
// spring. During a drag, and while the latest programmatic write asked for
|
|
93
|
+
// no motion (scrollTo behavior "instant"), the scroll entries go, and a
|
|
94
|
+
// user `all` narrows to the one other property the viewport writes
|
|
95
|
+
// (clipRadius) so it cannot put a spring back under the finger or the
|
|
96
|
+
// instant write.
|
|
97
|
+
let viewportTransition = () => {
|
|
98
|
+
let user = split().viewport
|
|
99
|
+
let entries: Record<string, unknown> = typeof user === "string" ? { all: user } : { ...(user ?? {}) }
|
|
100
|
+
if (dragging() || scroll.behavior() === "instant") {
|
|
101
|
+
let { scrollX, scrollY, all, ...rest } = entries
|
|
102
|
+
if (all !== undefined) rest.clipRadius = all
|
|
103
|
+
return Object.keys(rest).length ? rest : null
|
|
104
|
+
}
|
|
105
|
+
return { scrollX: SCROLL_SPRING, scrollY: SCROLL_SPRING, ...entries }
|
|
106
|
+
}
|
|
49
107
|
let direction = () => (props.horizontal ? "row" : "column")
|
|
50
108
|
let hasBackground = () =>
|
|
51
109
|
props.style?.backgroundColor != null || props.style?.borderRadius != null
|
|
@@ -53,6 +111,8 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
53
111
|
|
|
54
112
|
return (
|
|
55
113
|
<view
|
|
114
|
+
transition={split().root}
|
|
115
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
56
116
|
ref={props.ref}
|
|
57
117
|
{...props.layout}
|
|
58
118
|
x={props.style?.x}
|
|
@@ -70,16 +130,23 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
70
130
|
>
|
|
71
131
|
{hasBackground() ? (
|
|
72
132
|
<d-rect
|
|
133
|
+
transition={split().background}
|
|
134
|
+
onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
73
135
|
color={props.style?.backgroundColor ?? "transparent"}
|
|
74
136
|
radius={props.style?.borderRadius}
|
|
75
137
|
/>
|
|
76
138
|
) : null}
|
|
139
|
+
{/* transition before scrollX/scrollY: props apply in source order, and
|
|
140
|
+
an instant write needs the withdrawn declaration to land before the
|
|
141
|
+
value in the same flush, or the value starts a spring anyway. */}
|
|
77
142
|
<view
|
|
78
143
|
ref={(n: { id: number }) => (viewport = n)}
|
|
79
144
|
flex={1}
|
|
80
145
|
overflow="hidden"
|
|
81
146
|
clipRadius={props.style?.borderRadius}
|
|
82
147
|
flexDirection={direction()}
|
|
148
|
+
transition={viewportTransition()}
|
|
149
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
83
150
|
scrollX={scroll.offset().x}
|
|
84
151
|
scrollY={scroll.offset().y}
|
|
85
152
|
{...pan.handlers}
|
|
@@ -92,6 +159,8 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
92
159
|
{hasBorder() ? (
|
|
93
160
|
<d-rect
|
|
94
161
|
drawStyle="stroke"
|
|
162
|
+
transition={split().border}
|
|
163
|
+
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
95
164
|
color={props.style?.borderColor ?? "transparent"}
|
|
96
165
|
strokeWidth={props.style?.borderWidth}
|
|
97
166
|
radius={props.style?.borderRadius}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { createSignal, For } from "@solidrt/core"
|
|
1
|
+
import { createSignal, For, Show } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
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, lightOnDark } from "./typography"
|
|
8
|
-
import type { Option, StyleProps } from "./types"
|
|
8
|
+
import type { Option, StyleProps, TransitionProps } from "./types"
|
|
9
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
9
10
|
|
|
10
|
-
export interface SegmentedControlProps {
|
|
11
|
+
export interface SegmentedControlProps extends TransitionProps {
|
|
11
12
|
options: Option[]
|
|
12
13
|
// Controlled selected value. If omitted, the control is uncontrolled.
|
|
13
14
|
value?: unknown
|
|
@@ -59,8 +60,12 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
59
60
|
let label = (active: boolean) =>
|
|
60
61
|
props.disabled ? theme.color.textMuted : active ? theme.color.onPrimary : theme.color.text
|
|
61
62
|
|
|
63
|
+
let split = () => splitTransition(props.transition)
|
|
64
|
+
|
|
62
65
|
return (
|
|
63
66
|
<view
|
|
67
|
+
transition={split().root}
|
|
68
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
64
69
|
flexDirection="row"
|
|
65
70
|
gap={DIVIDER}
|
|
66
71
|
{...props.layout}
|
|
@@ -70,7 +75,7 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
70
75
|
rotate={styled().rotate}
|
|
71
76
|
opacity={styled().opacity}
|
|
72
77
|
>
|
|
73
|
-
<d-rect color={theme.color.border} radius={radius()} />
|
|
78
|
+
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={theme.color.border} radius={radius()} />
|
|
74
79
|
<For each={props.options}>
|
|
75
80
|
{(opt, i) => {
|
|
76
81
|
let active = () => value() === opt.value
|
|
@@ -81,7 +86,7 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
81
86
|
press.hovered() && !props.disabled && policy.interaction !== "touch"
|
|
82
87
|
? theme.color.overlayHover
|
|
83
88
|
: "transparent"
|
|
84
|
-
|
|
89
|
+
return (
|
|
85
90
|
<view
|
|
86
91
|
ref={press.ref}
|
|
87
92
|
repaintBoundary
|
|
@@ -93,10 +98,14 @@ export function SegmentedControl(props: SegmentedControlProps) {
|
|
|
93
98
|
paddingLeft={space("md")}
|
|
94
99
|
paddingRight={space("md")}
|
|
95
100
|
{...press.handlers}
|
|
101
|
+
focusable={!props.disabled}
|
|
96
102
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
97
103
|
>
|
|
98
104
|
<d-rect color={fill()} radius={corners(i())} />
|
|
99
105
|
<d-rect color={overlay()} radius={corners(i())} />
|
|
106
|
+
<Show when={press.focused() && policy.focusRing}>
|
|
107
|
+
<d-rect drawStyle="stroke" color={theme.color.ring} strokeWidth={theme.borderWidth.focus} radius={corners(i())} />
|
|
108
|
+
</Show>
|
|
100
109
|
<text
|
|
101
110
|
color={label(active())}
|
|
102
111
|
{...typeStyle("body", active() ? lightOnDark(label(true), activeFill()) : undefined)}
|
package/src/select.tsx
CHANGED
|
@@ -5,10 +5,11 @@ 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 { Option, StyleProps } from "./types"
|
|
8
|
+
import type { Option, StyleProps, TransitionProps } from "./types"
|
|
9
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
9
10
|
import { Icon } from "./icon"
|
|
10
11
|
|
|
11
|
-
export interface SelectProps {
|
|
12
|
+
export interface SelectProps extends TransitionProps {
|
|
12
13
|
options: Option[]
|
|
13
14
|
// Controlled selected value. If omitted, the select is uncontrolled.
|
|
14
15
|
value?: unknown
|
|
@@ -21,9 +22,10 @@ export interface SelectProps {
|
|
|
21
22
|
style?: StyleProps
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
// Distance between the trigger and the dropdown.
|
|
26
|
+
let gap = () => theme.spacing.sm
|
|
25
27
|
// Minimum distance kept between the dropdown and the window edges.
|
|
26
|
-
|
|
28
|
+
let margin = () => theme.spacing.sm
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* A single-choice picker whose presentation forks on the interaction policy:
|
|
@@ -62,12 +64,13 @@ export function Select(props: SelectProps) {
|
|
|
62
64
|
paddingLeft={space("md")}
|
|
63
65
|
paddingRight={space("md")}
|
|
64
66
|
{...press.handlers}
|
|
67
|
+
focusable
|
|
65
68
|
>
|
|
66
69
|
<d-rect
|
|
67
70
|
color={
|
|
68
71
|
press.pressed()
|
|
69
72
|
? theme.color.overlayPressed
|
|
70
|
-
: press.hovered() && policy.interaction !== "touch"
|
|
73
|
+
: (press.hovered() && policy.interaction !== "touch") || (press.focused() && policy.focusRing)
|
|
71
74
|
? theme.color.overlayHover
|
|
72
75
|
: "transparent"
|
|
73
76
|
}
|
|
@@ -89,9 +92,9 @@ export function Select(props: SelectProps) {
|
|
|
89
92
|
let b = menu && getBoundingBox(menu)
|
|
90
93
|
if (!a || !b) return
|
|
91
94
|
if (a.width !== minWidth()) setMinWidth(a.width)
|
|
92
|
-
let x = Math.round(Math.min(Math.max(a.x,
|
|
93
|
-
let below = a.y + a.height +
|
|
94
|
-
let y = Math.round(below + b.height > env.windowSize.height -
|
|
95
|
+
let x = Math.round(Math.min(Math.max(a.x, margin()), env.windowSize.width - b.width - margin()))
|
|
96
|
+
let below = a.y + a.height + gap()
|
|
97
|
+
let y = Math.round(below + b.height > env.windowSize.height - margin() ? a.y - b.height - gap() : below)
|
|
95
98
|
let cur = pos()
|
|
96
99
|
if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
|
|
97
100
|
})
|
|
@@ -158,10 +161,11 @@ export function Select(props: SelectProps) {
|
|
|
158
161
|
let style = () => ({
|
|
159
162
|
borderColor: theme.color.border,
|
|
160
163
|
borderWidth: theme.borderWidth.sm,
|
|
161
|
-
borderRadius: theme.radius.
|
|
164
|
+
borderRadius: theme.radius.md,
|
|
162
165
|
backgroundColor: theme.color.surface,
|
|
163
166
|
...theme.components.select,
|
|
164
167
|
...props.style,
|
|
168
|
+
...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
|
|
165
169
|
})
|
|
166
170
|
// Hover feedback: the theme overlay tint drawn over the trigger fill.
|
|
167
171
|
let overlay = () =>
|
|
@@ -170,8 +174,12 @@ export function Select(props: SelectProps) {
|
|
|
170
174
|
: "transparent"
|
|
171
175
|
|
|
172
176
|
|
|
177
|
+
let split = () => splitTransition(props.transition)
|
|
178
|
+
|
|
173
179
|
return (
|
|
174
180
|
<view
|
|
181
|
+
transition={split().root}
|
|
182
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
175
183
|
ref={(n: { id: number }) => {
|
|
176
184
|
press.ref(n)
|
|
177
185
|
trigger = n
|
|
@@ -181,8 +189,8 @@ export function Select(props: SelectProps) {
|
|
|
181
189
|
alignItems="center"
|
|
182
190
|
justifyContent="space-between"
|
|
183
191
|
gap={theme.spacing.md}
|
|
184
|
-
paddingTop={space("
|
|
185
|
-
paddingBottom={space("
|
|
192
|
+
paddingTop={space("md")}
|
|
193
|
+
paddingBottom={space("md")}
|
|
186
194
|
paddingLeft={space("md")}
|
|
187
195
|
paddingRight={space("md")}
|
|
188
196
|
{...props.layout}
|
|
@@ -192,9 +200,10 @@ export function Select(props: SelectProps) {
|
|
|
192
200
|
rotate={style().rotate}
|
|
193
201
|
opacity={style().opacity}
|
|
194
202
|
{...press.handlers}
|
|
203
|
+
focusable={!props.disabled}
|
|
195
204
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
196
205
|
>
|
|
197
|
-
<d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
|
|
206
|
+
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
|
|
198
207
|
<d-rect color={overlay()} radius={style().borderRadius} />
|
|
199
208
|
<Show
|
|
200
209
|
when={selected()}
|
|
@@ -227,6 +236,8 @@ export function Select(props: SelectProps) {
|
|
|
227
236
|
<Show when={(style().borderWidth ?? 0) > 0}>
|
|
228
237
|
<d-rect
|
|
229
238
|
drawStyle="stroke"
|
|
239
|
+
transition={split().border}
|
|
240
|
+
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
230
241
|
color={style().borderColor ?? "transparent"}
|
|
231
242
|
strokeWidth={style().borderWidth}
|
|
232
243
|
radius={style().borderRadius}
|
package/src/slider.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { arena, createSignal, getBoundingBox, onLayout, onSettled } from "@solidrt/core"
|
|
2
|
-
import type { LayoutProps, PointerEvent } from "@solidrt/core"
|
|
1
|
+
import { arena, createMemo, createSignal, focusedNode, getBoundingBox, onLayout, onSettled, Show } from "@solidrt/core"
|
|
2
|
+
import type { KeyEvent, LayoutProps, PointerEvent } from "@solidrt/core"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
|
+
import { policy } from "./policy"
|
|
4
5
|
import { densityScale } from "./density"
|
|
5
|
-
import type { StyleProps } from "./types"
|
|
6
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
7
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
6
8
|
|
|
7
|
-
export interface SliderProps {
|
|
9
|
+
export interface SliderProps extends TransitionProps {
|
|
8
10
|
// Controlled value. If omitted, the slider is uncontrolled.
|
|
9
11
|
value?: number
|
|
10
12
|
defaultValue?: number
|
|
@@ -27,6 +29,8 @@ let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi :
|
|
|
27
29
|
// A horizontal slider. The groove fills up to the thumb; dragging or pressing
|
|
28
30
|
// the track sets the value from the pointer x. Moves arrive on the frozen down
|
|
29
31
|
// path, so a drag keeps updating when the pointer drifts off the track.
|
|
32
|
+
// Focused (spatial nav), arrow keys step the value (by `step`, else 1% of the
|
|
33
|
+
// range) and the thumb draws the focus ring under the focusRing policy.
|
|
30
34
|
// Controlled via value/onChange, or uncontrolled via defaultValue.
|
|
31
35
|
export function Slider(props: SliderProps) {
|
|
32
36
|
let min = () => props.min ?? 0
|
|
@@ -95,14 +99,31 @@ export function Slider(props: SliderProps) {
|
|
|
95
99
|
if (active === e.pointerId) endDrag()
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
// focusedNode() is read first, unconditionally, so the memo depends on it
|
|
103
|
+
// even when the ref has not set `track` yet (see createPress).
|
|
104
|
+
let focused = createMemo(() => {
|
|
105
|
+
let id = focusedNode()
|
|
106
|
+
return id != null && id === track?.id
|
|
107
|
+
})
|
|
108
|
+
let handleKeyDown = (e: KeyEvent) => {
|
|
109
|
+
if (props.disabled) return
|
|
110
|
+
let dir = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0
|
|
111
|
+
if (dir === 0) return
|
|
112
|
+
e.stopPropagation()
|
|
113
|
+
let inc = props.step ?? (max() - min()) / 100
|
|
114
|
+
commit(clamp(value() + dir * inc, min(), max()))
|
|
115
|
+
}
|
|
116
|
+
|
|
98
117
|
return (
|
|
99
118
|
<view
|
|
119
|
+
transition={splitTransition(props.transition).root}
|
|
120
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
100
121
|
ref={(n: { id: number }) => (track = n)}
|
|
101
122
|
repaintBoundary
|
|
102
123
|
flexDirection="row"
|
|
103
124
|
alignItems="center"
|
|
104
125
|
height={height()}
|
|
105
|
-
width={
|
|
126
|
+
width={theme.size.slider}
|
|
106
127
|
{...props.layout}
|
|
107
128
|
x={props.style?.x}
|
|
108
129
|
y={props.style?.y}
|
|
@@ -110,15 +131,20 @@ export function Slider(props: SliderProps) {
|
|
|
110
131
|
rotate={props.style?.rotate}
|
|
111
132
|
opacity={props.style?.opacity}
|
|
112
133
|
pointerEvents={props.disabled ? "none" : "auto"}
|
|
134
|
+
focusable={!props.disabled}
|
|
113
135
|
onPointerDown={handleDown}
|
|
114
136
|
onPointerMove={handleMove}
|
|
115
137
|
onPointerUp={handleUp}
|
|
138
|
+
onKeyDown={handleKeyDown}
|
|
116
139
|
>
|
|
117
140
|
<view ref={(n: { id: number }) => (groove = n)} position="relative" flex={1} height={GROOVE}>
|
|
118
141
|
<d-rect color={theme.color.surfaceAlt} radius={GROOVE / 2} />
|
|
119
142
|
<d-rect color={theme.color.primary} w={fillPx()} h={GROOVE} radius={GROOVE / 2} />
|
|
120
143
|
<view position="absolute" left={0} top={(GROOVE - thumb()) / 2} x={fillPx() - thumb() / 2}>
|
|
121
144
|
<d-oval w={thumb()} h={thumb()} color={theme.color.primary} />
|
|
145
|
+
<Show when={focused() && policy.focusRing}>
|
|
146
|
+
<d-oval drawStyle="stroke" w={thumb()} h={thumb()} color={theme.color.ring} strokeWidth={theme.borderWidth.focus} />
|
|
147
|
+
</Show>
|
|
122
148
|
</view>
|
|
123
149
|
</view>
|
|
124
150
|
</view>
|
package/src/spinner.tsx
CHANGED
|
@@ -2,9 +2,10 @@ import { createSignal, onFrame, Show } from "@solidrt/core"
|
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
4
|
import { policy } from "./policy"
|
|
5
|
-
import type { StyleProps } from "./types"
|
|
5
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
6
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
6
7
|
|
|
7
|
-
export interface SpinnerProps {
|
|
8
|
+
export interface SpinnerProps extends TransitionProps {
|
|
8
9
|
// Overall diameter in pixels.
|
|
9
10
|
size?: number
|
|
10
11
|
// Arc stroke width in pixels.
|
|
@@ -53,6 +54,8 @@ export function Spinner(props: SpinnerProps) {
|
|
|
53
54
|
|
|
54
55
|
return (
|
|
55
56
|
<view
|
|
57
|
+
transition={splitTransition(props.transition).root}
|
|
58
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
56
59
|
width={size()}
|
|
57
60
|
height={size()}
|
|
58
61
|
{...props.layout}
|
package/src/split-view.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Show } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { policy } from "./policy"
|
|
4
|
+
import { theme } from "./theme"
|
|
4
5
|
|
|
5
6
|
export interface SplitViewProps {
|
|
6
7
|
// The list (or primary) pane.
|
|
@@ -10,13 +11,11 @@ export interface SplitViewProps {
|
|
|
10
11
|
// Single-pane mode only: show the detail instead of the list. The app owns
|
|
11
12
|
// this navigation state; two-pane mode ignores it.
|
|
12
13
|
showDetail?: boolean
|
|
13
|
-
// Width of the list pane in two-pane mode.
|
|
14
|
+
// Width of the list pane in two-pane mode; defaults to theme.size.splitViewList.
|
|
14
15
|
listWidth?: number
|
|
15
16
|
layout?: LayoutProps
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
const LIST_WIDTH = 320
|
|
19
|
-
|
|
20
19
|
/**
|
|
21
20
|
* A list-detail container driven by the layout policy: two-pane shows the list
|
|
22
21
|
* beside the detail, single-pane shows one pane at a time per `showDetail`.
|
|
@@ -45,7 +44,7 @@ export function SplitView(props: SplitViewProps) {
|
|
|
45
44
|
}
|
|
46
45
|
>
|
|
47
46
|
<view flexDirection="row" {...props.layout}>
|
|
48
|
-
<view width={props.listWidth ??
|
|
47
|
+
<view width={props.listWidth ?? theme.size.splitViewList} flexDirection="column">
|
|
49
48
|
{props.list}
|
|
50
49
|
</view>
|
|
51
50
|
<view flex={1} flexDirection="column">
|
package/src/switch.tsx
CHANGED
|
@@ -2,10 +2,12 @@ import { createSignal, Show } from "@solidrt/core"
|
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { createPress } from "./press"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
|
+
import { policy } from "./policy"
|
|
5
6
|
import { densityScale } from "./density"
|
|
6
|
-
import type { StyleProps } from "./types"
|
|
7
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
8
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
7
9
|
|
|
8
|
-
export interface SwitchProps {
|
|
10
|
+
export interface SwitchProps extends TransitionProps {
|
|
9
11
|
// Controlled on/off. If omitted, the switch is uncontrolled.
|
|
10
12
|
value?: boolean
|
|
11
13
|
// Initial value for uncontrolled use.
|
|
@@ -45,10 +47,13 @@ export function Switch(props: SwitchProps) {
|
|
|
45
47
|
borderRadius: h() / 2,
|
|
46
48
|
...theme.components.switch,
|
|
47
49
|
...props.style,
|
|
50
|
+
...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
|
|
48
51
|
})
|
|
49
52
|
|
|
50
53
|
return (
|
|
51
54
|
<view
|
|
55
|
+
transition={splitTransition(props.transition).root}
|
|
56
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
52
57
|
ref={press.ref}
|
|
53
58
|
repaintBoundary
|
|
54
59
|
width={w()}
|
|
@@ -60,6 +65,7 @@ export function Switch(props: SwitchProps) {
|
|
|
60
65
|
rotate={style().rotate}
|
|
61
66
|
opacity={style().opacity}
|
|
62
67
|
{...press.handlers}
|
|
68
|
+
focusable={!props.disabled}
|
|
63
69
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
64
70
|
>
|
|
65
71
|
<d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
|
package/src/text-input.tsx
CHANGED
|
@@ -2,10 +2,10 @@ import { untrack } from "@solidrt/core"
|
|
|
2
2
|
import { createTextBuffer } from "@solidrt/core/text-input"
|
|
3
3
|
import type { LayoutProps, TextInputHints } from "@solidrt/core"
|
|
4
4
|
import { EditorField } from "./editor-field"
|
|
5
|
-
import type { StyleProps } from "./types"
|
|
5
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
6
6
|
import { theme } from "./theme"
|
|
7
7
|
|
|
8
|
-
export interface TextInputProps {
|
|
8
|
+
export interface TextInputProps extends TransitionProps {
|
|
9
9
|
value?: string
|
|
10
10
|
defaultValue?: string
|
|
11
11
|
onInput?: (value: string) => void
|
|
@@ -46,6 +46,8 @@ export function TextInput(props: TextInputProps) {
|
|
|
46
46
|
let value = (): string => ""
|
|
47
47
|
return (
|
|
48
48
|
<EditorField
|
|
49
|
+
transition={props.transition}
|
|
50
|
+
onTransitionEnd={props.onTransitionEnd}
|
|
49
51
|
buffer={(step) => {
|
|
50
52
|
let buffer = createTextBuffer({
|
|
51
53
|
value: () => props.value,
|
package/src/text.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createMemo } from "@solidrt/core"
|
|
2
2
|
import type { PointerProps } from "@solidrt/core"
|
|
3
|
-
import type { StyleProps, TextLayoutProps } from "./types"
|
|
3
|
+
import type { StyleProps, TextLayoutProps, TransitionProps, TransitionViewProp } from "./types"
|
|
4
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
4
5
|
import { theme, type TextVariant } from "./theme"
|
|
5
6
|
import { policy } from "./policy"
|
|
6
7
|
import { typeWeight } from "./typography"
|
|
@@ -9,7 +10,7 @@ import { typeWeight } from "./typography"
|
|
|
9
10
|
// make sense as a text fill; style.color takes raw values for anything else.
|
|
10
11
|
export type TextColor = "text" | "textMuted" | "primary" | "onPrimary" | "danger"
|
|
11
12
|
|
|
12
|
-
export interface TextProps extends PointerProps {
|
|
13
|
+
export interface TextProps extends PointerProps, TransitionProps<TransitionViewProp | "color"> {
|
|
13
14
|
children?: any
|
|
14
15
|
// Typography role from the theme's type scale; defaults to "body". Explicit
|
|
15
16
|
// layout font props override the role's fields individually. fontSize
|
|
@@ -54,8 +55,25 @@ export function Text(props: TextProps) {
|
|
|
54
55
|
return out
|
|
55
56
|
})
|
|
56
57
|
|
|
58
|
+
// The text node owns `color`; everything else a Text animates is on the
|
|
59
|
+
// wrapper view. A shorthand or `all` reaches both.
|
|
60
|
+
let split = () => {
|
|
61
|
+
let t = splitTransition(props.transition)
|
|
62
|
+
if (t.root == null || typeof t.root === "string") return { root: t.root, text: t.root }
|
|
63
|
+
let { color, ...rest } = t.root as Record<string, unknown>
|
|
64
|
+
let text: Record<string, unknown> = {}
|
|
65
|
+
if (color !== undefined) text.color = color
|
|
66
|
+
if (rest.all !== undefined) text.all = rest.all
|
|
67
|
+
return {
|
|
68
|
+
root: Object.keys(rest).length ? (rest as typeof t.root) : undefined,
|
|
69
|
+
text: Object.keys(text).length ? (text as typeof t.root) : undefined,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
return (
|
|
58
74
|
<view
|
|
75
|
+
transition={split().root}
|
|
76
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
59
77
|
ref={props.ref}
|
|
60
78
|
{...box()}
|
|
61
79
|
x={props.style?.x}
|
|
@@ -77,6 +95,8 @@ export function Text(props: TextProps) {
|
|
|
77
95
|
pointerEvents={props.pointerEvents}
|
|
78
96
|
>
|
|
79
97
|
<text
|
|
98
|
+
transition={split().text}
|
|
99
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
80
100
|
color={color()}
|
|
81
101
|
fontFamily={props.layout?.fontFamily ?? theme.text.fontFamily}
|
|
82
102
|
fontSize={size()}
|