@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.
- package/AGENTS.md +34 -7
- package/README.md +47 -10
- package/demos/README.md +24 -0
- package/demos/assets/icon.png +0 -0
- package/demos/assets/icon.svg +23 -0
- package/demos/package.json +9 -0
- package/demos/src/gallery.tsx +866 -0
- package/demos/tsconfig.json +15 -0
- package/docs/button.md +1 -1
- package/docs/focus-nav.md +1 -1
- package/docs/icon.md +1 -1
- package/docs/item.md +1 -1
- package/docs/policy.md +1 -1
- package/docs/scroll-view.md +22 -1
- package/docs/split-view.md +1 -1
- package/docs/theme.md +14 -2
- package/docs/types.md +4 -0
- package/package.json +4 -3
- package/src/badge.tsx +21 -14
- package/src/button.tsx +13 -7
- package/src/card.tsx +10 -3
- package/src/checkbox.tsx +8 -2
- package/src/context-menu.tsx +9 -6
- package/src/divider.tsx +8 -3
- package/src/editor-field.tsx +18 -11
- package/src/field.tsx +4 -2
- package/src/icon.tsx +8 -4
- package/src/image.tsx +10 -3
- package/src/index.ts +10 -1
- package/src/item.tsx +12 -7
- package/src/nav-shell.tsx +6 -16
- package/src/policy.ts +9 -4
- package/src/pressable.tsx +11 -2
- package/src/progress-bar.tsx +4 -3
- package/src/qrcode.tsx +7 -5
- package/src/radio.tsx +12 -4
- package/src/rich-text-editor.tsx +5 -3
- package/src/scroll-view.tsx +76 -7
- package/src/segmented-control.tsx +14 -5
- package/src/select.tsx +23 -12
- package/src/slider.tsx +31 -5
- package/src/spinner.tsx +5 -2
- package/src/split-view.tsx +3 -4
- package/src/switch.tsx +8 -2
- package/src/text-input.tsx +4 -2
- package/src/text.tsx +22 -2
- package/src/theme.ts +72 -20
- package/src/tooltip.tsx +16 -5
- package/src/types.ts +119 -1
- package/src/view.tsx +11 -2
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
|
-
|
|
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
|
-
|
|
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,
|
|
93
|
-
let below = a.y + a.height +
|
|
94
|
-
let y = Math.round(below + b.height > env.windowSize.height -
|
|
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.
|
|
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("
|
|
185
|
-
paddingBottom={space("
|
|
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={
|
|
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}
|
package/src/split-view.tsx
CHANGED
|
@@ -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 ??
|
|
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} />
|
package/src/text-input.tsx
CHANGED
|
@@ -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()}
|
package/src/theme.ts
CHANGED
|
@@ -38,6 +38,8 @@ export type Theme = {
|
|
|
38
38
|
text: {
|
|
39
39
|
// Passed through to the core font stack: "sans" | "mono" | a family name.
|
|
40
40
|
fontFamily: string
|
|
41
|
+
// The monospace family for code (RichTextEditor inline code).
|
|
42
|
+
monoFamily: string
|
|
41
43
|
caption: TextStyle
|
|
42
44
|
label: TextStyle
|
|
43
45
|
body: TextStyle
|
|
@@ -69,10 +71,24 @@ export type Theme = {
|
|
|
69
71
|
// caller-set style.backgroundColor. Non-touch interaction policies only.
|
|
70
72
|
overlayHover: string
|
|
71
73
|
overlayPressed: string
|
|
74
|
+
// The focus ring (spatial nav under the focusRing policy), drawn at
|
|
75
|
+
// borderWidth.focus by every focusable control. Defaults to text so it
|
|
76
|
+
// stays visible on primary-filled controls.
|
|
77
|
+
ring: string
|
|
72
78
|
}
|
|
79
|
+
// Gaps and paddings, multiples of one base unit (sm 1x, md 2x, lg 4x,
|
|
80
|
+
// xl 5x); read through space() where density should apply.
|
|
73
81
|
spacing: { sm: number; md: number; lg: number; xl: number }
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
// Corner radii. md is THE control radius (Button, TextInput, Select,
|
|
83
|
+
// SegmentedControl); sm is one step under it (Checkbox, Item, menus,
|
|
84
|
+
// Tooltip), lg one step over (Card), full is the pill.
|
|
85
|
+
radius: { sm: number; md: number; lg: number; full: number }
|
|
86
|
+
borderWidth: { sm: number; focus: number }
|
|
87
|
+
// Default extents of the components that have one: the panes and rails an
|
|
88
|
+
// app lays its screens around, and the smallest sensible popup/track. Each
|
|
89
|
+
// is a per-instance layout override away (listWidth, layout.width, ...);
|
|
90
|
+
// the theme sets the app-wide default.
|
|
91
|
+
size: { navRail: number; navSidebar: number; splitViewList: number; menuMinWidth: number; slider: number }
|
|
76
92
|
// Semantic control glyphs, as SVG document strings (the Icon currency).
|
|
77
93
|
// Components draw their built-in vector paths by default; a theme that sets
|
|
78
94
|
// a slot swaps that glyph everywhere it appears. The package still bundles
|
|
@@ -93,9 +109,10 @@ export type Theme = {
|
|
|
93
109
|
export type ThemeColor = string | [light: string, dark: string]
|
|
94
110
|
|
|
95
111
|
export type ThemeDefinition = {
|
|
96
|
-
color: { [K in keyof Theme["color"]]: ThemeColor }
|
|
112
|
+
color: { [K in Exclude<keyof Theme["color"], "ring">]: ThemeColor } & { ring?: ThemeColor }
|
|
97
113
|
text?: {
|
|
98
114
|
fontFamily?: string
|
|
115
|
+
monoFamily?: string
|
|
99
116
|
// The body font size; the other roles derive from it. Default 14.
|
|
100
117
|
base?: number
|
|
101
118
|
// The step between adjacent roles (caption, label, body, title, heading
|
|
@@ -105,23 +122,43 @@ export type ThemeDefinition = {
|
|
|
105
122
|
// and weights.
|
|
106
123
|
roles?: { [K in TextVariant]?: Partial<TextStyle> }
|
|
107
124
|
}
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
// One base unit (the steps derive from it, see deriveSpacing) or explicit
|
|
126
|
+
// steps. Default 4.
|
|
127
|
+
spacing?: number | Partial<Theme["spacing"]>
|
|
128
|
+
// One base (the control radius; the steps derive from it, see
|
|
129
|
+
// deriveRadius) or explicit steps. Default 8.
|
|
130
|
+
radius?: number | Partial<Theme["radius"]>
|
|
110
131
|
borderWidth?: Partial<Theme["borderWidth"]>
|
|
132
|
+
size?: Partial<Theme["size"]>
|
|
111
133
|
icons?: Theme["icons"]
|
|
112
134
|
components?: Theme["components"]
|
|
113
135
|
}
|
|
114
136
|
|
|
115
|
-
const
|
|
116
|
-
const RADIUS = { sm: 4, md: 8, lg: 12 }
|
|
117
|
-
const BORDER_WIDTH = { sm: 1 }
|
|
137
|
+
const SPACING_BASE = 4
|
|
118
138
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
// The spacing scale from its base unit.
|
|
140
|
+
function deriveSpacing(base: number): Theme["spacing"] {
|
|
141
|
+
return { sm: base, md: base * 2, lg: base * 4, xl: base * 5 }
|
|
142
|
+
}
|
|
143
|
+
const RADIUS_BASE = 8
|
|
144
|
+
const RADIUS_FULL = 9999
|
|
145
|
+
|
|
146
|
+
// The radius scale from its base: sm half, lg one and a half, full the pill.
|
|
147
|
+
function deriveRadius(base: number): Theme["radius"] {
|
|
148
|
+
return { sm: Math.round(base / 2), md: base, lg: Math.round(base * 1.5), full: RADIUS_FULL }
|
|
149
|
+
}
|
|
150
|
+
const BORDER_WIDTH = { sm: 1, focus: 2 }
|
|
151
|
+
const SIZE = { navRail: 72, navSidebar: 220, splitViewList: 320, menuMinWidth: 120, slider: 200 }
|
|
152
|
+
|
|
153
|
+
// Line height and weight per role; body is the base text, label is body at
|
|
154
|
+
// an emphasized weight (form labels, key/value keys, tags), caption the one
|
|
155
|
+
// small role (badges, tab labels, timestamps), title and heading sit above
|
|
156
|
+
// body (card and page headings). De-emphasis is a color (textMuted), not a
|
|
157
|
+
// size: caption is small text to be glanced at, so keep it in the full text
|
|
158
|
+
// color rather than stacking small and muted.
|
|
122
159
|
const ROLE_DEFAULTS: { [K in TextVariant]: { step: number; lineHeight: number; weight: TextStyle["weight"] } } = {
|
|
123
|
-
caption: { step: -
|
|
124
|
-
label: { step:
|
|
160
|
+
caption: { step: -1, lineHeight: 1.3, weight: 400 },
|
|
161
|
+
label: { step: 0, lineHeight: 1.5, weight: 600 },
|
|
125
162
|
body: { step: 0, lineHeight: 1.5, weight: 400 },
|
|
126
163
|
title: { step: 1, lineHeight: 1.4, weight: 700 },
|
|
127
164
|
heading: { step: 2, lineHeight: 1.3, weight: 700 },
|
|
@@ -139,11 +176,13 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
|
|
|
139
176
|
for (let key in def.color) {
|
|
140
177
|
let k = key as keyof Theme["color"]
|
|
141
178
|
let value = def.color[k]
|
|
179
|
+
if (value == null) continue
|
|
142
180
|
if (Array.isArray(value)) {
|
|
143
181
|
if (!scheme) throw new Error(`Theme color "${key}" is a [light, dark] pair; pass a scheme to defineTheme`)
|
|
144
182
|
color[k] = value[scheme === "light" ? 0 : 1]
|
|
145
183
|
} else color[k] = value
|
|
146
184
|
}
|
|
185
|
+
if (def.color.ring == null) color.ring = color.text
|
|
147
186
|
let base = def.text?.base ?? 14
|
|
148
187
|
let ratio = def.text?.ratio ?? 1.26
|
|
149
188
|
let role = (name: TextVariant): TextStyle => {
|
|
@@ -158,6 +197,7 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
|
|
|
158
197
|
return {
|
|
159
198
|
text: {
|
|
160
199
|
fontFamily: def.text?.fontFamily ?? "sans",
|
|
200
|
+
monoFamily: def.text?.monoFamily ?? "mono",
|
|
161
201
|
caption: role("caption"),
|
|
162
202
|
label: role("label"),
|
|
163
203
|
body: role("body"),
|
|
@@ -165,9 +205,14 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
|
|
|
165
205
|
heading: role("heading"),
|
|
166
206
|
},
|
|
167
207
|
color,
|
|
168
|
-
spacing:
|
|
169
|
-
|
|
208
|
+
spacing:
|
|
209
|
+
typeof def.spacing === "number"
|
|
210
|
+
? deriveSpacing(def.spacing)
|
|
211
|
+
: { ...deriveSpacing(SPACING_BASE), ...def.spacing },
|
|
212
|
+
radius:
|
|
213
|
+
typeof def.radius === "number" ? deriveRadius(def.radius) : { ...deriveRadius(RADIUS_BASE), ...def.radius },
|
|
170
214
|
borderWidth: { ...BORDER_WIDTH, ...def.borderWidth },
|
|
215
|
+
size: { ...SIZE, ...def.size },
|
|
171
216
|
icons: def.icons ?? {},
|
|
172
217
|
components: def.components ?? {},
|
|
173
218
|
}
|
|
@@ -180,7 +225,10 @@ const DEFAULT: ThemeDefinition = {
|
|
|
180
225
|
background: ["#ffffff", "#0b0f17"],
|
|
181
226
|
surface: ["#f6f8fa", "#161b22"],
|
|
182
227
|
surfaceAlt: ["#eaeef2", "#21262d"],
|
|
183
|
-
text:
|
|
228
|
+
// Dark body text sits well under white (about 10:1 on the background,
|
|
229
|
+
// tuned by eye on a low-DPI display): full brightness glares on a dark
|
|
230
|
+
// ground, and the low-DPI weight compensation thickens it further.
|
|
231
|
+
text: ["#1f2328", "#b1bac4"],
|
|
184
232
|
// Muted is an opaque tone between text and background, mixed in oklab
|
|
185
233
|
// (like Material 3's tonal colors, not an alpha overlay): alpha text
|
|
186
234
|
// renders thin on low-DPI and its contrast depends on what sits behind
|
|
@@ -188,7 +236,10 @@ const DEFAULT: ThemeDefinition = {
|
|
|
188
236
|
// preset is data that must not need the render engine at import time
|
|
189
237
|
// (the website token build imports this module headless). If text or
|
|
190
238
|
// background changes, recompute: mixColors(text, background, 0.4).
|
|
191
|
-
|
|
239
|
+
// The dark tone sits a step above the 4.5:1 AA floor (about 5.4:1)
|
|
240
|
+
// instead of at the mix: the body text already sits low, and the
|
|
241
|
+
// strict mix falls under it.
|
|
242
|
+
textMuted: ["#707376", "#828993"],
|
|
192
243
|
border: ["rgba(0,0,0,0.15)", "rgba(255,255,255,0.14)"],
|
|
193
244
|
// Accent tuned to the puzzle mark's mid blue.
|
|
194
245
|
primary: "#547ebf",
|
|
@@ -202,9 +253,10 @@ const DEFAULT: ThemeDefinition = {
|
|
|
202
253
|
overlayHover: ["rgba(0,0,0,0.08)", "rgba(255,255,255,0.08)"],
|
|
203
254
|
overlayPressed: ["rgba(0,0,0,0.14)", "rgba(255,255,255,0.14)"],
|
|
204
255
|
},
|
|
205
|
-
// base 14 and ratio 1.26 derive title 18 and heading 22;
|
|
206
|
-
//
|
|
207
|
-
|
|
256
|
+
// base 14 and ratio 1.26 derive title 18 and heading 22; caption sits
|
|
257
|
+
// one step under body but the ratio lands on 11, too small to read on a
|
|
258
|
+
// low-DPI display or at TV distance, so it is pinned.
|
|
259
|
+
text: { roles: { caption: { size: 12 } } },
|
|
208
260
|
}
|
|
209
261
|
|
|
210
262
|
export let darkTheme: Theme = defineTheme(DEFAULT, "dark")
|
package/src/tooltip.tsx
CHANGED
|
@@ -4,8 +4,10 @@ import { theme } from "./theme"
|
|
|
4
4
|
import { policy } from "./policy"
|
|
5
5
|
import { space } from "./spacing"
|
|
6
6
|
import { typeStyle } from "./typography"
|
|
7
|
+
import type { TransitionProps } from "./types"
|
|
8
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
7
9
|
|
|
8
|
-
export interface TooltipProps {
|
|
10
|
+
export interface TooltipProps extends TransitionProps {
|
|
9
11
|
// The tooltip body. A string/number renders as themed text; anything else
|
|
10
12
|
// renders as-is.
|
|
11
13
|
content?: any
|
|
@@ -19,9 +21,10 @@ export interface TooltipProps {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const DELAY = 500
|
|
22
|
-
|
|
24
|
+
// Distance between the anchor and the bubble.
|
|
25
|
+
let gap = () => theme.spacing.sm
|
|
23
26
|
// Minimum distance kept between the bubble and the window edges.
|
|
24
|
-
|
|
27
|
+
let margin = () => theme.spacing.sm
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* A hover-only affordance: under desktop/hybrid interaction policies, resting a
|
|
@@ -61,8 +64,8 @@ export function Tooltip(props: TooltipProps) {
|
|
|
61
64
|
let b = bubble && getBoundingBox(bubble)
|
|
62
65
|
if (!a || !b) return
|
|
63
66
|
let x = a.x + a.width / 2 - b.width / 2
|
|
64
|
-
x = Math.round(Math.min(Math.max(x,
|
|
65
|
-
let y = Math.round(props.placement === "bottom" ? a.y + a.height +
|
|
67
|
+
x = Math.round(Math.min(Math.max(x, margin()), env.windowSize.width - b.width - margin()))
|
|
68
|
+
let y = Math.round(props.placement === "bottom" ? a.y + a.height + gap() : a.y - b.height - gap())
|
|
66
69
|
let cur = pos()
|
|
67
70
|
if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
|
|
68
71
|
})
|
|
@@ -86,6 +89,8 @@ export function Tooltip(props: TooltipProps) {
|
|
|
86
89
|
pointerEvents="none"
|
|
87
90
|
>
|
|
88
91
|
<d-rect
|
|
92
|
+
transition={split().background}
|
|
93
|
+
onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
|
|
89
94
|
color={theme.components.tooltip?.backgroundColor ?? theme.color.surfaceAlt}
|
|
90
95
|
radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
|
|
91
96
|
/>
|
|
@@ -96,6 +101,8 @@ export function Tooltip(props: TooltipProps) {
|
|
|
96
101
|
</Show>
|
|
97
102
|
<d-rect
|
|
98
103
|
drawStyle="stroke"
|
|
104
|
+
transition={split().border}
|
|
105
|
+
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
99
106
|
color={theme.components.tooltip?.borderColor ?? theme.color.border}
|
|
100
107
|
strokeWidth={theme.components.tooltip?.borderWidth ?? theme.borderWidth.sm}
|
|
101
108
|
radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
|
|
@@ -104,8 +111,12 @@ export function Tooltip(props: TooltipProps) {
|
|
|
104
111
|
)
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
let split = () => splitTransition(props.transition)
|
|
115
|
+
|
|
107
116
|
return (
|
|
108
117
|
<view
|
|
118
|
+
transition={split().root}
|
|
119
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
109
120
|
ref={(n: { id: number }) => (anchor = n)}
|
|
110
121
|
onPointerEnter={enter}
|
|
111
122
|
onPointerLeave={hide}
|