@solidrt/components 0.0.50 → 0.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +93 -85
- package/README.md +421 -311
- package/demos/README.md +24 -0
- package/demos/assets/icon.png +0 -0
- package/demos/assets/icon.svg +23 -0
- package/demos/package.json +9 -0
- package/demos/src/gallery.tsx +866 -0
- package/demos/tsconfig.json +15 -0
- package/docs/badge.md +10 -0
- package/docs/button.md +21 -0
- package/docs/card.md +11 -0
- package/docs/checkbox.md +9 -0
- package/docs/context-menu.md +17 -0
- package/docs/density.md +13 -0
- package/docs/divider.md +10 -0
- package/docs/field.md +11 -0
- package/docs/focus-nav.md +18 -0
- package/docs/icon.md +13 -0
- package/docs/image.md +21 -0
- package/docs/index.md +13 -0
- package/docs/item.md +25 -0
- package/docs/modal.md +15 -0
- package/docs/nav-shell.md +18 -0
- package/docs/policy.md +21 -0
- package/docs/portal.md +15 -0
- package/docs/pressable.md +17 -0
- package/docs/progress-bar.md +10 -0
- package/docs/qrcode.md +10 -0
- package/docs/radio.md +13 -0
- package/docs/rich-text-document.md +5 -0
- package/docs/rich-text-editor.md +24 -0
- package/docs/safe-area.md +12 -0
- package/docs/scroll-view.md +35 -0
- package/docs/segmented-control.md +13 -0
- package/docs/select.md +15 -0
- package/docs/slider.md +9 -0
- package/docs/spacing.md +7 -0
- package/docs/spinner.md +10 -0
- package/docs/split-view.md +14 -0
- package/docs/switch.md +13 -0
- package/docs/text-input.md +23 -0
- package/docs/text.md +15 -0
- package/docs/theme.md +68 -0
- package/docs/tooltip.md +11 -0
- package/docs/types.md +9 -0
- package/docs/typography.md +5 -0
- package/docs/view.md +14 -0
- package/docs/window.md +15 -0
- package/package.json +8 -4
- package/src/badge.tsx +30 -21
- package/src/button.tsx +50 -32
- package/src/card.tsx +22 -13
- package/src/checkbox.tsx +29 -11
- package/src/context-menu.tsx +14 -9
- package/src/density.tsx +39 -0
- package/src/divider.tsx +11 -4
- package/src/editor-field.tsx +368 -0
- package/src/field.tsx +47 -0
- package/src/icon.tsx +8 -4
- package/src/image.tsx +10 -3
- package/src/index.ts +19 -2
- package/src/item.tsx +121 -0
- package/src/nav-shell.tsx +7 -17
- package/src/policy.ts +9 -12
- package/src/press.ts +37 -8
- package/src/pressable.tsx +15 -3
- package/src/progress-bar.tsx +8 -5
- package/src/qrcode.tsx +7 -5
- package/src/radio.tsx +26 -16
- package/src/rich-text-document.ts +247 -0
- package/src/rich-text-editor.tsx +151 -0
- package/src/scroll-view.tsx +76 -7
- package/src/segmented-control.tsx +33 -19
- package/src/select.tsx +59 -30
- package/src/slider.tsx +32 -6
- package/src/spacing.ts +1 -1
- package/src/spinner.tsx +11 -6
- package/src/split-view.tsx +3 -4
- package/src/switch.tsx +10 -3
- package/src/text-input.tsx +54 -264
- package/src/text.tsx +22 -2
- package/src/theme.ts +220 -81
- package/src/tooltip.tsx +23 -9
- package/src/types.ts +119 -1
- package/src/view.tsx +11 -2
package/docs/theme.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Theming
|
|
2
|
+
|
|
3
|
+
Appearance (colors, spacing, border, font roles) comes from one shared, reactive theme backed by a Solid store: reads are tracked, so switching the theme at runtime recolors the live UI without remounting. Two presets ship, `darkTheme` and `lightTheme` (default dark); `setTheme(preset)` switches, `setTheme(partial)` merges an override one level deep per category. Custom themes are authored with `defineTheme`.
|
|
4
|
+
|
|
5
|
+
```jsx
|
|
6
|
+
import { setTheme, darkTheme, lightTheme } from "@solidrt/components"
|
|
7
|
+
|
|
8
|
+
setTheme(lightTheme) // switch to light
|
|
9
|
+
setTheme(darkTheme) // switch to dark
|
|
10
|
+
setTheme({ color: { primary: "#ff2d55" } }) // override one token
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Authoring with defineTheme
|
|
14
|
+
|
|
15
|
+
`defineTheme(definition, scheme?)` resolves a definition into a theme. Any color may be a single value or a `[light, dark]` pair; the `scheme` argument picks the side. Pairs are opt-in per token, and a definition without any needs no scheme at all - modes are a per-theme choice, not a framework requirement (a game ships one look, not two). The built-in presets are one definition resolved twice, so they cannot drift apart.
|
|
16
|
+
|
|
17
|
+
```jsx
|
|
18
|
+
import { defineTheme, setTheme } from "@solidrt/components"
|
|
19
|
+
|
|
20
|
+
let def = {
|
|
21
|
+
color: {
|
|
22
|
+
background: ["#ffffff", "#101014"], // [light, dark]
|
|
23
|
+
primary: "#ff2d55", // same in both
|
|
24
|
+
/* ... every color token ... */
|
|
25
|
+
},
|
|
26
|
+
text: { base: 15, ratio: 1.25 },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setTheme(defineTheme(def, "dark"))
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The type scale derives from `text.base` (the body size, default 14) and `text.ratio` (default 1.26): caption sits one step under body, label is body at an emphasized weight, title and heading sit one and two steps above, rounded to whole pixels, with per-role `text.roles` overrides for sizes, line heights, and weights. De-emphasis is a color (`textMuted`), not a size: caption is for small glanceable text (badges, tab labels, timestamps) and stays in the full text color.
|
|
33
|
+
|
|
34
|
+
## Tokens
|
|
35
|
+
|
|
36
|
+
The color tokens are `background` (window fill), `surface` (control/card fill), `surfaceAlt` (subtle raised/track fill), `text`, `textMuted`, `border`, `primary`/`onPrimary`, `secondary`/`onSecondary` (lower-emphasis accent), `danger` (validation/destructive), `scrim` (modal dim), `ring` (the focus ring; defaults to `text` so it stays visible on primary fills), and the feedback pair `overlayHover`/`overlayPressed`: translucent tints components draw OVER a control's own fill, so one token pair gives hover/pressed feedback on every fill color, including caller-set ones. Non-color tokens are `spacing`, `radius`, `borderWidth` (`sm` for borders, `focus` for the ring), `size` (app-wide default extents: `navRail` 72, `navSidebar` 220, `splitViewList` 320, `menuMinWidth` 120, `slider` 200; each overridable per instance through its layout or prop), and `text` (the type scale: `caption`/`label`/`body`/`title`/`heading` roles, each `{ size, lineHeight, weight }`, plus `fontFamily` and `monoFamily` for code).
|
|
37
|
+
|
|
38
|
+
## Spacing
|
|
39
|
+
|
|
40
|
+
Spacing is one base unit: `spacing` in a theme definition is a number (default 4) and the steps are multiples of it (`sm` 1x, `md` 2x, `lg` 4x, `xl` 5x). Components read them through `space()`, which applies the density policy on top, so a theme sets the rhythm and density tightens it. Pass an object (`spacing: { sm, md, lg, xl }`, any subset) to pin individual steps.
|
|
41
|
+
|
|
42
|
+
## Radius
|
|
43
|
+
|
|
44
|
+
Corner radius is set once: `radius` in a theme definition is a single number, the control radius (default 8), and the scale derives from it: `md` is the base (Button, TextInput, RichTextEditor, Select, SegmentedControl, QrCode), `sm` half of it (Checkbox, Item, NavShell items, Select and ContextMenu popups, Tooltip), `lg` one and a half (Card), and `full` the pill (Badge). Set `radius: 0` for a square theme, `radius: 12` for a soft one; buttons and inputs always match. Shapes derived from a control's own height (Switch, Slider, ProgressBar, Radio) are not on the scale. Pass an object (`radius: { sm, md, lg, full }`, any subset) to pin individual steps instead.
|
|
45
|
+
|
|
46
|
+
```jsx
|
|
47
|
+
setTheme({ radius: 4 }) // sm 2, md 4, lg 6
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Per-component overrides
|
|
51
|
+
|
|
52
|
+
`theme.components` restyles a component everywhere without wrapping it: a `StyleProps` object per component name, merged between the component's themed defaults and each instance's `style` prop (instance style still wins).
|
|
53
|
+
|
|
54
|
+
```jsx
|
|
55
|
+
setTheme({ components: { button: { borderRadius: 999 } } }) // pill buttons app-wide
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Keys: `button`, `card`, `badge`, `switch`, `checkbox`, `radio`, `item`, `select`, `segmentedControl`, `textInput`, `richTextEditor`, `tooltip`, `divider`, `progressBar`, `spinner`.
|
|
59
|
+
|
|
60
|
+
## Icon slots
|
|
61
|
+
|
|
62
|
+
`theme.icons` holds semantic control glyphs as SVG document strings (the same currency as `Icon`): `chevronDown` (the Select trigger) and `check` (the Checkbox mark). Components draw their built-in vector paths by default; a theme that sets a slot swaps that glyph everywhere it appears, and the package still bundles no icon set.
|
|
63
|
+
|
|
64
|
+
```jsx
|
|
65
|
+
import ChevronDown from "lucide-static/icons/chevron-down.svg"
|
|
66
|
+
|
|
67
|
+
setTheme({ icons: { chevronDown: ChevronDown } })
|
|
68
|
+
```
|
package/docs/tooltip.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Tooltip
|
|
2
|
+
|
|
3
|
+
A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, and hides on leave and on press. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the side (`"top"`, the default, or `"bottom"`).
|
|
4
|
+
|
|
5
|
+
```jsx
|
|
6
|
+
import { Tooltip, Button } from "@solidrt/components"
|
|
7
|
+
|
|
8
|
+
<Tooltip content="Save (Ctrl+S)">
|
|
9
|
+
<Button onPress={save}>Save</Button>
|
|
10
|
+
</Tooltip>
|
|
11
|
+
```
|
package/docs/types.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Layout and style
|
|
2
|
+
|
|
3
|
+
Most components group their props into two objects, split by one rule: `layout` properties feed the layout engine (flexbox/grid, sizing, padding, margin, position - the core `LayoutProps` set) and changing them triggers a relayout; `style` properties are paint-only and never affect layout: `color`, `backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`, `opacity`, and the transform (`x`, `y`, `scale`, `rotate`, `rotateX`/`rotateY` with `perspective`, `originX`/`originY`, `clipRadius`). Event handlers (`onPointerDown`, `onKeyDown`, ...) are top-level props, never inside `layout` or `style`.
|
|
4
|
+
|
|
5
|
+
`StyleProps` is that paint set. `TextLayoutProps` extends `LayoutProps` with the font fields (`fontFamily`, `fontSize`, `lineHeight`, `fontStyle`, `fontWeight`, `textAlign`, `maxLines`) because text shaping affects measurement; note `lineHeight` is a multiplier of `fontSize` (the theme uses 1.3-1.6), not a pixel value. `Option` (`{ value, label }`) is the shared shape of the single-choice controls (`Select`, `SegmentedControl`): shared shapes go through this module so components never import a sibling.
|
|
6
|
+
|
|
7
|
+
`TransitionProps` (`transition`, `onTransitionEnd`) is the third top-level group, in the component's own vocabulary rather than core's: a declaration names the view-level properties (`opacity`, `x`, `y`, `scale*`, `rotate*`, `origin*`, `perspective`, `clipRadius`) and the style ones (`backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`), plus `all`, a shorthand string, and `stagger` - `<Button transition={{ backgroundColor: { duration: 300 }, opacity: "200ms ease-out" }}>`. Core's paint names (`color`, `radius`, `strokeWidth`) are rejected by the types: a component is a root view plus the rects it draws for `style`, and `splitTransition` hands each entry to the node that owns it (the background rect gets `backgroundColor`/`borderRadius`, the stroke rect `borderColor`/`borderWidth`/`borderRadius`, the root view the rest). `onTransitionEnd` reports the component name (`backgroundColor`, not `color`). `Text` adds `color` (its text node), `ScrollView` adds `scrollX`/`scrollY` (its viewport).
|
|
8
|
+
|
|
9
|
+
Controls whose paint is their own - `Switch` knob, `Slider` thumb, `Checkbox` mark, `Radio` dot, `ProgressBar` fill, `Spinner`, `Icon`, `QrCode`, and the chrome of `NavShell`, `ContextMenu`, `Field` - animate the view-level entries only for now; their internal parts are not reachable through `transition` yet (okf/backlog/component-transitions-internal-paint.md).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Typography helpers
|
|
2
|
+
|
|
3
|
+
`typeStyle(variant)` resolves a theme type-scale role (`caption`/`label`/`body`/`title`/`heading`) to font props ready to spread onto a `<text>` or `d-text`: `fontSize` carries `policy.textScale`, and `fontWeight` carries the low-DPI weight compensation. Reactive when called inside a tracked scope, like any theme/policy read. `Text` applies it for you; reach for the helpers when building custom text out of core primitives.
|
|
4
|
+
|
|
5
|
+
The compensation exists because the renderer rasterizes glyphs unhinted and composites in nonlinear sRGB, which thins light-on-dark text on low-DPI displays as glyphs shrink. `typeWeight(weight, size, onDark?)` adds `policy.textWeightDelta` (0 on high-DPI displays) plus one extra step below 16px; dark-on-light text passes through untouched. `lightOnDark(text, fill)` computes the polarity for a known pair of colors (Button uses it for its fills); omitted, the theme's own palette polarity is used.
|
package/docs/view.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# View
|
|
2
|
+
|
|
3
|
+
A general-purpose box. Spreads `layout` onto the underlying view, applies the transform from `style`, and draws a background and/or border when those style props are set. Takes all pointer event props.
|
|
4
|
+
|
|
5
|
+
```jsx
|
|
6
|
+
import { View } from "@solidrt/components"
|
|
7
|
+
|
|
8
|
+
<View
|
|
9
|
+
layout={{ padding: 16, flexDirection: "column", gap: 8 }}
|
|
10
|
+
style={{ backgroundColor: "#222", borderRadius: 8 }}
|
|
11
|
+
>
|
|
12
|
+
{/* ... */}
|
|
13
|
+
</View>
|
|
14
|
+
```
|
package/docs/window.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Window
|
|
2
|
+
|
|
3
|
+
The root surface of an app: renders a core `<window>`, so `render()` accepts it. Applies `layout` and `style.backgroundColor` only (a window cannot be transformed or bordered), plus `title` and `fullscreen`.
|
|
4
|
+
|
|
5
|
+
```jsx
|
|
6
|
+
import { Window } from "@solidrt/components"
|
|
7
|
+
|
|
8
|
+
function App() {
|
|
9
|
+
return (
|
|
10
|
+
<Window title="My App" style={{ backgroundColor: "#111" }}>
|
|
11
|
+
{/* ... */}
|
|
12
|
+
</Window>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
```
|
package/package.json
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/components",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.52",
|
|
4
4
|
"license": "MIT",
|
|
5
|
+
"funding": "https://github.com/sponsors/wellawaretech",
|
|
5
6
|
"author": "Antoine van Wel",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"main": "src/index.ts",
|
|
8
9
|
"exports": {
|
|
9
|
-
".": "./src/index.ts"
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./theme": "./src/theme.ts"
|
|
10
12
|
},
|
|
11
13
|
"files": [
|
|
12
14
|
"src/",
|
|
15
|
+
"docs/",
|
|
13
16
|
"examples/",
|
|
17
|
+
"demos/",
|
|
14
18
|
"AGENTS.md"
|
|
15
19
|
],
|
|
16
20
|
"dependencies": {
|
|
17
21
|
"qrcode-generator": "^2.0.4"
|
|
18
22
|
},
|
|
19
23
|
"peerDependencies": {
|
|
20
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
21
|
-
"@solidrt/core": "0.0.
|
|
24
|
+
"@solidjs/signals": "2.0.0-rc.1",
|
|
25
|
+
"@solidrt/core": "0.0.52"
|
|
22
26
|
}
|
|
23
27
|
}
|
package/src/badge.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { Show, children } from "@solidrt/core"
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { theme } from "./theme"
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
4
|
+
import { space } from "./spacing"
|
|
5
|
+
import { typeStyle, typeWeight, lightOnDark } from "./typography"
|
|
6
|
+
import { policy } from "./policy"
|
|
7
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
8
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
6
9
|
|
|
7
10
|
export type BadgeVariant = "primary" | "neutral" | "danger"
|
|
8
11
|
|
|
9
|
-
export interface BadgeProps {
|
|
12
|
+
export interface BadgeProps extends TransitionProps {
|
|
10
13
|
// A string/number renders as the themed pill label; anything else is rendered
|
|
11
14
|
// as-is (an icon, a dot, ...).
|
|
12
15
|
children?: any
|
|
@@ -16,10 +19,6 @@ export interface BadgeProps {
|
|
|
16
19
|
style?: StyleProps
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
// A rounded radius large enough to fully pill any typical badge height; the
|
|
20
|
-
// renderer clamps it to half the box, so both ends stay round.
|
|
21
|
-
const RADIUS = 999
|
|
22
|
-
|
|
23
22
|
// A small rounded pill for counts, labels, and status. Accent fill with
|
|
24
23
|
// onPrimary text by default; override the fill via style.backgroundColor and the
|
|
25
24
|
// label color via style.color.
|
|
@@ -35,34 +34,44 @@ export function Badge(props: BadgeProps) {
|
|
|
35
34
|
return { bg: c.primary, fg: c.onPrimary }
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
|
-
|
|
39
|
-
let
|
|
40
|
-
let
|
|
37
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
38
|
+
let styled = () => ({ ...theme.components.badge, ...props.style })
|
|
39
|
+
let bg = () => styled().backgroundColor ?? colors().bg
|
|
40
|
+
let fg = () => styled().color ?? colors().fg
|
|
41
|
+
let radius = () => styled().borderRadius ?? theme.radius.full
|
|
41
42
|
// Resolved once via children(): the typeof probe and the mount sites must
|
|
42
43
|
// share one build - reading the raw getter again would orphan native nodes.
|
|
43
44
|
let resolved = children(() => props.children)
|
|
44
45
|
let isText = () => typeof resolved() === "string" || typeof resolved() === "number"
|
|
45
46
|
let labelOnDark = () => lightOnDark(fg(), bg())
|
|
46
47
|
|
|
48
|
+
let split = () => splitTransition(props.transition)
|
|
49
|
+
|
|
47
50
|
return (
|
|
48
51
|
<view
|
|
52
|
+
transition={split().root}
|
|
53
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
49
54
|
flexDirection="row"
|
|
50
55
|
alignItems="center"
|
|
51
56
|
justifyContent="center"
|
|
52
|
-
paddingLeft={
|
|
53
|
-
paddingRight={
|
|
54
|
-
paddingTop={2}
|
|
55
|
-
paddingBottom={2}
|
|
57
|
+
paddingLeft={space("md")}
|
|
58
|
+
paddingRight={space("md")}
|
|
59
|
+
paddingTop={Math.round(space("sm") / 2)}
|
|
60
|
+
paddingBottom={Math.round(space("sm") / 2)}
|
|
56
61
|
{...props.layout}
|
|
57
|
-
x={
|
|
58
|
-
y={
|
|
59
|
-
scale={
|
|
60
|
-
rotate={
|
|
61
|
-
opacity={
|
|
62
|
+
x={styled().x}
|
|
63
|
+
y={styled().y}
|
|
64
|
+
scale={styled().scale}
|
|
65
|
+
rotate={styled().rotate}
|
|
66
|
+
opacity={styled().opacity}
|
|
62
67
|
>
|
|
63
|
-
<d-rect color={bg()} radius={radius()} />
|
|
68
|
+
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
|
|
64
69
|
<Show when={isText()} fallback={resolved()}>
|
|
65
|
-
<text
|
|
70
|
+
<text
|
|
71
|
+
color={fg()}
|
|
72
|
+
{...typeStyle("caption", labelOnDark())}
|
|
73
|
+
fontWeight={typeWeight(600, theme.text.caption.size * policy.textScale, labelOnDark())}
|
|
74
|
+
>
|
|
66
75
|
{resolved()}
|
|
67
76
|
</text>
|
|
68
77
|
</Show>
|
package/src/button.tsx
CHANGED
|
@@ -4,13 +4,15 @@ import { theme } from "./theme"
|
|
|
4
4
|
import { policy } from "./policy"
|
|
5
5
|
import { space } from "./spacing"
|
|
6
6
|
import { typeStyle, lightOnDark } from "./typography"
|
|
7
|
+
import { Spinner } from "./spinner"
|
|
7
8
|
import type { LayoutProps } from "@solidrt/core"
|
|
8
|
-
import type { StyleProps } from "./types"
|
|
9
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
10
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
9
11
|
|
|
10
12
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
|
|
11
13
|
export type ButtonSize = "sm" | "md" | "lg"
|
|
12
14
|
|
|
13
|
-
export interface ButtonProps {
|
|
15
|
+
export interface ButtonProps extends TransitionProps {
|
|
14
16
|
// A string/number is rendered as the themed label; anything else is rendered
|
|
15
17
|
// as-is, so a button can hold custom content (an icon, a row, ...).
|
|
16
18
|
children?: any
|
|
@@ -22,7 +24,10 @@ export interface ButtonProps {
|
|
|
22
24
|
// stretches to the container's width (the default). Padding is the same at
|
|
23
25
|
// every size.
|
|
24
26
|
size?: ButtonSize
|
|
25
|
-
|
|
27
|
+
// A returned promise makes this an async action: the button shows a
|
|
28
|
+
// centered spinner in place of the label (geometry unchanged) and ignores
|
|
29
|
+
// presses until it settles. Non-thenable returns are ignored.
|
|
30
|
+
onPress?: () => unknown
|
|
26
31
|
disabled?: boolean
|
|
27
32
|
// Focus-navigation candidacy (spatial nav, TV remotes); on by default.
|
|
28
33
|
// Disabled buttons are never candidates.
|
|
@@ -37,44 +42,44 @@ export interface ButtonProps {
|
|
|
37
42
|
const SIZE_WIDTH: Record<ButtonSize, number> = { sm: 88, md: 120, lg: 160 }
|
|
38
43
|
|
|
39
44
|
// A themed press target: a padded, centered, accent-colored box with a label.
|
|
40
|
-
// Press feedback is a slight scale, hover feedback
|
|
41
|
-
// interaction policies only), both
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// Focus (spatial nav) draws a ring under
|
|
46
|
-
//
|
|
47
|
-
//
|
|
45
|
+
// Press feedback is a slight scale, hover feedback the theme's overlayHover
|
|
46
|
+
// tint drawn over the fill (non-touch interaction policies only), both
|
|
47
|
+
// reactive reads of the press state so no nodes are recreated. Override the
|
|
48
|
+
// box via style and the padding/sizing via layout; because hover is an
|
|
49
|
+
// overlay, it composes over a caller-set backgroundColor too. When disabled,
|
|
50
|
+
// it takes no pointer events at all. Focus (spatial nav) draws a ring under
|
|
51
|
+
// the focusRing policy in the theme's ring color; Enter/Space/remote-select
|
|
52
|
+
// activates (handled by createPress).
|
|
48
53
|
export function Button(props: ButtonProps) {
|
|
49
|
-
// Fill
|
|
50
|
-
//
|
|
54
|
+
// Fill and label color per variant, read reactively from the theme. No
|
|
55
|
+
// variant draws a border.
|
|
51
56
|
let colors = () => {
|
|
52
57
|
let c = theme.color
|
|
53
58
|
switch (props.variant ?? "primary") {
|
|
54
59
|
case "secondary":
|
|
55
|
-
return { fill: c.secondary,
|
|
60
|
+
return { fill: c.secondary, label: c.onSecondary }
|
|
56
61
|
case "ghost":
|
|
57
|
-
return { fill: "transparent",
|
|
62
|
+
return { fill: "transparent", label: c.text }
|
|
58
63
|
case "danger":
|
|
59
|
-
return { fill: c.danger,
|
|
64
|
+
return { fill: c.danger, label: c.onPrimary }
|
|
60
65
|
default:
|
|
61
|
-
return { fill: c.primary,
|
|
66
|
+
return { fill: c.primary, label: c.onPrimary }
|
|
62
67
|
}
|
|
63
68
|
}
|
|
69
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
70
|
+
let styled = (): StyleProps => ({ ...theme.components.button, ...props.style })
|
|
64
71
|
let idleFill = () =>
|
|
65
72
|
props.disabled
|
|
66
73
|
? props.variant === "ghost"
|
|
67
74
|
? "transparent"
|
|
68
75
|
: theme.color.surface
|
|
69
76
|
: colors().fill
|
|
70
|
-
let bg = (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
: colors().fill)
|
|
77
|
-
let radius = () => props.style?.borderRadius ?? theme.radius.md
|
|
77
|
+
let bg = () => styled().backgroundColor ?? idleFill()
|
|
78
|
+
// The hover feedback: the theme's overlay tint drawn over the fill, so it
|
|
79
|
+
// composes with any backgroundColor (variant, theme override, or caller).
|
|
80
|
+
let overlay = (s: PressState) =>
|
|
81
|
+
s.hovered && !props.disabled && policy.interaction !== "touch" ? theme.color.overlayHover : "transparent"
|
|
82
|
+
let radius = () => styled().borderRadius ?? theme.radius.md
|
|
78
83
|
let label = () => (props.disabled ? theme.color.textMuted : colors().label)
|
|
79
84
|
// Resolved once via children(): reading the raw children getter builds a new
|
|
80
85
|
// subtree per read, so the typeof probe and the two mount sites below must
|
|
@@ -84,23 +89,27 @@ export function Button(props: ButtonProps) {
|
|
|
84
89
|
// The label's polarity against the idle fill: onPrimary on a saturated fill
|
|
85
90
|
// is light-on-dark even in a light theme, so it needs the low-DPI weight
|
|
86
91
|
// compensation there too.
|
|
87
|
-
let labelOnDark = () => lightOnDark(label(),
|
|
92
|
+
let labelOnDark = () => lightOnDark(label(), bg())
|
|
88
93
|
|
|
89
94
|
// props (not a literal) so a swapped-in onPress is read at event time.
|
|
90
95
|
let press = createPress(props)
|
|
91
96
|
let style = () => ({
|
|
92
|
-
...
|
|
93
|
-
...(press.focused() && policy.focusRing ? { borderWidth:
|
|
94
|
-
backgroundColor: bg(
|
|
97
|
+
...styled(),
|
|
98
|
+
...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
|
|
99
|
+
backgroundColor: bg(),
|
|
95
100
|
borderRadius: radius(),
|
|
96
101
|
// Always a number: a scale that flips from a number back to undefined
|
|
97
102
|
// hits the transform decoder, which rejects null. Multiply so a
|
|
98
103
|
// caller-set scale is preserved under the press feedback.
|
|
99
|
-
scale: (
|
|
104
|
+
scale: (styled().scale ?? 1) * (press.pressed() && policy.motion !== "none" ? 0.97 : 1),
|
|
100
105
|
})
|
|
101
106
|
|
|
107
|
+
let split = () => splitTransition(props.transition)
|
|
108
|
+
|
|
102
109
|
return (
|
|
103
110
|
<view
|
|
111
|
+
transition={split().root}
|
|
112
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
104
113
|
ref={(n: { id: number }) => {
|
|
105
114
|
press.ref(n)
|
|
106
115
|
props.ref?.(n)
|
|
@@ -109,6 +118,7 @@ export function Button(props: ButtonProps) {
|
|
|
109
118
|
flexDirection="row"
|
|
110
119
|
alignItems="center"
|
|
111
120
|
justifyContent="center"
|
|
121
|
+
position="relative"
|
|
112
122
|
paddingTop={space("md")}
|
|
113
123
|
paddingBottom={space("md")}
|
|
114
124
|
paddingLeft={space("lg")}
|
|
@@ -124,15 +134,23 @@ export function Button(props: ButtonProps) {
|
|
|
124
134
|
focusable={(props.focusable ?? true) && props.disabled !== true}
|
|
125
135
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
126
136
|
>
|
|
127
|
-
<d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
|
|
137
|
+
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
|
|
138
|
+
<d-rect color={overlay(press.state())} radius={style().borderRadius} />
|
|
128
139
|
<Show when={isText()} fallback={resolved()}>
|
|
129
|
-
<text color={label()} {...typeStyle("body", labelOnDark())}>
|
|
140
|
+
<text color={press.pending() ? "transparent" : label()} {...typeStyle("body", labelOnDark())}>
|
|
130
141
|
{resolved()}
|
|
131
142
|
</text>
|
|
132
143
|
</Show>
|
|
144
|
+
<Show when={press.pending()}>
|
|
145
|
+
<view position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
|
|
146
|
+
<Spinner size={16} thickness={2} style={{ color: label() }} />
|
|
147
|
+
</view>
|
|
148
|
+
</Show>
|
|
133
149
|
<Show when={(style().borderWidth ?? 0) > 0}>
|
|
134
150
|
<d-rect
|
|
135
151
|
drawStyle="stroke"
|
|
152
|
+
transition={split().border}
|
|
153
|
+
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
136
154
|
color={style().borderColor ?? "transparent"}
|
|
137
155
|
strokeWidth={style().borderWidth}
|
|
138
156
|
radius={style().borderRadius}
|
package/src/card.tsx
CHANGED
|
@@ -3,9 +3,10 @@ import type { LayoutProps } from "@solidrt/core"
|
|
|
3
3
|
import { theme } from "./theme"
|
|
4
4
|
import { typeStyle } from "./typography"
|
|
5
5
|
import { space } from "./spacing"
|
|
6
|
-
import type { StyleProps } from "./types"
|
|
6
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
7
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
7
8
|
|
|
8
|
-
export interface CardProps {
|
|
9
|
+
export interface CardProps extends TransitionProps {
|
|
9
10
|
children?: any
|
|
10
11
|
// Optional heading rendered above the content.
|
|
11
12
|
title?: string
|
|
@@ -19,25 +20,31 @@ export interface CardProps {
|
|
|
19
20
|
// style.borderWidth or style.borderColor to draw an outline. Override any paint
|
|
20
21
|
// via style, spacing/sizing via layout.
|
|
21
22
|
export function Card(props: CardProps) {
|
|
22
|
-
|
|
23
|
-
let
|
|
24
|
-
let
|
|
23
|
+
// Theme-level per-component overrides merged under the instance style.
|
|
24
|
+
let styled = () => ({ ...theme.components.card, ...props.style })
|
|
25
|
+
let bg = () => styled().backgroundColor ?? theme.color.surface
|
|
26
|
+
let radius = () => styled().borderRadius ?? theme.radius.lg
|
|
27
|
+
let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
|
|
28
|
+
|
|
29
|
+
let split = () => splitTransition(props.transition)
|
|
25
30
|
|
|
26
31
|
return (
|
|
27
32
|
<view
|
|
33
|
+
transition={split().root}
|
|
34
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
28
35
|
ref={props.ref}
|
|
29
36
|
repaintBoundary
|
|
30
37
|
flexDirection="column"
|
|
31
38
|
gap={space("lg")}
|
|
32
39
|
padding={space("xl")}
|
|
33
40
|
{...props.layout}
|
|
34
|
-
x={
|
|
35
|
-
y={
|
|
36
|
-
scale={
|
|
37
|
-
rotate={
|
|
38
|
-
opacity={
|
|
41
|
+
x={styled().x}
|
|
42
|
+
y={styled().y}
|
|
43
|
+
scale={styled().scale}
|
|
44
|
+
rotate={styled().rotate}
|
|
45
|
+
opacity={styled().opacity}
|
|
39
46
|
>
|
|
40
|
-
<d-rect color={bg()} radius={radius()} />
|
|
47
|
+
<d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
|
|
41
48
|
<Show when={props.title != null}>
|
|
42
49
|
<text color={theme.color.text} {...typeStyle("title")}>
|
|
43
50
|
{props.title}
|
|
@@ -47,8 +54,10 @@ export function Card(props: CardProps) {
|
|
|
47
54
|
<Show when={hasBorder()}>
|
|
48
55
|
<d-rect
|
|
49
56
|
drawStyle="stroke"
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
transition={split().border}
|
|
58
|
+
onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
|
|
59
|
+
color={styled().borderColor ?? theme.color.border}
|
|
60
|
+
strokeWidth={styled().borderWidth ?? theme.borderWidth.sm}
|
|
52
61
|
radius={radius()}
|
|
53
62
|
/>
|
|
54
63
|
</Show>
|
package/src/checkbox.tsx
CHANGED
|
@@ -2,10 +2,13 @@ import { createSignal, Show } from "@solidrt/core"
|
|
|
2
2
|
import type { LayoutProps } from "@solidrt/core"
|
|
3
3
|
import { createPress } from "./press"
|
|
4
4
|
import { theme } from "./theme"
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
5
|
+
import { policy } from "./policy"
|
|
6
|
+
import { densityScale } from "./density"
|
|
7
|
+
import { Icon } from "./icon"
|
|
8
|
+
import type { StyleProps, TransitionProps } from "./types"
|
|
9
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
7
10
|
|
|
8
|
-
export interface CheckboxProps {
|
|
11
|
+
export interface CheckboxProps extends TransitionProps {
|
|
9
12
|
// Controlled checked state. If omitted, the checkbox is uncontrolled.
|
|
10
13
|
checked?: boolean
|
|
11
14
|
defaultChecked?: boolean
|
|
@@ -43,15 +46,20 @@ export function Checkbox(props: CheckboxProps) {
|
|
|
43
46
|
borderColor: theme.color.border,
|
|
44
47
|
borderWidth: theme.borderWidth.sm,
|
|
45
48
|
borderRadius: theme.radius.sm,
|
|
49
|
+
...theme.components.checkbox,
|
|
46
50
|
...props.style,
|
|
51
|
+
...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
|
|
47
52
|
})
|
|
48
53
|
|
|
49
54
|
return (
|
|
50
55
|
<view
|
|
56
|
+
transition={splitTransition(props.transition).root}
|
|
57
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
51
58
|
ref={press.ref}
|
|
52
59
|
repaintBoundary
|
|
53
60
|
width={size()}
|
|
54
61
|
height={size()}
|
|
62
|
+
position="relative"
|
|
55
63
|
{...props.layout}
|
|
56
64
|
x={style().x}
|
|
57
65
|
y={style().y}
|
|
@@ -59,18 +67,28 @@ export function Checkbox(props: CheckboxProps) {
|
|
|
59
67
|
rotate={style().rotate}
|
|
60
68
|
opacity={style().opacity}
|
|
61
69
|
{...press.handlers}
|
|
70
|
+
focusable={!props.disabled}
|
|
62
71
|
pointerEvents={props.disabled ? "none" : undefined}
|
|
63
72
|
>
|
|
64
73
|
<d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
|
|
65
74
|
<Show when={checked()}>
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
<Show
|
|
76
|
+
when={theme.icons.check}
|
|
77
|
+
fallback={
|
|
78
|
+
<d-path
|
|
79
|
+
d={check()}
|
|
80
|
+
drawStyle="stroke"
|
|
81
|
+
color={theme.color.onPrimary}
|
|
82
|
+
strokeWidth={2}
|
|
83
|
+
strokeCap="round"
|
|
84
|
+
strokeJoin="round"
|
|
85
|
+
/>
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
<view position="absolute" top={0} bottom={0} left={0} right={0} alignItems="center" justifyContent="center">
|
|
89
|
+
<Icon src={theme.icons.check!} size={Math.round(size() * 0.75)} color={theme.color.onPrimary} />
|
|
90
|
+
</view>
|
|
91
|
+
</Show>
|
|
74
92
|
</Show>
|
|
75
93
|
<Show when={(style().borderWidth ?? 0) > 0}>
|
|
76
94
|
<d-rect
|
package/src/context-menu.tsx
CHANGED
|
@@ -5,6 +5,8 @@ import { theme } from "./theme"
|
|
|
5
5
|
import { policy } from "./policy"
|
|
6
6
|
import { space } from "./spacing"
|
|
7
7
|
import { typeStyle } from "./typography"
|
|
8
|
+
import type { TransitionProps } from "./types"
|
|
9
|
+
import { splitTransition, transitionEndFor } from "./types"
|
|
8
10
|
|
|
9
11
|
export interface ContextMenuItem {
|
|
10
12
|
label: string
|
|
@@ -12,7 +14,7 @@ export interface ContextMenuItem {
|
|
|
12
14
|
disabled?: boolean
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export interface ContextMenuProps {
|
|
17
|
+
export interface ContextMenuProps extends TransitionProps {
|
|
16
18
|
items: ContextMenuItem[]
|
|
17
19
|
// The content the menu attaches to.
|
|
18
20
|
children?: any
|
|
@@ -23,8 +25,7 @@ const LONG_PRESS_MS = 500
|
|
|
23
25
|
// Finger travel (window px) that cancels a pending long-press.
|
|
24
26
|
const MOVE_SLOP = 8
|
|
25
27
|
// Minimum distance kept between the menu and the window edges.
|
|
26
|
-
|
|
27
|
-
const MIN_WIDTH = 120
|
|
28
|
+
let margin = () => theme.spacing.sm
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Secondary actions on the wrapped content. The opening gesture follows the
|
|
@@ -91,9 +92,11 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
91
92
|
>
|
|
92
93
|
<d-rect
|
|
93
94
|
color={
|
|
94
|
-
press.pressed()
|
|
95
|
-
? theme.color.
|
|
96
|
-
: "
|
|
95
|
+
press.pressed()
|
|
96
|
+
? theme.color.overlayPressed
|
|
97
|
+
: press.hovered() && policy.interaction !== "touch"
|
|
98
|
+
? theme.color.overlayHover
|
|
99
|
+
: "transparent"
|
|
97
100
|
}
|
|
98
101
|
/>
|
|
99
102
|
<text {...bodyText(p.item.disabled ? theme.color.textMuted : theme.color.text)}>{p.item.label}</text>
|
|
@@ -111,9 +114,9 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
111
114
|
let b = menu && getBoundingBox(menu)
|
|
112
115
|
if (!b) return
|
|
113
116
|
let p = point()
|
|
114
|
-
let x = Math.round(Math.min(Math.max(p.x,
|
|
117
|
+
let x = Math.round(Math.min(Math.max(p.x, margin()), env.windowSize.width - b.width - margin()))
|
|
115
118
|
let y = Math.round(
|
|
116
|
-
Math.max(p.y + b.height > env.windowSize.height -
|
|
119
|
+
Math.max(p.y + b.height > env.windowSize.height - margin() ? p.y - b.height : p.y, margin()),
|
|
117
120
|
)
|
|
118
121
|
let cur = pos()
|
|
119
122
|
if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
|
|
@@ -128,7 +131,7 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
128
131
|
left={0}
|
|
129
132
|
x={pos()?.x ?? -10000}
|
|
130
133
|
y={pos()?.y ?? 0}
|
|
131
|
-
minWidth={
|
|
134
|
+
minWidth={theme.size.menuMinWidth}
|
|
132
135
|
flexDirection="column"
|
|
133
136
|
paddingTop={theme.spacing.sm}
|
|
134
137
|
paddingBottom={theme.spacing.sm}
|
|
@@ -175,6 +178,8 @@ export function ContextMenu(props: ContextMenuProps) {
|
|
|
175
178
|
|
|
176
179
|
return (
|
|
177
180
|
<view
|
|
181
|
+
transition={splitTransition(props.transition).root}
|
|
182
|
+
onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
|
|
178
183
|
onPointerDown={handleDown}
|
|
179
184
|
onPointerMove={handleMove}
|
|
180
185
|
onPointerUp={cancelHold}
|