@solidrt/components 0.0.51 → 0.0.53

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