@solidrt/components 0.0.51 → 0.0.52

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.
Files changed (50) hide show
  1. package/AGENTS.md +34 -7
  2. package/README.md +47 -10
  3. package/demos/README.md +24 -0
  4. package/demos/assets/icon.png +0 -0
  5. package/demos/assets/icon.svg +23 -0
  6. package/demos/package.json +9 -0
  7. package/demos/src/gallery.tsx +866 -0
  8. package/demos/tsconfig.json +15 -0
  9. package/docs/button.md +1 -1
  10. package/docs/focus-nav.md +1 -1
  11. package/docs/icon.md +1 -1
  12. package/docs/item.md +1 -1
  13. package/docs/policy.md +1 -1
  14. package/docs/scroll-view.md +22 -1
  15. package/docs/split-view.md +1 -1
  16. package/docs/theme.md +14 -2
  17. package/docs/types.md +4 -0
  18. package/package.json +4 -3
  19. package/src/badge.tsx +21 -14
  20. package/src/button.tsx +13 -7
  21. package/src/card.tsx +10 -3
  22. package/src/checkbox.tsx +8 -2
  23. package/src/context-menu.tsx +9 -6
  24. package/src/divider.tsx +8 -3
  25. package/src/editor-field.tsx +18 -11
  26. package/src/field.tsx +4 -2
  27. package/src/icon.tsx +8 -4
  28. package/src/image.tsx +10 -3
  29. package/src/index.ts +10 -1
  30. package/src/item.tsx +12 -7
  31. package/src/nav-shell.tsx +6 -16
  32. package/src/policy.ts +9 -4
  33. package/src/pressable.tsx +11 -2
  34. package/src/progress-bar.tsx +4 -3
  35. package/src/qrcode.tsx +7 -5
  36. package/src/radio.tsx +12 -4
  37. package/src/rich-text-editor.tsx +5 -3
  38. package/src/scroll-view.tsx +76 -7
  39. package/src/segmented-control.tsx +14 -5
  40. package/src/select.tsx +23 -12
  41. package/src/slider.tsx +31 -5
  42. package/src/spinner.tsx +5 -2
  43. package/src/split-view.tsx +3 -4
  44. package/src/switch.tsx +8 -2
  45. package/src/text-input.tsx +4 -2
  46. package/src/text.tsx +22 -2
  47. package/src/theme.ts +72 -20
  48. package/src/tooltip.tsx +16 -5
  49. package/src/types.ts +119 -1
  50. package/src/view.tsx +11 -2
package/src/image.tsx CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createImage, createEffect, Loading, Errored, pct } from "@solidrt/core"
2
2
  import type { ImageSource, LayoutProps, Pct, PointerProps, TextureProps } from "@solidrt/core"
3
- import type { StyleProps } from "./types"
3
+ import type { StyleProps, TransitionProps } from "./types"
4
+ import { splitTransition, transitionEndFor } from "./types"
4
5
 
5
- export interface ImageProps extends PointerProps {
6
+ export interface ImageProps extends PointerProps, TransitionProps {
6
7
  src: string | Uint8Array
7
8
  /**
8
9
  * How the image maps into the Image's box (CSS object-fit): "fill"
@@ -38,6 +39,7 @@ function FallbackTexture(props: {
38
39
  height?: number | Pct
39
40
  }) {
40
41
  let tex = createImage(() => props.src)
42
+
41
43
  return (
42
44
  <Errored fallback={(_err: unknown) => null}>
43
45
  <texture src={tex()} fit={props.fit} width={props.width} height={props.height} />
@@ -69,9 +71,12 @@ export function Image(props: ImageProps) {
69
71
  let texW = () => (props.fit != null ? pct(100) : typeof props.layout?.width === "number" ? props.layout.width : undefined)
70
72
  let texH = () =>
71
73
  props.fit != null ? pct(100) : typeof props.layout?.height === "number" ? props.layout.height : undefined
74
+ let split = () => splitTransition(props.transition)
72
75
 
73
76
  return (
74
77
  <view
78
+ transition={split().root}
79
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
75
80
  {...props.layout}
76
81
  overflow={props.style?.borderRadius != null ? "hidden" : props.layout?.overflow}
77
82
  clipRadius={props.style?.borderRadius}
@@ -94,7 +99,7 @@ export function Image(props: ImageProps) {
94
99
  pointerEvents={props.pointerEvents}
95
100
  >
96
101
  {props.style?.backgroundColor != null ? (
97
- <d-rect color={props.style?.backgroundColor} radius={props.style?.borderRadius} />
102
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={props.style?.backgroundColor} radius={props.style?.borderRadius} />
98
103
  ) : null}
99
104
  <Loading fallback={null}>
100
105
  <Errored
@@ -110,6 +115,8 @@ export function Image(props: ImageProps) {
110
115
  {hasBorder() ? (
111
116
  <d-rect
112
117
  drawStyle="stroke"
118
+ transition={split().border}
119
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
113
120
  color={props.style?.borderColor ?? "transparent"}
114
121
  strokeWidth={props.style?.borderWidth}
115
122
  radius={props.style?.borderRadius}
package/src/index.ts CHANGED
@@ -60,4 +60,13 @@ export {
60
60
  export { Density, type DensityProps, densityScale } from "./density"
61
61
  export { typeStyle, typeWeight, lightOnDark } from "./typography"
62
62
  export { space } from "./spacing"
63
- export type { StyleProps, TextLayoutProps, Option } from "./types"
63
+ export type {
64
+ StyleProps,
65
+ TextLayoutProps,
66
+ Option,
67
+ TransitionProps,
68
+ ComponentTransition,
69
+ TransitionViewProp,
70
+ TransitionStyleProp,
71
+ TransitionScrollProp,
72
+ } from "./types"
package/src/item.tsx CHANGED
@@ -5,16 +5,17 @@ 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 { StyleProps } from "./types"
8
+ import type { StyleProps, TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
9
10
 
10
- export interface ItemProps {
11
+ export interface ItemProps extends TransitionProps {
11
12
  // Leading content: an icon, avatar, checkbox, ...
12
13
  startContent?: any
13
14
  // Primary text. A string/number renders as themed body text; anything else
14
15
  // as-is.
15
16
  label: any
16
17
  // Secondary line under the label. A string/number renders as themed muted
17
- // caption text; anything else as-is.
18
+ // body text; anything else as-is.
18
19
  description?: any
19
20
  // Trailing content: a badge, timestamp, chevron, action, ...
20
21
  endContent?: any
@@ -63,8 +64,12 @@ export function Item(props: ItemProps) {
63
64
  let description = children(() => props.description)
64
65
  let descriptionIsText = () => typeof description() === "string" || typeof description() === "number"
65
66
 
67
+ let split = () => splitTransition(props.transition)
68
+
66
69
  return (
67
70
  <view
71
+ transition={split().root}
72
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
68
73
  ref={(n: { id: number }) => {
69
74
  press.ref(n)
70
75
  props.ref?.(n)
@@ -90,10 +95,10 @@ export function Item(props: ItemProps) {
90
95
  focusable={(props.focusable ?? true) && interactive()}
91
96
  pointerEvents={props.disabled ? "none" : undefined}
92
97
  >
93
- <d-rect color={bg()} radius={radius()} />
98
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
94
99
  <d-rect color={overlay(press.state())} radius={radius()} />
95
100
  {props.startContent}
96
- <view flexDirection="column" flexGrow={1} flexShrink={1} gap={2}>
101
+ <view flexDirection="column" flexGrow={1} flexShrink={1} gap={Math.round(space("sm") / 2)}>
97
102
  <Show when={labelIsText()} fallback={label()}>
98
103
  <text color={theme.color.text} {...typeStyle("body")} maxLines={1}>
99
104
  {label()}
@@ -101,7 +106,7 @@ export function Item(props: ItemProps) {
101
106
  </Show>
102
107
  <Show when={props.description != null}>
103
108
  <Show when={descriptionIsText()} fallback={description()}>
104
- <text color={theme.color.textMuted} {...typeStyle("caption")} maxLines={1}>
109
+ <text color={theme.color.textMuted} {...typeStyle("body")} maxLines={1}>
105
110
  {description()}
106
111
  </text>
107
112
  </Show>
@@ -109,7 +114,7 @@ export function Item(props: ItemProps) {
109
114
  </view>
110
115
  {props.endContent}
111
116
  <Show when={press.focused() && policy.focusRing}>
112
- <d-rect drawStyle="stroke" color={theme.color.text} strokeWidth={2} radius={radius()} />
117
+ <d-rect drawStyle="stroke" color={theme.color.ring} strokeWidth={theme.borderWidth.focus} radius={radius()} />
113
118
  </Show>
114
119
  </view>
115
120
  )
package/src/nav-shell.tsx CHANGED
@@ -5,6 +5,8 @@ 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 { TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
8
10
 
9
11
  export interface NavItem {
10
12
  value: unknown
@@ -14,7 +16,7 @@ export interface NavItem {
14
16
  icon?: any
15
17
  }
16
18
 
17
- export interface NavShellProps {
19
+ export interface NavShellProps extends TransitionProps {
18
20
  items: NavItem[]
19
21
  // Controlled selected value. If omitted, the shell is uncontrolled.
20
22
  value?: unknown
@@ -25,9 +27,6 @@ export interface NavShellProps {
25
27
  layout?: LayoutProps
26
28
  }
27
29
 
28
- const RAIL_WIDTH = 72
29
- const SIDEBAR_WIDTH = 220
30
-
31
30
  /**
32
31
  * An app shell that arranges primary navigation around the content per the
33
32
  * navigation policy: bottom tabs under it, a narrow rail or a wide sidebar
@@ -77,15 +76,8 @@ export function NavShell(props: NavShellProps) {
77
76
  )
78
77
  }
79
78
 
80
- let Hairline = (p: { vertical?: boolean }) => (
81
- <view width={p.vertical ? 1 : undefined} height={p.vertical ? undefined : 1}>
82
- <d-rect color={theme.color.border} />
83
- </view>
84
- )
85
-
86
79
  let Tabs = () => (
87
80
  <view flexDirection="column" flexShrink={0}>
88
- <Hairline />
89
81
  <view flexDirection="row">
90
82
  <d-rect color={theme.color.surface} />
91
83
  <For each={props.items}>
@@ -97,17 +89,16 @@ export function NavShell(props: NavShellProps) {
97
89
 
98
90
  let Rail = () => (
99
91
  <view flexDirection="row" flexShrink={0}>
100
- <view flexDirection="column" width={RAIL_WIDTH} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
92
+ <view flexDirection="column" width={theme.size.navRail} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
101
93
  <d-rect color={theme.color.surface} />
102
94
  <For each={props.items}>{(item: NavItem) => <StackedItem item={item} padY={theme.spacing.md} />}</For>
103
95
  </view>
104
- <Hairline vertical />
105
96
  </view>
106
97
  )
107
98
 
108
99
  let Sidebar = () => (
109
100
  <view flexDirection="row" flexShrink={0}>
110
- <view flexDirection="column" width={SIDEBAR_WIDTH} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
101
+ <view flexDirection="column" width={theme.size.navSidebar} gap={theme.spacing.sm} paddingTop={theme.spacing.md}>
111
102
  <d-rect color={theme.color.surface} />
112
103
  <For each={props.items}>
113
104
  {(item: NavItem) => {
@@ -140,14 +131,13 @@ export function NavShell(props: NavShellProps) {
140
131
  }}
141
132
  </For>
142
133
  </view>
143
- <Hairline vertical />
144
134
  </view>
145
135
  )
146
136
 
147
137
  // Children order is (content, nav): "column" puts the nav under the content,
148
138
  // "row-reverse" puts it to the left, and the content node never moves.
149
139
  return (
150
- <view flexDirection={policy.navigation === "bottomTabs" ? "column" : "row-reverse"} {...props.layout}>
140
+ <view flexDirection={policy.navigation === "bottomTabs" ? "column" : "row-reverse"} transition={splitTransition(props.transition).root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
151
141
  <view flex={1} flexDirection="column">
152
142
  {props.children}
153
143
  </view>
package/src/policy.ts CHANGED
@@ -29,7 +29,8 @@ export type Policies = {
29
29
  // only (per-run polarity, or the theme's palette polarity as the default),
30
30
  // with one extra step for small font sizes; see typography.ts.
31
31
  textWeightDelta: number
32
- // Application policies: recommendations derived from the window size class.
32
+ // Application policies: recommendations derived from the window size class
33
+ // (layout), and from the resulting pane count (navigation).
33
34
  // The application owns the final decision; accept them by consuming
34
35
  // policy.navigation / policy.layout, or override via setPolicy.
35
36
  navigation: NavigationPolicy
@@ -52,6 +53,7 @@ export function defaultPolicyResolver(caps: Capabilities): Policies {
52
53
  : caps.precisePointer
53
54
  ? "desktop"
54
55
  : "hybrid"
56
+ let layout: LayoutPolicy = caps.windowSizeClass === "expanded" ? "twoPane" : "singlePane"
55
57
  return {
56
58
  interaction,
57
59
  density: interaction === "desktop" ? "compact" : "comfortable",
@@ -62,9 +64,12 @@ export function defaultPolicyResolver(caps: Capabilities): Policies {
62
64
  focusRing: caps.keyboardNav || gamepads().some((p) => p != null),
63
65
  textScale: env.textScale,
64
66
  textWeightDelta: env.displayScale < 1.5 ? 100 : 0,
65
- navigation:
66
- caps.windowSizeClass === "expanded" ? "sidebar" : caps.windowSizeClass === "medium" ? "rail" : "bottomTabs",
67
- layout: caps.windowSizeClass === "expanded" ? "twoPane" : "singlePane",
67
+ // Navigation follows the pane count, not its own breakpoint: a side strip
68
+ // spends width, which a single-pane window is short of, so only a
69
+ // two-pane layout earns one. "rail" is never a default output; it is the
70
+ // narrow side nav an app can pick for a content-dense two-pane layout.
71
+ navigation: layout === "twoPane" ? "sidebar" : "bottomTabs",
72
+ layout,
68
73
  }
69
74
  }
70
75
 
package/src/pressable.tsx CHANGED
@@ -1,11 +1,12 @@
1
1
  import { children } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerProps } from "@solidrt/core"
3
- import type { StyleProps } from "./types"
3
+ import type { StyleProps, TransitionProps } from "./types"
4
+ import { splitTransition, transitionEndFor } from "./types"
4
5
  import { createPress, type PressState } from "./press"
5
6
 
6
7
  export type { PressState } from "./press"
7
8
 
8
- export interface PressableProps extends PointerProps {
9
+ export interface PressableProps extends PointerProps, TransitionProps {
9
10
  // children and style may be functions of the press state, so a caller can
10
11
  // restyle on press/hover without wiring their own signals. The state is live
11
12
  // (getters, not a snapshot): read it inside a prop or child expression, never
@@ -50,8 +51,12 @@ export function Pressable(props: PressableProps) {
50
51
  let hasBackground = () => style()?.backgroundColor != null || style()?.borderRadius != null
51
52
  let hasBorder = () => (style()?.borderWidth ?? 0) > 0
52
53
 
54
+ let split = () => splitTransition(props.transition)
55
+
53
56
  return (
54
57
  <view
58
+ transition={split().root}
59
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
55
60
  ref={(n: { id: number }) => {
56
61
  press.ref(n)
57
62
  props.ref?.(n)
@@ -79,6 +84,8 @@ export function Pressable(props: PressableProps) {
79
84
  >
80
85
  {hasBackground() ? (
81
86
  <d-rect
87
+ transition={split().background}
88
+ onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
82
89
  color={style()?.backgroundColor ?? "transparent"}
83
90
  radius={style()?.borderRadius}
84
91
  />
@@ -87,6 +94,8 @@ export function Pressable(props: PressableProps) {
87
94
  {hasBorder() ? (
88
95
  <d-rect
89
96
  drawStyle="stroke"
97
+ transition={split().border}
98
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
90
99
  color={style()?.borderColor ?? "transparent"}
91
100
  strokeWidth={style()?.borderWidth}
92
101
  radius={style()?.borderRadius}
@@ -2,9 +2,10 @@ import { createSignal, onFrame, onLayout, getBoundingBox, Show } from "@solidrt/
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 ProgressBarProps {
8
+ export interface ProgressBarProps extends TransitionProps {
8
9
  // Progress from 0 to 1. Omit (or leave undefined) for an indeterminate bar: a
9
10
  // segment that slides back and forth.
10
11
  value?: number
@@ -71,7 +72,7 @@ export function ProgressBar(props: ProgressBarProps) {
71
72
  let offset = () => effectivePhase() * trackWidth() * (1 - SEGMENT)
72
73
 
73
74
  return (
74
- <view ref={(n: { id: number }) => (trackNode = n)} position="relative" width="100%" height={h()} {...props.layout}>
75
+ <view ref={(n: { id: number }) => (trackNode = n)} position="relative" width="100%" height={h()} transition={splitTransition(props.transition).root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
75
76
  <Show when={animating()}>
76
77
  <Animate />
77
78
  </Show>
package/src/qrcode.tsx CHANGED
@@ -1,8 +1,11 @@
1
1
  import { createMemo } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import qrcode from "qrcode-generator"
4
+ import { theme } from "./theme"
5
+ import type { TransitionProps } from "./types"
6
+ import { splitTransition, transitionEndFor } from "./types"
4
7
 
5
- export interface QrCodeProps {
8
+ export interface QrCodeProps extends TransitionProps {
6
9
  // The string to encode (URL, pairing ticket, text, ...).
7
10
  data: string
8
11
  // Pixels per QR module (the smallest square). The grid is
@@ -18,14 +21,13 @@ export interface QrCodeProps {
18
21
  // Error-correction level: higher tolerates more damage but packs denser and
19
22
  // caps the data length sooner.
20
23
  level?: "L" | "M" | "Q" | "H"
21
- // Corner radius of the background panel.
24
+ // Corner radius of the background panel; defaults to the theme control radius.
22
25
  radius?: number
23
26
  layout?: LayoutProps
24
27
  }
25
28
 
26
29
  const MODULE_SIZE = 6
27
30
  const MARGIN = 16
28
- const RADIUS = 8
29
31
 
30
32
  // Render a QR for `data` as primitives: merge horizontal runs of dark modules
31
33
  // per row into a single d-rect, placed at explicit coordinates on a light
@@ -62,8 +64,8 @@ export function QrCode(props: QrCodeProps) {
62
64
  let side = () => grid().n * size() + 2 * margin()
63
65
 
64
66
  return (
65
- <view repaintBoundary width={side()} height={side()} {...props.layout}>
66
- <d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? RADIUS} />
67
+ <view repaintBoundary width={side()} height={side()} transition={splitTransition(props.transition).root} onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)} {...props.layout}>
68
+ <d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? theme.radius.md} />
67
69
  {grid().runs.map((run) => (
68
70
  <d-rect
69
71
  x={margin() + run.x * size()}
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)}