@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/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. Controlled via checked/onChange, or uncontrolled
25
- // via defaultChecked. When disabled, it takes no pointer events at all.
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={splitTransition(props.transition).root}
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 color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
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
- <Show
76
- when={theme.icons.check}
77
- fallback={
78
- <d-path
79
- d={check()}
80
- drawStyle="stroke"
81
- color={theme.color.onPrimary}
82
- strokeWidth={2}
83
- strokeCap="round"
84
- strokeJoin="round"
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
- <view position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
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
- </view>
91
- </Show>
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}
@@ -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
- <d-rect
94
- color={
95
- press.pressed()
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 position="absolute" top={0} left={0} right={0} bottom={0}>
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
  }
@@ -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
- // (shift-movement, highlight) is not wired yet. Outside-click-to-blur is the
82
- // caller's job.
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
- // Tap-to-position: the viewport's local point plus its scroll is a content
124
- // point; the nearest caret stop on the line under it takes the caret. Runs
125
- // before the field's own handler above (bubbling), which focuses.
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 line = editor.lineAtY(e.localY + editor.scrollY())
129
- let offset = editor.offsetAtX(line, e.localX + editor.scrollX())
130
- buffer.setSelection(offset, offset)
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
- let moveLine = (delta: number) => {
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
  >