@solidrt/components 0.0.54 → 0.0.56
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 -10
- package/README.md +22 -12
- package/docs/button.md +1 -1
- package/docs/checkbox.md +1 -1
- package/docs/context-menu.md +1 -1
- package/docs/modal.md +1 -1
- package/docs/policy.md +1 -1
- package/docs/progress-bar.md +1 -1
- package/docs/radio.md +1 -1
- package/docs/segmented-control.md +1 -1
- package/docs/select.md +1 -1
- package/docs/switch.md +1 -1
- package/docs/theme.md +8 -0
- package/docs/tooltip.md +1 -1
- package/docs/types.md +3 -1
- package/package.json +3 -3
- package/src/badge.tsx +4 -2
- package/src/button.tsx +16 -15
- package/src/card.tsx +5 -4
- package/src/checkbox.tsx +45 -21
- package/src/context-menu.tsx +22 -14
- package/src/divider.tsx +3 -2
- package/src/editor-field.tsx +112 -19
- package/src/field.tsx +3 -1
- package/src/item.tsx +11 -14
- package/src/modal.tsx +7 -1
- package/src/motion.tsx +122 -0
- package/src/nav-shell.tsx +24 -13
- package/src/pressable.tsx +7 -4
- package/src/progress-bar.tsx +19 -6
- package/src/radio.tsx +36 -7
- package/src/segmented-control.tsx +70 -20
- package/src/select.tsx +58 -43
- package/src/slider.tsx +7 -3
- package/src/spinner.tsx +2 -0
- package/src/switch.tsx +30 -11
- package/src/text.tsx +3 -2
- package/src/theme.ts +27 -2
- package/src/tooltip.tsx +10 -4
- package/src/types.ts +82 -10
- package/src/view.tsx +4 -3
- package/src/window.tsx +2 -1
package/src/checkbox.tsx
CHANGED
|
@@ -6,7 +6,8 @@ import { policy } from "./policy"
|
|
|
6
6
|
import { densityScale } from "./density"
|
|
7
7
|
import { Icon } from "./icon"
|
|
8
8
|
import type { StyleProps, TransitionProps } from "./types"
|
|
9
|
-
import { splitTransition, transitionEndFor } from "./types"
|
|
9
|
+
import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
10
|
+
import { colorFade, markMotion, pressScale, scaleFeedback } from "./motion"
|
|
10
11
|
|
|
11
12
|
export interface CheckboxProps extends TransitionProps {
|
|
12
13
|
// Controlled checked state. If omitted, the checkbox is uncontrolled.
|
|
@@ -21,8 +22,10 @@ export interface CheckboxProps extends TransitionProps {
|
|
|
21
22
|
const SIZE = 20
|
|
22
23
|
|
|
23
24
|
// A checkbox. When checked, fills with primary and draws a checkmark; otherwise
|
|
24
|
-
// shows an empty bordered box.
|
|
25
|
-
//
|
|
25
|
+
// shows an empty bordered box. The fill fades, the mark pops in/out
|
|
26
|
+
// (markMotion), and a press shrinks the box slightly (pressScale). Controlled
|
|
27
|
+
// via checked/onChange, or uncontrolled via defaultChecked. When disabled, it
|
|
28
|
+
// takes no pointer events at all.
|
|
26
29
|
export function Checkbox(props: CheckboxProps) {
|
|
27
30
|
let [internal, setInternal] = createSignal(props.defaultChecked ?? false)
|
|
28
31
|
let checked = () => (props.checked !== undefined ? props.checked : internal())
|
|
@@ -51,9 +54,11 @@ export function Checkbox(props: CheckboxProps) {
|
|
|
51
54
|
...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
|
|
52
55
|
})
|
|
53
56
|
|
|
57
|
+
let split = () => splitTransition(props.transition)
|
|
58
|
+
|
|
54
59
|
return (
|
|
55
60
|
<view
|
|
56
|
-
transition={
|
|
61
|
+
transition={withTransitionDefaults(split().root, scaleFeedback())}
|
|
57
62
|
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
58
63
|
ref={press.ref}
|
|
59
64
|
repaintBoundary
|
|
@@ -63,36 +68,55 @@ export function Checkbox(props: CheckboxProps) {
|
|
|
63
68
|
{...props.layout}
|
|
64
69
|
x={style().x}
|
|
65
70
|
y={style().y}
|
|
66
|
-
scale={style().scale}
|
|
71
|
+
scale={(style().scale ?? 1) * pressScale(press.pressed())}
|
|
67
72
|
rotate={style().rotate}
|
|
68
73
|
opacity={style().opacity}
|
|
69
74
|
{...press.handlers}
|
|
70
75
|
focusable={!props.disabled}
|
|
71
76
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
72
77
|
>
|
|
73
|
-
<d-rect
|
|
78
|
+
<d-rect
|
|
79
|
+
transition={withTransitionDefaults(split().background, colorFade())}
|
|
80
|
+
onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
81
|
+
color={style().backgroundColor ?? "transparent"}
|
|
82
|
+
radius={style().borderRadius}
|
|
83
|
+
/>
|
|
74
84
|
<Show when={checked()}>
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
85
|
+
<view
|
|
86
|
+
position="absolute"
|
|
87
|
+
top={0}
|
|
88
|
+
bottom={0}
|
|
89
|
+
left={0}
|
|
90
|
+
right={0}
|
|
91
|
+
alignItems="center"
|
|
92
|
+
justifyContent="center"
|
|
93
|
+
opacity={1}
|
|
94
|
+
scale={1}
|
|
95
|
+
transition={markMotion()}
|
|
87
96
|
>
|
|
88
|
-
<
|
|
97
|
+
<Show
|
|
98
|
+
when={theme.icons.check}
|
|
99
|
+
fallback={
|
|
100
|
+
<d-path
|
|
101
|
+
d={check()}
|
|
102
|
+
drawStyle="stroke"
|
|
103
|
+
transition={colorFade()}
|
|
104
|
+
color={theme.color.onPrimary}
|
|
105
|
+
strokeWidth={2}
|
|
106
|
+
strokeCap="round"
|
|
107
|
+
strokeJoin="round"
|
|
108
|
+
/>
|
|
109
|
+
}
|
|
110
|
+
>
|
|
89
111
|
<Icon src={theme.icons.check!} size={Math.round(size() * 0.75)} color={theme.color.onPrimary} />
|
|
90
|
-
</
|
|
91
|
-
</
|
|
112
|
+
</Show>
|
|
113
|
+
</view>
|
|
92
114
|
</Show>
|
|
93
115
|
<Show when={(style().borderWidth ?? 0) > 0}>
|
|
94
116
|
<d-rect
|
|
95
117
|
drawStyle="stroke"
|
|
118
|
+
transition={withTransitionDefaults(split().border, colorFade())}
|
|
119
|
+
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
96
120
|
color={style().borderColor ?? "transparent"}
|
|
97
121
|
strokeWidth={style().borderWidth}
|
|
98
122
|
radius={style().borderRadius}
|
package/src/context-menu.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { space } from "./spacing"
|
|
|
7
7
|
import { typeStyle } from "./typography"
|
|
8
8
|
import type { TransitionProps } from "./types"
|
|
9
9
|
import { splitTransition, transitionEndFor } from "./types"
|
|
10
|
+
import { colorFade, popupFade, popupFadeOut, PressFeedback } from "./motion"
|
|
10
11
|
|
|
11
12
|
export interface ContextMenuItem {
|
|
12
13
|
label: string
|
|
@@ -90,16 +91,11 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
90
91
|
{...press.handlers}
|
|
91
92
|
pointerEvents={p.item.disabled ? "none" : undefined}
|
|
92
93
|
>
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
? theme.color.overlayPressed
|
|
97
|
-
: press.hovered() && policy.interaction !== "touch"
|
|
98
|
-
? theme.color.overlayHover
|
|
99
|
-
: "transparent"
|
|
100
|
-
}
|
|
94
|
+
<PressFeedback
|
|
95
|
+
pressed={press.pressed()}
|
|
96
|
+
hovered={press.hovered() && policy.interaction !== "touch"}
|
|
101
97
|
/>
|
|
102
|
-
<text {...bodyText(p.item.disabled ? theme.color.textMuted : theme.color.text)}>{p.item.label}</text>
|
|
98
|
+
<text transition={colorFade()} {...bodyText(p.item.disabled ? theme.color.textMuted : theme.color.text)}>{p.item.label}</text>
|
|
103
99
|
</view>
|
|
104
100
|
)
|
|
105
101
|
}
|
|
@@ -121,8 +117,19 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
121
117
|
let cur = pos()
|
|
122
118
|
if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
|
|
123
119
|
})
|
|
120
|
+
// The fade is driven by the position write, not a mount-time `from`: the
|
|
121
|
+
// menu parks offscreen until measured, so a `from` would play unseen.
|
|
122
|
+
// The exit fade lives on this root (the node the close unmounts).
|
|
124
123
|
return createPortal(
|
|
125
|
-
<view
|
|
124
|
+
<view
|
|
125
|
+
position="absolute"
|
|
126
|
+
top={0}
|
|
127
|
+
left={0}
|
|
128
|
+
right={0}
|
|
129
|
+
bottom={0}
|
|
130
|
+
opacity={pos() ? 1 : 0}
|
|
131
|
+
transition={popupFadeOut()}
|
|
132
|
+
>
|
|
126
133
|
<view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)} />
|
|
127
134
|
<view
|
|
128
135
|
ref={(n: { id: number }) => (menu = n)}
|
|
@@ -136,12 +143,13 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
136
143
|
paddingTop={theme.spacing.sm}
|
|
137
144
|
paddingBottom={theme.spacing.sm}
|
|
138
145
|
>
|
|
139
|
-
<d-rect color={theme.color.surface} radius={theme.radius.sm} />
|
|
146
|
+
<d-rect transition={colorFade()} color={theme.color.surface} radius={theme.radius.sm} />
|
|
140
147
|
<For each={props.items}>
|
|
141
148
|
{(item: ContextMenuItem) => <ItemRow item={item} padY={space("sm")} />}
|
|
142
149
|
</For>
|
|
143
150
|
<d-rect
|
|
144
151
|
drawStyle="stroke"
|
|
152
|
+
transition={colorFade()}
|
|
145
153
|
color={theme.color.border}
|
|
146
154
|
strokeWidth={theme.borderWidth.sm}
|
|
147
155
|
radius={theme.radius.sm}
|
|
@@ -155,9 +163,9 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
155
163
|
// trick) so a row press never has the scrim on its hit path.
|
|
156
164
|
let Sheet = () =>
|
|
157
165
|
createPortal(
|
|
158
|
-
<view position="absolute" top={0} left={0} right={0} bottom={0}>
|
|
166
|
+
<view position="absolute" top={0} left={0} right={0} bottom={0} opacity={1} transition={popupFade()}>
|
|
159
167
|
<view position="absolute" top={0} left={0} right={0} bottom={0} onPointerDown={() => setOpen(false)}>
|
|
160
|
-
<d-rect color={theme.color.scrim} />
|
|
168
|
+
<d-rect transition={colorFade()} color={theme.color.scrim} />
|
|
161
169
|
</view>
|
|
162
170
|
<view
|
|
163
171
|
position="absolute"
|
|
@@ -168,7 +176,7 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
168
176
|
paddingTop={theme.spacing.md}
|
|
169
177
|
paddingBottom={theme.spacing.md + env.safeArea.bottom}
|
|
170
178
|
>
|
|
171
|
-
<d-rect color={theme.color.surface} radius={theme.radius.sm} />
|
|
179
|
+
<d-rect transition={colorFade()} color={theme.color.surface} radius={theme.radius.sm} />
|
|
172
180
|
<For each={props.items}>
|
|
173
181
|
{(item: ContextMenuItem) => <ItemRow item={item} padY={Math.round(theme.spacing.md * 1.5)} />}
|
|
174
182
|
</For>
|
package/src/divider.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { LayoutProps } from "@solidrt/core"
|
|
2
2
|
import { theme } from "./theme"
|
|
3
3
|
import type { StyleProps, TransitionProps } from "./types"
|
|
4
|
-
import { splitTransition, transitionEndFor } from "./types"
|
|
4
|
+
import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
5
|
+
import { colorFade } from "./motion"
|
|
5
6
|
|
|
6
7
|
export interface DividerProps extends TransitionProps {
|
|
7
8
|
// Line direction. Horizontal (default) is a full-width rule; vertical is a
|
|
@@ -34,7 +35,7 @@ export function Divider(props: DividerProps) {
|
|
|
34
35
|
alignSelf="stretch"
|
|
35
36
|
{...props.layout}
|
|
36
37
|
>
|
|
37
|
-
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={color()} />
|
|
38
|
+
<d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={color()} />
|
|
38
39
|
</view>
|
|
39
40
|
)
|
|
40
41
|
}
|
package/src/editor-field.tsx
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// package.
|
|
7
7
|
import {
|
|
8
8
|
For,
|
|
9
|
+
arena,
|
|
9
10
|
createEffect,
|
|
10
11
|
createMemo,
|
|
11
12
|
createSignal,
|
|
@@ -23,7 +24,8 @@ import type { Element } from "solid-js"
|
|
|
23
24
|
import type { MeasureTextOptions, TextRunRange } from "flux:rendertree"
|
|
24
25
|
import { registerNavAction } from "./focus-nav"
|
|
25
26
|
import type { StyleProps, TransitionProps } from "./types"
|
|
26
|
-
import { splitTransition, transitionEndFor } from "./types"
|
|
27
|
+
import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
28
|
+
import { colorFade } from "./motion"
|
|
27
29
|
import { theme } from "./theme"
|
|
28
30
|
import { policy } from "./policy"
|
|
29
31
|
import { space } from "./spacing"
|
|
@@ -77,9 +79,13 @@ export interface EditorFieldProps extends TransitionProps {
|
|
|
77
79
|
// and stops those keys from bubbling further. Focused and editing are
|
|
78
80
|
// distinct (see activateField): navigation focuses, select begins editing,
|
|
79
81
|
// Enter while editing submits (single-line) or inserts a newline
|
|
80
|
-
// (multiline). A tap puts the caret at the nearest position. Range selection
|
|
81
|
-
//
|
|
82
|
-
//
|
|
82
|
+
// (multiline). A tap puts the caret at the nearest position. Range selection:
|
|
83
|
+
// Shift+movement (and Shift+tap) extends from the anchor, a mouse/pen drag
|
|
84
|
+
// selects (stealing the gesture arena on its first move), Ctrl/Cmd+A selects
|
|
85
|
+
// all; the highlight draws behind the lines while focused, and edits on a
|
|
86
|
+
// range replace it (the buffer's own behavior). Ctrl/Cmd+C/X/V go through
|
|
87
|
+
// navigator.clipboard (copy and cut need a range; single-line paste flattens
|
|
88
|
+
// line breaks). Outside-click-to-blur is the caller's job.
|
|
83
89
|
export function EditorField(props: EditorFieldProps) {
|
|
84
90
|
let [caretOn, setCaretOn] = createSignal(true)
|
|
85
91
|
|
|
@@ -104,6 +110,12 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
104
110
|
let buffer = untrack(() => props.buffer)((_text, offset, direction) => editor.step(offset, direction))
|
|
105
111
|
let value = buffer.value
|
|
106
112
|
|
|
113
|
+
// The selection's text, for copy/cut.
|
|
114
|
+
let selectedText = (): string => {
|
|
115
|
+
let { anchor, focus } = buffer.selection()
|
|
116
|
+
return value().slice(Math.min(anchor, focus), Math.max(anchor, focus))
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
// autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
|
|
108
120
|
// the node's onTextInput handler to toggle the keyboard, and those handlers
|
|
109
121
|
// are only registered after the element's props are applied. The ref can fire
|
|
@@ -120,17 +132,59 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
120
132
|
if (node) setFocus(node.id)
|
|
121
133
|
}
|
|
122
134
|
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
135
|
+
// The viewport's local point plus its scroll is a content point; the
|
|
136
|
+
// nearest caret stop on the line under it. Locals are exact even on the
|
|
137
|
+
// frozen down path, so a drag keeps mapping after the pointer leaves the
|
|
138
|
+
// field.
|
|
139
|
+
let offsetAt = (e: PointerEvent) => {
|
|
140
|
+
let line = editor.lineAtY(e.localY + editor.scrollY())
|
|
141
|
+
return editor.offsetAtX(line, e.localX + editor.scrollX())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Drag-select: a mouse/pen down on the viewport arms the pointer, and its
|
|
145
|
+
// first move steals the gesture arena - precise pointers select text over
|
|
146
|
+
// an enclosing scroller's pan, as desktop editors do. Touch never arms, so
|
|
147
|
+
// a finger drag still scrolls (touch range selection is a later,
|
|
148
|
+
// handle-based interaction); Shift+tap extends everywhere.
|
|
149
|
+
let dragArmed: number | null = null
|
|
150
|
+
let dragActive: number | null = null
|
|
151
|
+
let dragOwner = {
|
|
152
|
+
cancel: () => {
|
|
153
|
+
dragActive = null
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Tap-to-position: the offset under the tap takes the caret (Shift keeps
|
|
158
|
+
// the anchor to extend). Runs before the field's own handler above
|
|
159
|
+
// (bubbling), which focuses.
|
|
126
160
|
let handleViewportPointerDown = (e: PointerEvent) => {
|
|
127
161
|
if (props.disabled) return
|
|
128
|
-
let
|
|
129
|
-
|
|
130
|
-
|
|
162
|
+
let offset = offsetAt(e)
|
|
163
|
+
buffer.setSelection(e.shiftKey ? buffer.selection().anchor : offset, offset)
|
|
164
|
+
if (e.pointerType !== "touch" && (e.button == null || e.button === 0)) dragArmed = e.pointerId
|
|
131
165
|
setCaretOn(true)
|
|
132
166
|
}
|
|
133
167
|
|
|
168
|
+
let handleViewportPointerMove = (e: PointerEvent) => {
|
|
169
|
+
if (props.disabled) return
|
|
170
|
+
if (dragArmed === e.pointerId) {
|
|
171
|
+
dragArmed = null
|
|
172
|
+
if (arena.steal(e.pointerId, dragOwner)) dragActive = e.pointerId
|
|
173
|
+
}
|
|
174
|
+
if (dragActive === e.pointerId) {
|
|
175
|
+
buffer.setSelection(buffer.selection().anchor, offsetAt(e))
|
|
176
|
+
setCaretOn(true)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let handleViewportPointerUp = (e: PointerEvent) => {
|
|
181
|
+
if (dragArmed === e.pointerId) dragArmed = null
|
|
182
|
+
if (dragActive === e.pointerId) {
|
|
183
|
+
arena.release(e.pointerId, dragOwner)
|
|
184
|
+
dragActive = null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
134
188
|
let handleFocus = () => {
|
|
135
189
|
setCaretOn(true)
|
|
136
190
|
if (blinkId == null) {
|
|
@@ -160,24 +214,53 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
160
214
|
buffer.deleteForward()
|
|
161
215
|
setCaretOn(true)
|
|
162
216
|
} else if (e.key === "ArrowLeft") {
|
|
163
|
-
buffer.move("left")
|
|
217
|
+
buffer.move("left", { extend: e.shiftKey })
|
|
164
218
|
setCaretOn(true)
|
|
165
219
|
} else if (e.key === "ArrowRight") {
|
|
166
|
-
buffer.move("right")
|
|
220
|
+
buffer.move("right", { extend: e.shiftKey })
|
|
167
221
|
setCaretOn(true)
|
|
168
222
|
} else if (e.key === "Home" || e.key === "End") {
|
|
169
223
|
// Multiline: the current line's ends (offsetAtX at 0 / far right, so a
|
|
170
224
|
// wrap boundary resolves to the position that shows on this line).
|
|
171
225
|
if (props.multiline) {
|
|
172
226
|
let offset = editor.offsetAtX(editor.caretLine(), e.key === "Home" ? 0 : 1e9)
|
|
173
|
-
buffer.setSelection(offset, offset)
|
|
227
|
+
buffer.setSelection(e.shiftKey ? buffer.selection().anchor : offset, offset)
|
|
174
228
|
} else {
|
|
175
|
-
buffer.move(e.key === "Home" ? "start" : "end")
|
|
229
|
+
buffer.move(e.key === "Home" ? "start" : "end", { extend: e.shiftKey })
|
|
176
230
|
}
|
|
177
231
|
setCaretOn(true)
|
|
178
232
|
} else if (props.multiline && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
|
179
|
-
moveLine(e.key === "ArrowUp" ? -1 : 1)
|
|
233
|
+
moveLine(e.key === "ArrowUp" ? -1 : 1, e.shiftKey)
|
|
234
|
+
setCaretOn(true)
|
|
235
|
+
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
|
|
236
|
+
buffer.setSelection(0, value().length)
|
|
180
237
|
setCaretOn(true)
|
|
238
|
+
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
|
|
239
|
+
// Copy acts only on a range; an empty selection leaves the key (and the
|
|
240
|
+
// clipboard) alone and lets it bubble to app shortcuts.
|
|
241
|
+
let text = selectedText()
|
|
242
|
+
if (text.length === 0) consumed = false
|
|
243
|
+
else navigator.clipboard.writeText(text).catch((err) => console.warn("Clipboard copy failed: " + err))
|
|
244
|
+
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "x") {
|
|
245
|
+
let text = selectedText()
|
|
246
|
+
if (text.length === 0) consumed = false
|
|
247
|
+
else {
|
|
248
|
+
// The range deletes immediately, as native editors cut; a failed
|
|
249
|
+
// clipboard write is reported, not undone.
|
|
250
|
+
navigator.clipboard.writeText(text).catch((err) => console.warn("Clipboard cut failed: " + err))
|
|
251
|
+
buffer.insertText("")
|
|
252
|
+
setCaretOn(true)
|
|
253
|
+
}
|
|
254
|
+
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
|
|
255
|
+
navigator.clipboard.readText().then(
|
|
256
|
+
(text) => {
|
|
257
|
+
if (text.length === 0) return
|
|
258
|
+
if (!props.multiline) text = text.replace(/\r?\n/g, " ")
|
|
259
|
+
buffer.insertText(text)
|
|
260
|
+
setCaretOn(true)
|
|
261
|
+
},
|
|
262
|
+
(err) => console.warn("Clipboard paste failed: " + err),
|
|
263
|
+
)
|
|
181
264
|
} else if (props.multiline && e.key === "Enter" && textInputActive()) {
|
|
182
265
|
buffer.insertText("\n")
|
|
183
266
|
setCaretOn(true)
|
|
@@ -200,12 +283,13 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
200
283
|
|
|
201
284
|
// Up/Down: the offset on the neighbouring line nearest the caret's x; on
|
|
202
285
|
// the first/last line they go to the text's start/end, as editors do.
|
|
203
|
-
|
|
286
|
+
// `extend` (Shift held) keeps the anchor to grow a selection.
|
|
287
|
+
let moveLine = (delta: number, extend: boolean) => {
|
|
204
288
|
let target = editor.caretLine() + delta
|
|
205
289
|
let count = editor.lines().length
|
|
206
290
|
let offset =
|
|
207
291
|
target < 0 ? 0 : target >= count ? value().length : editor.offsetAtX(target, editor.caret().x)
|
|
208
|
-
buffer.setSelection(offset, offset)
|
|
292
|
+
buffer.setSelection(extend ? buffer.selection().anchor : offset, offset)
|
|
209
293
|
}
|
|
210
294
|
|
|
211
295
|
// Select on the focused field: focused and editing are distinct states. A
|
|
@@ -232,6 +316,8 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
232
316
|
onCleanup(() => {
|
|
233
317
|
if (blinkId != null) clearInterval(blinkId)
|
|
234
318
|
unregisterNav?.()
|
|
319
|
+
// An unmount mid-drag must not leave a resolved arena claim behind.
|
|
320
|
+
if (dragActive != null) arena.release(dragActive, dragOwner)
|
|
235
321
|
})
|
|
236
322
|
|
|
237
323
|
// Style overrides fall back to theme defaults. The border doubles as the
|
|
@@ -323,10 +409,10 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
323
409
|
onKeyDown={handleKeyDown}
|
|
324
410
|
onTextInput={handleTextInput}
|
|
325
411
|
>
|
|
326
|
-
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={surfaceColor()} radius={borderRadius()} />
|
|
412
|
+
<d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={surfaceColor()} radius={borderRadius()} />
|
|
327
413
|
<d-rect
|
|
328
414
|
drawStyle="stroke"
|
|
329
|
-
transition={split().border}
|
|
415
|
+
transition={withTransitionDefaults(split().border, colorFade())}
|
|
330
416
|
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
331
417
|
color={borderColor()}
|
|
332
418
|
strokeWidth={borderWidth()}
|
|
@@ -341,6 +427,8 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
341
427
|
scrollX={editor.scrollX()}
|
|
342
428
|
scrollY={editor.scrollY()}
|
|
343
429
|
onPointerDown={handleViewportPointerDown}
|
|
430
|
+
onPointerMove={handleViewportPointerMove}
|
|
431
|
+
onPointerUp={handleViewportPointerUp}
|
|
344
432
|
>
|
|
345
433
|
{showPlaceholder() ? (
|
|
346
434
|
<d-text w={PLACEHOLDER_SHAPE_WIDTH} {...font()} color={theme.color.textMuted} maxLines={1}>
|
|
@@ -348,6 +436,11 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
348
436
|
</d-text>
|
|
349
437
|
) : (
|
|
350
438
|
<>
|
|
439
|
+
{focused() ? (
|
|
440
|
+
<For each={editor.selectionRects(buffer.selection().anchor, buffer.selection().focus)} keyed={false}>
|
|
441
|
+
{(r) => <d-rect color={theme.color.selection} x={r().x} y={r().y} w={r().width} h={r().height} />}
|
|
442
|
+
</For>
|
|
443
|
+
) : null}
|
|
351
444
|
<For each={editor.lines()} keyed={false}>
|
|
352
445
|
{(line) => props.renderLine({ line, font, color: textColor })}
|
|
353
446
|
</For>
|
package/src/field.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { space } from "./spacing"
|
|
|
5
5
|
import { typeStyle } from "./typography"
|
|
6
6
|
import type { TransitionProps } from "./types"
|
|
7
7
|
import { splitTransition, transitionEndFor } from "./types"
|
|
8
|
+
import { colorFade } from "./motion"
|
|
8
9
|
|
|
9
10
|
export interface FieldProps extends TransitionProps {
|
|
10
11
|
// Label above the control.
|
|
@@ -29,13 +30,14 @@ export function Field(props: FieldProps) {
|
|
|
29
30
|
return (
|
|
30
31
|
<view flexDirection="column" gap={space("sm")} transition={splitTransition(props.transition).root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
|
|
31
32
|
<Show when={props.label != null}>
|
|
32
|
-
<text color={theme.color.text} {...typeStyle("label")}>
|
|
33
|
+
<text transition={colorFade()} color={theme.color.text} {...typeStyle("label")}>
|
|
33
34
|
{props.label}
|
|
34
35
|
</text>
|
|
35
36
|
</Show>
|
|
36
37
|
{props.children}
|
|
37
38
|
<Show when={props.error != null || props.description != null}>
|
|
38
39
|
<text
|
|
40
|
+
transition={colorFade()}
|
|
39
41
|
color={props.error != null ? theme.color.danger : theme.color.textMuted}
|
|
40
42
|
{...typeStyle("caption")}
|
|
41
43
|
>
|
package/src/item.tsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Show, children } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
|
-
import { createPress
|
|
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
8
|
import type { StyleProps, TransitionProps } from "./types"
|
|
9
|
-
import { splitTransition, transitionEndFor } from "./types"
|
|
9
|
+
import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
10
|
+
import { colorFade, PressFeedback } from "./motion"
|
|
10
11
|
|
|
11
12
|
export interface ItemProps extends TransitionProps {
|
|
12
13
|
// Leading content: an icon, avatar, checkbox, ...
|
|
@@ -47,14 +48,6 @@ export function Item(props: ItemProps) {
|
|
|
47
48
|
let press = createPress(props)
|
|
48
49
|
|
|
49
50
|
let bg = () => styled().backgroundColor ?? (props.selected ? theme.color.surfaceAlt : "transparent")
|
|
50
|
-
let overlay = (s: PressState) =>
|
|
51
|
-
!interactive()
|
|
52
|
-
? "transparent"
|
|
53
|
-
: s.pressed
|
|
54
|
-
? theme.color.overlayPressed
|
|
55
|
-
: s.hovered && policy.interaction !== "touch"
|
|
56
|
-
? theme.color.overlayHover
|
|
57
|
-
: "transparent"
|
|
58
51
|
let radius = () => styled().borderRadius ?? theme.radius.sm
|
|
59
52
|
|
|
60
53
|
// Resolved once via children(): the typeof probe and the mount site must
|
|
@@ -95,18 +88,22 @@ export function Item(props: ItemProps) {
|
|
|
95
88
|
focusable={(props.focusable ?? true) && interactive()}
|
|
96
89
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
97
90
|
>
|
|
98
|
-
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
|
|
99
|
-
<
|
|
91
|
+
<d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
|
|
92
|
+
<PressFeedback
|
|
93
|
+
pressed={interactive() && press.pressed()}
|
|
94
|
+
hovered={interactive() && press.hovered() && policy.interaction !== "touch"}
|
|
95
|
+
radius={radius()}
|
|
96
|
+
/>
|
|
100
97
|
{props.startContent}
|
|
101
98
|
<view flexDirection="column" flexGrow={1} flexShrink={1} gap={Math.round(space("sm") / 2)}>
|
|
102
99
|
<Show when={labelIsText()} fallback={label()}>
|
|
103
|
-
<text color={theme.color.text} {...typeStyle("body")} maxLines={1}>
|
|
100
|
+
<text transition={colorFade()} color={theme.color.text} {...typeStyle("body")} maxLines={1}>
|
|
104
101
|
{label()}
|
|
105
102
|
</text>
|
|
106
103
|
</Show>
|
|
107
104
|
<Show when={props.description != null}>
|
|
108
105
|
<Show when={descriptionIsText()} fallback={description()}>
|
|
109
|
-
<text color={theme.color.textMuted} {...typeStyle("body")} maxLines={1}>
|
|
106
|
+
<text transition={colorFade()} color={theme.color.textMuted} {...typeStyle("body")} maxLines={1}>
|
|
110
107
|
{description()}
|
|
111
108
|
</text>
|
|
112
109
|
</Show>
|
package/src/modal.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { createPortal, onCleanup } from "@solidrt/core"
|
|
|
2
2
|
import type { Color, PointerEvent } from "@solidrt/core"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
4
|
import { pushNavScope } from "./focus-nav"
|
|
5
|
+
import { colorFade, popupFade } from "./motion"
|
|
5
6
|
|
|
6
7
|
export interface ModalProps {
|
|
7
8
|
// Called when the backdrop (the area around the content) is pressed, unless
|
|
@@ -40,6 +41,9 @@ export function Modal(props: ModalProps) {
|
|
|
40
41
|
let popNavScope: (() => void) | null = null
|
|
41
42
|
onCleanup(() => popNavScope?.())
|
|
42
43
|
|
|
44
|
+
// The whole overlay (scrim and content together) fades in at mount and
|
|
45
|
+
// out on removal via popupFade; an exiting modal is hit-test invisible,
|
|
46
|
+
// so a press during the fade-out reaches what is behind it.
|
|
43
47
|
return createPortal(
|
|
44
48
|
<view
|
|
45
49
|
ref={(n: { id: number }) => {
|
|
@@ -52,6 +56,8 @@ export function Modal(props: ModalProps) {
|
|
|
52
56
|
bottom={0}
|
|
53
57
|
alignItems="center"
|
|
54
58
|
justifyContent="center"
|
|
59
|
+
opacity={1}
|
|
60
|
+
transition={popupFade()}
|
|
55
61
|
>
|
|
56
62
|
<view
|
|
57
63
|
position="absolute"
|
|
@@ -61,7 +67,7 @@ export function Modal(props: ModalProps) {
|
|
|
61
67
|
bottom={0}
|
|
62
68
|
onPointerDown={dismiss}
|
|
63
69
|
>
|
|
64
|
-
<d-rect color={props.backdropColor ?? theme.color.scrim} />
|
|
70
|
+
<d-rect transition={colorFade()} color={props.backdropColor ?? theme.color.scrim} />
|
|
65
71
|
</view>
|
|
66
72
|
{props.children}
|
|
67
73
|
</view>
|