@phreshos/react-ui 0.1.21 → 0.1.23

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 CHANGED
@@ -38,27 +38,30 @@ import { Button, Surface } from "@phreshos/react-ui"
38
38
  ```
39
39
 
40
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.
41
+ follow the browser's complete visual preferences. `AppearanceProvider` can replace
42
+ Appearance or Preferences for a subtree; omitted values inherit from the nearest provider.
43
43
  React UI also exports that same canonical `defaultAppearance` value for callers
44
44
  that need it explicitly.
45
45
  See [Appearance](https://docs.phreshos.com/system/appearance) for the contract
46
46
  interpreted by the provider and components.
47
47
 
48
48
  ```tsx
49
- import { AppearanceProvider, Button } from "@phreshos/react-ui"
49
+ import { AppearanceProvider, Button, useBrowserPreferences } from "@phreshos/react-ui"
50
50
 
51
- <AppearanceProvider theme="dark">
52
- <Button>Dark subtree</Button>
53
- </AppearanceProvider>
51
+ function Example() {
52
+ const browser = useBrowserPreferences()
53
+
54
+ return <AppearanceProvider preferences={{ ...browser, theme: "dark" }}>
55
+ <Button>Dark subtree</Button>
56
+ </AppearanceProvider>
57
+ }
54
58
  ```
55
59
 
56
60
  `Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Switch`, and `Radio`
57
61
  use the shared Surface implementation while retaining their native behavior. Color and material
58
62
  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.
63
+ as `background:base` or `primary:soft`, or a direct CSS color. Surface defaults
64
+ to `background:base`; solid controls default to `default:base`.
62
65
  Hover and press derive shades of that fill. The resting fill chooses the
63
66
  higher-contrast text from Appearance's background and foreground once; interaction
64
67
  shades keep that choice.
@@ -128,8 +131,7 @@ Native properties and the forwarded ref target the outer Surface.
128
131
 
129
132
  Every input uses Appearance colors and the same five `size` levels as Button.
130
133
  `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
134
+ and Button use `default:base` when it is omitted. Invalid
133
135
  fields use danger instead. Text fields share Surface's glass edge. Interaction shades
134
136
  derive from the base color's lightness, and text or selection marks use whichever
135
137
  Appearance background or foreground has higher contrast against the base fill.
@@ -142,14 +144,14 @@ Fields distinguish hover, pointer focus, keyboard focus, and invalid state.
142
144
  Shared CSS transitions use `appearance.transaction` for colors and corner
143
145
  radius. The material fill and opacity values transition on the painted layers, not on the
144
146
  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
147
+ opacity fade for entry and exit, using React Aria's animation lifecycle. Preferences with animations disabled make
146
148
  these changes immediate without changing the Appearance value.
147
149
  Blur, distortion, geometry, and Surface host opacity are not transitioned; gradients
148
150
  and structurally removed effects change directly rather than adding extra
149
151
  layers or keeping disabled effects alive.
150
152
 
151
153
  Motion animates toggle presses, selection marks, switch travel, the Select
152
- chevron, and Slider thumb feedback. Reduced-motion preferences remove spatial
154
+ chevron, and Slider thumb feedback. Preferences with animations disabled remove spatial
153
155
  feedback and make state transitions immediate. Slider values and native input
154
156
  behavior are never delayed by visual animation.
155
157
 
@@ -203,7 +205,7 @@ import { Input, Textarea, Checkbox, Radio, RadioGroup, Switch, Select, Slider }
203
205
 
204
206
  React Aria owns focus, keyboard, form, and selection behavior. Select's popup
205
207
  uses Surface defaults. Toggle indicators use Motion internally and respect
206
- reduced-motion preferences. The preview Program demonstrates each input's
208
+ animation preferences. The preview Program demonstrates each input's
207
209
  sizes, colors, state, and interaction without overriding its visual defaults.
208
210
 
209
211
  ## Development
@@ -1,4 +1,4 @@
1
- import { type Appearance, type Theme, type ThemedValue } from "@phreshos/core";
1
+ import { type Appearance, type DesktopPreferences as Preferences, type ThemedValue } from "@phreshos/core";
2
2
  export declare const AppearanceContext: import("react").Context<Readonly<{
3
3
  colors: ThemedValue<import("@phreshos/core").AppearanceColors>;
4
4
  spacing: number;
@@ -9,10 +9,15 @@ export declare const AppearanceContext: import("react").Context<Readonly<{
9
9
  signInWallpaper: ThemedValue<string | null>;
10
10
  desktopWallpaper: ThemedValue<string | null>;
11
11
  }>>;
12
- export declare const ThemeContext: import("react").Context<Theme | null>;
12
+ export declare const PreferencesContext: import("react").Context<Readonly<{
13
+ theme: import("@phreshos/core").Theme;
14
+ animations: boolean;
15
+ }> | null>;
13
16
  /** Returns the nearest unresolved Appearance, or Core's complete default. */
14
17
  export declare function useAppearance(): Appearance;
15
- /** Returns the nearest explicit Theme, or reactively follows the browser. */
16
- export declare function useTheme(): Theme;
18
+ /** Returns the nearest complete Preferences, or reactively follows the browser. */
19
+ export declare function usePreferences(): Preferences;
20
+ /** Reactively reads the browser's complete visual preferences. */
21
+ export declare function useBrowserPreferences(): Preferences;
17
22
  /** Selects the active branch of one complete ThemedValue. */
18
23
  export declare function useThemedValue<Value>(value: ThemedValue<Value>): Value;
@@ -1,37 +1,41 @@
1
- import { createContext, useContext, useSyncExternalStore } from "react";
1
+ import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
2
2
  import { defaultAppearance } from "@phreshos/core";
3
3
  export const AppearanceContext = createContext(defaultAppearance);
4
- export const ThemeContext = createContext(null);
4
+ export const PreferencesContext = createContext(null);
5
5
  /** Returns the nearest unresolved Appearance, or Core's complete default. */
6
6
  export function useAppearance() {
7
7
  return useContext(AppearanceContext);
8
8
  }
9
- /** Returns the nearest explicit Theme, or reactively follows the browser. */
10
- export function useTheme() {
11
- const theme = useContext(ThemeContext);
12
- const browserTheme = useBrowserTheme();
13
- return theme ?? browserTheme;
9
+ /** Returns the nearest complete Preferences, or reactively follows the browser. */
10
+ export function usePreferences() {
11
+ const preferences = useContext(PreferencesContext);
12
+ const browserPreferences = useBrowserPreferences();
13
+ return preferences ?? browserPreferences;
14
+ }
15
+ /** Reactively reads the browser's complete visual preferences. */
16
+ export function useBrowserPreferences() {
17
+ const theme = useMediaPreference(darkThemeQuery) ? "dark" : "light";
18
+ const animations = !useMediaPreference(reducedMotionQuery);
19
+ return useMemo(() => ({ theme, animations }), [animations, theme]);
14
20
  }
15
21
  /** Selects the active branch of one complete ThemedValue. */
16
22
  export function useThemedValue(value) {
17
- return value[useTheme()];
23
+ return value[usePreferences().theme];
18
24
  }
19
- function useBrowserTheme() {
20
- return useSyncExternalStore(subscribeBrowserTheme, browserTheme, serverTheme);
25
+ function useMediaPreference(query) {
26
+ return useSyncExternalStore(change => subscribeMediaPreference(query, change), () => browserMediaPreference(query), () => false);
21
27
  }
22
- function subscribeBrowserTheme(change) {
28
+ function subscribeMediaPreference(query, change) {
23
29
  if (typeof window === "undefined" || typeof window.matchMedia !== "function")
24
30
  return () => undefined;
25
- const preference = window.matchMedia(darkThemeQuery);
31
+ const preference = window.matchMedia(query);
26
32
  preference.addEventListener("change", change);
27
33
  return () => preference.removeEventListener("change", change);
28
34
  }
29
- function browserTheme() {
30
- return typeof window !== "undefined"
35
+ function browserMediaPreference(query) {
36
+ return Boolean(typeof window !== "undefined"
31
37
  && typeof window.matchMedia === "function"
32
- && window.matchMedia(darkThemeQuery).matches
33
- ? "dark"
34
- : "light";
38
+ && window.matchMedia(query).matches);
35
39
  }
36
- function serverTheme() { return "light"; }
37
40
  const darkThemeQuery = "(prefers-color-scheme: dark)";
41
+ const reducedMotionQuery = "(prefers-reduced-motion: reduce)";
@@ -1,10 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
- import { type Appearance, type Theme } from "@phreshos/core";
3
- export { useAppearance, useTheme, useThemedValue } from "./appearance-context.js";
4
- /** Optionally overrides unresolved Appearance and effective Theme for a React subtree. */
5
- export declare function AppearanceProvider({ appearance, children, theme }: AppearanceProviderProps): import("react").JSX.Element;
2
+ import { type Appearance, type DesktopPreferences as Preferences } from "@phreshos/core";
3
+ export { useAppearance, useBrowserPreferences, usePreferences, useThemedValue } from "./appearance-context.js";
4
+ /** Optionally overrides Appearance and complete visual Preferences for a React subtree. */
5
+ export declare function AppearanceProvider({ appearance, children, preferences }: AppearanceProviderProps): import("react").JSX.Element;
6
6
  export interface AppearanceProviderProps {
7
7
  readonly appearance?: Appearance;
8
8
  readonly children: ReactNode;
9
- readonly theme?: Theme;
9
+ readonly preferences?: Preferences;
10
10
  }
@@ -1,14 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import {} from "@phreshos/core";
3
- import DocumentScrollbars from "./document-scrollbars.js";
4
3
  import MotionStyle from "./motion-style.js";
5
- import { AppearanceContext, ThemeContext, useAppearance, useTheme } from "./appearance-context.js";
6
- export { useAppearance, useTheme, useThemedValue } from "./appearance-context.js";
7
- /** Optionally overrides unresolved Appearance and effective Theme for a React subtree. */
8
- export function AppearanceProvider({ appearance, children, theme }) {
4
+ import { AppearanceContext, PreferencesContext, useAppearance, usePreferences } from "./appearance-context.js";
5
+ export { useAppearance, useBrowserPreferences, usePreferences, useThemedValue } from "./appearance-context.js";
6
+ /** Optionally overrides Appearance and complete visual Preferences for a React subtree. */
7
+ export function AppearanceProvider({ appearance, children, preferences }) {
9
8
  const inheritedAppearance = useAppearance();
10
- const inheritedTheme = useTheme();
9
+ const inheritedPreferences = usePreferences();
11
10
  const resolvedAppearance = appearance ?? inheritedAppearance;
12
- const resolvedTheme = theme ?? inheritedTheme;
13
- return _jsx(AppearanceContext.Provider, { value: resolvedAppearance, children: _jsxs(ThemeContext.Provider, { value: resolvedTheme, children: [_jsx(MotionStyle, {}), _jsx(DocumentScrollbars, { appearance: resolvedAppearance, theme: resolvedTheme }), children] }) });
11
+ const resolvedPreferences = preferences ?? inheritedPreferences;
12
+ return _jsx(AppearanceContext.Provider, { value: resolvedAppearance, children: _jsxs(PreferencesContext.Provider, { value: resolvedPreferences, children: [_jsx(MotionStyle, {}), children] }) });
14
13
  }
package/dist/button.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { RadiusProps } from "./radius.js";
5
5
  import { type ControlColor } from "./control.js";
6
6
  import type { MaterialOverrides } from "./material-options.js";
7
7
  type NativeButtonProps = Omit<AriaButtonProps, "children" | "className" | "color" | "isDisabled" | "isPending" | "onClick" | "onPress" | "style">;
8
- /** A semantic color from Appearance; omission uses background and foreground. */
8
+ /** A semantic color from Appearance; omission uses `default:base`. */
9
9
  export type ButtonColor = ControlColor;
10
10
  /** Properties accepted by the shared interactive button. */
11
11
  export interface ButtonProps extends NativeButtonProps, RadiusProps, MaterialOverrides {
package/dist/button.js CHANGED
@@ -31,6 +31,7 @@ function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pe
31
31
  gridAutoFlow: "column",
32
32
  gridAutoColumns: "max-content",
33
33
  placeItems: "center",
34
+ justifyContent: "center",
34
35
  flexShrink: 0,
35
36
  minWidth: 0,
36
37
  height,
@@ -56,6 +57,6 @@ function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pe
56
57
  };
57
58
  }
58
59
  function buttonPaint(theme, interactive, hovered, pressed) {
59
- const paints = theme.colored ? theme.paints.palette : theme.paints.neutral;
60
+ const paints = theme.paints.palette;
60
61
  return interactive && pressed ? paints.pressed : interactive && hovered ? paints.hover : paints.rest;
61
62
  }
package/dist/color.js CHANGED
@@ -43,16 +43,16 @@ export function useColor(value) {
43
43
  /** Resolves a semantic Appearance color or preserves a direct CSS color. */
44
44
  export function useResolveColor(value = defaultColor) {
45
45
  const appearance = useAppearance();
46
- const semantic = parseSemanticColor(value);
47
- const source = useThemedValue(appearance.colors)[semantic?.name ?? "background"];
48
- return semantic ? color(source)[semantic.level] : value;
46
+ const colors = useThemedValue(appearance.colors);
47
+ const semantic = parseSemanticColor(value, colors);
48
+ return semantic ? color(semantic.source)[semantic.level] : value;
49
49
  }
50
50
  /** Resolves a color to concrete opaque paint for solid interactive states. */
51
51
  export function useResolveSolidColor(value) {
52
52
  const appearance = useAppearance();
53
- const semantic = parseSemanticColor(value);
54
- const source = useThemedValue(appearance.colors)[semantic?.name ?? "background"];
55
- return semantic ? resolveColorLevel(source, semantic.level) : opaqueColor(value);
53
+ const colors = useThemedValue(appearance.colors);
54
+ const semantic = parseSemanticColor(value, colors);
55
+ return semantic ? resolveColorLevel(semantic.source, semantic.level) : opaqueColor(value);
56
56
  }
57
57
  /** Applies material opacity without restricting the source CSS color syntax. */
58
58
  export function colorOpacity(value, opacity) {
@@ -99,17 +99,14 @@ export function solidColors(base, background, foreground) {
99
99
  pressed: paint(colorShade(fill, 0.085))
100
100
  };
101
101
  }
102
- function parseSemanticColor(value) {
102
+ function parseSemanticColor(value, colors) {
103
103
  const separator = value.indexOf(":");
104
104
  if (separator < 0 || value.indexOf(":", separator + 1) >= 0)
105
105
  return null;
106
106
  const name = value.slice(0, separator);
107
107
  const level = value.slice(separator + 1);
108
- if (!isAppearanceColor(name) || !isColorLevel(level))
108
+ const source = Reflect.get(colors, name);
109
+ if (typeof source !== "string" || !isColorLevel(level))
109
110
  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";
111
+ return { source, level };
115
112
  }
package/dist/control.d.ts CHANGED
@@ -30,7 +30,6 @@ export interface ControlTheme {
30
30
  readonly foreground: string;
31
31
  readonly background: string;
32
32
  readonly tint: string;
33
- readonly colored: boolean;
34
33
  readonly danger: string;
35
34
  readonly paints: {
36
35
  readonly palette: SolidColors;
package/dist/control.js CHANGED
@@ -21,20 +21,20 @@ export function useControlTheme({ size = "medium", color, radius = "medium" }) {
21
21
  const colors = useThemedValue(appearance.colors);
22
22
  const foreground = colors.foreground;
23
23
  const background = colors.background;
24
- const tint = useResolveSolidColor(color ?? "primary:base");
24
+ const tint = useResolveSolidColor(color ?? "default:base");
25
+ const neutral = useResolveSolidColor("default:base");
25
26
  const danger = useResolveSolidColor("danger:base");
26
27
  const paints = useMemo(() => ({
27
28
  palette: solidColors(tint, background, foreground),
28
- neutral: solidColors(background, background, foreground),
29
+ neutral: solidColors(neutral, background, foreground),
29
30
  danger: solidColors(danger, background, foreground)
30
- }), [tint, background, foreground, danger]);
31
+ }), [tint, neutral, background, foreground, danger]);
31
32
  return {
32
33
  transition,
33
34
  spacing,
34
35
  foreground,
35
36
  background,
36
37
  tint,
37
- colored: color !== undefined,
38
38
  danger,
39
39
  paints,
40
40
  radius: resolveRadius(radius, appearance),
@@ -58,7 +58,7 @@ export function fieldStyle(theme, disabled = false, style) {
58
58
  };
59
59
  }
60
60
  export function controlPaint(theme, focused, invalid, hovered = false) {
61
- const paints = invalid ? theme.paints.danger : theme.colored ? theme.paints.palette : theme.paints.neutral;
61
+ const paints = invalid ? theme.paints.danger : theme.paints.palette;
62
62
  return focused ? paints.pressed : hovered ? paints.hover : paints.rest;
63
63
  }
64
64
  export function controlStyle(theme, focused, invalid, hovered = false, focusVisible = focused) {
package/dist/main.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * behavior contract has been established by the package's tests.
6
6
  */
7
7
  export { defaultAppearance } from "@phreshos/core";
8
- export { AppearanceProvider, useAppearance, useThemedValue, useTheme, type AppearanceProviderProps } from "./appearance-provider.js";
8
+ export { AppearanceProvider, useAppearance, useBrowserPreferences, usePreferences, useThemedValue, type AppearanceProviderProps } from "./appearance-provider.js";
9
+ export type { DesktopPreferences as Preferences } from "@phreshos/core";
9
10
  export { Flex, type FlexProps } from "./flex.js";
10
11
  export { Grid, type GridProps } from "./grid.js";
11
12
  export { Surface, type MaterialOptions, type SurfaceComponent, type SurfaceHost, type SurfaceHostProps, type SurfaceOwnProps, type SurfaceProps } from "./surface.js";
@@ -19,6 +20,7 @@ export { Radio, RadioGroup, type RadioProps, type RadioGroupProps } from "./radi
19
20
  export { Switch, type SwitchProps } from "./switch.js";
20
21
  export { Select, type SelectProps, type SelectOption } from "./select.js";
21
22
  export { Slider, type SliderProps } from "./slider.js";
23
+ export { ScrollArea, type ScrollAreaAxis, type ScrollAreaProps } from "./scroll-area.js";
22
24
  export type { ControlColor } from "./control.js";
23
25
  export type { LayoutAlignment, LayoutGap, LayoutJustification } from "./layout.js";
24
26
  export { resolveRadius, type Radius, type RadiusProps } from "./radius.js";
package/dist/main.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * behavior contract has been established by the package's tests.
6
6
  */
7
7
  export { defaultAppearance } from "@phreshos/core";
8
- export { AppearanceProvider, useAppearance, useThemedValue, useTheme } from "./appearance-provider.js";
8
+ export { AppearanceProvider, useAppearance, useBrowserPreferences, usePreferences, useThemedValue } from "./appearance-provider.js";
9
9
  export { Flex } from "./flex.js";
10
10
  export { Grid } from "./grid.js";
11
11
  export { Surface } from "./surface.js";
@@ -19,6 +19,7 @@ export { Radio, RadioGroup } from "./radio.js";
19
19
  export { Switch } from "./switch.js";
20
20
  export { Select } from "./select.js";
21
21
  export { Slider } from "./slider.js";
22
+ export { ScrollArea } from "./scroll-area.js";
22
23
  export { resolveRadius } from "./radius.js";
23
24
  export { resolveSpacing } from "./spacing.js";
24
25
  export { useColor } from "./color.js";
@@ -5,9 +5,10 @@ export declare function useVisualTransition(): CSSProperties;
5
5
  /** CSS timing for SVG paint owned by a Material. */
6
6
  export declare function usePaintTransition(): CSSProperties;
7
7
  /** Motion timing for interactive geometry owned by React UI. */
8
- export declare function useControlTransition(reduced?: boolean): Transition;
8
+ export declare function useControlTransition(): Transition;
9
9
  /** Per-overlay variables consumed by the shared entrance keyframes. */
10
10
  export declare function useOverlayTransition(): CSSProperties;
11
11
  export declare const overlayMotionClass = "phreshos-ui-overlay";
12
+ export declare function useTransitionTiming(): CSSProperties;
12
13
  /** React hoists and deduplicates this stylesheet, including portals. */
13
14
  export default function MotionStyle(): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { defaultAppearance } from "@phreshos/core";
3
- import { useAppearance } from "./appearance-context.js";
3
+ import { useAppearance, usePreferences } from "./appearance-context.js";
4
4
  const visualProperties = "background-color, color, border-color, border-radius";
5
5
  const paintProperties = "fill, stroke, opacity";
6
6
  /** CSS timing for values painted directly by React UI. */
@@ -12,27 +12,30 @@ export function usePaintTransition() {
12
12
  return { ...useTransitionTiming(), transitionProperty: paintProperties };
13
13
  }
14
14
  /** Motion timing for interactive geometry owned by React UI. */
15
- export function useControlTransition(reduced = false) {
15
+ export function useControlTransition() {
16
16
  const { duration, easing } = useAppearance().transaction;
17
+ const { animations } = usePreferences();
17
18
  return {
18
19
  type: "tween",
19
- duration: reduced ? 0 : duration / 1_000,
20
+ duration: animations ? duration / 1_000 : 0,
20
21
  ease: motionEasing(easing)
21
22
  };
22
23
  }
23
24
  /** Per-overlay variables consumed by the shared entrance keyframes. */
24
25
  export function useOverlayTransition() {
25
26
  const { duration, easing } = useAppearance().transaction;
27
+ const { animations } = usePreferences();
26
28
  return {
27
- "--phreshos-ui-motion-duration": `${duration}ms`,
29
+ "--phreshos-ui-motion-duration": `${animations ? duration : 0}ms`,
28
30
  "--phreshos-ui-motion-easing": cssEasing(easing)
29
31
  };
30
32
  }
31
33
  export const overlayMotionClass = "phreshos-ui-overlay";
32
- function useTransitionTiming() {
34
+ export function useTransitionTiming() {
33
35
  const { duration, easing } = useAppearance().transaction;
36
+ const { animations } = usePreferences();
34
37
  return {
35
- transitionDuration: `${duration}ms`,
38
+ transitionDuration: `${animations ? duration : 0}ms`,
36
39
  transitionTimingFunction: cssEasing(easing)
37
40
  };
38
41
  }
@@ -73,10 +76,6 @@ const stylesheet = `
73
76
  from { opacity: 0; translate: var(--phreshos-ui-overlay-x) var(--phreshos-ui-overlay-y); }
74
77
  to { opacity: 1; translate: 0 0; }
75
78
  }
76
- @media (prefers-reduced-motion: reduce) {
77
- :root { --phreshos-ui-motion-duration: 0ms; }
78
- .phreshos-ui-overlay[data-entering], .phreshos-ui-overlay[data-exiting] { animation: none; }
79
- }
80
79
  `;
81
80
  /** React hoists and deduplicates this stylesheet, including portals. */
82
81
  export default function MotionStyle() {
@@ -0,0 +1,10 @@
1
+ import type { ComponentPropsWithoutRef, Ref, ReactNode, UIEventHandler } from "react";
2
+ export type ScrollAreaAxis = "vertical" | "horizontal" | "both";
3
+ export interface ScrollAreaProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "onScroll"> {
4
+ readonly axis?: ScrollAreaAxis;
5
+ readonly children?: ReactNode;
6
+ readonly onScroll?: UIEventHandler<HTMLDivElement>;
7
+ readonly viewportRef?: Ref<HTMLDivElement>;
8
+ }
9
+ /** A native scrolling viewport with locally owned React UI scrollbars. */
10
+ export declare const ScrollArea: import("react").ForwardRefExoticComponent<ScrollAreaProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area";
3
+ import { forwardRef, useState } from "react";
4
+ import { useAppearance, useThemedValue } from "./appearance-provider.js";
5
+ import { colorOpacity } from "./color.js";
6
+ import { useTransitionTiming } from "./motion-style.js";
7
+ /** A native scrolling viewport with locally owned React UI scrollbars. */
8
+ export const ScrollArea = forwardRef(function ScrollArea({ axis = "vertical", children, onScroll, style, viewportRef, ...properties }, ref) {
9
+ const appearance = useAppearance();
10
+ const foreground = useThemedValue(appearance.colors).foreground;
11
+ const radius = Math.min(appearance.radius, 8);
12
+ const vertical = axis === "vertical" || axis === "both";
13
+ const horizontal = axis === "horizontal" || axis === "both";
14
+ return _jsxs(BaseScrollArea.Root, { ...properties, "data-phreshos-scroll-area": "", ref: ref, style: {
15
+ ...style,
16
+ position: style?.position ?? "relative",
17
+ minWidth: style?.minWidth ?? 0,
18
+ minHeight: style?.minHeight ?? 0,
19
+ overflow: "hidden"
20
+ }, children: [_jsx(BaseScrollArea.Viewport, { "data-phreshos-scroll-area-viewport": "", ref: viewportRef, onScroll: onScroll, style: {
21
+ width: "100%",
22
+ height: "100%",
23
+ overflowX: horizontal ? "scroll" : "hidden",
24
+ overflowY: vertical ? "scroll" : "hidden"
25
+ }, children: _jsx(BaseScrollArea.Content, { "data-phreshos-scroll-area-content": "", style: vertical && !horizontal ? { minWidth: "100%", width: "100%" } : undefined, children: children }) }), vertical && _jsx(Scrollbar, { orientation: "vertical", foreground: foreground, radius: radius }), horizontal && _jsx(Scrollbar, { orientation: "horizontal", foreground: foreground, radius: radius }), vertical && horizontal && _jsx(BaseScrollArea.Corner, { style: { background: "transparent" } })] });
26
+ });
27
+ function Scrollbar({ orientation, foreground, radius }) {
28
+ const [hovered, setHovered] = useState(false);
29
+ const transition = useTransitionTiming();
30
+ const vertical = orientation === "vertical";
31
+ return _jsx(BaseScrollArea.Scrollbar, { "data-phreshos-scroll-area-scrollbar": "", orientation: orientation, keepMounted: true, onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), style: state => {
32
+ const present = vertical ? state.hasOverflowY : state.hasOverflowX;
33
+ return {
34
+ boxSizing: "border-box",
35
+ width: vertical ? 16 : undefined,
36
+ height: vertical ? undefined : 16,
37
+ padding: 5,
38
+ opacity: present && (state.hovering || state.scrolling) ? 1 : 0,
39
+ pointerEvents: present && (state.hovering || state.scrolling) ? "auto" : "none",
40
+ transitionDuration: transition.transitionDuration,
41
+ transitionTimingFunction: transition.transitionTimingFunction,
42
+ transitionProperty: "opacity"
43
+ };
44
+ }, children: _jsx(BaseScrollArea.Thumb, { "data-phreshos-scroll-area-thumb": "", style: {
45
+ borderRadius: radius,
46
+ backgroundColor: colorOpacity(foreground, hovered ? 0.2 : 0.1),
47
+ transitionDuration: transition.transitionDuration,
48
+ transitionTimingFunction: transition.transitionTimingFunction,
49
+ transitionProperty: "background-color"
50
+ } }) });
51
+ }
package/dist/select.js CHANGED
@@ -1,30 +1,30 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
- import { motion, useReducedMotion } from "motion/react";
3
+ import { motion } from "motion/react";
4
4
  import { Select as AriaSelect, Button, SelectValue, Popover, ListBox, ListBoxItem } from "react-aria-components";
5
5
  import { controlPaint, controlStyle, FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
6
6
  import { SurfaceButton } from "./control-surface.js";
7
7
  import { Surface } from "./surface.js";
8
+ import { ScrollArea } from "./scroll-area.js";
8
9
  import { overlayMotionClass, useControlTransition, useOverlayTransition } from "./motion-style.js";
9
10
  /** Single selection from string-valued options; keyboard navigation and typeahead stay native to React Aria. */
10
11
  export const Select = forwardRef(function Select({ label, description, errorMessage, disabled, required, invalid, options, value, defaultValue, onChange, size, color, radius, style, material, ...properties }, ref) {
11
12
  const theme = useControlTheme({ size, color, radius });
12
- const reduced = useReducedMotion();
13
- const transition = useControlTransition(Boolean(reduced));
13
+ const transition = useControlTransition();
14
14
  const overlayTransition = useOverlayTransition();
15
15
  return _jsx(AriaSelect, { ...properties, ref: ref, value: value, defaultValue: defaultValue, onChange: key => onChange?.(key == null ? null : String(key)), disabledKeys: options.filter(option => option.disabled).map(option => option.value), isDisabled: disabled, isRequired: required, isInvalid: invalid, style: fieldStyle(theme, disabled, style), children: state => _jsxs(_Fragment, { children: [_jsx(FieldLabel, { label: label }), _jsxs(Button, { render: (native, button) => _jsx(SurfaceButton, { native: native, material: material, paint: controlPaint(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered) }), style: button => ({
16
16
  ...controlStyle(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered, button.isFocusVisible),
17
17
  display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
18
18
  cursor: disabled ? "not-allowed" : "pointer", textAlign: "start"
19
- }), children: [_jsx(SelectValue, { style: ({ isPlaceholder }) => ({ opacity: isPlaceholder ? 0.6 : 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }) }), _jsx(motion.svg, { "aria-hidden": "true", width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", initial: false, animate: { rotate: state.isOpen ? 180 : 0 }, transition: transition, style: { flexShrink: 0 }, children: _jsx("path", { d: "m2 4 4 4 4-4" }) })] }), _jsx(Popover, { className: overlayMotionClass, placement: "bottom start", offset: theme.gap, maxHeight: 280 + theme.gap * 2, style: { ...overlayTransition, width: "var(--trigger-width)", maxWidth: "calc(100vw - 16px)" }, children: _jsx(Surface, { style: { padding: theme.gap, display: "flex", flexDirection: "column", maxHeight: "inherit", boxSizing: "border-box" }, children: _jsx(ListBox, { items: options, style: { display: "grid", gap: theme.gap, minHeight: 0, overflow: "auto", outline: "none", fontSize: theme.fontSize, color: theme.foreground }, children: option => _jsx(ListBoxItem, { id: option.value, textValue: option.label, style: item => ({
20
- ...theme.transition,
21
- display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
22
- minHeight: theme.height, paddingInline: Math.max(8, theme.spacing), boxSizing: "border-box",
23
- borderRadius: theme.radius,
24
- outline: item.isFocused && !item.isSelected ? `2px solid ${theme.foreground}` : "none", outlineOffset: -2,
25
- cursor: item.isDisabled ? "not-allowed" : "pointer",
26
- opacity: item.isDisabled ? 0.46 : 1,
27
- ...(item.isSelected ? item.isFocused ? theme.paints.palette.hover : theme.paints.palette.rest
28
- : { background: "transparent", color: theme.foreground })
29
- }), children: item => _jsxs(_Fragment, { children: [option.label, _jsx("span", { "aria-hidden": "true", children: item.isSelected ? "✓" : null })] }) }) }) }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] }) });
19
+ }), children: [_jsx(SelectValue, { style: ({ isPlaceholder }) => ({ opacity: isPlaceholder ? 0.6 : 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }) }), _jsx(motion.svg, { "aria-hidden": "true", width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", initial: false, animate: { rotate: state.isOpen ? 180 : 0 }, transition: transition, style: { flexShrink: 0 }, children: _jsx("path", { d: "m2 4 4 4 4-4" }) })] }), _jsx(Popover, { className: overlayMotionClass, placement: "bottom start", offset: theme.gap, maxHeight: 280 + theme.gap * 2, style: { ...overlayTransition, width: "var(--trigger-width)", maxWidth: "calc(100vw - 16px)" }, children: _jsx(Surface, { style: { padding: theme.gap, display: "flex", flexDirection: "column", maxHeight: "inherit", boxSizing: "border-box" }, children: _jsx(ScrollArea, { style: { flex: "1 1 auto", minHeight: 0 }, children: _jsx(ListBox, { items: options, style: { display: "grid", gap: theme.gap, outline: "none", fontSize: theme.fontSize, color: theme.foreground }, children: option => _jsx(ListBoxItem, { id: option.value, textValue: option.label, style: item => ({
20
+ ...theme.transition,
21
+ display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
22
+ minHeight: theme.height, paddingInline: Math.max(8, theme.spacing), boxSizing: "border-box",
23
+ borderRadius: theme.radius,
24
+ outline: item.isFocused && !item.isSelected ? `2px solid ${theme.foreground}` : "none", outlineOffset: -2,
25
+ cursor: item.isDisabled ? "not-allowed" : "pointer",
26
+ opacity: item.isDisabled ? 0.46 : 1,
27
+ ...(item.isSelected ? item.isFocused ? theme.paints.palette.hover : theme.paints.palette.rest
28
+ : { background: "transparent", color: theme.foreground })
29
+ }), children: item => _jsxs(_Fragment, { children: [option.label, _jsx("span", { "aria-hidden": "true", children: item.isSelected ? "✓" : null })] }) }) }) }) }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] }) });
30
30
  });
package/dist/slider.js CHANGED
@@ -1,17 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useId } from "react";
3
- import { motion, useReducedMotion } from "motion/react";
3
+ import { motion } from "motion/react";
4
4
  import { Slider as AriaSlider, SliderTrack, SliderThumb, SliderFill, SliderOutput } from "react-aria-components";
5
5
  import { FieldLabel, fieldStyle, useControlTheme } from "./control.js";
6
6
  import { useControlTransition } from "./motion-style.js";
7
+ import { usePreferences } from "./appearance-provider.js";
7
8
  /** One numeric value with pointer, touch, keyboard, and form support. */
8
9
  export const Slider = forwardRef(function Slider({ label, description, name, disabled, size, color, style, orientation = "horizontal", ...properties }, ref) {
9
10
  const theme = useControlTheme({ size, color });
10
11
  const descriptionId = useId();
11
12
  const vertical = orientation === "vertical";
12
13
  const diameter = theme.fontSize + 6;
13
- const reduced = useReducedMotion();
14
- const transition = useControlTransition(Boolean(reduced));
14
+ const { animations } = usePreferences();
15
+ const transition = useControlTransition();
15
16
  const rail = Math.max(4, Math.round(diameter / 3));
16
17
  return _jsxs(AriaSlider, { ...properties, ref: ref, isDisabled: disabled, orientation: orientation, style: fieldStyle(theme, disabled, style), "aria-describedby": [properties["aria-describedby"], description != null ? descriptionId : null].filter(Boolean).join(" ") || undefined, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: theme.gap }, children: [_jsx(FieldLabel, { label: label }), _jsx(SliderOutput, { style: { fontVariantNumeric: "tabular-nums", opacity: 0.7 } })] }), _jsxs(SliderTrack, { style: {
17
18
  position: "relative", touchAction: "none",
@@ -30,7 +31,7 @@ export const Slider = forwardRef(function Slider({ label, description, name, dis
30
31
  ...(vertical ? { left: "50%" } : { top: "50%" }),
31
32
  outline: state.isFocusVisible ? `2px solid ${theme.foreground}` : "none", outlineOffset: 2,
32
33
  cursor: state.isDisabled ? "not-allowed" : state.isDragging ? "grabbing" : "grab"
33
- }), children: state => _jsx(motion.span, { "aria-hidden": "true", initial: false, animate: { scale: reduced || state.isDisabled ? 1 : state.isDragging ? 0.92 : state.isHovered ? 1.08 : 1 }, transition: transition, style: { ...theme.transition, position: "absolute", inset: 0, borderRadius: "inherit", pointerEvents: "none",
34
+ }), children: state => _jsx(motion.span, { "aria-hidden": "true", initial: false, animate: { scale: !animations || state.isDisabled ? 1 : state.isDragging ? 0.92 : state.isHovered ? 1.08 : 1 }, transition: transition, style: { ...theme.transition, position: "absolute", inset: 0, borderRadius: "inherit", pointerEvents: "none",
34
35
  background: (state.isDragging ? theme.paints.palette.pressed : state.isHovered ? theme.paints.palette.hover : theme.paints.palette.rest).color,
35
36
  border: `3px solid ${(state.isDragging ? theme.paints.palette.pressed : state.isHovered ? theme.paints.palette.hover : theme.paints.palette.rest).background}`,
36
37
  boxSizing: "border-box" } }) })] }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: 0.7 }, children: description })] });
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { motion, useReducedMotion } from "motion/react";
2
+ import { motion } from "motion/react";
3
3
  import { useLocale } from "react-aria-components";
4
4
  import { useControlTransition } from "./motion-style.js";
5
+ import { usePreferences } from "./appearance-provider.js";
5
6
  import { Surface } from "./surface.js";
6
7
  /** Shared paint only. Selection, focus, validation, and native input behavior belong to React Aria. */
7
8
  export function ToggleIndicator({ kind, selected, indeterminate = false, focused, invalid = false, hovered = false, pressed = false, theme, material: options }) {
8
- const reduced = useReducedMotion();
9
- const transition = useControlTransition(Boolean(reduced));
9
+ const { animations } = usePreferences();
10
+ const transition = useControlTransition();
10
11
  const { direction } = useLocale();
11
12
  const diameter = theme.fontSize + 6;
12
13
  const active = selected || indeterminate;
@@ -14,9 +15,9 @@ export function ToggleIndicator({ kind, selected, indeterminate = false, focused
14
15
  const paint = pressed ? paints.pressed : hovered ? paints.hover : paints.rest;
15
16
  const radius = kind === "checkbox" ? Math.min(diameter / 4, typeof theme.radius === "number" ? theme.radius : diameter / 4) : diameter;
16
17
  const switching = kind === "switch";
17
- const thumbWidth = diameter - 6 + (pressed && !reduced ? 3 : 0);
18
+ const thumbWidth = diameter - 6 + (pressed && animations ? 3 : 0);
18
19
  const travel = (diameter * 2 - thumbWidth) / 2 - 3;
19
- return _jsx(Surface, { as: motion.span, material: options, color: paint.background, "aria-hidden": "true", initial: false, animate: { scale: reduced || switching ? 1 : pressed ? 0.94 : hovered ? 1.03 : 1 }, transition: transition, style: {
20
+ return _jsx(Surface, { as: motion.span, material: options, color: paint.background, "aria-hidden": "true", initial: false, animate: { scale: !animations || switching ? 1 : pressed ? 0.94 : hovered ? 1.03 : 1 }, transition: transition, style: {
20
21
  color: paint.color,
21
22
  ...theme.transition,
22
23
  display: "inline-grid",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "React components for coherent PhreshOS Program interfaces.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -55,13 +55,13 @@
55
55
  "react-dom": "^19.2.0"
56
56
  },
57
57
  "dependencies": {
58
- "@phreshos/core": "^0.1.48",
58
+ "@base-ui/react": "^1.7.0",
59
+ "@phreshos/core": "^0.1.49",
59
60
  "colorjs.io": "^0.7.1",
60
61
  "motion": "^13.2.0",
61
62
  "react-aria-components": "^1.20.0"
62
63
  },
63
64
  "devDependencies": {
64
- "@base-ui/react": "^1.7.0",
65
65
  "@testing-library/dom": "^10.4.1",
66
66
  "@testing-library/react": "^16.3.0",
67
67
  "@testing-library/user-event": "^14.6.1",
@@ -1,6 +0,0 @@
1
- import type { Appearance, Theme } from "@phreshos/core";
2
- /** Applies one Appearance to the complete owning document without rendering. */
3
- export default function DocumentScrollbars({ appearance, theme }: Readonly<{
4
- appearance: Appearance;
5
- theme: Theme;
6
- }>): null;
@@ -1,164 +0,0 @@
1
- import { useInsertionEffect, useRef } from "react";
2
- import { colorOpacity } from "./color.js";
3
- const properties = {
4
- thumb: "--phreshos-scrollbar-thumb",
5
- thumbHover: "--phreshos-scrollbar-thumb-hover",
6
- size: "--phreshos-scrollbar-size",
7
- padding: "--phreshos-scrollbar-padding",
8
- radius: "--phreshos-scrollbar-radius"
9
- };
10
- const stylesheet = `
11
- @supports not selector(::-webkit-scrollbar) {
12
- * {
13
- scrollbar-color: var(${properties.thumb}) transparent;
14
- scrollbar-width: thin;
15
- }
16
-
17
- @media (hover: hover) and (pointer: fine) {
18
- * {
19
- scrollbar-color: transparent transparent;
20
- }
21
-
22
- *:hover {
23
- scrollbar-color: var(${properties.thumb}) transparent;
24
- }
25
- }
26
-
27
- @media (forced-colors: active) {
28
- *,
29
- *:hover {
30
- scrollbar-color: auto;
31
- }
32
- }
33
- }
34
-
35
- @supports selector(::-webkit-scrollbar) {
36
- *::-webkit-scrollbar {
37
- width: var(${properties.size});
38
- height: var(${properties.size});
39
- background: transparent;
40
- }
41
-
42
- *::-webkit-scrollbar-track,
43
- *::-webkit-scrollbar-corner {
44
- background: transparent;
45
- }
46
-
47
- *::-webkit-scrollbar-button {
48
- display: none;
49
- }
50
-
51
- *::-webkit-scrollbar-thumb {
52
- border: var(${properties.padding}) solid transparent;
53
- border-radius: var(${properties.radius});
54
- background-color: var(${properties.thumb});
55
- background-clip: padding-box;
56
- }
57
-
58
- @media (hover: hover) and (pointer: fine) {
59
- *:hover {
60
- --phreshos-scrollbar-repaint: ;
61
- }
62
-
63
- *::-webkit-scrollbar-thumb {
64
- background-color: transparent;
65
- }
66
-
67
- *:hover::-webkit-scrollbar-thumb {
68
- background-color: var(${properties.thumb});
69
- }
70
-
71
- *::-webkit-scrollbar-thumb:hover {
72
- background-color: var(${properties.thumbHover});
73
- }
74
- }
75
-
76
- @media (forced-colors: active) {
77
- *::-webkit-scrollbar-thumb,
78
- *:hover::-webkit-scrollbar-thumb,
79
- *::-webkit-scrollbar-thumb:hover {
80
- background-color: ButtonText;
81
- }
82
- }
83
- }
84
- `;
85
- const documents = new WeakMap();
86
- /** Applies one Appearance to the complete owning document without rendering. */
87
- export default function DocumentScrollbars({ appearance, theme }) {
88
- const identity = useRef(Symbol("AppearanceProvider")).current;
89
- const foreground = appearance.colors[theme].foreground;
90
- const padding = 5;
91
- const thumbSize = 6;
92
- const size = thumbSize + padding * 2;
93
- const values = {
94
- thumb: colorOpacity(foreground, 0.1),
95
- thumbHover: colorOpacity(foreground, 0.2),
96
- size: `${size}px`,
97
- padding: `${padding}px`,
98
- radius: `${Math.min(appearance.radius, padding + thumbSize / 2)}px`
99
- };
100
- useInsertionEffect(function () {
101
- if (typeof document === "undefined")
102
- return;
103
- return register(document, identity, values);
104
- }, []);
105
- useInsertionEffect(function () {
106
- if (typeof document !== "undefined")
107
- update(document, identity, values);
108
- }, [values.thumb, values.thumbHover, values.size, values.padding, values.radius]);
109
- return null;
110
- }
111
- function register(owner, identity, values) {
112
- const state = documents.get(owner) ?? create(owner);
113
- state.providers.set(identity, values);
114
- apply(state);
115
- return function () {
116
- state.providers.delete(identity);
117
- if (state.providers.size) {
118
- apply(state);
119
- return;
120
- }
121
- state.style.remove();
122
- restore(state);
123
- documents.delete(owner);
124
- };
125
- }
126
- function update(owner, identity, values) {
127
- const state = documents.get(owner);
128
- if (!state?.providers.has(identity))
129
- return;
130
- state.providers.set(identity, values);
131
- apply(state);
132
- }
133
- function create(owner) {
134
- const root = owner.documentElement;
135
- const style = owner.createElement("style");
136
- const original = new Map(Object.values(properties).map(property => [property, {
137
- value: root.style.getPropertyValue(property),
138
- priority: root.style.getPropertyPriority(property)
139
- }]));
140
- style.dataset.phreshosScrollbars = "";
141
- style.textContent = stylesheet;
142
- owner.head.append(style);
143
- const state = { root, style, original, providers: new Map() };
144
- documents.set(owner, state);
145
- return state;
146
- }
147
- function apply(state) {
148
- const values = [...state.providers.values()].at(-1);
149
- if (!values)
150
- return;
151
- state.root.style.setProperty(properties.thumb, values.thumb);
152
- state.root.style.setProperty(properties.thumbHover, values.thumbHover);
153
- state.root.style.setProperty(properties.size, values.size);
154
- state.root.style.setProperty(properties.padding, values.padding);
155
- state.root.style.setProperty(properties.radius, values.radius);
156
- }
157
- function restore(state) {
158
- for (const [property, original] of state.original) {
159
- if (original.value)
160
- state.root.style.setProperty(property, original.value, original.priority);
161
- else
162
- state.root.style.removeProperty(property);
163
- }
164
- }