@solidrt/components 0.0.55 → 0.0.57
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 +18 -15
- package/README.md +40 -17
- package/docs/button.md +2 -2
- package/docs/checkbox.md +1 -1
- package/docs/context-menu.md +1 -1
- package/docs/focus-nav.md +16 -3
- 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 +116 -19
- package/src/field.tsx +3 -1
- package/src/focus-nav.ts +152 -53
- package/src/index.ts +1 -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/press.ts +6 -11
- 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 +25 -1
- package/src/tooltip.tsx +10 -4
- package/src/types.ts +74 -6
- package/src/view.tsx +4 -3
- package/src/window.tsx +2 -1
package/src/card.tsx
CHANGED
|
@@ -4,7 +4,8 @@ import { theme } from "./theme"
|
|
|
4
4
|
import { typeStyle } from "./typography"
|
|
5
5
|
import { space } from "./spacing"
|
|
6
6
|
import type { StyleProps, TransitionProps } from "./types"
|
|
7
|
-
import { splitTransition, transitionEndFor } from "./types"
|
|
7
|
+
import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
|
|
8
|
+
import { colorFade } from "./motion"
|
|
8
9
|
|
|
9
10
|
export interface CardProps extends TransitionProps {
|
|
10
11
|
children?: any
|
|
@@ -44,9 +45,9 @@ export function Card(props: CardProps) {
|
|
|
44
45
|
rotate={styled().rotate}
|
|
45
46
|
opacity={styled().opacity}
|
|
46
47
|
>
|
|
47
|
-
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
|
|
48
|
+
<d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
|
|
48
49
|
<Show when={props.title != null}>
|
|
49
|
-
<text color={theme.color.text} {...typeStyle("title")}>
|
|
50
|
+
<text transition={colorFade()} color={theme.color.text} {...typeStyle("title")}>
|
|
50
51
|
{props.title}
|
|
51
52
|
</text>
|
|
52
53
|
</Show>
|
|
@@ -54,7 +55,7 @@ export function Card(props: CardProps) {
|
|
|
54
55
|
<Show when={hasBorder()}>
|
|
55
56
|
<d-rect
|
|
56
57
|
drawStyle="stroke"
|
|
57
|
-
transition={split().border}
|
|
58
|
+
transition={withTransitionDefaults(split().border, colorFade())}
|
|
58
59
|
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
59
60
|
color={styled().borderColor ?? theme.color.border}
|
|
60
61
|
strokeWidth={styled().borderWidth ?? theme.borderWidth.sm}
|
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)
|
|
@@ -186,6 +269,10 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
186
269
|
activateField()
|
|
187
270
|
} else if (e.key === "Escape") {
|
|
188
271
|
if (node) setFocus(null)
|
|
272
|
+
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && textInputActive()) {
|
|
273
|
+
// A printable key is this field's text (it arrives as a text-input
|
|
274
|
+
// event); consumed here so a typed Space never reaches the window as
|
|
275
|
+
// the focus navigation's select.
|
|
189
276
|
} else {
|
|
190
277
|
consumed = false
|
|
191
278
|
}
|
|
@@ -200,12 +287,13 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
200
287
|
|
|
201
288
|
// Up/Down: the offset on the neighbouring line nearest the caret's x; on
|
|
202
289
|
// the first/last line they go to the text's start/end, as editors do.
|
|
203
|
-
|
|
290
|
+
// `extend` (Shift held) keeps the anchor to grow a selection.
|
|
291
|
+
let moveLine = (delta: number, extend: boolean) => {
|
|
204
292
|
let target = editor.caretLine() + delta
|
|
205
293
|
let count = editor.lines().length
|
|
206
294
|
let offset =
|
|
207
295
|
target < 0 ? 0 : target >= count ? value().length : editor.offsetAtX(target, editor.caret().x)
|
|
208
|
-
buffer.setSelection(offset, offset)
|
|
296
|
+
buffer.setSelection(extend ? buffer.selection().anchor : offset, offset)
|
|
209
297
|
}
|
|
210
298
|
|
|
211
299
|
// Select on the focused field: focused and editing are distinct states. A
|
|
@@ -232,6 +320,8 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
232
320
|
onCleanup(() => {
|
|
233
321
|
if (blinkId != null) clearInterval(blinkId)
|
|
234
322
|
unregisterNav?.()
|
|
323
|
+
// An unmount mid-drag must not leave a resolved arena claim behind.
|
|
324
|
+
if (dragActive != null) arena.release(dragActive, dragOwner)
|
|
235
325
|
})
|
|
236
326
|
|
|
237
327
|
// Style overrides fall back to theme defaults. The border doubles as the
|
|
@@ -323,10 +413,10 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
323
413
|
onKeyDown={handleKeyDown}
|
|
324
414
|
onTextInput={handleTextInput}
|
|
325
415
|
>
|
|
326
|
-
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={surfaceColor()} radius={borderRadius()} />
|
|
416
|
+
<d-rect transition={withTransitionDefaults(split().background, colorFade())} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={surfaceColor()} radius={borderRadius()} />
|
|
327
417
|
<d-rect
|
|
328
418
|
drawStyle="stroke"
|
|
329
|
-
transition={split().border}
|
|
419
|
+
transition={withTransitionDefaults(split().border, colorFade())}
|
|
330
420
|
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
331
421
|
color={borderColor()}
|
|
332
422
|
strokeWidth={borderWidth()}
|
|
@@ -341,6 +431,8 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
341
431
|
scrollX={editor.scrollX()}
|
|
342
432
|
scrollY={editor.scrollY()}
|
|
343
433
|
onPointerDown={handleViewportPointerDown}
|
|
434
|
+
onPointerMove={handleViewportPointerMove}
|
|
435
|
+
onPointerUp={handleViewportPointerUp}
|
|
344
436
|
>
|
|
345
437
|
{showPlaceholder() ? (
|
|
346
438
|
<d-text w={PLACEHOLDER_SHAPE_WIDTH} {...font()} color={theme.color.textMuted} maxLines={1}>
|
|
@@ -348,6 +440,11 @@ export function EditorField(props: EditorFieldProps) {
|
|
|
348
440
|
</d-text>
|
|
349
441
|
) : (
|
|
350
442
|
<>
|
|
443
|
+
{focused() ? (
|
|
444
|
+
<For each={editor.selectionRects(buffer.selection().anchor, buffer.selection().focus)} keyed={false}>
|
|
445
|
+
{(r) => <d-rect color={theme.color.selection} x={r().x} y={r().y} w={r().width} h={r().height} />}
|
|
446
|
+
</For>
|
|
447
|
+
) : null}
|
|
351
448
|
<For each={editor.lines()} keyed={false}>
|
|
352
449
|
{(line) => props.renderLine({ line, font, color: textColor })}
|
|
353
450
|
</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
|
>
|