@solidrt/components 0.0.50 → 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 (85) hide show
  1. package/AGENTS.md +93 -85
  2. package/README.md +421 -311
  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/badge.md +10 -0
  10. package/docs/button.md +21 -0
  11. package/docs/card.md +11 -0
  12. package/docs/checkbox.md +9 -0
  13. package/docs/context-menu.md +17 -0
  14. package/docs/density.md +13 -0
  15. package/docs/divider.md +10 -0
  16. package/docs/field.md +11 -0
  17. package/docs/focus-nav.md +18 -0
  18. package/docs/icon.md +13 -0
  19. package/docs/image.md +21 -0
  20. package/docs/index.md +13 -0
  21. package/docs/item.md +25 -0
  22. package/docs/modal.md +15 -0
  23. package/docs/nav-shell.md +18 -0
  24. package/docs/policy.md +21 -0
  25. package/docs/portal.md +15 -0
  26. package/docs/pressable.md +17 -0
  27. package/docs/progress-bar.md +10 -0
  28. package/docs/qrcode.md +10 -0
  29. package/docs/radio.md +13 -0
  30. package/docs/rich-text-document.md +5 -0
  31. package/docs/rich-text-editor.md +24 -0
  32. package/docs/safe-area.md +12 -0
  33. package/docs/scroll-view.md +35 -0
  34. package/docs/segmented-control.md +13 -0
  35. package/docs/select.md +15 -0
  36. package/docs/slider.md +9 -0
  37. package/docs/spacing.md +7 -0
  38. package/docs/spinner.md +10 -0
  39. package/docs/split-view.md +14 -0
  40. package/docs/switch.md +13 -0
  41. package/docs/text-input.md +23 -0
  42. package/docs/text.md +15 -0
  43. package/docs/theme.md +68 -0
  44. package/docs/tooltip.md +11 -0
  45. package/docs/types.md +9 -0
  46. package/docs/typography.md +5 -0
  47. package/docs/view.md +14 -0
  48. package/docs/window.md +15 -0
  49. package/package.json +8 -4
  50. package/src/badge.tsx +30 -21
  51. package/src/button.tsx +50 -32
  52. package/src/card.tsx +22 -13
  53. package/src/checkbox.tsx +29 -11
  54. package/src/context-menu.tsx +14 -9
  55. package/src/density.tsx +39 -0
  56. package/src/divider.tsx +11 -4
  57. package/src/editor-field.tsx +368 -0
  58. package/src/field.tsx +47 -0
  59. package/src/icon.tsx +8 -4
  60. package/src/image.tsx +10 -3
  61. package/src/index.ts +19 -2
  62. package/src/item.tsx +121 -0
  63. package/src/nav-shell.tsx +7 -17
  64. package/src/policy.ts +9 -12
  65. package/src/press.ts +37 -8
  66. package/src/pressable.tsx +15 -3
  67. package/src/progress-bar.tsx +8 -5
  68. package/src/qrcode.tsx +7 -5
  69. package/src/radio.tsx +26 -16
  70. package/src/rich-text-document.ts +247 -0
  71. package/src/rich-text-editor.tsx +151 -0
  72. package/src/scroll-view.tsx +76 -7
  73. package/src/segmented-control.tsx +33 -19
  74. package/src/select.tsx +59 -30
  75. package/src/slider.tsx +32 -6
  76. package/src/spacing.ts +1 -1
  77. package/src/spinner.tsx +11 -6
  78. package/src/split-view.tsx +3 -4
  79. package/src/switch.tsx +10 -3
  80. package/src/text-input.tsx +54 -264
  81. package/src/text.tsx +22 -2
  82. package/src/theme.ts +220 -81
  83. package/src/tooltip.tsx +23 -9
  84. package/src/types.ts +119 -1
  85. package/src/view.tsx +11 -2
package/src/select.tsx CHANGED
@@ -5,9 +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"
10
+ import { Icon } from "./icon"
9
11
 
10
- export interface SelectProps {
12
+ export interface SelectProps extends TransitionProps {
11
13
  options: Option[]
12
14
  // Controlled selected value. If omitted, the select is uncontrolled.
13
15
  value?: unknown
@@ -20,9 +22,10 @@ export interface SelectProps {
20
22
  style?: StyleProps
21
23
  }
22
24
 
23
- const GAP = 4
25
+ // Distance between the trigger and the dropdown.
26
+ let gap = () => theme.spacing.sm
24
27
  // Minimum distance kept between the dropdown and the window edges.
25
- const MARGIN = 4
28
+ let margin = () => theme.spacing.sm
26
29
 
27
30
  /**
28
31
  * A single-choice picker whose presentation forks on the interaction policy:
@@ -61,12 +64,15 @@ export function Select(props: SelectProps) {
61
64
  paddingLeft={space("md")}
62
65
  paddingRight={space("md")}
63
66
  {...press.handlers}
67
+ focusable
64
68
  >
65
69
  <d-rect
66
70
  color={
67
- press.pressed() || (press.hovered() && policy.interaction !== "touch")
68
- ? theme.color.surfaceHover
69
- : "transparent"
71
+ press.pressed()
72
+ ? theme.color.overlayPressed
73
+ : (press.hovered() && policy.interaction !== "touch") || (press.focused() && policy.focusRing)
74
+ ? theme.color.overlayHover
75
+ : "transparent"
70
76
  }
71
77
  />
72
78
  <text {...bodyText(p.option.value === value() ? theme.color.primary : theme.color.text)}>{p.option.label}</text>
@@ -86,9 +92,9 @@ export function Select(props: SelectProps) {
86
92
  let b = menu && getBoundingBox(menu)
87
93
  if (!a || !b) return
88
94
  if (a.width !== minWidth()) setMinWidth(a.width)
89
- let x = Math.round(Math.min(Math.max(a.x, MARGIN), env.windowSize.width - b.width - MARGIN))
90
- let below = a.y + a.height + GAP
91
- 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)
92
98
  let cur = pos()
93
99
  if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
94
100
  })
@@ -147,21 +153,33 @@ export function Select(props: SelectProps) {
147
153
  </view>,
148
154
  )
149
155
 
150
- let press = createPress({ onPress: () => setOpen(!open()) })
156
+ let press = createPress({
157
+ onPress: () => {
158
+ setOpen(!open())
159
+ },
160
+ })
151
161
  let style = () => ({
152
162
  borderColor: theme.color.border,
153
163
  borderWidth: theme.borderWidth.sm,
154
- borderRadius: theme.radius.sm,
164
+ borderRadius: theme.radius.md,
165
+ backgroundColor: theme.color.surface,
166
+ ...theme.components.select,
155
167
  ...props.style,
156
- backgroundColor:
157
- props.style?.backgroundColor ??
158
- (press.hovered() && !props.disabled && policy.interaction !== "touch"
159
- ? theme.color.surfaceHover
160
- : theme.color.surface),
168
+ ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
161
169
  })
170
+ // Hover feedback: the theme overlay tint drawn over the trigger fill.
171
+ let overlay = () =>
172
+ press.hovered() && !props.disabled && policy.interaction !== "touch"
173
+ ? theme.color.overlayHover
174
+ : "transparent"
175
+
176
+
177
+ let split = () => splitTransition(props.transition)
162
178
 
163
179
  return (
164
180
  <view
181
+ transition={split().root}
182
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
165
183
  ref={(n: { id: number }) => {
166
184
  press.ref(n)
167
185
  trigger = n
@@ -171,8 +189,8 @@ export function Select(props: SelectProps) {
171
189
  alignItems="center"
172
190
  justifyContent="space-between"
173
191
  gap={theme.spacing.md}
174
- paddingTop={space("sm")}
175
- paddingBottom={space("sm")}
192
+ paddingTop={space("md")}
193
+ paddingBottom={space("md")}
176
194
  paddingLeft={space("md")}
177
195
  paddingRight={space("md")}
178
196
  {...props.layout}
@@ -182,25 +200,34 @@ export function Select(props: SelectProps) {
182
200
  rotate={style().rotate}
183
201
  opacity={style().opacity}
184
202
  {...press.handlers}
203
+ focusable={!props.disabled}
185
204
  pointerEvents={props.disabled ? "none" : undefined}
186
205
  >
187
- <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} />
207
+ <d-rect color={overlay()} radius={style().borderRadius} />
188
208
  <Show
189
209
  when={selected()}
190
210
  fallback={<text {...bodyText(theme.color.textMuted)}>{props.placeholder ?? ""}</text>}
191
211
  >
192
212
  <text {...bodyText(props.disabled ? theme.color.textMuted : theme.color.text)}>{selected()!.label}</text>
193
213
  </Show>
194
- <view width={12} height={8}>
195
- <d-path
196
- d="M 2 2 L 6 6 L 10 2"
197
- drawStyle="stroke"
198
- color={theme.color.textMuted}
199
- strokeWidth={2}
200
- strokeCap="round"
201
- strokeJoin="round"
202
- />
203
- </view>
214
+ <Show
215
+ when={theme.icons.chevronDown}
216
+ fallback={
217
+ <view width={12} height={8}>
218
+ <d-path
219
+ d="M 2 2 L 6 6 L 10 2"
220
+ drawStyle="stroke"
221
+ color={theme.color.textMuted}
222
+ strokeWidth={2}
223
+ strokeCap="round"
224
+ strokeJoin="round"
225
+ />
226
+ </view>
227
+ }
228
+ >
229
+ <Icon src={theme.icons.chevronDown!} size={12} color={theme.color.textMuted} />
230
+ </Show>
204
231
  <Show when={open()}>
205
232
  <Show when={policy.interaction === "touch"} fallback={<Dropdown />}>
206
233
  <Sheet />
@@ -209,6 +236,8 @@ export function Select(props: SelectProps) {
209
236
  <Show when={(style().borderWidth ?? 0) > 0}>
210
237
  <d-rect
211
238
  drawStyle="stroke"
239
+ transition={split().border}
240
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
212
241
  color={style().borderColor ?? "transparent"}
213
242
  strokeWidth={style().borderWidth}
214
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 { densityScale } from "./policy"
5
- import type { StyleProps } from "./types"
4
+ import { policy } from "./policy"
5
+ import { densityScale } from "./density"
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/spacing.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { theme, type Theme } from "./theme"
2
- import { densityScale } from "./policy"
2
+ import { densityScale } from "./density"
3
3
 
4
4
  // Density-scaled spacing: a theme.spacing token multiplied by the density
5
5
  // policy's metric scale, rounded to whole pixels. Use it for gaps and paddings
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.
@@ -24,7 +25,9 @@ const THICKNESS = 3
24
25
  export function Spinner(props: SpinnerProps) {
25
26
  let size = () => props.size ?? SIZE
26
27
  let thickness = () => props.thickness ?? THICKNESS
27
- let color = () => props.style?.color ?? theme.color.primary
28
+ // Theme-level per-component overrides merged under the instance style.
29
+ let styled = () => ({ ...theme.components.spinner, ...props.style })
30
+ let color = () => styled().color ?? theme.color.primary
28
31
  let speed = () => props.speed ?? 1
29
32
 
30
33
  // tick is in milliseconds (like performance.now()). The frame loop is mounted
@@ -51,13 +54,15 @@ export function Spinner(props: SpinnerProps) {
51
54
 
52
55
  return (
53
56
  <view
57
+ transition={splitTransition(props.transition).root}
58
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
54
59
  width={size()}
55
60
  height={size()}
56
61
  {...props.layout}
57
62
  rotate={angle()}
58
- x={props.style?.x}
59
- y={props.style?.y}
60
- opacity={props.style?.opacity}
63
+ x={styled().x}
64
+ y={styled().y}
65
+ opacity={styled().opacity}
61
66
  >
62
67
  <Show when={policy.motion !== "none"}>
63
68
  <Animate />
@@ -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 { densityScale } from "./policy"
6
- import type { StyleProps } from "./types"
5
+ import { policy } from "./policy"
6
+ import { densityScale } from "./density"
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.
@@ -43,11 +45,15 @@ export function Switch(props: SwitchProps) {
43
45
  let style = () => ({
44
46
  backgroundColor: on() ? theme.color.primary : theme.color.surfaceAlt,
45
47
  borderRadius: h() / 2,
48
+ ...theme.components.switch,
46
49
  ...props.style,
50
+ ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
47
51
  })
48
52
 
49
53
  return (
50
54
  <view
55
+ transition={splitTransition(props.transition).root}
56
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
51
57
  ref={press.ref}
52
58
  repaintBoundary
53
59
  width={w()}
@@ -59,6 +65,7 @@ export function Switch(props: SwitchProps) {
59
65
  rotate={style().rotate}
60
66
  opacity={style().opacity}
61
67
  {...press.handlers}
68
+ focusable={!props.disabled}
62
69
  pointerEvents={props.disabled ? "none" : undefined}
63
70
  >
64
71
  <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />