@solidrt/components 0.0.18 → 0.0.20

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/switch.tsx ADDED
@@ -0,0 +1,58 @@
1
+ import { createSignal } from "@solidjs/signals"
2
+ import type { LayoutProps } from "@solidrt/core"
3
+ import { Pressable } from "./pressable"
4
+ import { theme } from "./theme"
5
+ import { densityScale } from "./policy"
6
+ import type { StyleProps } from "./types"
7
+
8
+ export interface SwitchProps {
9
+ // Controlled on/off. If omitted, the switch is uncontrolled.
10
+ value?: boolean
11
+ // Initial value for uncontrolled use.
12
+ defaultValue?: boolean
13
+ onChange?: (value: boolean) => void
14
+ disabled?: boolean
15
+ layout?: LayoutProps
16
+ style?: StyleProps
17
+ }
18
+
19
+ // Designed (comfortable-density) metrics; w/h below scale them by the density
20
+ // policy.
21
+ const W = 44
22
+ const H = 24
23
+ const PAD = 2
24
+
25
+ // A toggle. Track fills with primary when on, surfaceAlt when off; the thumb
26
+ // slides across. Controlled via value/onChange, or uncontrolled via
27
+ // defaultValue. Built on Pressable, so disabled takes no pointer events.
28
+ export function Switch(props: SwitchProps) {
29
+ let [internal, setInternal] = createSignal(props.defaultValue ?? false)
30
+ let on = () => (props.value !== undefined ? props.value : internal())
31
+
32
+ let toggle = () => {
33
+ let next = !on()
34
+ if (props.value === undefined) setInternal(next)
35
+ props.onChange?.(next)
36
+ }
37
+
38
+ let w = () => Math.round(W * densityScale())
39
+ let h = () => Math.round(H * densityScale())
40
+ let thumb = () => h() - PAD * 2
41
+
42
+ return (
43
+ <Pressable
44
+ onPress={toggle}
45
+ disabled={props.disabled}
46
+ layout={{ width: w(), height: h(), ...props.layout }}
47
+ style={{
48
+ backgroundColor: on() ? theme.color.primary : theme.color.surfaceAlt,
49
+ borderRadius: h() / 2,
50
+ ...props.style,
51
+ }}
52
+ >
53
+ <view position="absolute" top={PAD} left={PAD} x={on() ? w() - thumb() - PAD * 2 : 0}>
54
+ <d-oval w={thumb()} h={thumb()} color={theme.color.onPrimary} />
55
+ </view>
56
+ </Pressable>
57
+ )
58
+ }
@@ -1,14 +1,20 @@
1
1
  import { createEffect, createSignal, onCleanup } from "@solidjs/signals"
2
- import { setFocus } from "@solidrt/core"
2
+ import { measureText, setFocus } from "@solidrt/core"
3
3
  import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
4
4
  import type { LayoutProps } from "@solidrt/core"
5
5
  import type { StyleProps } from "./types"
6
6
  import { theme } from "./theme"
7
+ import { policy, densityScale } from "./policy"
7
8
 
8
9
  // Caret thickness. Shared so the drawn caret and the scroll offset's reserved
9
10
  // edge column cannot drift apart.
10
11
  const CARET_WIDTH = 1
11
12
 
13
+ // Shaping width handed to the detached value/placeholder text: effectively
14
+ // unbounded, so a single line never wraps. The viewport clips it and scrollX
15
+ // slides it.
16
+ const TEXT_SHAPE_WIDTH = 1e9
17
+
12
18
  export interface TextInputProps {
13
19
  value?: string
14
20
  defaultValue?: string
@@ -120,28 +126,29 @@ export function TextInput(props: TextInputProps) {
120
126
  if (blinkId != null) clearInterval(blinkId)
121
127
  })
122
128
 
123
- // Style overrides fall back to theme defaults.
129
+ // Style overrides fall back to theme defaults. The border doubles as the
130
+ // focus ring: primary while focused, when the focus-ring policy asks for a
131
+ // visible indicator.
124
132
  let textColor = () => props.style?.color ?? theme.color.text
125
133
  let surfaceColor = () => props.style?.backgroundColor ?? theme.color.surface
126
- let borderColor = () => props.style?.borderColor ?? theme.color.border
134
+ let borderColor = () =>
135
+ props.style?.borderColor ?? (focused() && policy.focusRing ? theme.color.primary : theme.color.border)
127
136
  let borderWidth = () => props.style?.borderWidth ?? theme.borderWidth.sm
128
137
  let borderRadius = () => props.style?.borderRadius ?? theme.radius.sm
129
138
 
130
139
  let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
131
140
  let showCaret = () => focused() && caretOn() && !showPlaceholder()
132
141
 
133
- // The text is split at the caret into two nodes with a zero-size anchor view
134
- // between them. Flow places the anchor at the caret x (the before-text width),
135
- // and the caret is a detached d-rect inside it: detached nodes take no layout
136
- // slot, so the anchor stays zero-width and the after-text is not shifted. The
137
- // anchor sits at the row's vertical center (alignItems center, zero height),
138
- // so the caret is offset up by half its height to straddle it. While the
139
- // placeholder shows, value() is "" so the slices and the scroll offset are 0
140
- // with no special case. The viewport node is the inner scroll container;
141
- // createCaretScroll reads its laid-out width after layout, keeps the caret in
142
- // view, and flushes the offset before paint.
143
- let beforeCaret = () => value().slice(0, buffer.caret())
144
- let afterCaret = () => value().slice(buffer.caret())
142
+ // Everything inside the viewport is detached: the value is one d-text shaped
143
+ // at an unbounded width and the caret a d-rect at the measured before-caret
144
+ // width, so typing, caret movement, blink and scroll never touch layout. The
145
+ // viewport carries an explicit height (detached content takes no layout
146
+ // slot) equal to the one-line paragraph height, which keeps the text where
147
+ // the old centered attached row sat. createCaretScroll keeps the caret in
148
+ // view and flushes the offset before paint; scrollX is a paint-time
149
+ // translate that also applies to detached children.
150
+ let rowHeight = () => Math.round(theme.text.body.size * theme.text.body.lineHeight)
151
+ let caretX = () => measureText(value().slice(0, buffer.caret()), { fontSize: theme.text.body.size }).width
145
152
  let scrollX = createCaretScroll(
146
153
  () => viewport,
147
154
  () => ({
@@ -156,11 +163,11 @@ export function TextInput(props: TextInputProps) {
156
163
  )
157
164
 
158
165
  let textStyle = (color: string) => ({
166
+ w: TEXT_SHAPE_WIDTH,
159
167
  fontSize: theme.text.body.size,
160
168
  lineHeight: theme.text.body.lineHeight,
161
169
  color,
162
170
  maxLines: 1,
163
- flexShrink: 0,
164
171
  })
165
172
 
166
173
  return (
@@ -168,10 +175,10 @@ export function TextInput(props: TextInputProps) {
168
175
  ref={(n: { id: number }) => (node = n)}
169
176
  flexDirection="row"
170
177
  alignItems="center"
171
- paddingLeft={theme.spacing.md}
172
- paddingRight={theme.spacing.md}
173
- paddingTop={theme.spacing.sm}
174
- paddingBottom={theme.spacing.sm}
178
+ paddingLeft={Math.round(theme.spacing.md * densityScale())}
179
+ paddingRight={Math.round(theme.spacing.md * densityScale())}
180
+ paddingTop={Math.round(theme.spacing.sm * densityScale())}
181
+ paddingBottom={Math.round(theme.spacing.sm * densityScale())}
175
182
  {...props.layout}
176
183
  x={props.style?.x}
177
184
  y={props.style?.y}
@@ -193,28 +200,25 @@ export function TextInput(props: TextInputProps) {
193
200
  <view
194
201
  ref={(n: { id: number }) => (viewport = n)}
195
202
  flex={1}
196
- flexDirection="row"
197
- alignItems="center"
203
+ height={rowHeight()}
198
204
  overflow="hidden"
199
205
  scrollX={scrollX()}
200
206
  >
201
207
  {showPlaceholder() ? (
202
- <text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</text>
208
+ <d-text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</d-text>
203
209
  ) : (
204
- <view flexDirection="row" alignItems="center" flexShrink={0}>
205
- <text {...textStyle(textColor())}>{beforeCaret()}</text>
210
+ <>
211
+ <d-text {...textStyle(textColor())}>{value()}</d-text>
206
212
  {showCaret() ? (
207
- <view>
208
- <d-rect
209
- color={textColor()}
210
- y={-theme.text.body.size / 2}
211
- w={CARET_WIDTH}
212
- h={theme.text.body.size}
213
- />
214
- </view>
213
+ <d-rect
214
+ color={textColor()}
215
+ x={caretX()}
216
+ y={(rowHeight() - theme.text.body.size) / 2}
217
+ w={CARET_WIDTH}
218
+ h={theme.text.body.size}
219
+ />
215
220
  ) : null}
216
- <text {...textStyle(textColor())}>{afterCaret()}</text>
217
- </view>
221
+ </>
218
222
  )}
219
223
  </view>
220
224
  </view>
package/src/theme.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createStore } from "@solidrt/core"
2
+
1
3
  export type TextStyle = {
2
4
  size: number
3
5
  lineHeight: number
@@ -6,12 +8,24 @@ export type TextStyle = {
6
8
  export type Theme = {
7
9
  text: { body: TextStyle }
8
10
  color: {
11
+ // Window fill.
12
+ background: string
13
+ // Control/card fill.
14
+ surface: string
15
+ // Subtle raised/track fill (switch off-state, slider track, ...).
16
+ surfaceAlt: string
17
+ // Hover tint for surface-colored controls (non-touch interaction policies).
18
+ surfaceHover: string
9
19
  text: string
10
20
  textMuted: string
11
- surface: string
12
21
  border: string
13
22
  primary: string
23
+ // Hover tint for primary-colored controls.
24
+ primaryHover: string
14
25
  onPrimary: string
26
+ // Validation / destructive.
27
+ danger: string
28
+ // Overlay dim behind modals.
15
29
  scrim: string
16
30
  }
17
31
  spacing: { sm: number; md: number }
@@ -19,29 +33,70 @@ export type Theme = {
19
33
  borderWidth: { sm: number }
20
34
  }
21
35
 
22
- export let theme: Theme = {
23
- text: {
24
- body: { size: 14, lineHeight: 1.5 },
25
- },
36
+ // Scheme-independent tokens, shared by both presets.
37
+ const TEXT = { body: { size: 14, lineHeight: 1.5 } }
38
+ const SPACING = { sm: 4, md: 8 }
39
+ const RADIUS = { sm: 4 }
40
+ const BORDER_WIDTH = { sm: 1 }
41
+
42
+ export let darkTheme: Theme = {
43
+ text: TEXT,
26
44
  color: {
27
- text: "#333",
28
- textMuted: "rgba(0,0,0,0.4)",
29
- surface: "#ccc",
30
- border: "rgba(0,0,0,0.2)",
45
+ background: "#0b0f17",
46
+ surface: "#161b22",
47
+ surfaceAlt: "#21262d",
48
+ surfaceHover: "#262c34",
49
+ text: "#e6edf3",
50
+ textMuted: "rgba(230,237,243,0.5)",
51
+ border: "rgba(255,255,255,0.14)",
31
52
  primary: "#1f6feb",
53
+ primaryHover: "#388bfd",
32
54
  onPrimary: "#ffffff",
55
+ danger: "#f85149",
33
56
  scrim: "rgba(0,0,0,0.6)",
34
57
  },
35
- spacing: { sm: 4, md: 8 },
36
- radius: { sm: 4 },
37
- borderWidth: { sm: 1 },
58
+ spacing: SPACING,
59
+ radius: RADIUS,
60
+ borderWidth: BORDER_WIDTH,
61
+ }
62
+
63
+ export let lightTheme: Theme = {
64
+ text: TEXT,
65
+ color: {
66
+ background: "#ffffff",
67
+ surface: "#f6f8fa",
68
+ surfaceAlt: "#eaeef2",
69
+ surfaceHover: "#e0e5eb",
70
+ text: "#1f2328",
71
+ textMuted: "rgba(31,35,40,0.5)",
72
+ border: "rgba(0,0,0,0.15)",
73
+ primary: "#1f6feb",
74
+ primaryHover: "#1a5fd0",
75
+ onPrimary: "#ffffff",
76
+ danger: "#cf222e",
77
+ scrim: "rgba(0,0,0,0.4)",
78
+ },
79
+ spacing: SPACING,
80
+ radius: RADIUS,
81
+ borderWidth: BORDER_WIDTH,
38
82
  }
39
83
 
84
+ // Backed by a Solid store so reads are tracked: calling setTheme at runtime
85
+ // recolors the live UI without remounting. Components read theme.* through
86
+ // thunks/JSX expressions, so they pick this up with no call-site changes.
87
+ let [theme, setThemeStore] = createStore<Theme>({ ...darkTheme })
88
+ export { theme }
89
+
40
90
  type ThemePartial = { [K in keyof Theme]?: Partial<Theme[K]> }
41
91
 
92
+ // Switch themes with a full preset (setTheme(lightTheme)) or apply a targeted
93
+ // override (setTheme({ color: { primary: "#f00" } })). Merges one level deep per
94
+ // category, matching the previous Object.assign behavior.
42
95
  export function setTheme(partial: ThemePartial) {
43
- for (let key in partial) {
44
- let k = key as keyof Theme
45
- Object.assign(theme[k], partial[k])
46
- }
47
- }
96
+ setThemeStore((s) => {
97
+ for (let key in partial) {
98
+ let k = key as keyof Theme
99
+ Object.assign(s[k], (partial as any)[k])
100
+ }
101
+ })
102
+ }
@@ -0,0 +1,112 @@
1
+ import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, env } from "@solidrt/core"
2
+ import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
+ import { theme } from "./theme"
4
+ import { policy, densityScale } from "./policy"
5
+
6
+ export interface TooltipProps {
7
+ // The tooltip body. A string/number renders as themed text; anything else
8
+ // renders as-is.
9
+ content?: any
10
+ // The anchor content the tooltip attaches to.
11
+ children?: any
12
+ // Hover delay in milliseconds before showing.
13
+ delay?: number
14
+ // Which side of the anchor the bubble appears on.
15
+ placement?: "top" | "bottom"
16
+ layout?: LayoutProps
17
+ }
18
+
19
+ const DELAY = 500
20
+ const GAP = 6
21
+ // Minimum distance kept between the bubble and the window edges.
22
+ const MARGIN = 4
23
+
24
+ /**
25
+ * A hover-only affordance: under desktop/hybrid interaction policies, resting a
26
+ * mouse pointer on the wrapped content shows a bubble near it after a short
27
+ * delay. Under the touch policy it never shows, so tooltip content must stay
28
+ * non-essential. The bubble is portal-mounted at the window root and takes no
29
+ * pointer events; it hides on leave and on press.
30
+ */
31
+ export function Tooltip(props: TooltipProps) {
32
+ let anchor: { id: number } | undefined
33
+ let [open, setOpen] = createSignal(false)
34
+ let timer: ReturnType<typeof setTimeout> | undefined
35
+
36
+ let enter = (e: PointerEvent) => {
37
+ // The policy gates the behavior; the pointerType check keeps a finger from
38
+ // arming the tooltip in a hybrid session.
39
+ if (policy.interaction === "touch" || e.pointerType !== "mouse") return
40
+ clearTimeout(timer)
41
+ timer = setTimeout(() => setOpen(true), props.delay ?? DELAY)
42
+ }
43
+ let hide = () => {
44
+ clearTimeout(timer)
45
+ setOpen(false)
46
+ }
47
+ onCleanup(() => clearTimeout(timer))
48
+
49
+ // The bubble sits at the window root's origin and is placed with the x/y
50
+ // paint transforms, so repositioning never reflows. Measured after its first
51
+ // layout (parked offscreen until then), then pinned to the anchor's current
52
+ // box; recomputed each layout so it stays attached when the anchor moves
53
+ // (scrolling, resizes).
54
+ let Bubble = () => {
55
+ let bubble: { id: number } | undefined
56
+ let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null)
57
+ onLayout(() => {
58
+ let a = anchor && getBoundingBox(anchor)
59
+ let b = bubble && getBoundingBox(bubble)
60
+ if (!a || !b) return
61
+ let x = a.x + a.width / 2 - b.width / 2
62
+ x = Math.round(Math.min(Math.max(x, MARGIN), env.windowSize.width - b.width - MARGIN))
63
+ let y = Math.round(props.placement === "bottom" ? a.y + a.height + GAP : a.y - b.height - GAP)
64
+ let cur = pos()
65
+ if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
66
+ })
67
+ let isText = () => typeof props.content === "string" || typeof props.content === "number"
68
+ return createPortal(
69
+ <view
70
+ ref={(n: { id: number }) => (bubble = n)}
71
+ position="absolute"
72
+ top={0}
73
+ left={0}
74
+ x={pos()?.x ?? -10000}
75
+ y={pos()?.y ?? 0}
76
+ paddingTop={Math.round(theme.spacing.sm * densityScale())}
77
+ paddingBottom={Math.round(theme.spacing.sm * densityScale())}
78
+ paddingLeft={Math.round(theme.spacing.md * densityScale())}
79
+ paddingRight={Math.round(theme.spacing.md * densityScale())}
80
+ pointerEvents="none"
81
+ >
82
+ <d-rect color={theme.color.surfaceAlt} radius={theme.radius.sm} />
83
+ <Show when={isText()} fallback={props.content}>
84
+ <text color={theme.color.text} fontSize={theme.text.body.size} lineHeight={theme.text.body.lineHeight}>
85
+ {props.content}
86
+ </text>
87
+ </Show>
88
+ <d-rect
89
+ drawStyle="stroke"
90
+ color={theme.color.border}
91
+ strokeWidth={theme.borderWidth.sm}
92
+ radius={theme.radius.sm}
93
+ />
94
+ </view>,
95
+ )
96
+ }
97
+
98
+ return (
99
+ <view
100
+ ref={(n: { id: number }) => (anchor = n)}
101
+ onPointerEnter={enter}
102
+ onPointerLeave={hide}
103
+ onPointerDown={hide}
104
+ {...props.layout}
105
+ >
106
+ {props.children}
107
+ <Show when={open()}>
108
+ <Bubble />
109
+ </Show>
110
+ </view>
111
+ )
112
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Color, LayoutProps } from "@solidrt/core"
1
+ import type { Color, Gradient, LayoutProps } from "@solidrt/core"
2
2
 
3
3
  // The split between layout and style follows one rule: layout properties feed
4
4
  // into Taffy and changing them triggers a relayout; style properties are
@@ -8,9 +8,9 @@ import type { Color, LayoutProps } from "@solidrt/core"
8
8
  // drawn as a stroke overlay (not part of the box model), and the transform is
9
9
  // applied at paint time, so both live here.
10
10
  export interface StyleProps {
11
- color?: Color
12
- backgroundColor?: Color
13
- borderColor?: Color
11
+ color?: Color | Gradient
12
+ backgroundColor?: Color | Gradient
13
+ borderColor?: Color | Gradient
14
14
  borderWidth?: number
15
15
  borderRadius?: number | [number, number, number, number]
16
16
  x?: number