@solidrt/components 0.0.19 → 0.0.21
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 +7 -0
- package/README.md +23 -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 +37 -0
- package/src/image.tsx +1 -0
- package/src/index.ts +15 -4
- package/src/nav-shell.tsx +7 -6
- package/src/policy.ts +18 -1
- package/src/pressable.tsx +2 -0
- package/src/qrcode.tsx +26 -20
- 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 +43 -42
- 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
|
@@ -70,6 +70,13 @@ Most components group props into two objects, plus top-level event handlers:
|
|
|
70
70
|
`moduleSize`/`margin`/`radius`/`level` (L/M/Q/H). Paints black-on-white by
|
|
71
71
|
default (NOT the theme) to stay scannable; override `color`/`background` only
|
|
72
72
|
if contrast holds. Deps on `qrcode-generator`.
|
|
73
|
+
- `Icon` - thin themed wrapper over the core `<svg>` document primitive. `src`
|
|
74
|
+
is an SVG string (an imported `.svg` asset, a `lucide-static` string export, or
|
|
75
|
+
an inline literal); `size` sets a square box (default 24); `color` drives
|
|
76
|
+
`currentColor` (default `theme.color.text`). Carries no icon set of its own and
|
|
77
|
+
no icon-name registry: pass the SVG string in, so any currentColor set (Lucide,
|
|
78
|
+
Feather, Heroicons) works and only used icons are bundled. Multi-color
|
|
79
|
+
documents keep their own fills. Reach for `<svg>` directly for a non-square box.
|
|
73
80
|
- `SafeArea` - pads children clear of system UI (notches, status bars); top and
|
|
74
81
|
bottom on by default, pass `false`/a number per edge.
|
|
75
82
|
- `theme` / `setTheme` / `darkTheme` / `lightTheme` - shared REACTIVE appearance
|
package/README.md
CHANGED
|
@@ -536,6 +536,29 @@ import { QrCode } from "@solidrt/components"
|
|
|
536
536
|
| `radius` | `number` | `8` | Corner radius of the background panel. |
|
|
537
537
|
| `layout` | `LayoutProps` | - | Layout of the outer box. |
|
|
538
538
|
|
|
539
|
+
### Icon
|
|
540
|
+
|
|
541
|
+
A thin themed wrapper over the core `<svg>` primitive. `src` is a whole SVG document as a string; the component draws it in a square box and, for monochrome icons that stroke/fill with `currentColor`, recolors it from the theme. It carries no icon set and no name registry, so any `currentColor` SVG works (Lucide, Feather, Heroicons) and only the icons you import are bundled. Multi-color documents keep their own fills. For a non-square box, use `<svg>` directly.
|
|
542
|
+
|
|
543
|
+
Icons are just SVG strings. Import them as assets (`import House from "lucide-static/icons/house.svg"`, resolved to a string), pull them from a string export, or inline a literal:
|
|
544
|
+
|
|
545
|
+
```jsx
|
|
546
|
+
import { Icon } from "@solidrt/components"
|
|
547
|
+
import House from "lucide-static/icons/house.svg"
|
|
548
|
+
|
|
549
|
+
<Icon src={House} />
|
|
550
|
+
<Icon src={House} size={32} color={theme.color.primary} />
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
**Props**
|
|
554
|
+
|
|
555
|
+
| Prop | Type | Default | Description |
|
|
556
|
+
| -------- | ------------- | ------------------ | -------------------------------------------------------------- |
|
|
557
|
+
| `src` | `string` | - | The SVG document to draw. |
|
|
558
|
+
| `size` | `number` | `24` | Square box side in pixels. |
|
|
559
|
+
| `color` | `string` | `theme.color.text` | Drives `currentColor`; explicit fills/strokes still win. |
|
|
560
|
+
| `layout` | `LayoutProps` | - | Layout of the box. |
|
|
561
|
+
|
|
539
562
|
## License
|
|
540
563
|
|
|
541
564
|
MIT. Copyright (c) 2026 Antoine van Wel.
|
|
@@ -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.21",
|
|
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.21"
|
|
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
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LayoutProps } from "@solidrt/core"
|
|
2
|
+
import { theme } from "./theme"
|
|
3
|
+
|
|
4
|
+
export interface IconProps {
|
|
5
|
+
// An SVG document as a string: an imported `.svg` asset, a `lucide-static`
|
|
6
|
+
// string export, or an inline template literal. Monochrome icons that stroke/
|
|
7
|
+
// fill with `currentColor` (Lucide, Feather, Heroicons, ...) get recolored by
|
|
8
|
+
// `color`; a multi-color document keeps its own fills.
|
|
9
|
+
src: string
|
|
10
|
+
// Rendered box in pixels, square. Defaults to 24 (the common icon grid).
|
|
11
|
+
size?: number
|
|
12
|
+
// Drives `currentColor` in the document. Defaults to the theme text color.
|
|
13
|
+
color?: string
|
|
14
|
+
layout?: LayoutProps
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SIZE = 24
|
|
18
|
+
|
|
19
|
+
// A themed wrapper over the core <svg> document primitive: a square box sized to
|
|
20
|
+
// `size` and colored from the theme by default. This is the only value it adds
|
|
21
|
+
// over `<svg src>` directly, so reach for the primitive when you need a
|
|
22
|
+
// non-square box or want no theme coupling.
|
|
23
|
+
export function Icon(props: IconProps) {
|
|
24
|
+
let size = () => props.size ?? SIZE
|
|
25
|
+
|
|
26
|
+
return (
|
|
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>
|
|
36
|
+
)
|
|
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"
|
|
@@ -24,7 +24,16 @@ export { ContextMenu, type ContextMenuProps, type ContextMenuItem } from "./cont
|
|
|
24
24
|
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
|
-
export {
|
|
27
|
+
export { Icon, type IconProps } from "./icon"
|
|
28
|
+
export {
|
|
29
|
+
theme,
|
|
30
|
+
setTheme,
|
|
31
|
+
darkTheme,
|
|
32
|
+
lightTheme,
|
|
33
|
+
type Theme,
|
|
34
|
+
type TextStyle,
|
|
35
|
+
type TextVariant,
|
|
36
|
+
} from "./theme"
|
|
28
37
|
export {
|
|
29
38
|
policy,
|
|
30
39
|
setPolicy,
|
|
@@ -39,4 +48,6 @@ export {
|
|
|
39
48
|
type NavigationPolicy,
|
|
40
49
|
type LayoutPolicy,
|
|
41
50
|
} from "./policy"
|
|
51
|
+
export { typeStyle, typeWeight, lightOnDark } from "./typography"
|
|
52
|
+
export { space } from "./spacing"
|
|
42
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
|
@@ -27,45 +27,51 @@ const MODULE_SIZE = 6
|
|
|
27
27
|
const MARGIN = 16
|
|
28
28
|
const RADIUS = 8
|
|
29
29
|
|
|
30
|
-
// Render a QR for `data` as primitives: merge horizontal runs of
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
// Render a QR for `data` as primitives: merge horizontal runs of dark modules
|
|
31
|
+
// per row into a single d-rect, placed at explicit coordinates on a light
|
|
32
|
+
// quiet-zone panel. Everything inside the panel is detached, so a data change
|
|
33
|
+
// repaints without touching layout; the panel view itself has a fixed size
|
|
34
|
+
// (module count * module size + margins) that only changes when the data
|
|
35
|
+
// crosses a QR version boundary.
|
|
33
36
|
export function QrCode(props: QrCodeProps) {
|
|
34
|
-
let
|
|
37
|
+
let grid = createMemo(() => {
|
|
35
38
|
let qr = qrcode(0, props.level ?? "M")
|
|
36
39
|
qr.addData(props.data)
|
|
37
40
|
qr.make()
|
|
38
41
|
let n = qr.getModuleCount()
|
|
39
42
|
|
|
40
|
-
let
|
|
43
|
+
let runs: { x: number; y: number; len: number }[] = []
|
|
41
44
|
for (let y = 0; y < n; y++) {
|
|
42
|
-
let runs: { dark: boolean; len: number }[] = []
|
|
43
45
|
let x = 0
|
|
44
46
|
while (x < n) {
|
|
45
|
-
|
|
47
|
+
if (!qr.isDark(y, x)) {
|
|
48
|
+
x++
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
46
51
|
let len = 1
|
|
47
|
-
while (x + len < n && qr.isDark(y, x + len)
|
|
48
|
-
runs.push({
|
|
52
|
+
while (x + len < n && qr.isDark(y, x + len)) len++
|
|
53
|
+
runs.push({ x, y, len })
|
|
49
54
|
x += len
|
|
50
55
|
}
|
|
51
|
-
out.push(runs)
|
|
52
56
|
}
|
|
53
|
-
return
|
|
57
|
+
return { n, runs }
|
|
54
58
|
})
|
|
55
59
|
|
|
56
60
|
let size = () => props.moduleSize ?? MODULE_SIZE
|
|
61
|
+
let margin = () => props.margin ?? MARGIN
|
|
62
|
+
let side = () => grid().n * size() + 2 * margin()
|
|
57
63
|
|
|
58
64
|
return (
|
|
59
|
-
<view
|
|
65
|
+
<view repaintBoundary width={side()} height={side()} {...props.layout}>
|
|
60
66
|
<d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? RADIUS} />
|
|
61
|
-
{
|
|
62
|
-
<
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
{grid().runs.map((run) => (
|
|
68
|
+
<d-rect
|
|
69
|
+
x={margin() + run.x * size()}
|
|
70
|
+
y={margin() + run.y * size()}
|
|
71
|
+
w={run.len * size()}
|
|
72
|
+
h={size()}
|
|
73
|
+
color={props.color ?? "#000000"}
|
|
74
|
+
/>
|
|
69
75
|
))}
|
|
70
76
|
</view>
|
|
71
77
|
)
|
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
|
@@ -1,15 +1,21 @@
|
|
|
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
|
|
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.
|
|
11
12
|
const CARET_WIDTH = 1
|
|
12
13
|
|
|
14
|
+
// Shaping width handed to the detached value/placeholder text: effectively
|
|
15
|
+
// unbounded, so a single line never wraps. The viewport clips it and scrollX
|
|
16
|
+
// slides it.
|
|
17
|
+
const TEXT_SHAPE_WIDTH = 1e9
|
|
18
|
+
|
|
13
19
|
export interface TextInputProps {
|
|
14
20
|
value?: string
|
|
15
21
|
defaultValue?: string
|
|
@@ -134,25 +140,24 @@ export function TextInput(props: TextInputProps) {
|
|
|
134
140
|
let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
|
|
135
141
|
let showCaret = () => focused() && caretOn() && !showPlaceholder()
|
|
136
142
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
let
|
|
150
|
-
let afterCaret = () => value().slice(buffer.caret())
|
|
143
|
+
// Everything inside the viewport is detached: the value is one d-text shaped
|
|
144
|
+
// at an unbounded width and the caret a d-rect at the measured before-caret
|
|
145
|
+
// width, so typing, caret movement, blink and scroll never touch layout. The
|
|
146
|
+
// viewport carries an explicit height (detached content takes no layout
|
|
147
|
+
// slot) equal to the one-line paragraph height, which keeps the text where
|
|
148
|
+
// the old centered attached row sat. createCaretScroll keeps the caret in
|
|
149
|
+
// view and flushes the offset before paint; scrollX is a paint-time
|
|
150
|
+
// translate that also applies to detached children.
|
|
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
|
|
151
156
|
let scrollX = createCaretScroll(
|
|
152
157
|
() => viewport,
|
|
153
158
|
() => ({
|
|
154
159
|
text: value(),
|
|
155
|
-
fontSize:
|
|
160
|
+
fontSize: fontSize(),
|
|
156
161
|
caret: buffer.caret(),
|
|
157
162
|
// Constant, not tied to caret visibility: the caret's footprint does not
|
|
158
163
|
// change as it blinks, so reserving the column only when shown would swing
|
|
@@ -162,11 +167,11 @@ export function TextInput(props: TextInputProps) {
|
|
|
162
167
|
)
|
|
163
168
|
|
|
164
169
|
let textStyle = (color: string) => ({
|
|
165
|
-
|
|
170
|
+
w: TEXT_SHAPE_WIDTH,
|
|
171
|
+
fontSize: fontSize(),
|
|
166
172
|
lineHeight: theme.text.body.lineHeight,
|
|
167
173
|
color,
|
|
168
174
|
maxLines: 1,
|
|
169
|
-
flexShrink: 0,
|
|
170
175
|
})
|
|
171
176
|
|
|
172
177
|
return (
|
|
@@ -174,15 +179,16 @@ export function TextInput(props: TextInputProps) {
|
|
|
174
179
|
ref={(n: { id: number }) => (node = n)}
|
|
175
180
|
flexDirection="row"
|
|
176
181
|
alignItems="center"
|
|
177
|
-
paddingLeft={
|
|
178
|
-
paddingRight={
|
|
179
|
-
paddingTop={
|
|
180
|
-
paddingBottom={
|
|
182
|
+
paddingLeft={space("md")}
|
|
183
|
+
paddingRight={space("md")}
|
|
184
|
+
paddingTop={space("sm")}
|
|
185
|
+
paddingBottom={space("sm")}
|
|
181
186
|
{...props.layout}
|
|
182
187
|
x={props.style?.x}
|
|
183
188
|
y={props.style?.y}
|
|
184
189
|
scale={props.style?.scale}
|
|
185
190
|
rotate={props.style?.rotate}
|
|
191
|
+
opacity={props.style?.opacity}
|
|
186
192
|
onPointerDown={handlePointerDown}
|
|
187
193
|
onFocus={handleFocus}
|
|
188
194
|
onBlur={handleBlur}
|
|
@@ -199,30 +205,25 @@ export function TextInput(props: TextInputProps) {
|
|
|
199
205
|
<view
|
|
200
206
|
ref={(n: { id: number }) => (viewport = n)}
|
|
201
207
|
flex={1}
|
|
202
|
-
|
|
203
|
-
alignItems="center"
|
|
208
|
+
height={rowHeight()}
|
|
204
209
|
overflow="hidden"
|
|
205
210
|
scrollX={scrollX()}
|
|
206
211
|
>
|
|
207
212
|
{showPlaceholder() ? (
|
|
208
|
-
<text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</text>
|
|
213
|
+
<d-text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</d-text>
|
|
209
214
|
) : (
|
|
210
|
-
|
|
211
|
-
<text {...textStyle(textColor())}>{
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
) : null}
|
|
223
|
-
</view>
|
|
224
|
-
<text {...textStyle(textColor())}>{afterCaret()}</text>
|
|
225
|
-
</view>
|
|
215
|
+
<>
|
|
216
|
+
<d-text {...textStyle(textColor())}>{value()}</d-text>
|
|
217
|
+
{showCaret() ? (
|
|
218
|
+
<d-rect
|
|
219
|
+
color={textColor()}
|
|
220
|
+
x={caretX()}
|
|
221
|
+
y={(rowHeight() - fontSize()) / 2}
|
|
222
|
+
w={CARET_WIDTH}
|
|
223
|
+
h={fontSize()}
|
|
224
|
+
/>
|
|
225
|
+
) : null}
|
|
226
|
+
</>
|
|
226
227
|
)}
|
|
227
228
|
</view>
|
|
228
229
|
</view>
|
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}
|