@phreshos/react-ui 0.1.16 → 0.1.18
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/README.md +158 -8
- package/dist/appearance-provider.d.ts +6 -8
- package/dist/appearance-provider.js +34 -19
- package/dist/button.d.ts +11 -5
- package/dist/button.js +28 -36
- package/dist/checkbox.d.ts +11 -0
- package/dist/checkbox.js +10 -0
- package/dist/color.d.ts +33 -0
- package/dist/color.js +84 -5
- package/dist/control-surface.d.ts +20 -0
- package/dist/control-surface.js +11 -0
- package/dist/control.d.ts +55 -0
- package/dist/control.js +87 -0
- package/dist/document-scrollbars.js +1 -1
- package/dist/field-style.d.ts +3 -0
- package/dist/field-style.js +11 -0
- package/dist/flex.js +2 -2
- package/dist/grid.js +2 -2
- package/dist/input.d.ts +4 -0
- package/dist/input.js +11 -0
- package/dist/main.d.ts +13 -3
- package/dist/main.js +9 -0
- package/dist/material-options.d.ts +24 -0
- package/dist/material-options.js +22 -0
- package/dist/material-paint.d.ts +11 -0
- package/dist/material-paint.js +76 -0
- package/dist/motion-style.d.ts +21 -0
- package/dist/motion-style.js +45 -0
- package/dist/panel.d.ts +1 -1
- package/dist/radio.d.ts +14 -0
- package/dist/radio.js +17 -0
- package/dist/select.d.ts +17 -0
- package/dist/select.js +28 -0
- package/dist/slider.d.ts +7 -0
- package/dist/slider.js +36 -0
- package/dist/surface-edge.d.ts +10 -0
- package/dist/surface-edge.js +59 -0
- package/dist/surface.d.ts +23 -44
- package/dist/surface.js +47 -86
- package/dist/switch.d.ts +10 -0
- package/dist/switch.js +10 -0
- package/dist/text-control.d.ts +9 -0
- package/dist/text-control.js +0 -0
- package/dist/textarea.d.ts +6 -0
- package/dist/textarea.js +17 -0
- package/dist/toggle-indicator.d.ts +16 -0
- package/dist/toggle-indicator.js +48 -0
- package/package.json +3 -3
- package/dist/surface-material.d.ts +0 -14
- package/dist/surface-material.js +0 -128
package/README.md
CHANGED
|
@@ -25,22 +25,90 @@ applications own composition.
|
|
|
25
25
|
| Bun | `bun add @phreshos/react-ui` |
|
|
26
26
|
| Yarn | `yarn add @phreshos/react-ui` |
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Core is a built-in runtime dependency. React and React DOM remain peer
|
|
29
|
+
dependencies because the application and React UI must share one React
|
|
30
|
+
runtime.
|
|
29
31
|
|
|
30
32
|
```tsx
|
|
31
|
-
import {
|
|
32
|
-
import { AppearanceProvider, Button, Surface } from "@phreshos/react-ui"
|
|
33
|
+
import { Button, Surface } from "@phreshos/react-ui"
|
|
33
34
|
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
</Surface>
|
|
38
|
-
</AppearanceProvider>
|
|
35
|
+
<Surface>
|
|
36
|
+
<Button>Continue</Button>
|
|
37
|
+
</Surface>
|
|
39
38
|
```
|
|
40
39
|
|
|
40
|
+
Without a provider, components use Core's `defaultAppearance` and reactively
|
|
41
|
+
follow the browser color scheme. `AppearanceProvider` independently overrides
|
|
42
|
+
either value for a subtree; omitted values inherit from the nearest provider.
|
|
43
|
+
React UI also exports that same canonical `defaultAppearance` value for callers
|
|
44
|
+
that need it explicitly.
|
|
41
45
|
See [Appearance](https://docs.phreshos.com/system/appearance) for the contract
|
|
42
46
|
interpreted by the provider and components.
|
|
43
47
|
|
|
48
|
+
```tsx
|
|
49
|
+
import { AppearanceProvider, Button } from "@phreshos/react-ui"
|
|
50
|
+
|
|
51
|
+
<AppearanceProvider theme="dark">
|
|
52
|
+
<Button>Dark subtree</Button>
|
|
53
|
+
</AppearanceProvider>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Switch`, and `Radio`
|
|
57
|
+
use the shared Surface implementation while retaining their native behavior. Color and material
|
|
58
|
+
are independent inputs. `color` accepts an Appearance color and resting level such
|
|
59
|
+
as `background:base` or `primary:soft`, or a direct CSS color. Surface and neutral
|
|
60
|
+
surface hosts default to `background:base`; a component may select a semantic
|
|
61
|
+
default required by its own behavior, such as `primary:base` for selection.
|
|
62
|
+
Hover and press derive shades of that fill. The resting fill chooses the
|
|
63
|
+
higher-contrast text from Appearance's background and foreground once; interaction
|
|
64
|
+
shades keep that choice.
|
|
65
|
+
`size` accepts `xsmall`, `small`, `medium` (default), `large`, or `xlarge`;
|
|
66
|
+
spacing and radius follow Appearance. `disabled` prevents activation and focus;
|
|
67
|
+
`pending` prevents activation while retaining focus.
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
<Button>Cancel</Button>
|
|
71
|
+
<Button color="primary:base" onPress={save}>Save</Button>
|
|
72
|
+
<Button color="danger:soft" size="small">Delete</Button>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`Surface` is the material-owning element. It renders a `div` by default, while
|
|
76
|
+
`as` selects another React element and preserves that element's native properties
|
|
77
|
+
and ref type. Surface owns its paint, opacity, frost, refraction, grain, edge,
|
|
78
|
+
radius, and clipping requirements. It never creates or consumes a shadow; shadow
|
|
79
|
+
remains an independent visual concern. `radius` accepts a size level, a number in
|
|
80
|
+
pixels, or a CSS radius and defaults to `medium`.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
<Surface color="background:soft" radius="large">Derived values</Surface>
|
|
84
|
+
<Surface as="button" type="button" color="#345678" radius={18}>Action</Surface>
|
|
85
|
+
<Surface as={Grid} columns={3} gap="medium">Grid content</Surface>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`as` can also select an outside React component. A valid Surface host preserves
|
|
89
|
+
the `style` and `children` it receives on one host element and forwards its ref
|
|
90
|
+
to that same element. This lets layout components carry the material without a
|
|
91
|
+
wrapper. The host retains ownership of its own behavior and layout properties;
|
|
92
|
+
Surface retains ownership of material, edge, radius, and required geometry.
|
|
93
|
+
|
|
94
|
+
`MaterialOptions` defines `opacity`, `backdrop`, `grain`, `grainAmount`,
|
|
95
|
+
`distortion`, and `saturation`. Surface accepts these properties directly because
|
|
96
|
+
it is the material-owning element; it does not accept a nested `material` prop.
|
|
97
|
+
Color remains a separate Surface property. Omitted material values follow
|
|
98
|
+
`appearance.material`. Effect options accept a scale level or a direct number;
|
|
99
|
+
opacity affects Surface paint only, never its content. The material edge is part
|
|
100
|
+
of the same Surface rather than a second public entity.
|
|
101
|
+
|
|
102
|
+
Surface-based controls expose the same separate `color` and `material` props.
|
|
103
|
+
For text fields and Select, `material` targets the field or trigger; for Checkbox,
|
|
104
|
+
Switch, and Radio, it targets the indicator. RadioGroup supplies material defaults
|
|
105
|
+
to its options, and a Radio can override them.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
<Button color="primary:base" material={{ opacity: 0.6, backdrop: 0 }}>Save</Button>
|
|
109
|
+
<Input label="Name" radius="large" material={{ grain: "small" }} />
|
|
110
|
+
```
|
|
111
|
+
|
|
44
112
|
`Panel` composes an outer `Surface`, an optional header, and an inset content
|
|
45
113
|
`Surface`. Both materials retain Surface defaults; the content inset follows
|
|
46
114
|
Appearance spacing. Positioning, modality, and lifecycle belong to the caller.
|
|
@@ -56,6 +124,88 @@ import { Panel } from "@phreshos/react-ui"
|
|
|
56
124
|
Native properties and the forwarded ref target the outer Surface.
|
|
57
125
|
`contentProps` targets the inner Surface; `children` supplies its content.
|
|
58
126
|
|
|
127
|
+
## Inputs
|
|
128
|
+
|
|
129
|
+
Every input uses Appearance colors and the same five `size` levels as Button.
|
|
130
|
+
`color` selects the fill independently from material. Input, Textarea, Select,
|
|
131
|
+
and Button use `background:base` when it is omitted; selection indicators use
|
|
132
|
+
`primary:base`. Invalid
|
|
133
|
+
fields use danger instead. Text fields share Surface's glass edge. Interaction shades
|
|
134
|
+
derive from the base color's lightness, and text or selection marks use whichever
|
|
135
|
+
Appearance background or foreground has higher contrast against the base fill.
|
|
136
|
+
That text or mark color stays unchanged across interaction shades.
|
|
137
|
+
These are component-owned derivations, not additional Appearance settings.
|
|
138
|
+
Theme names never imply particular colors. No component requires a Client or
|
|
139
|
+
Server SDK.
|
|
140
|
+
|
|
141
|
+
Fields distinguish hover, pointer focus, keyboard focus, and invalid state.
|
|
142
|
+
Shared CSS transitions use a fixed 120ms ease-out timing for colors and corner
|
|
143
|
+
radius. The material fill and opacity values transition on the painted layers, not on the
|
|
144
|
+
Surface host. Select menus combine a small placement-aware slide with an overlay
|
|
145
|
+
opacity fade for entry and exit, using React Aria's animation lifecycle. Reduced-motion preferences make
|
|
146
|
+
these changes immediate. No Appearance configuration is added for motion yet.
|
|
147
|
+
Blur, distortion, geometry, and Surface host opacity are not transitioned; gradients
|
|
148
|
+
and structurally removed effects change directly rather than adding extra
|
|
149
|
+
layers or keeping disabled effects alive.
|
|
150
|
+
|
|
151
|
+
Motion animates toggle presses, selection marks, switch travel, the Select
|
|
152
|
+
chevron, and Slider thumb feedback. Reduced-motion preferences remove spatial
|
|
153
|
+
feedback and make state transitions immediate. Slider values and native input
|
|
154
|
+
behavior are never delayed by visual animation.
|
|
155
|
+
|
|
156
|
+
| Component | Value contract | Purpose |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| `Input` | `value`, `defaultValue`, `onChange(string)` | Single-line text; supports text, email, password, search, URL, and telephone types |
|
|
159
|
+
| `Textarea` | `value`, `defaultValue`, `onChange(string)` | Multiline text; four rows by default, vertically resizable |
|
|
160
|
+
| `Checkbox` | `checked`, `defaultChecked`, `onChange(boolean)` | Independent selection; `indeterminate` represents a mixed state |
|
|
161
|
+
| `Switch` | `checked`, `defaultChecked`, `onChange(boolean)` | An on/off setting |
|
|
162
|
+
| `RadioGroup` / `Radio` | Group `value`, `defaultValue`, `onChange(string)`; Radio `value` | One exclusive choice; vertical by default, optionally horizontal |
|
|
163
|
+
| `Select` | `value: string \| null`, `defaultValue`, `onChange(string \| null)` | One choice from `options: { value, label, disabled? }[]` |
|
|
164
|
+
| `Slider` | `value`, `defaultValue`, `onChange(number)` | One numeric value; `minValue`, `maxValue`, `step`, and `onChangeEnd`; horizontal by default |
|
|
165
|
+
|
|
166
|
+
Use `label` for visible labels, or `aria-label` / `aria-labelledby` for an
|
|
167
|
+
accessible name without visible text. `description` provides associated help.
|
|
168
|
+
`disabled` prevents interaction and removes the control from keyboard focus.
|
|
169
|
+
`name` participates in native form submission. Controlled values remain owned
|
|
170
|
+
by the caller; omit them and use defaults for internal state and form reset.
|
|
171
|
+
|
|
172
|
+
Text fields, Checkbox, Switch, and RadioGroup also support `readOnly`: the
|
|
173
|
+
value cannot change, but the control remains focusable. These fields and
|
|
174
|
+
Select support `required`, `invalid`, `errorMessage`, and React Aria's native
|
|
175
|
+
or ARIA validation behavior. A Radio's selection and validation belong to its
|
|
176
|
+
group. Slider represents a bounded number rather than a required text or
|
|
177
|
+
choice field, and has no read-only or validation-error mode. Select has no
|
|
178
|
+
read-only mode; disable it when selection must be unavailable.
|
|
179
|
+
|
|
180
|
+
`Input`, `Textarea`, and `Select` accept `radius`. Other inputs retain their
|
|
181
|
+
intrinsic indicator shapes. `className` and `style` address the field's root;
|
|
182
|
+
default visual properties remain component-owned. Input and Textarea refs
|
|
183
|
+
target their native text controls; other refs target the root div. Checkbox,
|
|
184
|
+
Switch, and Radio additionally accept `inputRef` for their native input.
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
import { Input, Textarea, Checkbox, Radio, RadioGroup, Switch, Select, Slider } from "@phreshos/react-ui"
|
|
188
|
+
|
|
189
|
+
<Input label="Name" name="name" required />
|
|
190
|
+
<Textarea label="Description" name="description" />
|
|
191
|
+
<Checkbox label="Remember this choice" name="remember" />
|
|
192
|
+
<Switch label="Notifications" name="notifications" defaultChecked />
|
|
193
|
+
<RadioGroup label="Layout" name="layout" defaultValue="grid">
|
|
194
|
+
<Radio label="Grid" value="grid" />
|
|
195
|
+
<Radio label="List" value="list" />
|
|
196
|
+
</RadioGroup>
|
|
197
|
+
<Select label="Sort" name="sort" options={[
|
|
198
|
+
{ value: "name", label: "Name" },
|
|
199
|
+
{ value: "date", label: "Date" }
|
|
200
|
+
]} />
|
|
201
|
+
<Slider label="Volume" name="volume" defaultValue={50} minValue={0} maxValue={100} step={1} />
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
React Aria owns focus, keyboard, form, and selection behavior. Select's popup
|
|
205
|
+
uses Surface defaults. Toggle indicators use Motion internally and respect
|
|
206
|
+
reduced-motion preferences. The preview Program demonstrates each input's
|
|
207
|
+
sizes, colors, state, and interaction without overriding its visual defaults.
|
|
208
|
+
|
|
59
209
|
## Development
|
|
60
210
|
|
|
61
211
|
```sh
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import type
|
|
3
|
-
/**
|
|
2
|
+
import { type Appearance, type Theme, type ThemedValue } from "@phreshos/core";
|
|
3
|
+
/** Optionally overrides unresolved Appearance and effective Theme for a React subtree. */
|
|
4
4
|
export declare function AppearanceProvider({ appearance, children, theme }: AppearanceProviderProps): import("react").JSX.Element;
|
|
5
|
-
/** Returns the
|
|
5
|
+
/** Returns the nearest unresolved Appearance, or Core's complete default. */
|
|
6
6
|
export declare function useAppearance(): Appearance;
|
|
7
|
-
/** Returns the
|
|
7
|
+
/** Returns the nearest explicit Theme, or reactively follows the browser. */
|
|
8
8
|
export declare function useTheme(): Theme;
|
|
9
9
|
/** Resolves one themed value only where it is consumed. */
|
|
10
10
|
export declare function useResolveTheme<Value, DarkValue extends Value = never>(value: ThemedValue<Value, DarkValue>): Exclude<Value, undefined>;
|
|
11
|
-
/** Internal optional read for primitives that also accept direct values. */
|
|
12
|
-
export declare function useAppearanceIfAvailable(): Appearance | null;
|
|
13
11
|
export interface AppearanceProviderProps {
|
|
14
|
-
readonly appearance
|
|
12
|
+
readonly appearance?: Appearance;
|
|
15
13
|
readonly children: ReactNode;
|
|
16
|
-
readonly theme
|
|
14
|
+
readonly theme?: Theme;
|
|
17
15
|
}
|
|
@@ -1,34 +1,49 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext } from "react";
|
|
2
|
+
import { createContext, useContext, useSyncExternalStore } from "react";
|
|
3
|
+
import { defaultAppearance } from "@phreshos/core";
|
|
3
4
|
import DocumentScrollbars from "./document-scrollbars.js";
|
|
4
|
-
|
|
5
|
-
const AppearanceContext = createContext(
|
|
6
|
-
const ThemeContext = createContext(
|
|
7
|
-
/**
|
|
5
|
+
import MotionStyle from "./motion-style.js";
|
|
6
|
+
const AppearanceContext = createContext(defaultAppearance);
|
|
7
|
+
const ThemeContext = createContext(null);
|
|
8
|
+
/** Optionally overrides unresolved Appearance and effective Theme for a React subtree. */
|
|
8
9
|
export function AppearanceProvider({ appearance, children, theme }) {
|
|
9
|
-
|
|
10
|
+
const inheritedAppearance = useAppearance();
|
|
11
|
+
const inheritedTheme = useTheme();
|
|
12
|
+
const resolvedAppearance = appearance ?? inheritedAppearance;
|
|
13
|
+
const resolvedTheme = theme ?? inheritedTheme;
|
|
14
|
+
return _jsx(AppearanceContext.Provider, { value: resolvedAppearance, children: _jsxs(ThemeContext.Provider, { value: resolvedTheme, children: [_jsx(MotionStyle, {}), _jsx(DocumentScrollbars, { appearance: resolvedAppearance, theme: resolvedTheme }), children] }) });
|
|
10
15
|
}
|
|
11
|
-
/** Returns the
|
|
16
|
+
/** Returns the nearest unresolved Appearance, or Core's complete default. */
|
|
12
17
|
export function useAppearance() {
|
|
13
|
-
|
|
14
|
-
if (appearance === missing)
|
|
15
|
-
throw new Error("useAppearance() requires an AppearanceProvider");
|
|
16
|
-
return appearance;
|
|
18
|
+
return useContext(AppearanceContext);
|
|
17
19
|
}
|
|
18
|
-
/** Returns the
|
|
20
|
+
/** Returns the nearest explicit Theme, or reactively follows the browser. */
|
|
19
21
|
export function useTheme() {
|
|
20
22
|
const theme = useContext(ThemeContext);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return theme;
|
|
23
|
+
const browserTheme = useBrowserTheme();
|
|
24
|
+
return theme ?? browserTheme;
|
|
24
25
|
}
|
|
25
26
|
/** Resolves one themed value only where it is consumed. */
|
|
26
27
|
export function useResolveTheme(value) {
|
|
27
28
|
const theme = useTheme();
|
|
28
29
|
return (theme === "dark" && "dark" in value ? value.dark : value.light);
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const appearance = useContext(AppearanceContext);
|
|
33
|
-
return appearance === missing ? null : appearance;
|
|
31
|
+
function useBrowserTheme() {
|
|
32
|
+
return useSyncExternalStore(subscribeBrowserTheme, browserTheme, serverTheme);
|
|
34
33
|
}
|
|
34
|
+
function subscribeBrowserTheme(change) {
|
|
35
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function")
|
|
36
|
+
return () => undefined;
|
|
37
|
+
const preference = window.matchMedia(darkThemeQuery);
|
|
38
|
+
preference.addEventListener("change", change);
|
|
39
|
+
return () => preference.removeEventListener("change", change);
|
|
40
|
+
}
|
|
41
|
+
function browserTheme() {
|
|
42
|
+
return typeof window !== "undefined"
|
|
43
|
+
&& typeof window.matchMedia === "function"
|
|
44
|
+
&& window.matchMedia(darkThemeQuery).matches
|
|
45
|
+
? "dark"
|
|
46
|
+
: "light";
|
|
47
|
+
}
|
|
48
|
+
function serverTheme() { return "light"; }
|
|
49
|
+
const darkThemeQuery = "(prefers-color-scheme: dark)";
|
package/dist/button.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { CSSProperties, ReactNode } from "react";
|
|
2
2
|
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import type { ScaleLevel } from "./scale.js";
|
|
4
|
+
import type { RadiusProps } from "./radius.js";
|
|
5
|
+
import { type ControlColor } from "./control.js";
|
|
6
|
+
import type { MaterialOverrides } from "./material-options.js";
|
|
7
|
+
type NativeButtonProps = Omit<AriaButtonProps, "children" | "className" | "color" | "isDisabled" | "isPending" | "onClick" | "onPress" | "style">;
|
|
8
|
+
/** A semantic color from Appearance; omission uses background and foreground. */
|
|
9
|
+
export type ButtonColor = ControlColor;
|
|
6
10
|
/** Properties accepted by the shared interactive button. */
|
|
7
|
-
export interface ButtonProps extends NativeButtonProps, RadiusProps {
|
|
11
|
+
export interface ButtonProps extends NativeButtonProps, RadiusProps, MaterialOverrides {
|
|
8
12
|
/** Visible Button content. */
|
|
9
13
|
readonly children?: ReactNode;
|
|
14
|
+
/** Base color for the material. Omission keeps the Button neutral. */
|
|
15
|
+
readonly color?: ButtonColor;
|
|
10
16
|
/** Native class name applied without replacing the component contract. */
|
|
11
17
|
readonly className?: string;
|
|
12
18
|
/** Prevents focus and activation. */
|
|
@@ -17,7 +23,7 @@ export interface ButtonProps extends NativeButtonProps, RadiusProps {
|
|
|
17
23
|
readonly onPress?: () => void;
|
|
18
24
|
/** Derives the Button's spacing from Appearance's concrete default. */
|
|
19
25
|
readonly size?: ScaleLevel;
|
|
20
|
-
/**
|
|
26
|
+
/** Native styles applied after Button defaults. */
|
|
21
27
|
readonly style?: CSSProperties;
|
|
22
28
|
}
|
|
23
29
|
/** An Appearance-aware action with normalized pointer and keyboard behavior. */
|
package/dist/button.js
CHANGED
|
@@ -1,34 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { forwardRef } from "react";
|
|
3
3
|
import { Button as AriaButton } from "react-aria-components";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { controlFontSizes, useControlTheme } from "./control.js";
|
|
5
|
+
import { SurfaceButton } from "./control-surface.js";
|
|
6
|
+
import { visualTransition } from "./motion-style.js";
|
|
7
7
|
/** An Appearance-aware action with normalized pointer and keyboard behavior. */
|
|
8
|
-
export const Button = forwardRef(function Button({ children, disabled = false, pending = false, onPress, radius = "medium", size = "medium", style, type = "button", ...properties }, ref) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const foreground = useResolveTheme(appearance.foreground);
|
|
13
|
-
return _jsx(AriaButton, { ...properties, ref: ref, type: type, isDisabled: disabled, isPending: pending, onPress: onPress, style: ({ isFocusVisible, isHovered, isPressed }) => buttonStyle({
|
|
14
|
-
borderRadius,
|
|
8
|
+
export const Button = forwardRef(function Button({ children, color, disabled = false, pending = false, onPress, radius = "medium", size = "medium", style, material, type = "button", ...properties }, ref) {
|
|
9
|
+
const theme = useControlTheme({ color, radius, size });
|
|
10
|
+
return _jsx(AriaButton, { ...properties, ref: ref, type: type, isDisabled: disabled, isPending: pending, onPress: onPress, render: (native, state) => _jsx(SurfaceButton, { native: native, material: material, paint: buttonPaint(theme, !disabled && !pending, state.isHovered, state.isPressed) }), style: ({ isFocusVisible, isHovered, isPressed }) => buttonStyle({
|
|
11
|
+
theme,
|
|
15
12
|
disabled,
|
|
16
13
|
isFocusVisible,
|
|
17
14
|
isHovered,
|
|
18
15
|
isPressed,
|
|
19
16
|
pending,
|
|
20
17
|
size,
|
|
21
|
-
spacing,
|
|
22
|
-
foreground,
|
|
23
18
|
style
|
|
24
19
|
}), children: children });
|
|
25
20
|
});
|
|
26
|
-
function buttonStyle({
|
|
27
|
-
const fontSize =
|
|
28
|
-
const
|
|
21
|
+
function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pending, size, style }) {
|
|
22
|
+
const fontSize = controlFontSizes[size];
|
|
23
|
+
const { spacing, foreground } = theme;
|
|
24
|
+
const height = Math.max(24, 24 + spacing);
|
|
25
|
+
const interactive = !disabled && !pending;
|
|
26
|
+
const paint = buttonPaint(theme, interactive, isHovered, isPressed);
|
|
29
27
|
return {
|
|
30
|
-
...
|
|
28
|
+
...visualTransition,
|
|
31
29
|
appearance: "none",
|
|
30
|
+
boxSizing: "border-box",
|
|
32
31
|
display: "inline-grid",
|
|
33
32
|
gridAutoFlow: "column",
|
|
34
33
|
gridAutoColumns: "max-content",
|
|
@@ -37,34 +36,27 @@ function buttonStyle({ borderRadius, disabled, isFocusVisible, isHovered, isPres
|
|
|
37
36
|
minWidth: 0,
|
|
38
37
|
height,
|
|
39
38
|
paddingBlock: 0,
|
|
40
|
-
paddingInline: Math.max(8, spacing
|
|
39
|
+
paddingInline: Math.max(8, spacing),
|
|
41
40
|
gap: Math.max(4, spacing / 2),
|
|
42
|
-
border: "
|
|
43
|
-
borderRadius,
|
|
44
|
-
outline: "none",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
boxShadow: isFocusVisible
|
|
48
|
-
? "0 0 0 2px rgba(255, 255, 255, 0.85), inset 0 1px 0 rgba(255, 255, 255, 0.8)"
|
|
49
|
-
: "inset 0 1px 0 rgba(255, 255, 255, 0.8)",
|
|
41
|
+
border: "none",
|
|
42
|
+
borderRadius: theme.radius,
|
|
43
|
+
outline: isFocusVisible ? `2px solid ${foreground}` : "none",
|
|
44
|
+
outlineOffset: 2,
|
|
45
|
+
...paint,
|
|
50
46
|
opacity: disabled ? 0.46 : pending ? 0.68 : 1,
|
|
51
|
-
transform: isPressed ? "scale(0.95)" : "scale(1)",
|
|
52
|
-
transition: "background-color 100ms ease, box-shadow 100ms ease, opacity 100ms ease, transform 100ms ease",
|
|
53
47
|
cursor: disabled ? "not-allowed" : pending ? "progress" : "pointer",
|
|
54
48
|
font: "inherit",
|
|
55
49
|
fontSize,
|
|
56
|
-
fontWeight:
|
|
50
|
+
fontWeight: 550,
|
|
57
51
|
lineHeight: 1,
|
|
58
52
|
textAlign: "center",
|
|
59
53
|
textDecoration: "none",
|
|
60
54
|
userSelect: "none",
|
|
61
|
-
WebkitTapHighlightColor: "transparent"
|
|
55
|
+
WebkitTapHighlightColor: "transparent",
|
|
56
|
+
...style
|
|
62
57
|
};
|
|
63
58
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
large: 13,
|
|
69
|
-
xlarge: 14
|
|
70
|
-
});
|
|
59
|
+
function buttonPaint(theme, interactive, hovered, pressed) {
|
|
60
|
+
const paints = theme.colored ? theme.paints.palette : theme.paints.neutral;
|
|
61
|
+
return interactive && pressed ? paints.pressed : interactive && hovered ? paints.hover : paints.rest;
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CheckboxFieldProps } from "react-aria-components";
|
|
2
|
+
import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
|
|
3
|
+
import type { MaterialOverrides } from "./material-options.js";
|
|
4
|
+
export interface CheckboxProps extends Omit<CheckboxFieldProps, ControlOverrides | "isReadOnly" | "isSelected" | "defaultSelected" | "isIndeterminate">, ControlProps, FieldProps, MaterialOverrides {
|
|
5
|
+
readonly checked?: boolean;
|
|
6
|
+
readonly defaultChecked?: boolean;
|
|
7
|
+
readonly indeterminate?: boolean;
|
|
8
|
+
readonly readOnly?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** An independent boolean field with optional mixed-state presentation. */
|
|
11
|
+
export declare const Checkbox: import("react").ForwardRefExoticComponent<CheckboxProps & import("react").RefAttributes<HTMLDivElement>>;
|
package/dist/checkbox.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import { CheckboxField, CheckboxButton } from "react-aria-components";
|
|
4
|
+
import { FieldFeedback, fieldStyle, useControlTheme } from "./control.js";
|
|
5
|
+
import { ToggleIndicator, toggleStyle } from "./toggle-indicator.js";
|
|
6
|
+
/** An independent boolean field with optional mixed-state presentation. */
|
|
7
|
+
export const Checkbox = forwardRef(function Checkbox({ label, description, errorMessage, disabled, required, invalid, readOnly, checked, defaultChecked, indeterminate, size, color, style, material, ...properties }, ref) {
|
|
8
|
+
const theme = useControlTheme({ size, color });
|
|
9
|
+
return _jsxs(CheckboxField, { ...properties, ref: ref, isDisabled: disabled, isRequired: required, isInvalid: invalid, isReadOnly: readOnly, isSelected: checked, defaultSelected: defaultChecked, isIndeterminate: indeterminate, style: state => fieldStyle(theme, state.isDisabled, style), children: [_jsx(CheckboxButton, { style: state => toggleStyle(theme, state.isDisabled, state.isReadOnly), children: state => _jsxs(_Fragment, { children: [_jsx(ToggleIndicator, { kind: "checkbox", material: material, theme: theme, selected: state.isSelected, indeterminate: state.isIndeterminate, focused: state.isFocusVisible, invalid: state.isInvalid, hovered: !state.isDisabled && !state.isReadOnly && state.isHovered, pressed: !state.isDisabled && !state.isReadOnly && state.isPressed }), label] }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] });
|
|
10
|
+
});
|
package/dist/color.d.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
+
import type { AppearanceColor } from "@phreshos/core";
|
|
1
2
|
/** A visual treatment derived from one concrete CSS color. */
|
|
2
3
|
export type ColorLevel = "subtle" | "soft" | "base" | "strong" | "intense";
|
|
3
4
|
/** Every visual treatment derived from one concrete CSS color. */
|
|
4
5
|
export type ColorScale = Readonly<Record<ColorLevel, string>>;
|
|
6
|
+
/** One Appearance color and resting level, or a direct CSS color. */
|
|
7
|
+
export type Color = `${AppearanceColor}:${ColorLevel}` | (string & {});
|
|
8
|
+
export declare const defaultColor = "background:base";
|
|
9
|
+
/** Distinguishes derived color levels from direct CSS colors. */
|
|
10
|
+
export declare function isColorLevel(value: string | undefined): value is ColorLevel;
|
|
5
11
|
/** Derives visual treatments while preserving the concrete value at `base`. */
|
|
6
12
|
export declare function color(value: string): ColorScale;
|
|
13
|
+
/** Resolves the same named treatment for solid paint and contrast calculations. */
|
|
14
|
+
export declare function resolveColorLevel(value: string, level: ColorLevel): string;
|
|
7
15
|
/** Returns the complete visual treatments derived from one concrete color. */
|
|
8
16
|
export declare function useColor(value: string): ColorScale;
|
|
17
|
+
/** Resolves a semantic Appearance color or preserves a direct CSS color. */
|
|
18
|
+
export declare function useResolveColor(value?: Color): string;
|
|
19
|
+
/** Resolves a color to concrete opaque paint for solid interactive states. */
|
|
20
|
+
export declare function useResolveSolidColor(value: Color): string;
|
|
9
21
|
/** Applies material opacity without restricting the source CSS color syntax. */
|
|
10
22
|
export declare function colorOpacity(value: string, opacity: number): string;
|
|
11
23
|
/** Reads perceptual lightness from a resolved CSS color. */
|
|
@@ -15,3 +27,24 @@ export declare function orderColors(first: string, second: string): {
|
|
|
15
27
|
lighter: string;
|
|
16
28
|
darker: string;
|
|
17
29
|
};
|
|
30
|
+
/** Solid controls use opaque, gamut-mapped colors for both paint and contrast. */
|
|
31
|
+
export declare function opaqueColor(value: string): string;
|
|
32
|
+
/** Changes only perceptual lightness; theme names never determine direction. */
|
|
33
|
+
export declare function colorShade(value: string, amount: number): string;
|
|
34
|
+
/** Chooses only from the two Appearance colors, against the actual painted fill. */
|
|
35
|
+
export declare function onColor(fill: string, background: string, foreground: string): string;
|
|
36
|
+
/** Solid controls start at base; interaction shades preserve its text choice. */
|
|
37
|
+
export declare function solidColors(base: string, background: string, foreground: string): {
|
|
38
|
+
rest: {
|
|
39
|
+
background: string;
|
|
40
|
+
color: string;
|
|
41
|
+
};
|
|
42
|
+
hover: {
|
|
43
|
+
background: string;
|
|
44
|
+
color: string;
|
|
45
|
+
};
|
|
46
|
+
pressed: {
|
|
47
|
+
background: string;
|
|
48
|
+
color: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
package/dist/color.js
CHANGED
|
@@ -1,22 +1,59 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
|
-
import { ColorSpace, parse, sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65 } from "colorjs.io/fn";
|
|
2
|
+
import { ColorSpace, mix, parse, serialize, to, toGamut, contrastWCAG21, sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65 } from "colorjs.io/fn";
|
|
3
|
+
import { useAppearance, useResolveTheme } from "./appearance-provider.js";
|
|
3
4
|
// Register the CSS color spaces, without bundling unrelated color-model APIs.
|
|
4
5
|
for (const space of [sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65])
|
|
5
6
|
ColorSpace.register(space);
|
|
7
|
+
export const defaultColor = "background:base";
|
|
8
|
+
const treatments = {
|
|
9
|
+
subtle: { weight: 25, target: "white" },
|
|
10
|
+
soft: { weight: 60, target: "white" },
|
|
11
|
+
strong: { weight: 82, target: "black" },
|
|
12
|
+
intense: { weight: 68, target: "black" }
|
|
13
|
+
};
|
|
14
|
+
/** Distinguishes derived color levels from direct CSS colors. */
|
|
15
|
+
export function isColorLevel(value) {
|
|
16
|
+
return value === "subtle" || value === "soft" || value === "base" || value === "strong" || value === "intense";
|
|
17
|
+
}
|
|
6
18
|
/** Derives visual treatments while preserving the concrete value at `base`. */
|
|
7
19
|
export function color(value) {
|
|
20
|
+
const shade = (level) => {
|
|
21
|
+
const { weight, target } = treatments[level];
|
|
22
|
+
return `color-mix(in oklch, ${value} ${weight}%, ${target})`;
|
|
23
|
+
};
|
|
8
24
|
return Object.freeze({
|
|
9
|
-
subtle:
|
|
10
|
-
soft:
|
|
25
|
+
subtle: shade("subtle"),
|
|
26
|
+
soft: shade("soft"),
|
|
11
27
|
base: value,
|
|
12
|
-
strong:
|
|
13
|
-
intense:
|
|
28
|
+
strong: shade("strong"),
|
|
29
|
+
intense: shade("intense")
|
|
14
30
|
});
|
|
15
31
|
}
|
|
32
|
+
/** Resolves the same named treatment for solid paint and contrast calculations. */
|
|
33
|
+
export function resolveColorLevel(value, level) {
|
|
34
|
+
if (level === "base")
|
|
35
|
+
return opaqueColor(value);
|
|
36
|
+
const { weight, target } = treatments[level];
|
|
37
|
+
return opaqueColor(serialize(mix(opaqueColor(value), target, 1 - weight / 100, { space: "oklch" })));
|
|
38
|
+
}
|
|
16
39
|
/** Returns the complete visual treatments derived from one concrete color. */
|
|
17
40
|
export function useColor(value) {
|
|
18
41
|
return useMemo(() => color(value), [value]);
|
|
19
42
|
}
|
|
43
|
+
/** Resolves a semantic Appearance color or preserves a direct CSS color. */
|
|
44
|
+
export function useResolveColor(value = defaultColor) {
|
|
45
|
+
const appearance = useAppearance();
|
|
46
|
+
const semantic = parseSemanticColor(value);
|
|
47
|
+
const source = useResolveTheme(appearance.colors[semantic?.name ?? "background"]);
|
|
48
|
+
return semantic ? color(source)[semantic.level] : value;
|
|
49
|
+
}
|
|
50
|
+
/** Resolves a color to concrete opaque paint for solid interactive states. */
|
|
51
|
+
export function useResolveSolidColor(value) {
|
|
52
|
+
const appearance = useAppearance();
|
|
53
|
+
const semantic = parseSemanticColor(value);
|
|
54
|
+
const source = useResolveTheme(appearance.colors[semantic?.name ?? "background"]);
|
|
55
|
+
return semantic ? resolveColorLevel(source, semantic.level) : opaqueColor(value);
|
|
56
|
+
}
|
|
20
57
|
/** Applies material opacity without restricting the source CSS color syntax. */
|
|
21
58
|
export function colorOpacity(value, opacity) {
|
|
22
59
|
const percentage = Math.round(opacity * 10_000) / 100;
|
|
@@ -34,3 +71,45 @@ export function orderColors(first, second) {
|
|
|
34
71
|
? { lighter: first, darker: second }
|
|
35
72
|
: { lighter: second, darker: first };
|
|
36
73
|
}
|
|
74
|
+
/** Solid controls use opaque, gamut-mapped colors for both paint and contrast. */
|
|
75
|
+
export function opaqueColor(value) {
|
|
76
|
+
return serialize(toGamut(to({ ...parse(value), alpha: 1 }, "srgb")), { format: "rgb", precision: 6 });
|
|
77
|
+
}
|
|
78
|
+
/** Changes only perceptual lightness; theme names never determine direction. */
|
|
79
|
+
export function colorShade(value, amount) {
|
|
80
|
+
const result = to(value, "oklch");
|
|
81
|
+
const lightness = result.coords[0] ?? 0;
|
|
82
|
+
result.coords[0] = Math.max(0, Math.min(1, lightness + (lightness > 0.6 ? -amount : amount)));
|
|
83
|
+
return opaqueColor(serialize(result));
|
|
84
|
+
}
|
|
85
|
+
/** Chooses only from the two Appearance colors, against the actual painted fill. */
|
|
86
|
+
export function onColor(fill, background, foreground) {
|
|
87
|
+
const first = opaqueColor(background);
|
|
88
|
+
const second = opaqueColor(foreground);
|
|
89
|
+
return contrastWCAG21(fill, first) > contrastWCAG21(fill, second) ? first : second;
|
|
90
|
+
}
|
|
91
|
+
/** Solid controls start at base; interaction shades preserve its text choice. */
|
|
92
|
+
export function solidColors(base, background, foreground) {
|
|
93
|
+
const fill = resolveColorLevel(base, "base");
|
|
94
|
+
const color = onColor(fill, background, foreground);
|
|
95
|
+
const paint = (background) => ({ background, color });
|
|
96
|
+
return {
|
|
97
|
+
rest: paint(fill),
|
|
98
|
+
hover: paint(colorShade(fill, 0.045)),
|
|
99
|
+
pressed: paint(colorShade(fill, 0.085))
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function parseSemanticColor(value) {
|
|
103
|
+
const separator = value.indexOf(":");
|
|
104
|
+
if (separator < 0 || value.indexOf(":", separator + 1) >= 0)
|
|
105
|
+
return null;
|
|
106
|
+
const name = value.slice(0, separator);
|
|
107
|
+
const level = value.slice(separator + 1);
|
|
108
|
+
if (!isAppearanceColor(name) || !isColorLevel(level))
|
|
109
|
+
return null;
|
|
110
|
+
return { name, level };
|
|
111
|
+
}
|
|
112
|
+
function isAppearanceColor(value) {
|
|
113
|
+
return value === "background" || value === "foreground" || value === "primary" || value === "secondary"
|
|
114
|
+
|| value === "success" || value === "warning" || value === "danger" || value === "info";
|
|
115
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, CSSProperties, ReactNode } from "react";
|
|
2
|
+
import type { MaterialOptions } from "./material-options.js";
|
|
3
|
+
type Paint = Readonly<{
|
|
4
|
+
background: string;
|
|
5
|
+
color: string;
|
|
6
|
+
}>;
|
|
7
|
+
/** A native button uses Surface without changing its native contract. */
|
|
8
|
+
export declare function SurfaceButton({ native, paint, material: options }: {
|
|
9
|
+
readonly native: ComponentPropsWithRef<"button">;
|
|
10
|
+
readonly paint: Paint;
|
|
11
|
+
readonly material?: MaterialOptions;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
/** Void text controls use a material host while retaining native sizing. */
|
|
14
|
+
export declare function SurfaceField({ paint, radius, children, material: options }: Readonly<{
|
|
15
|
+
paint: Paint;
|
|
16
|
+
radius: CSSProperties["borderRadius"];
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
material?: MaterialOptions;
|
|
19
|
+
}>): import("react").JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Surface } from "./surface.js";
|
|
3
|
+
/** A native button uses Surface without changing its native contract. */
|
|
4
|
+
export function SurfaceButton({ native, paint, material: options }) {
|
|
5
|
+
const { ref, style, children, ...properties } = native;
|
|
6
|
+
return _jsx(Surface, { ...properties, ...options, as: "button", color: paint.background, ref: ref, style: { ...style, color: paint.color }, children: children });
|
|
7
|
+
}
|
|
8
|
+
/** Void text controls use a material host while retaining native sizing. */
|
|
9
|
+
export function SurfaceField({ paint, radius, children, material: options }) {
|
|
10
|
+
return _jsx(Surface, { ...options, as: "span", color: paint.background, radius: radius, style: { color: paint.color, display: "grid", minWidth: 0 }, children: children });
|
|
11
|
+
}
|