@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/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)
@@ -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
- let moveLine = (delta: number) => {
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, type PressState } from "./press"
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
- <d-rect color={overlay(press.state())} radius={radius()} />
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>