@solidrt/components 0.0.20 → 0.0.22
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 +17 -0
- package/README.md +35 -0
- package/examples/README.md +11 -0
- package/examples/theme-toggle.tsx +37 -0
- package/package.json +3 -2
- package/src/badge.tsx +21 -3
- package/src/button.tsx +45 -14
- package/src/card.tsx +8 -6
- package/src/context-menu.tsx +7 -10
- package/src/icon.tsx +9 -7
- package/src/image.tsx +1 -0
- package/src/index.ts +14 -4
- package/src/nav-shell.tsx +7 -6
- package/src/policy.ts +18 -1
- package/src/pressable.tsx +2 -0
- package/src/qrcode.tsx +1 -1
- package/src/radio.tsx +2 -1
- package/src/scroll-view.tsx +1 -0
- package/src/select.tsx +11 -14
- package/src/slider.tsx +2 -0
- package/src/spacing.ts +11 -0
- package/src/spinner.tsx +1 -0
- package/src/text-input.tsx +16 -11
- package/src/text.tsx +28 -5
- package/src/theme.ts +38 -10
- package/src/tooltip.tsx +9 -6
- package/src/types.ts +1 -0
- package/src/typography.ts +57 -0
- package/src/view.tsx +1 -0
package/AGENTS.md
CHANGED
|
@@ -85,6 +85,23 @@ Most components group props into two objects, plus top-level event handlers:
|
|
|
85
85
|
`setTheme({...})` merges a one-level-deep override. Default is dark. Color
|
|
86
86
|
tokens: `background`, `surface`, `surfaceAlt`, `text`, `textMuted`, `border`,
|
|
87
87
|
`primary`, `onPrimary`, `danger`, `scrim`.
|
|
88
|
+
- `policy` / `setPolicy` / `setPolicyResolver` / `defaultPolicyResolver` /
|
|
89
|
+
`densityScale` - shared REACTIVE behavior (theme answers "how does it look",
|
|
90
|
+
policy answers "how does it behave"). Derived from `@solidrt/core`'s
|
|
91
|
+
`capabilities`/`env` (touch vs. desktop, window size class, display scale).
|
|
92
|
+
Fields: `interaction` (`"touch" | "desktop" | "hybrid"`, gates hover vs.
|
|
93
|
+
long-press affordances), `density` (`"comfortable" | "compact" | "dense"`,
|
|
94
|
+
drives `densityScale()`: 1 / 0.85 / 0.7), `motion` (`"normal" | "reduced" |
|
|
95
|
+
"none"`), `focusRing` (boolean), `textScale`/`textWeightDelta` (Dynamic-Type
|
|
96
|
+
and low-DPI weight compensation, consumed by `Text`), `navigation`
|
|
97
|
+
(`"bottomTabs" | "rail" | "sidebar"`) and `layout` (`"singlePane" |
|
|
98
|
+
"twoPane"`, both recommendations derived from window size class). Read
|
|
99
|
+
`policy.*` directly (reactive getters, like `theme`); `setPolicy({ field:
|
|
100
|
+
value })` pins one field regardless of the derived value, `setPolicy({
|
|
101
|
+
field: undefined })` hands it back to the resolver; `setPolicyResolver(caps
|
|
102
|
+
=> Policies)` replaces the whole derivation for full custom control.
|
|
103
|
+
`Tooltip`, `Select`, `ContextMenu` fork on `policy.interaction`; `NavShell`
|
|
104
|
+
on `policy.navigation`; `SplitView` on `policy.layout`.
|
|
88
105
|
|
|
89
106
|
## Minimal app (verified to render)
|
|
90
107
|
|
package/README.md
CHANGED
|
@@ -28,6 +28,41 @@ setTheme({ color: { primary: "#ff2d55" } }) // override one token
|
|
|
28
28
|
|
|
29
29
|
The color tokens are `background` (window fill), `surface` (control/card fill), `surfaceAlt` (subtle raised/track fill), `text`, `textMuted`, `border`, `primary`, `onPrimary`, `danger`, and `scrim` (modal dim). Non-color tokens are `spacing`, `radius`, `borderWidth`, and `text` (font sizes), shared across presets.
|
|
30
30
|
|
|
31
|
+
## Policies
|
|
32
|
+
|
|
33
|
+
Theme answers "how does it look"; policies answer "how does it behave". Policies are a second reactive layer, derived from the platform facts in `@solidrt/core` (`capabilities`, `env`), so components adapt to touch vs. desktop, window size, and display without every app wiring that logic itself.
|
|
34
|
+
|
|
35
|
+
```jsx
|
|
36
|
+
import { policy, setPolicy, densityScale } from "@solidrt/components"
|
|
37
|
+
|
|
38
|
+
policy.interaction // touch vs. desktop affordances (hover, long-press, ...)
|
|
39
|
+
policy.density // control/spacing scale
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`policy` fields:
|
|
43
|
+
|
|
44
|
+
| Field | Type | Description |
|
|
45
|
+
| ----------------- | ---------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
46
|
+
| `interaction` | `"touch" \| "desktop" \| "hybrid"` | Which interaction affordances a component shows (hover states vs. long-press). |
|
|
47
|
+
| `density` | `"comfortable" \| "compact" \| "dense"` | Control/hit-target/spacing scale; see `densityScale()`. |
|
|
48
|
+
| `motion` | `"normal" \| "reduced" \| "none"` | Animation intensity. |
|
|
49
|
+
| `focusRing` | `boolean` | Whether focused controls draw a visible focus indicator. |
|
|
50
|
+
| `textScale` | `number` | Multiplier on type-scale font sizes; defaults to the OS text-scale preference. |
|
|
51
|
+
| `textWeightDelta` | `number` | Weight compensation (in steps of 100) for light-on-dark text on low-DPI displays. |
|
|
52
|
+
| `navigation` | `"bottomTabs" \| "rail" \| "sidebar"` | Recommended nav layout, derived from window size class. |
|
|
53
|
+
| `layout` | `"singlePane" \| "twoPane"` | Recommended single vs. two-pane layout, derived from window size class. |
|
|
54
|
+
|
|
55
|
+
Reads are reactive like `theme`, so a window resize or the first mouse move on a touch-capable device updates every consuming component live.
|
|
56
|
+
|
|
57
|
+
```jsx
|
|
58
|
+
setPolicy({ density: "compact" }) // pin a field, overriding the derived value
|
|
59
|
+
setPolicy({ density: undefined }) // hand it back to the resolver
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`setPolicyResolver((caps) => Policies)` replaces the whole system-derivation function for full custom control; `defaultPolicyResolver` is exported to wrap or extend instead of replacing it outright.
|
|
63
|
+
|
|
64
|
+
`densityScale()` is a reactive multiplier (1 / 0.85 / 0.7 for comfortable/compact/dense) driven by `policy.density`, used internally for spacing and hit-target sizing.
|
|
65
|
+
|
|
31
66
|
## Layout and style
|
|
32
67
|
|
|
33
68
|
Most components group their props into two objects:
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @solidrt/components examples
|
|
2
|
+
|
|
3
|
+
Single-concept SolidRT patterns using @solidrt/components. Each file is a
|
|
4
|
+
complete, runnable app (ends in `render(() => <App />)`) demonstrating exactly
|
|
5
|
+
one thing - copy one and adapt it. For core primitives see
|
|
6
|
+
`@solidrt/core/examples`; for the component prop model see
|
|
7
|
+
`@solidrt/components/AGENTS.md`.
|
|
8
|
+
|
|
9
|
+
## Theme
|
|
10
|
+
- `theme-toggle.tsx` - a `Switch` flipping `setTheme` between `darkTheme` and
|
|
11
|
+
`lightTheme`; every component recolors reactively, no remount.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A Switch toggling between the two built-in themes. Every component reads
|
|
2
|
+
// theme.* reactively, so flipping setTheme() recolors the whole tree with no
|
|
3
|
+
// remount - the Switch itself included.
|
|
4
|
+
import { render, createSignal, createEffect, env } from "@solidrt/core"
|
|
5
|
+
import { Window, View, Text, Switch, Card, theme, setTheme, darkTheme, lightTheme } from "@solidrt/components"
|
|
6
|
+
|
|
7
|
+
function App() {
|
|
8
|
+
// Start from the OS light/dark preference; env.systemTheme is
|
|
9
|
+
// "dark" | "light" | "unknown" (unknown falls back to dark, the default).
|
|
10
|
+
let [dark, setDark] = createSignal(() => env.systemTheme !== "light")
|
|
11
|
+
|
|
12
|
+
createEffect(
|
|
13
|
+
() => dark(),
|
|
14
|
+
(on) => setTheme(on ? darkTheme : lightTheme),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Window
|
|
19
|
+
title="Theme toggle"
|
|
20
|
+
layout={{ flexDirection: "column", alignItems: "center", justifyContent: "center" }}
|
|
21
|
+
style={{ backgroundColor: theme.color.background }}
|
|
22
|
+
>
|
|
23
|
+
<Card title={dark() ? "Dark theme" : "Light theme"} layout={{ width: 280 }}>
|
|
24
|
+
<View
|
|
25
|
+
layout={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}
|
|
26
|
+
>
|
|
27
|
+
<Text layout={{ fontSize: 14 }} style={{ color: theme.color.text }}>
|
|
28
|
+
Dark mode
|
|
29
|
+
</Text>
|
|
30
|
+
<Switch value={dark()} onChange={setDark} />
|
|
31
|
+
</View>
|
|
32
|
+
</Card>
|
|
33
|
+
</Window>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
render(() => <App />)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/components",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"src/",
|
|
13
|
+
"examples/",
|
|
13
14
|
"AGENTS.md"
|
|
14
15
|
],
|
|
15
16
|
"dependencies": {
|
|
@@ -17,6 +18,6 @@
|
|
|
17
18
|
},
|
|
18
19
|
"peerDependencies": {
|
|
19
20
|
"@solidjs/signals": "2.0.0-beta.15",
|
|
20
|
-
"@solidrt/core": "0.0.
|
|
21
|
+
"@solidrt/core": "0.0.22"
|
|
21
22
|
}
|
|
22
23
|
}
|
package/src/badge.tsx
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { Show } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
|
+
import { typeStyle, lightOnDark } from "./typography"
|
|
4
5
|
import type { StyleProps } from "./types"
|
|
5
6
|
|
|
7
|
+
export type BadgeVariant = "primary" | "neutral" | "danger"
|
|
8
|
+
|
|
6
9
|
export interface BadgeProps {
|
|
7
10
|
// A string/number renders as the themed pill label; anything else is rendered
|
|
8
11
|
// as-is (an icon, a dot, ...).
|
|
9
12
|
children?: any
|
|
13
|
+
// Visual role: primary (accent), neutral (subtle surface), danger.
|
|
14
|
+
variant?: BadgeVariant
|
|
10
15
|
layout?: LayoutProps
|
|
11
16
|
style?: StyleProps
|
|
12
17
|
}
|
|
@@ -19,10 +24,22 @@ const RADIUS = 999
|
|
|
19
24
|
// onPrimary text by default; override the fill via style.backgroundColor and the
|
|
20
25
|
// label color via style.color.
|
|
21
26
|
export function Badge(props: BadgeProps) {
|
|
22
|
-
let
|
|
23
|
-
|
|
27
|
+
let colors = () => {
|
|
28
|
+
let c = theme.color
|
|
29
|
+
switch (props.variant ?? "primary") {
|
|
30
|
+
case "neutral":
|
|
31
|
+
return { bg: c.surfaceAlt, fg: c.text }
|
|
32
|
+
case "danger":
|
|
33
|
+
return { bg: c.danger, fg: c.onPrimary }
|
|
34
|
+
default:
|
|
35
|
+
return { bg: c.primary, fg: c.onPrimary }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let bg = () => props.style?.backgroundColor ?? colors().bg
|
|
39
|
+
let fg = () => props.style?.color ?? colors().fg
|
|
24
40
|
let radius = () => props.style?.borderRadius ?? RADIUS
|
|
25
41
|
let isText = () => typeof props.children === "string" || typeof props.children === "number"
|
|
42
|
+
let labelOnDark = () => lightOnDark(fg(), bg())
|
|
26
43
|
|
|
27
44
|
return (
|
|
28
45
|
<view
|
|
@@ -38,10 +55,11 @@ export function Badge(props: BadgeProps) {
|
|
|
38
55
|
y={props.style?.y}
|
|
39
56
|
scale={props.style?.scale}
|
|
40
57
|
rotate={props.style?.rotate}
|
|
58
|
+
opacity={props.style?.opacity}
|
|
41
59
|
>
|
|
42
60
|
<d-rect color={bg()} radius={radius()} />
|
|
43
61
|
<Show when={isText()} fallback={props.children}>
|
|
44
|
-
<text color={fg()}
|
|
62
|
+
<text color={fg()} {...typeStyle("label", labelOnDark())}>
|
|
45
63
|
{props.children}
|
|
46
64
|
</text>
|
|
47
65
|
</Show>
|
package/src/button.tsx
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { Show } from "@solidrt/core"
|
|
2
2
|
import { Pressable, type PressState } from "./pressable"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
|
-
import { policy
|
|
4
|
+
import { policy } from "./policy"
|
|
5
|
+
import { space } from "./spacing"
|
|
6
|
+
import { typeStyle, lightOnDark } from "./typography"
|
|
5
7
|
import type { LayoutProps } from "@solidrt/core"
|
|
6
8
|
import type { StyleProps } from "./types"
|
|
7
9
|
|
|
10
|
+
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
|
|
11
|
+
|
|
8
12
|
export interface ButtonProps {
|
|
9
13
|
// A string/number is rendered as the themed label; anything else is rendered
|
|
10
14
|
// as-is, so a button can hold custom content (an icon, a row, ...).
|
|
11
15
|
children?: any
|
|
16
|
+
// Visual role: primary (accent fill), secondary (surface fill with border),
|
|
17
|
+
// ghost (no fill until hover), danger (destructive accent fill).
|
|
18
|
+
variant?: ButtonVariant
|
|
12
19
|
onPress?: () => void
|
|
13
20
|
disabled?: boolean
|
|
14
21
|
layout?: LayoutProps
|
|
@@ -22,16 +29,42 @@ export interface ButtonProps {
|
|
|
22
29
|
// layout. A caller-set backgroundColor disables the hover tint: we cannot know
|
|
23
30
|
// its hover variant.
|
|
24
31
|
export function Button(props: ButtonProps) {
|
|
32
|
+
// Fill, hover fill, and label color per variant, read reactively from the
|
|
33
|
+
// theme. Only secondary draws a border.
|
|
34
|
+
let colors = () => {
|
|
35
|
+
let c = theme.color
|
|
36
|
+
switch (props.variant ?? "primary") {
|
|
37
|
+
case "secondary":
|
|
38
|
+
return { fill: c.surface, hover: c.surfaceHover, label: c.text, border: c.border }
|
|
39
|
+
case "ghost":
|
|
40
|
+
return { fill: "transparent", hover: c.surfaceHover, label: c.text, border: undefined }
|
|
41
|
+
case "danger":
|
|
42
|
+
return { fill: c.danger, hover: c.dangerHover, label: c.onPrimary, border: undefined }
|
|
43
|
+
default:
|
|
44
|
+
return { fill: c.primary, hover: c.primaryHover, label: c.onPrimary, border: undefined }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
25
47
|
let bg = (s: PressState) =>
|
|
26
48
|
props.style?.backgroundColor ??
|
|
27
49
|
(props.disabled
|
|
28
|
-
?
|
|
50
|
+
? props.variant === "ghost"
|
|
51
|
+
? "transparent"
|
|
52
|
+
: theme.color.surface
|
|
29
53
|
: s.hovered && policy.interaction !== "touch"
|
|
30
|
-
?
|
|
31
|
-
:
|
|
54
|
+
? colors().hover
|
|
55
|
+
: colors().fill)
|
|
32
56
|
let radius = () => props.style?.borderRadius ?? theme.radius.sm
|
|
33
|
-
let label = () => (props.disabled ? theme.color.textMuted :
|
|
57
|
+
let label = () => (props.disabled ? theme.color.textMuted : colors().label)
|
|
34
58
|
let isText = () => typeof props.children === "string" || typeof props.children === "number"
|
|
59
|
+
// The label's polarity against the idle fill: onPrimary on a saturated fill
|
|
60
|
+
// is light-on-dark even in a light theme, so it needs the low-DPI weight
|
|
61
|
+
// compensation there too.
|
|
62
|
+
let labelOnDark = () =>
|
|
63
|
+
lightOnDark(
|
|
64
|
+
label(),
|
|
65
|
+
props.style?.backgroundColor ??
|
|
66
|
+
(props.disabled ? (props.variant === "ghost" ? "transparent" : theme.color.surface) : colors().fill),
|
|
67
|
+
)
|
|
35
68
|
|
|
36
69
|
return (
|
|
37
70
|
<Pressable
|
|
@@ -41,13 +74,15 @@ export function Button(props: ButtonProps) {
|
|
|
41
74
|
flexDirection: "row",
|
|
42
75
|
alignItems: "center",
|
|
43
76
|
justifyContent: "center",
|
|
44
|
-
paddingTop:
|
|
45
|
-
paddingBottom:
|
|
46
|
-
paddingLeft:
|
|
47
|
-
paddingRight:
|
|
77
|
+
paddingTop: space("sm"),
|
|
78
|
+
paddingBottom: space("sm"),
|
|
79
|
+
paddingLeft: space("md"),
|
|
80
|
+
paddingRight: space("md"),
|
|
48
81
|
...props.layout,
|
|
49
82
|
}}
|
|
50
83
|
style={(s: PressState) => ({
|
|
84
|
+
borderColor: colors().border,
|
|
85
|
+
borderWidth: colors().border != null ? theme.borderWidth.sm : undefined,
|
|
51
86
|
...props.style,
|
|
52
87
|
backgroundColor: bg(s),
|
|
53
88
|
borderRadius: radius(),
|
|
@@ -58,11 +93,7 @@ export function Button(props: ButtonProps) {
|
|
|
58
93
|
})}
|
|
59
94
|
>
|
|
60
95
|
<Show when={isText()} fallback={props.children}>
|
|
61
|
-
<text
|
|
62
|
-
color={label()}
|
|
63
|
-
fontSize={theme.text.body.size}
|
|
64
|
-
lineHeight={theme.text.body.lineHeight}
|
|
65
|
-
>
|
|
96
|
+
<text color={label()} {...typeStyle("body", labelOnDark())}>
|
|
66
97
|
{props.children}
|
|
67
98
|
</text>
|
|
68
99
|
</Show>
|
package/src/card.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Show } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
|
+
import { typeStyle } from "./typography"
|
|
5
|
+
import { space } from "./spacing"
|
|
4
6
|
import type { StyleProps } from "./types"
|
|
5
7
|
|
|
6
8
|
export interface CardProps {
|
|
@@ -12,8 +14,6 @@ export interface CardProps {
|
|
|
12
14
|
style?: StyleProps
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
const RADIUS = 12
|
|
16
|
-
|
|
17
17
|
// A themed surface container: a padded column box with a subtle border and
|
|
18
18
|
// rounded corners, reading its colors from the theme so it recolors live.
|
|
19
19
|
// Override any paint via style, spacing/sizing via layout.
|
|
@@ -21,23 +21,25 @@ export function Card(props: CardProps) {
|
|
|
21
21
|
let bg = () => props.style?.backgroundColor ?? theme.color.surface
|
|
22
22
|
let border = () => props.style?.borderColor ?? theme.color.border
|
|
23
23
|
let width = () => props.style?.borderWidth ?? theme.borderWidth.sm
|
|
24
|
-
let radius = () => props.style?.borderRadius ??
|
|
24
|
+
let radius = () => props.style?.borderRadius ?? theme.radius.lg
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
27
|
<view
|
|
28
28
|
ref={props.ref}
|
|
29
|
+
repaintBoundary
|
|
29
30
|
flexDirection="column"
|
|
30
|
-
gap={
|
|
31
|
-
padding={
|
|
31
|
+
gap={space("lg")}
|
|
32
|
+
padding={space("xl")}
|
|
32
33
|
{...props.layout}
|
|
33
34
|
x={props.style?.x}
|
|
34
35
|
y={props.style?.y}
|
|
35
36
|
scale={props.style?.scale}
|
|
36
37
|
rotate={props.style?.rotate}
|
|
38
|
+
opacity={props.style?.opacity}
|
|
37
39
|
>
|
|
38
40
|
<d-rect color={bg()} radius={radius()} />
|
|
39
41
|
<Show when={props.title != null}>
|
|
40
|
-
<text color={theme.color.text}
|
|
42
|
+
<text color={theme.color.text} {...typeStyle("title")}>
|
|
41
43
|
{props.title}
|
|
42
44
|
</text>
|
|
43
45
|
</Show>
|
package/src/context-menu.tsx
CHANGED
|
@@ -2,7 +2,9 @@ import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show,
|
|
|
2
2
|
import type { LayoutProps, PointerEvent } from "@solidrt/core"
|
|
3
3
|
import { Pressable, type PressState } from "./pressable"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
|
-
import { policy
|
|
5
|
+
import { policy } from "./policy"
|
|
6
|
+
import { space } from "./spacing"
|
|
7
|
+
import { typeStyle } from "./typography"
|
|
6
8
|
|
|
7
9
|
export interface ContextMenuItem {
|
|
8
10
|
label: string
|
|
@@ -70,12 +72,7 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
70
72
|
item.onSelect?.()
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
let bodyText = (color: string) => ({
|
|
74
|
-
fontSize: theme.text.body.size,
|
|
75
|
-
lineHeight: theme.text.body.lineHeight,
|
|
76
|
-
color,
|
|
77
|
-
maxLines: 1,
|
|
78
|
-
})
|
|
75
|
+
let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 })
|
|
79
76
|
|
|
80
77
|
let ItemRow = (p: { item: ContextMenuItem; padY: number }) => (
|
|
81
78
|
<Pressable
|
|
@@ -86,8 +83,8 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
86
83
|
alignItems: "center",
|
|
87
84
|
paddingTop: p.padY,
|
|
88
85
|
paddingBottom: p.padY,
|
|
89
|
-
paddingLeft:
|
|
90
|
-
paddingRight:
|
|
86
|
+
paddingLeft: space("md"),
|
|
87
|
+
paddingRight: space("md"),
|
|
91
88
|
}}
|
|
92
89
|
style={(s: PressState) => ({
|
|
93
90
|
backgroundColor:
|
|
@@ -132,7 +129,7 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
132
129
|
>
|
|
133
130
|
<d-rect color={theme.color.surface} radius={theme.radius.sm} />
|
|
134
131
|
<For each={props.items}>
|
|
135
|
-
{(item: ContextMenuItem) => <ItemRow item={item} padY={
|
|
132
|
+
{(item: ContextMenuItem) => <ItemRow item={item} padY={space("sm")} />}
|
|
136
133
|
</For>
|
|
137
134
|
<d-rect
|
|
138
135
|
drawStyle="stroke"
|
package/src/icon.tsx
CHANGED
|
@@ -24,12 +24,14 @@ export function Icon(props: IconProps) {
|
|
|
24
24
|
let size = () => props.size ?? SIZE
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
<view repaintBoundary>
|
|
28
|
+
<svg
|
|
29
|
+
width={size()}
|
|
30
|
+
height={size()}
|
|
31
|
+
src={props.src}
|
|
32
|
+
color={props.color ?? theme.color.text}
|
|
33
|
+
{...props.layout}
|
|
34
|
+
/>
|
|
35
|
+
</view>
|
|
34
36
|
)
|
|
35
37
|
}
|
package/src/image.tsx
CHANGED
|
@@ -32,6 +32,7 @@ export function Image(props: ImageProps) {
|
|
|
32
32
|
y={props.style?.y}
|
|
33
33
|
scale={props.style?.scale}
|
|
34
34
|
rotate={props.style?.rotate}
|
|
35
|
+
opacity={props.style?.opacity}
|
|
35
36
|
onPointerEnter={props.onPointerEnter}
|
|
36
37
|
onPointerLeave={props.onPointerLeave}
|
|
37
38
|
onPointerDown={props.onPointerDown}
|
package/src/index.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
export { Window, type WindowProps } from "./window"
|
|
2
2
|
export { View, type ViewProps } from "./view"
|
|
3
|
-
export { Text, type TextProps } from "./text"
|
|
3
|
+
export { Text, type TextProps, type TextColor } from "./text"
|
|
4
4
|
export { Image, type ImageProps } from "./image"
|
|
5
5
|
export { SafeArea } from "./safe-area"
|
|
6
6
|
export { TextInput, type TextInputProps } from "./text-input"
|
|
7
7
|
export { ScrollView, type ScrollViewProps } from "./scroll-view"
|
|
8
8
|
export { Pressable, type PressableProps, type PressState } from "./pressable"
|
|
9
|
-
export { Button, type ButtonProps } from "./button"
|
|
9
|
+
export { Button, type ButtonProps, type ButtonVariant } from "./button"
|
|
10
10
|
export { Switch, type SwitchProps } from "./switch"
|
|
11
11
|
export { Checkbox, type CheckboxProps } from "./checkbox"
|
|
12
12
|
export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
|
|
13
13
|
export { Slider, type SliderProps } from "./slider"
|
|
14
14
|
export { Card, type CardProps } from "./card"
|
|
15
15
|
export { Divider, type DividerProps } from "./divider"
|
|
16
|
-
export { Badge, type BadgeProps } from "./badge"
|
|
16
|
+
export { Badge, type BadgeProps, type BadgeVariant } from "./badge"
|
|
17
17
|
export { Spinner, type SpinnerProps } from "./spinner"
|
|
18
18
|
export { ProgressBar, type ProgressBarProps } from "./progress-bar"
|
|
19
19
|
export { Portal, type PortalProps } from "./portal"
|
|
@@ -25,7 +25,15 @@ export { NavShell, type NavShellProps, type NavItem } from "./nav-shell"
|
|
|
25
25
|
export { SplitView, type SplitViewProps } from "./split-view"
|
|
26
26
|
export { QrCode, type QrCodeProps } from "./qrcode"
|
|
27
27
|
export { Icon, type IconProps } from "./icon"
|
|
28
|
-
export {
|
|
28
|
+
export {
|
|
29
|
+
theme,
|
|
30
|
+
setTheme,
|
|
31
|
+
darkTheme,
|
|
32
|
+
lightTheme,
|
|
33
|
+
type Theme,
|
|
34
|
+
type TextStyle,
|
|
35
|
+
type TextVariant,
|
|
36
|
+
} from "./theme"
|
|
29
37
|
export {
|
|
30
38
|
policy,
|
|
31
39
|
setPolicy,
|
|
@@ -40,4 +48,6 @@ export {
|
|
|
40
48
|
type NavigationPolicy,
|
|
41
49
|
type LayoutPolicy,
|
|
42
50
|
} from "./policy"
|
|
51
|
+
export { typeStyle, typeWeight, lightOnDark } from "./typography"
|
|
52
|
+
export { space } from "./spacing"
|
|
43
53
|
export type { StyleProps, TextLayoutProps } from "./types"
|
package/src/nav-shell.tsx
CHANGED
|
@@ -2,7 +2,9 @@ import { createSignal, Switch, Match, For } from "@solidrt/core"
|
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { Pressable, type PressState } from "./pressable"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
|
-
import { policy
|
|
5
|
+
import { policy } from "./policy"
|
|
6
|
+
import { space } from "./spacing"
|
|
7
|
+
import { typeStyle } from "./typography"
|
|
6
8
|
|
|
7
9
|
export interface NavItem {
|
|
8
10
|
value: unknown
|
|
@@ -66,7 +68,7 @@ export function NavShell(props: NavShellProps) {
|
|
|
66
68
|
style={(s: PressState) => ({ backgroundColor: itemBg(p.item, s), borderRadius: theme.radius.sm })}
|
|
67
69
|
>
|
|
68
70
|
{p.item.icon}
|
|
69
|
-
<text color={labelColor(p.item)}
|
|
71
|
+
<text color={labelColor(p.item)} {...typeStyle("caption")}>
|
|
70
72
|
{p.item.label}
|
|
71
73
|
</text>
|
|
72
74
|
</Pressable>
|
|
@@ -112,8 +114,8 @@ export function NavShell(props: NavShellProps) {
|
|
|
112
114
|
flexDirection: "row",
|
|
113
115
|
alignItems: "center",
|
|
114
116
|
gap: theme.spacing.md,
|
|
115
|
-
paddingTop:
|
|
116
|
-
paddingBottom:
|
|
117
|
+
paddingTop: space("sm") + 2,
|
|
118
|
+
paddingBottom: space("sm") + 2,
|
|
117
119
|
paddingLeft: theme.spacing.md,
|
|
118
120
|
paddingRight: theme.spacing.md,
|
|
119
121
|
marginLeft: theme.spacing.sm,
|
|
@@ -124,8 +126,7 @@ export function NavShell(props: NavShellProps) {
|
|
|
124
126
|
{item.icon}
|
|
125
127
|
<text
|
|
126
128
|
color={item.value === value() ? theme.color.primary : theme.color.text}
|
|
127
|
-
|
|
128
|
-
lineHeight={theme.text.body.lineHeight}
|
|
129
|
+
{...typeStyle("body")}
|
|
129
130
|
>
|
|
130
131
|
{item.label}
|
|
131
132
|
</text>
|
package/src/policy.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capabilities, createSignal } from "@solidrt/core"
|
|
1
|
+
import { capabilities, env, createSignal } from "@solidrt/core"
|
|
2
2
|
import type { Capabilities } from "@solidrt/core"
|
|
3
3
|
|
|
4
4
|
// Policies: how components should behave. Derived from capabilities by a
|
|
@@ -20,6 +20,15 @@ export type Policies = {
|
|
|
20
20
|
// keyboard presence; the runtime cannot yet tell keyboard focus from pointer
|
|
21
21
|
// focus (no Tab traversal), so this is per-session, not per-focus-source.
|
|
22
22
|
focusRing: boolean
|
|
23
|
+
// Multiplier on type-scale font sizes (Dynamic Type). Follows the OS
|
|
24
|
+
// preference (env.textScale); override via setPolicy to pin it.
|
|
25
|
+
textScale: number
|
|
26
|
+
// Base weight compensation for light-on-dark text on this display, in
|
|
27
|
+
// steps of 100 (other steps decode as 400): low-DPI rendering thins
|
|
28
|
+
// inverted-polarity glyphs. Applied by typeWeight to light-on-dark runs
|
|
29
|
+
// only (per-run polarity, or the theme's palette polarity as the default),
|
|
30
|
+
// with one extra step for small font sizes; see typography.ts.
|
|
31
|
+
textWeightDelta: number
|
|
23
32
|
// Application policies: recommendations derived from the window size class.
|
|
24
33
|
// The application owns the final decision; accept them by consuming
|
|
25
34
|
// policy.navigation / policy.layout, or override via setPolicy.
|
|
@@ -48,6 +57,8 @@ export function defaultPolicyResolver(caps: Capabilities): Policies {
|
|
|
48
57
|
density: interaction === "desktop" ? "compact" : "comfortable",
|
|
49
58
|
motion: "normal",
|
|
50
59
|
focusRing: caps.keyboardNav,
|
|
60
|
+
textScale: env.textScale,
|
|
61
|
+
textWeightDelta: env.displayScale < 1.5 ? 100 : 0,
|
|
51
62
|
navigation:
|
|
52
63
|
caps.windowSizeClass === "expanded" ? "sidebar" : caps.windowSizeClass === "medium" ? "rail" : "bottomTabs",
|
|
53
64
|
layout: caps.windowSizeClass === "expanded" ? "twoPane" : "singlePane",
|
|
@@ -76,6 +87,12 @@ export let policy = {
|
|
|
76
87
|
get focusRing(): boolean {
|
|
77
88
|
return overrides().focusRing ?? resolved().focusRing
|
|
78
89
|
},
|
|
90
|
+
get textScale(): number {
|
|
91
|
+
return overrides().textScale ?? resolved().textScale
|
|
92
|
+
},
|
|
93
|
+
get textWeightDelta(): number {
|
|
94
|
+
return overrides().textWeightDelta ?? resolved().textWeightDelta
|
|
95
|
+
},
|
|
79
96
|
get navigation(): NavigationPolicy {
|
|
80
97
|
return overrides().navigation ?? resolved().navigation
|
|
81
98
|
},
|
package/src/pressable.tsx
CHANGED
|
@@ -54,11 +54,13 @@ export function Pressable(props: PressableProps) {
|
|
|
54
54
|
return (
|
|
55
55
|
<view
|
|
56
56
|
ref={props.ref}
|
|
57
|
+
repaintBoundary
|
|
57
58
|
{...props.layout}
|
|
58
59
|
x={style()?.x}
|
|
59
60
|
y={style()?.y}
|
|
60
61
|
scale={style()?.scale}
|
|
61
62
|
rotate={style()?.rotate}
|
|
63
|
+
opacity={style()?.opacity}
|
|
62
64
|
onPointerEnter={handleEnter}
|
|
63
65
|
onPointerLeave={handleLeave}
|
|
64
66
|
onPointerDown={handleDown}
|
package/src/qrcode.tsx
CHANGED
|
@@ -62,7 +62,7 @@ export function QrCode(props: QrCodeProps) {
|
|
|
62
62
|
let side = () => grid().n * size() + 2 * margin()
|
|
63
63
|
|
|
64
64
|
return (
|
|
65
|
-
<view width={side()} height={side()} {...props.layout}>
|
|
65
|
+
<view repaintBoundary width={side()} height={side()} {...props.layout}>
|
|
66
66
|
<d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? RADIUS} />
|
|
67
67
|
{grid().runs.map((run) => (
|
|
68
68
|
<d-rect
|
package/src/radio.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import type { LayoutProps } from "@solidrt/core"
|
|
|
3
3
|
import { Pressable } from "./pressable"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
5
|
import { densityScale } from "./policy"
|
|
6
|
+
import { typeStyle } from "./typography"
|
|
6
7
|
import type { StyleProps } from "./types"
|
|
7
8
|
|
|
8
9
|
// Shared selection state for a group. Created and consumed within this module, so
|
|
@@ -91,7 +92,7 @@ export function Radio(props: RadioProps) {
|
|
|
91
92
|
</Show>
|
|
92
93
|
</view>
|
|
93
94
|
<Show when={isText()} fallback={props.children}>
|
|
94
|
-
<text color={theme.color.text}
|
|
95
|
+
<text color={theme.color.text} {...typeStyle("body")}>
|
|
95
96
|
{props.children}
|
|
96
97
|
</text>
|
|
97
98
|
</Show>
|
package/src/scroll-view.tsx
CHANGED
|
@@ -65,6 +65,7 @@ export function ScrollView(props: ScrollViewProps) {
|
|
|
65
65
|
y={props.style?.y}
|
|
66
66
|
scale={props.style?.scale}
|
|
67
67
|
rotate={props.style?.rotate}
|
|
68
|
+
opacity={props.style?.opacity}
|
|
68
69
|
onPointerEnter={props.onPointerEnter}
|
|
69
70
|
onPointerLeave={props.onPointerLeave}
|
|
70
71
|
onPointerDown={props.onPointerDown}
|
package/src/select.tsx
CHANGED
|
@@ -2,7 +2,9 @@ import { createSignal, createPortal, onLayout, getBoundingBox, Show, For, env }
|
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { Pressable, type PressState } from "./pressable"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
|
-
import { policy
|
|
5
|
+
import { policy } from "./policy"
|
|
6
|
+
import { space } from "./spacing"
|
|
7
|
+
import { typeStyle } from "./typography"
|
|
6
8
|
import type { StyleProps } from "./types"
|
|
7
9
|
|
|
8
10
|
export interface SelectOption {
|
|
@@ -47,12 +49,7 @@ export function Select(props: SelectProps) {
|
|
|
47
49
|
props.onChange?.(v)
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
let bodyText = (color: string) => ({
|
|
51
|
-
fontSize: theme.text.body.size,
|
|
52
|
-
lineHeight: theme.text.body.lineHeight,
|
|
53
|
-
color,
|
|
54
|
-
maxLines: 1,
|
|
55
|
-
})
|
|
52
|
+
let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 })
|
|
56
53
|
|
|
57
54
|
// One option row, shared by both presentations; only the vertical padding
|
|
58
55
|
// differs (the sheet gets taller touch targets).
|
|
@@ -64,8 +61,8 @@ export function Select(props: SelectProps) {
|
|
|
64
61
|
alignItems: "center",
|
|
65
62
|
paddingTop: p.padY,
|
|
66
63
|
paddingBottom: p.padY,
|
|
67
|
-
paddingLeft:
|
|
68
|
-
paddingRight:
|
|
64
|
+
paddingLeft: space("md"),
|
|
65
|
+
paddingRight: space("md"),
|
|
69
66
|
}}
|
|
70
67
|
style={(s: PressState) => ({
|
|
71
68
|
backgroundColor:
|
|
@@ -111,7 +108,7 @@ export function Select(props: SelectProps) {
|
|
|
111
108
|
>
|
|
112
109
|
<d-rect color={theme.color.surface} radius={theme.radius.sm} />
|
|
113
110
|
<For each={props.options}>
|
|
114
|
-
{(o: SelectOption) => <OptionRow option={o} padY={
|
|
111
|
+
{(o: SelectOption) => <OptionRow option={o} padY={space("sm")} />}
|
|
115
112
|
</For>
|
|
116
113
|
<d-rect
|
|
117
114
|
drawStyle="stroke"
|
|
@@ -159,10 +156,10 @@ export function Select(props: SelectProps) {
|
|
|
159
156
|
alignItems: "center",
|
|
160
157
|
justifyContent: "space-between",
|
|
161
158
|
gap: theme.spacing.md,
|
|
162
|
-
paddingTop:
|
|
163
|
-
paddingBottom:
|
|
164
|
-
paddingLeft:
|
|
165
|
-
paddingRight:
|
|
159
|
+
paddingTop: space("sm"),
|
|
160
|
+
paddingBottom: space("sm"),
|
|
161
|
+
paddingLeft: space("md"),
|
|
162
|
+
paddingRight: space("md"),
|
|
166
163
|
...props.layout,
|
|
167
164
|
}}
|
|
168
165
|
style={(s: PressState) => ({
|
package/src/slider.tsx
CHANGED
|
@@ -90,6 +90,7 @@ export function Slider(props: SliderProps) {
|
|
|
90
90
|
return (
|
|
91
91
|
<view
|
|
92
92
|
ref={(n: { id: number }) => (track = n)}
|
|
93
|
+
repaintBoundary
|
|
93
94
|
flexDirection="row"
|
|
94
95
|
alignItems="center"
|
|
95
96
|
height={height()}
|
|
@@ -99,6 +100,7 @@ export function Slider(props: SliderProps) {
|
|
|
99
100
|
y={props.style?.y}
|
|
100
101
|
scale={props.style?.scale}
|
|
101
102
|
rotate={props.style?.rotate}
|
|
103
|
+
opacity={props.style?.opacity}
|
|
102
104
|
pointerEvents={props.disabled ? "none" : "auto"}
|
|
103
105
|
onPointerDown={handleDown}
|
|
104
106
|
onPointerMove={handleMove}
|
package/src/spacing.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { theme, type Theme } from "./theme"
|
|
2
|
+
import { densityScale } from "./policy"
|
|
3
|
+
|
|
4
|
+
// Density-scaled spacing: a theme.spacing token multiplied by the density
|
|
5
|
+
// policy's metric scale, rounded to whole pixels. Use it for gaps and paddings
|
|
6
|
+
// that should tighten under compact/dense density; read theme.spacing directly
|
|
7
|
+
// only for distances that must not move with density. Reactive when called
|
|
8
|
+
// inside a tracked scope, like any theme/policy read.
|
|
9
|
+
export function space(token: keyof Theme["spacing"]): number {
|
|
10
|
+
return Math.round(theme.spacing[token] * densityScale())
|
|
11
|
+
}
|
package/src/spinner.tsx
CHANGED
package/src/text-input.tsx
CHANGED
|
@@ -4,7 +4,8 @@ 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
|
|
7
|
+
import { policy } from "./policy"
|
|
8
|
+
import { space } from "./spacing"
|
|
8
9
|
|
|
9
10
|
// Caret thickness. Shared so the drawn caret and the scroll offset's reserved
|
|
10
11
|
// edge column cannot drift apart.
|
|
@@ -147,13 +148,16 @@ export function TextInput(props: TextInputProps) {
|
|
|
147
148
|
// the old centered attached row sat. createCaretScroll keeps the caret in
|
|
148
149
|
// view and flushes the offset before paint; scrollX is a paint-time
|
|
149
150
|
// translate that also applies to detached children.
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
// All one-line metrics derive from the scaled body size, so the field, the
|
|
152
|
+
// caret, and the scroll math grow together under policy.textScale.
|
|
153
|
+
let fontSize = () => theme.text.body.size * policy.textScale
|
|
154
|
+
let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
|
|
155
|
+
let caretX = () => measureText(value().slice(0, buffer.caret()), { fontSize: fontSize() }).width
|
|
152
156
|
let scrollX = createCaretScroll(
|
|
153
157
|
() => viewport,
|
|
154
158
|
() => ({
|
|
155
159
|
text: value(),
|
|
156
|
-
fontSize:
|
|
160
|
+
fontSize: fontSize(),
|
|
157
161
|
caret: buffer.caret(),
|
|
158
162
|
// Constant, not tied to caret visibility: the caret's footprint does not
|
|
159
163
|
// change as it blinks, so reserving the column only when shown would swing
|
|
@@ -164,7 +168,7 @@ export function TextInput(props: TextInputProps) {
|
|
|
164
168
|
|
|
165
169
|
let textStyle = (color: string) => ({
|
|
166
170
|
w: TEXT_SHAPE_WIDTH,
|
|
167
|
-
fontSize:
|
|
171
|
+
fontSize: fontSize(),
|
|
168
172
|
lineHeight: theme.text.body.lineHeight,
|
|
169
173
|
color,
|
|
170
174
|
maxLines: 1,
|
|
@@ -175,15 +179,16 @@ export function TextInput(props: TextInputProps) {
|
|
|
175
179
|
ref={(n: { id: number }) => (node = n)}
|
|
176
180
|
flexDirection="row"
|
|
177
181
|
alignItems="center"
|
|
178
|
-
paddingLeft={
|
|
179
|
-
paddingRight={
|
|
180
|
-
paddingTop={
|
|
181
|
-
paddingBottom={
|
|
182
|
+
paddingLeft={space("md")}
|
|
183
|
+
paddingRight={space("md")}
|
|
184
|
+
paddingTop={space("sm")}
|
|
185
|
+
paddingBottom={space("sm")}
|
|
182
186
|
{...props.layout}
|
|
183
187
|
x={props.style?.x}
|
|
184
188
|
y={props.style?.y}
|
|
185
189
|
scale={props.style?.scale}
|
|
186
190
|
rotate={props.style?.rotate}
|
|
191
|
+
opacity={props.style?.opacity}
|
|
187
192
|
onPointerDown={handlePointerDown}
|
|
188
193
|
onFocus={handleFocus}
|
|
189
194
|
onBlur={handleBlur}
|
|
@@ -213,9 +218,9 @@ export function TextInput(props: TextInputProps) {
|
|
|
213
218
|
<d-rect
|
|
214
219
|
color={textColor()}
|
|
215
220
|
x={caretX()}
|
|
216
|
-
y={(rowHeight() -
|
|
221
|
+
y={(rowHeight() - fontSize()) / 2}
|
|
217
222
|
w={CARET_WIDTH}
|
|
218
|
-
h={
|
|
223
|
+
h={fontSize()}
|
|
219
224
|
/>
|
|
220
225
|
) : null}
|
|
221
226
|
</>
|
package/src/text.tsx
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import { createMemo } from "@solidjs/signals"
|
|
2
2
|
import type { PointerProps } from "@solidrt/core"
|
|
3
3
|
import type { StyleProps, TextLayoutProps } from "./types"
|
|
4
|
+
import { theme, type TextVariant } from "./theme"
|
|
5
|
+
import { policy } from "./policy"
|
|
6
|
+
import { typeWeight } from "./typography"
|
|
7
|
+
|
|
8
|
+
// Semantic text colors, resolved through the theme. Curated: only tokens that
|
|
9
|
+
// make sense as a text fill; style.color takes raw values for anything else.
|
|
10
|
+
export type TextColor = "text" | "textMuted" | "primary" | "onPrimary" | "danger"
|
|
4
11
|
|
|
5
12
|
export interface TextProps extends PointerProps {
|
|
6
13
|
children?: any
|
|
14
|
+
// Typography role from the theme's type scale; defaults to "body". Explicit
|
|
15
|
+
// layout font props override the role's fields individually. fontSize
|
|
16
|
+
// (role-derived or explicit) is multiplied by policy.textScale and
|
|
17
|
+
// fontWeight carries the typeWeight low-DPI compensation; use the core
|
|
18
|
+
// <text> primitive for text that must not scale.
|
|
19
|
+
variant?: TextVariant
|
|
20
|
+
// Semantic color from the theme; defaults to "text". style.color still wins.
|
|
21
|
+
color?: TextColor
|
|
22
|
+
// Sugar for color="textMuted".
|
|
23
|
+
muted?: boolean
|
|
7
24
|
ref?: (node: { id: number }) => void
|
|
8
25
|
layout?: TextLayoutProps
|
|
9
26
|
style?: StyleProps
|
|
@@ -22,6 +39,11 @@ const FONT_KEYS = [
|
|
|
22
39
|
]
|
|
23
40
|
|
|
24
41
|
export function Text(props: TextProps) {
|
|
42
|
+
let role = () => theme.text[props.variant ?? "body"]
|
|
43
|
+
let size = () => (props.layout?.fontSize ?? role().size) * policy.textScale
|
|
44
|
+
let color = () =>
|
|
45
|
+
props.style?.color ?? theme.color[props.color ?? (props.muted ? "textMuted" : "text")]
|
|
46
|
+
|
|
25
47
|
let box = createMemo(() => {
|
|
26
48
|
let l = props.layout
|
|
27
49
|
if (!l) return {}
|
|
@@ -40,6 +62,7 @@ export function Text(props: TextProps) {
|
|
|
40
62
|
y={props.style?.y}
|
|
41
63
|
scale={props.style?.scale}
|
|
42
64
|
rotate={props.style?.rotate}
|
|
65
|
+
opacity={props.style?.opacity}
|
|
43
66
|
onPointerEnter={props.onPointerEnter}
|
|
44
67
|
onPointerLeave={props.onPointerLeave}
|
|
45
68
|
onPointerDown={props.onPointerDown}
|
|
@@ -54,12 +77,12 @@ export function Text(props: TextProps) {
|
|
|
54
77
|
pointerEvents={props.pointerEvents}
|
|
55
78
|
>
|
|
56
79
|
<text
|
|
57
|
-
color={
|
|
58
|
-
fontFamily={props.layout?.fontFamily}
|
|
59
|
-
fontSize={
|
|
60
|
-
lineHeight={props.layout?.lineHeight}
|
|
80
|
+
color={color()}
|
|
81
|
+
fontFamily={props.layout?.fontFamily ?? theme.text.fontFamily}
|
|
82
|
+
fontSize={size()}
|
|
83
|
+
lineHeight={props.layout?.lineHeight ?? role().lineHeight}
|
|
61
84
|
fontStyle={props.layout?.fontStyle}
|
|
62
|
-
fontWeight={props.layout?.fontWeight}
|
|
85
|
+
fontWeight={typeWeight(props.layout?.fontWeight ?? role().weight, size())}
|
|
63
86
|
textAlign={props.layout?.textAlign}
|
|
64
87
|
maxLines={props.layout?.maxLines}
|
|
65
88
|
>
|
package/src/theme.ts
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
|
-
import { createStore } from "@solidrt/core"
|
|
1
|
+
import { createStore, mixColors } from "@solidrt/core"
|
|
2
2
|
|
|
3
3
|
export type TextStyle = {
|
|
4
4
|
size: number
|
|
5
5
|
lineHeight: number
|
|
6
|
+
weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
// The type scale's role names. <Text variant> and theme.text are keyed by these.
|
|
10
|
+
export type TextVariant = "caption" | "label" | "body" | "title" | "heading"
|
|
11
|
+
|
|
8
12
|
export type Theme = {
|
|
9
|
-
text: {
|
|
13
|
+
text: {
|
|
14
|
+
// Passed through to the core font stack: "sans" | "mono" | a family name.
|
|
15
|
+
fontFamily: string
|
|
16
|
+
caption: TextStyle
|
|
17
|
+
label: TextStyle
|
|
18
|
+
body: TextStyle
|
|
19
|
+
title: TextStyle
|
|
20
|
+
heading: TextStyle
|
|
21
|
+
}
|
|
10
22
|
color: {
|
|
11
23
|
// Window fill.
|
|
12
24
|
background: string
|
|
@@ -25,18 +37,29 @@ export type Theme = {
|
|
|
25
37
|
onPrimary: string
|
|
26
38
|
// Validation / destructive.
|
|
27
39
|
danger: string
|
|
40
|
+
// Hover tint for danger-colored controls.
|
|
41
|
+
dangerHover: string
|
|
28
42
|
// Overlay dim behind modals.
|
|
29
43
|
scrim: string
|
|
30
44
|
}
|
|
31
|
-
spacing: { sm: number; md: number }
|
|
32
|
-
radius: { sm: number }
|
|
45
|
+
spacing: { sm: number; md: number; lg: number; xl: number }
|
|
46
|
+
radius: { sm: number; md: number; lg: number }
|
|
33
47
|
borderWidth: { sm: number }
|
|
34
48
|
}
|
|
35
49
|
|
|
36
|
-
// Scheme-independent tokens, shared by both presets.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
50
|
+
// Scheme-independent tokens, shared by both presets. The type scale: body is
|
|
51
|
+
// the base text style; caption and label sit under it (secondary and
|
|
52
|
+
// emphasized small text), title and heading above it (card and page headings).
|
|
53
|
+
const TEXT: Theme["text"] = {
|
|
54
|
+
fontFamily: "sans",
|
|
55
|
+
caption: { size: 11, lineHeight: 1.3, weight: 400 },
|
|
56
|
+
label: { size: 12, lineHeight: 1.3, weight: 600 },
|
|
57
|
+
body: { size: 14, lineHeight: 1.5, weight: 400 },
|
|
58
|
+
title: { size: 18, lineHeight: 1.4, weight: 700 },
|
|
59
|
+
heading: { size: 22, lineHeight: 1.3, weight: 700 },
|
|
60
|
+
}
|
|
61
|
+
const SPACING = { sm: 4, md: 8, lg: 16, xl: 20 }
|
|
62
|
+
const RADIUS = { sm: 4, md: 8, lg: 12 }
|
|
40
63
|
const BORDER_WIDTH = { sm: 1 }
|
|
41
64
|
|
|
42
65
|
export let darkTheme: Theme = {
|
|
@@ -47,12 +70,16 @@ export let darkTheme: Theme = {
|
|
|
47
70
|
surfaceAlt: "#21262d",
|
|
48
71
|
surfaceHover: "#262c34",
|
|
49
72
|
text: "#e6edf3",
|
|
50
|
-
|
|
73
|
+
// Muted is an opaque tone between text and background, mixed in LAB (like
|
|
74
|
+
// Material 3's tonal colors, not an alpha overlay): alpha text renders
|
|
75
|
+
// thin on low-DPI and its contrast depends on what sits behind it.
|
|
76
|
+
textMuted: mixColors("#e6edf3", "#0b0f17", 0.4),
|
|
51
77
|
border: "rgba(255,255,255,0.14)",
|
|
52
78
|
primary: "#1f6feb",
|
|
53
79
|
primaryHover: "#388bfd",
|
|
54
80
|
onPrimary: "#ffffff",
|
|
55
81
|
danger: "#f85149",
|
|
82
|
+
dangerHover: "#ff7b72",
|
|
56
83
|
scrim: "rgba(0,0,0,0.6)",
|
|
57
84
|
},
|
|
58
85
|
spacing: SPACING,
|
|
@@ -68,12 +95,13 @@ export let lightTheme: Theme = {
|
|
|
68
95
|
surfaceAlt: "#eaeef2",
|
|
69
96
|
surfaceHover: "#e0e5eb",
|
|
70
97
|
text: "#1f2328",
|
|
71
|
-
textMuted: "
|
|
98
|
+
textMuted: mixColors("#1f2328", "#ffffff", 0.4),
|
|
72
99
|
border: "rgba(0,0,0,0.15)",
|
|
73
100
|
primary: "#1f6feb",
|
|
74
101
|
primaryHover: "#1a5fd0",
|
|
75
102
|
onPrimary: "#ffffff",
|
|
76
103
|
danger: "#cf222e",
|
|
104
|
+
dangerHover: "#a40e26",
|
|
77
105
|
scrim: "rgba(0,0,0,0.4)",
|
|
78
106
|
},
|
|
79
107
|
spacing: SPACING,
|
package/src/tooltip.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, env } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps, PointerEvent } from "@solidrt/core"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
|
-
import { policy
|
|
4
|
+
import { policy } from "./policy"
|
|
5
|
+
import { space } from "./spacing"
|
|
6
|
+
import { typeStyle } from "./typography"
|
|
5
7
|
|
|
6
8
|
export interface TooltipProps {
|
|
7
9
|
// The tooltip body. A string/number renders as themed text; anything else
|
|
@@ -68,20 +70,21 @@ export function Tooltip(props: TooltipProps) {
|
|
|
68
70
|
return createPortal(
|
|
69
71
|
<view
|
|
70
72
|
ref={(n: { id: number }) => (bubble = n)}
|
|
73
|
+
repaintBoundary
|
|
71
74
|
position="absolute"
|
|
72
75
|
top={0}
|
|
73
76
|
left={0}
|
|
74
77
|
x={pos()?.x ?? -10000}
|
|
75
78
|
y={pos()?.y ?? 0}
|
|
76
|
-
paddingTop={
|
|
77
|
-
paddingBottom={
|
|
78
|
-
paddingLeft={
|
|
79
|
-
paddingRight={
|
|
79
|
+
paddingTop={space("sm")}
|
|
80
|
+
paddingBottom={space("sm")}
|
|
81
|
+
paddingLeft={space("md")}
|
|
82
|
+
paddingRight={space("md")}
|
|
80
83
|
pointerEvents="none"
|
|
81
84
|
>
|
|
82
85
|
<d-rect color={theme.color.surfaceAlt} radius={theme.radius.sm} />
|
|
83
86
|
<Show when={isText()} fallback={props.content}>
|
|
84
|
-
<text color={theme.color.text}
|
|
87
|
+
<text color={theme.color.text} {...typeStyle("body")}>
|
|
85
88
|
{props.content}
|
|
86
89
|
</text>
|
|
87
90
|
</Show>
|
package/src/types.ts
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { brightness } from "@solidrt/core"
|
|
2
|
+
import { theme, type TextStyle, type TextVariant } from "./theme"
|
|
3
|
+
import { policy } from "./policy"
|
|
4
|
+
|
|
5
|
+
// Below this effective font size, light-on-dark text on a low-DPI display
|
|
6
|
+
// needs an extra compensation step (edge pixels dominate small glyphs).
|
|
7
|
+
const SMALL_TEXT = 16
|
|
8
|
+
|
|
9
|
+
// The rendering polarity of `text` drawn on `fill`, for typeWeight/typeStyle:
|
|
10
|
+
// true when the text is the lighter of the two. Returns undefined (= fall
|
|
11
|
+
// back to the theme's default polarity) when either side is not a comparable
|
|
12
|
+
// color (gradients, "transparent").
|
|
13
|
+
export function lightOnDark(text: unknown, fill: unknown): boolean | undefined {
|
|
14
|
+
if (typeof text !== "string" || typeof fill !== "string" || fill === "transparent") return undefined
|
|
15
|
+
return brightness(text) > brightness(fill)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The theme's default polarity, derived from its own palette: light text on
|
|
19
|
+
// a dark window background means a dark scheme. Nothing to declare per
|
|
20
|
+
// preset, and it cannot disagree with the colors.
|
|
21
|
+
function themeOnDark(): boolean {
|
|
22
|
+
return lightOnDark(theme.color.text, theme.color.background) ?? false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A themed font weight with low-DPI rendering compensation applied. The
|
|
26
|
+
// renderer (Impeller) rasterizes glyphs unhinted and composites the coverage
|
|
27
|
+
// in nonlinear sRGB, which steals stem ink from light-on-dark text only (and
|
|
28
|
+
// donates it to dark-on-light); the loss grows as glyphs shrink. Compensated
|
|
29
|
+
// text adds policy.textWeightDelta (0 on high-DPI displays) plus one extra
|
|
30
|
+
// step under SMALL_TEXT px; dark-on-light text passes through untouched.
|
|
31
|
+
// `onDark` is the run's own polarity where the caller knows both colors (use
|
|
32
|
+
// the lightOnDark() helper, like Button does for its fills); omitted, it
|
|
33
|
+
// defaults to the theme's palette polarity. `size` is the effective
|
|
34
|
+
// (post-textScale) font size. Clamped to the 900 ceiling; reactive like any
|
|
35
|
+
// theme/policy read.
|
|
36
|
+
export function typeWeight(weight: number, size: number, onDark?: boolean): TextStyle["weight"] {
|
|
37
|
+
let delta = (onDark ?? themeOnDark()) ? policy.textWeightDelta : 0
|
|
38
|
+
if (delta > 0 && size < SMALL_TEXT) delta += 100
|
|
39
|
+
return Math.min(900, weight + delta) as TextStyle["weight"]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Resolved font props for a type-scale role, with the text policies applied:
|
|
43
|
+
// spread onto a <text> or d-text. fontSize carries policy.textScale
|
|
44
|
+
// (lineHeight is relative to the size, so it scales implicitly), fontWeight
|
|
45
|
+
// carries the typeWeight compensation (pass `onDark` when the text sits on a
|
|
46
|
+
// known fill). Reactive when called inside a tracked scope, like any
|
|
47
|
+
// theme/policy read.
|
|
48
|
+
export function typeStyle(variant: TextVariant, onDark?: boolean) {
|
|
49
|
+
let role = theme.text[variant]
|
|
50
|
+
let size = role.size * policy.textScale
|
|
51
|
+
return {
|
|
52
|
+
fontFamily: theme.text.fontFamily,
|
|
53
|
+
fontSize: size,
|
|
54
|
+
lineHeight: role.lineHeight,
|
|
55
|
+
fontWeight: typeWeight(role.weight, size, onDark),
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/view.tsx
CHANGED
|
@@ -21,6 +21,7 @@ export function View(props: ViewProps) {
|
|
|
21
21
|
y={props.style?.y}
|
|
22
22
|
scale={props.style?.scale}
|
|
23
23
|
rotate={props.style?.rotate}
|
|
24
|
+
opacity={props.style?.opacity}
|
|
24
25
|
onPointerEnter={props.onPointerEnter}
|
|
25
26
|
onPointerLeave={props.onPointerLeave}
|
|
26
27
|
onPointerDown={props.onPointerDown}
|