@phreshos/react-ui 0.1.28 → 0.1.30

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
@@ -79,11 +79,12 @@ use the shared Surface implementation while retaining their native behavior. Col
79
79
  are independent inputs. `color` accepts an Appearance color and resting level such
80
80
  as `background:base` or `primary:soft`, or a direct CSS color. Surface defaults
81
81
  to `background:base`; solid controls default to `default:base`.
82
- Hover and press derive shades of that fill. The resting fill chooses the
83
- higher-contrast text from Appearance's background and foreground once; interaction
84
- shades keep that choice.
82
+ Hover and press derive shades of that fill. Surface and solid controls choose
83
+ the higher-contrast text from Appearance's background and foreground whenever
84
+ their fill can be evaluated; interaction shades keep that choice.
85
85
  `size` accepts `xsmall`, `small`, `medium` (default), `large`, or `xlarge`;
86
- spacing and radius follow Appearance. `disabled` prevents activation and focus;
86
+ spacing and radius follow Appearance, while typography remains inherited and
87
+ component sizes are relative to the surrounding text. `disabled` prevents activation and focus;
87
88
  `pending` prevents activation while retaining focus.
88
89
 
89
90
  ```tsx
@@ -95,7 +96,7 @@ spacing and radius follow Appearance. `disabled` prevents activation and focus;
95
96
  `Surface` is the material- and shadow-owning element. It renders a `div` by default, while
96
97
  `as` selects another React element and preserves that element's native properties
97
98
  and ref type. Surface owns its paint, opacity, frost, refraction, grain, edge,
98
- outer shadow, radius, and clipping requirements. `radius` accepts a size level, `full`, a
99
+ outer shadow, and radius. `radius` accepts a size level, `full`, a
99
100
  number in pixels, or a CSS radius and defaults to `medium`.
100
101
 
101
102
  ```tsx
@@ -114,11 +115,18 @@ required geometry.
114
115
  `MaterialOptions` defines `opacity`, `backdrop`, `grain`, `grainAmount`,
115
116
  `distortion`, and `saturation`. Every material-bearing component, including
116
117
  Surface, exposes these values through its `material` prop. Color remains a
117
- separate property. Omission or `true` uses `appearance.material`; `false`
118
- removes the material and paints the resolved color as a normal background.
119
- Effect options accept a scale level or a direct number;
120
- opacity affects Surface paint only, never its content. The material edge is part
121
- of the same Surface rather than a second public entity.
118
+ separate property. Omission uses the resource-efficient `basic` rendering mode.
119
+ `none` ignores Material and paints the resolved color as a normal background;
120
+ `basic` adds Material paint, grain, and its edge while remaining solid, with
121
+ Material opacity applied only to the grain; `extended` applies that opacity to
122
+ the complete Material instead; and `full` adds backdrop, distortion, and
123
+ saturation. A `MaterialOptions` object selects full rendering with those
124
+ overrides. Effect options accept a scale level or a direct number; opacity
125
+ affects Surface paint only, never its content. The material edge is part of the
126
+ same Surface rather than a second public entity.
127
+
128
+ The modes are progressive: each level preserves the rendering of the previous
129
+ one and adds only its own Material capability.
122
130
 
123
131
  Surface-based controls expose the same separate `color` and `material` props.
124
132
  `ShadowOptions` similarly groups `x`, `y`, `blur`, `spread`, and `opacity` under
@@ -132,7 +140,8 @@ to its options, and a Radio can override them. Shadow follows the same targets a
132
140
  inheritance path.
133
141
 
134
142
  ```tsx
135
- <Button color="primary:base" material={{ opacity: 0.6, backdrop: 0 }}>Save</Button>
143
+ <Button color="primary:base" material="full">Save</Button>
144
+ <Surface material={{ opacity: 0.6, backdrop: 0 }}>Translucent</Surface>
136
145
  <Input label="Name" radius="large" material={{ grain: "small" }} shadow={{ blur: "small" }} />
137
146
  ```
138
147
 
@@ -153,6 +162,31 @@ Native properties and the root ref target the outer Surface. `Panel.Header`
153
162
  owns the optional leading region, while `Panel.Content` is the independently
154
163
  configurable inner Surface.
155
164
 
165
+ `Window` provides one Surface with a ready header-and-content layout.
166
+ `Window.Header.Identity` presents the icon and truncating title,
167
+ `Window.Header.Center` is optional flexible space, and
168
+ `Window.Header.Actions` aligns compact controls at the end. `Window.Content`
169
+ fills the remaining area without deciding its overflow behavior. The header
170
+ uses the enclosing Surface rather than creating another material layer, and it
171
+ can also be composed independently inside any Surface. Its controls are React
172
+ UI Buttons; callers connect them to their own window operations. Pointer and
173
+ double-click handlers on the header can implement a drag area, while the center
174
+ and actions keep their interactions separate.
175
+
176
+ ```tsx
177
+ <Window>
178
+ <Window.Header active={active} onPointerDown={beginDrag}>
179
+ <Window.Header.Identity icon={icon} title={title} />
180
+ <Window.Header.Actions>
181
+ <Window.Header.Minimize onPress={minimize} />
182
+ <Window.Header.Maximize maximized={maximized} onPress={toggleMaximize} />
183
+ <Window.Header.Close onPress={close} />
184
+ </Window.Header.Actions>
185
+ </Window.Header>
186
+ <Window.Content>{children}</Window.Content>
187
+ </Window>
188
+ ```
189
+
156
190
  ## Overlays
157
191
 
158
192
  Overlay components expose each behavioral role as a named part. Roots own open
@@ -202,8 +236,8 @@ Blur, distortion, geometry, and Surface host opacity are not transitioned; gradi
202
236
  and structurally removed effects change directly rather than adding extra
203
237
  layers or keeping disabled effects alive.
204
238
 
205
- Motion animates toggle presses, selection marks, switch travel, the Select
206
- chevron, and Slider thumb feedback. Preferences with animations disabled remove spatial
239
+ Motion animates toggle presses, selection marks, and switch travel. CSS transitions
240
+ animate paint and the Select chevron. Preferences with animations disabled remove spatial
207
241
  feedback and make state transitions immediate. Slider values and native input
208
242
  behavior are never delayed by visual animation.
209
243
 
@@ -255,8 +289,9 @@ import { Input, Textarea, Checkbox, Radio, RadioGroup, Switch, Select, Slider }
255
289
  <Slider label="Volume" name="volume" defaultValue={50} minValue={0} maxValue={100} step={1} />
256
290
  ```
257
291
 
258
- React Aria owns focus, keyboard, form, and selection behavior. Select's popup
259
- uses Surface defaults. Toggle indicators use Motion internally and respect
292
+ Each control delegates focus, keyboard, form, and selection behavior to its
293
+ accessible behavioral primitive rather than recreating those systems. Select's
294
+ popup uses Surface defaults. Toggle indicators use Motion internally and respect
260
295
  animation preferences. The preview Program demonstrates each input's
261
296
  sizes, colors, state, and interaction without overriding its visual defaults.
262
297
 
@@ -1,7 +1,11 @@
1
1
  import { type Appearance, type ThemedValue } from "./appearance.js";
2
2
  import type { Preferences } from "./preferences.js";
3
3
  export declare const AppearanceContext: import("react").Context<Appearance>;
4
- export declare const PreferencesContext: import("react").Context<Preferences | null>;
4
+ export interface PreferencesSource {
5
+ readonly getSnapshot: () => Preferences;
6
+ readonly getServerSnapshot: () => Preferences;
7
+ readonly subscribe: (change: () => void) => () => void;
8
+ }
5
9
  /** Returns the nearest unresolved appearance, or React UI's default. */
6
10
  export declare function useAppearance(): Appearance;
7
11
  /** Returns the nearest complete Preferences, or reactively follows the browser. */
@@ -10,3 +14,41 @@ export declare function usePreferences(): Preferences;
10
14
  export declare function useBrowserPreferences(): Preferences;
11
15
  /** Selects the active branch of one complete ThemedValue. */
12
16
  export declare function useThemedValue<Value>(value: ThemedValue<Value>): Value;
17
+ /** Resolves one coherent Appearance snapshot for internal component derivation. */
18
+ export declare function useResolvedAppearance(): {
19
+ appearance: Appearance;
20
+ preferences: Preferences;
21
+ colors: Readonly<{
22
+ background: string;
23
+ foreground: string;
24
+ default: string;
25
+ primary: string;
26
+ secondary: string;
27
+ success: string;
28
+ warning: string;
29
+ danger: string;
30
+ info: string;
31
+ }>;
32
+ material: Readonly<{
33
+ grain: number;
34
+ grainAmount: number;
35
+ backdrop: number;
36
+ opacity: number;
37
+ distortion: number;
38
+ saturation: number;
39
+ }>;
40
+ shadow: Readonly<{
41
+ x: number;
42
+ y: number;
43
+ blur: number;
44
+ spread: number;
45
+ opacity: number;
46
+ }>;
47
+ transaction: Readonly<{
48
+ duration: number;
49
+ easing: import("@phreshos/core").Easing;
50
+ }>;
51
+ };
52
+ /** Creates a stable source for a complete explicitly supplied Preferences value. */
53
+ export declare function fixedPreferencesSource(preferences: Preferences): PreferencesSource;
54
+ export declare const PreferencesContext: import("react").Context<PreferencesSource>;
@@ -1,41 +1,91 @@
1
1
  import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
2
2
  import { defaultAppearance } from "./appearance.js";
3
3
  export const AppearanceContext = createContext(defaultAppearance);
4
- export const PreferencesContext = createContext(null);
5
4
  /** Returns the nearest unresolved appearance, or React UI's default. */
6
5
  export function useAppearance() {
7
6
  return useContext(AppearanceContext);
8
7
  }
9
8
  /** Returns the nearest complete Preferences, or reactively follows the browser. */
10
9
  export function usePreferences() {
11
- const preferences = useContext(PreferencesContext);
12
- const browserPreferences = useBrowserPreferences();
13
- return preferences ?? browserPreferences;
10
+ const source = useContext(PreferencesContext);
11
+ return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getServerSnapshot);
14
12
  }
15
13
  /** Reactively reads the browser's complete visual preferences. */
16
14
  export function useBrowserPreferences() {
17
- const theme = useMediaPreference(darkThemeQuery) ? "dark" : "light";
18
- const animations = !useMediaPreference(reducedMotionQuery);
19
- return useMemo(() => ({ theme, animations }), [animations, theme]);
15
+ return useSyncExternalStore(browserPreferencesSource.subscribe, browserPreferencesSource.getSnapshot, browserPreferencesSource.getServerSnapshot);
20
16
  }
21
17
  /** Selects the active branch of one complete ThemedValue. */
22
18
  export function useThemedValue(value) {
23
19
  return value[usePreferences().theme];
24
20
  }
25
- function useMediaPreference(query) {
26
- return useSyncExternalStore(change => subscribeMediaPreference(query, change), () => browserMediaPreference(query), () => false);
21
+ /** Resolves one coherent Appearance snapshot for internal component derivation. */
22
+ export function useResolvedAppearance() {
23
+ const appearance = useAppearance();
24
+ const preferences = usePreferences();
25
+ return useMemo(() => ({
26
+ appearance,
27
+ preferences,
28
+ colors: appearance.colors[preferences.theme],
29
+ material: appearance.material[preferences.theme],
30
+ shadow: appearance.shadow[preferences.theme],
31
+ transaction: appearance.transaction
32
+ }), [appearance, preferences]);
27
33
  }
28
- function subscribeMediaPreference(query, change) {
34
+ /** Creates a stable source for a complete explicitly supplied Preferences value. */
35
+ export function fixedPreferencesSource(preferences) {
36
+ return {
37
+ getSnapshot: () => preferences,
38
+ getServerSnapshot: () => preferences,
39
+ subscribe: emptySubscription
40
+ };
41
+ }
42
+ const defaultPreferences = Object.freeze({ theme: "light", animations: true });
43
+ const subscribers = new Set();
44
+ let darkPreference;
45
+ let reducedMotionPreference;
46
+ let browserSnapshot = defaultPreferences;
47
+ function readBrowserPreferences() {
29
48
  if (typeof window === "undefined" || typeof window.matchMedia !== "function")
30
- return () => undefined;
31
- const preference = window.matchMedia(query);
32
- preference.addEventListener("change", change);
33
- return () => preference.removeEventListener("change", change);
49
+ return defaultPreferences;
50
+ darkPreference ??= window.matchMedia(darkThemeQuery);
51
+ reducedMotionPreference ??= window.matchMedia(reducedMotionQuery);
52
+ const theme = darkPreference.matches ? "dark" : "light";
53
+ const animations = !reducedMotionPreference.matches;
54
+ if (browserSnapshot.theme !== theme || browserSnapshot.animations !== animations) {
55
+ browserSnapshot = Object.freeze({ theme, animations });
56
+ }
57
+ return browserSnapshot;
58
+ }
59
+ function subscribeBrowserPreferences(change) {
60
+ subscribers.add(change);
61
+ readBrowserPreferences();
62
+ if (subscribers.size === 1) {
63
+ darkPreference?.addEventListener("change", publishBrowserPreferences);
64
+ reducedMotionPreference?.addEventListener("change", publishBrowserPreferences);
65
+ }
66
+ return () => {
67
+ subscribers.delete(change);
68
+ if (subscribers.size !== 0)
69
+ return;
70
+ darkPreference?.removeEventListener("change", publishBrowserPreferences);
71
+ reducedMotionPreference?.removeEventListener("change", publishBrowserPreferences);
72
+ darkPreference = undefined;
73
+ reducedMotionPreference = undefined;
74
+ };
75
+ }
76
+ function publishBrowserPreferences() {
77
+ readBrowserPreferences();
78
+ for (const subscriber of subscribers)
79
+ subscriber();
34
80
  }
35
- function browserMediaPreference(query) {
36
- return Boolean(typeof window !== "undefined"
37
- && typeof window.matchMedia === "function"
38
- && window.matchMedia(query).matches);
81
+ function emptySubscription() {
82
+ return () => undefined;
39
83
  }
84
+ const browserPreferencesSource = {
85
+ getSnapshot: readBrowserPreferences,
86
+ getServerSnapshot: () => defaultPreferences,
87
+ subscribe: subscribeBrowserPreferences
88
+ };
89
+ export const PreferencesContext = createContext(browserPreferencesSource);
40
90
  const darkThemeQuery = "(prefers-color-scheme: dark)";
41
91
  const reducedMotionQuery = "(prefers-reduced-motion: reduce)";
package/dist/button.js CHANGED
@@ -1,7 +1,7 @@
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 { controlFontSizes, useControlTheme } from "./control.js";
4
+ import { controlOpacity, useControlTheme } from "./control.js";
5
5
  import { SurfaceButton } from "./control-surface.js";
6
6
  /** An Appearance-aware action with normalized pointer and keyboard behavior. */
7
7
  export const Button = forwardRef(function Button({ children, color, disabled = false, pending = false, onPress, radius = "medium", size = "medium", style, material, shadow, type = "button", ...properties }, ref) {
@@ -13,14 +13,11 @@ export const Button = forwardRef(function Button({ children, color, disabled = f
13
13
  isHovered,
14
14
  isPressed,
15
15
  pending,
16
- size,
17
16
  style
18
17
  }), children: children });
19
18
  });
20
- function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pending, size, style }) {
21
- const fontSize = controlFontSizes[size];
19
+ function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pending, style }) {
22
20
  const { spacing, focusColor } = theme;
23
- const height = Math.max(24, 24 + spacing);
24
21
  const interactive = !disabled && !pending;
25
22
  const paint = buttonPaint(theme, interactive, isHovered, isPressed);
26
23
  return {
@@ -34,20 +31,19 @@ function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pe
34
31
  justifyContent: "center",
35
32
  flexShrink: 0,
36
33
  minWidth: 0,
37
- height,
34
+ height: theme.height,
38
35
  paddingBlock: 0,
39
36
  paddingInline: Math.max(8, spacing),
40
- gap: Math.max(4, spacing / 2),
37
+ gap: theme.gap,
41
38
  border: "none",
42
39
  borderRadius: theme.radius,
43
40
  outline: isFocusVisible ? `1px solid ${focusColor}` : "none",
44
41
  outlineOffset: 1,
45
42
  ...paint,
46
- opacity: disabled ? 0.46 : pending ? 0.68 : 1,
43
+ opacity: disabled ? controlOpacity.disabled : pending ? controlOpacity.pending : 1,
47
44
  cursor: disabled ? "not-allowed" : pending ? "progress" : "pointer",
48
45
  font: "inherit",
49
- fontSize,
50
- fontWeight: 550,
46
+ fontSize: theme.fontSize,
51
47
  lineHeight: 1,
52
48
  textAlign: "center",
53
49
  textDecoration: "none",
package/dist/color.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AppearanceColor } from "./appearance.js";
1
+ import type { AppearanceColor, AppearanceColors } from "./appearance.js";
2
2
  /** A visual treatment derived from one concrete CSS color. */
3
3
  export type ColorLevel = "subtle" | "soft" | "base" | "strong" | "intense";
4
4
  /** Every visual treatment derived from one concrete CSS color. */
@@ -14,10 +14,10 @@ export declare function color(value: string): ColorScale;
14
14
  export declare function resolveColorLevel(value: string, level: ColorLevel): string;
15
15
  /** Returns the complete visual treatments derived from one concrete color. */
16
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;
17
+ /** Resolves a semantic color from an already selected Appearance branch. */
18
+ export declare function resolveColor(value: Color | undefined, colors: AppearanceColors): string;
19
+ /** Resolves opaque control paint from an already selected Appearance branch. */
20
+ export declare function resolveSolidColor(value: Color, colors: AppearanceColors): string;
21
21
  /** Applies material opacity without restricting the source CSS color syntax. */
22
22
  export declare function colorOpacity(value: string, opacity: number): string;
23
23
  /** Reads perceptual lightness from a resolved CSS color. */
@@ -33,6 +33,8 @@ export declare function opaqueColor(value: string): string;
33
33
  export declare function colorShade(value: string, amount: number): string;
34
34
  /** Chooses only from the two Appearance colors, against the actual painted fill. */
35
35
  export declare function onColor(fill: string, background: string, foreground: string): string;
36
+ /** Chooses readable content when the fill can be evaluated, otherwise preserves Appearance foreground. */
37
+ export declare function contrastingColor(value: Color, colors: AppearanceColors): string;
36
38
  /** Solid controls start at base; interaction shades preserve its text choice. */
37
39
  export declare function solidColors(base: string, background: string, foreground: string): {
38
40
  rest: {
package/dist/color.js CHANGED
@@ -1,6 +1,25 @@
1
1
  import { useMemo } from "react";
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, useThemedValue } from "./ui-provider.js";
2
+ import ColorSpace from "colorjs.io/src/ColorSpace.js";
3
+ import { mix } from "colorjs.io/src/interpolation.js";
4
+ import parse from "colorjs.io/src/parse.js";
5
+ import serialize from "colorjs.io/src/serialize.js";
6
+ import to from "colorjs.io/src/to.js";
7
+ import toGamut from "colorjs.io/src/toGamut.js";
8
+ import contrastWCAG21 from "colorjs.io/src/contrast/WCAG21.js";
9
+ import sRGB from "colorjs.io/src/spaces/srgb.js";
10
+ import sRGB_Linear from "colorjs.io/src/spaces/srgb-linear.js";
11
+ import HSL from "colorjs.io/src/spaces/hsl.js";
12
+ import HWB from "colorjs.io/src/spaces/hwb.js";
13
+ import Lab from "colorjs.io/src/spaces/lab.js";
14
+ import LCH from "colorjs.io/src/spaces/lch.js";
15
+ import OKLab from "colorjs.io/src/spaces/oklab.js";
16
+ import OKLCH from "colorjs.io/src/spaces/oklch.js";
17
+ import P3 from "colorjs.io/src/spaces/p3.js";
18
+ import A98RGB from "colorjs.io/src/spaces/a98rgb.js";
19
+ import ProPhoto from "colorjs.io/src/spaces/prophoto.js";
20
+ import REC_2020 from "colorjs.io/src/spaces/rec2020.js";
21
+ import XYZ_D50 from "colorjs.io/src/spaces/xyz-d50.js";
22
+ import XYZ_D65 from "colorjs.io/src/spaces/xyz-d65.js";
4
23
  // Register the CSS color spaces, without bundling unrelated color-model APIs.
5
24
  for (const space of [sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65])
6
25
  ColorSpace.register(space);
@@ -40,17 +59,14 @@ export function resolveColorLevel(value, level) {
40
59
  export function useColor(value) {
41
60
  return useMemo(() => color(value), [value]);
42
61
  }
43
- /** Resolves a semantic Appearance color or preserves a direct CSS color. */
44
- export function useResolveColor(value = defaultColor) {
45
- const appearance = useAppearance();
46
- const colors = useThemedValue(appearance.colors);
47
- const semantic = parseSemanticColor(value, colors);
48
- return semantic ? color(semantic.source)[semantic.level] : value;
62
+ /** Resolves a semantic color from an already selected Appearance branch. */
63
+ export function resolveColor(value, colors) {
64
+ const resolved = value ?? defaultColor;
65
+ const semantic = parseSemanticColor(resolved, colors);
66
+ return semantic ? color(semantic.source)[semantic.level] : resolved;
49
67
  }
50
- /** Resolves a color to concrete opaque paint for solid interactive states. */
51
- export function useResolveSolidColor(value) {
52
- const appearance = useAppearance();
53
- const colors = useThemedValue(appearance.colors);
68
+ /** Resolves opaque control paint from an already selected Appearance branch. */
69
+ export function resolveSolidColor(value, colors) {
54
70
  const semantic = parseSemanticColor(value, colors);
55
71
  return semantic ? resolveColorLevel(semantic.source, semantic.level) : opaqueColor(value);
56
72
  }
@@ -88,6 +104,15 @@ export function onColor(fill, background, foreground) {
88
104
  const second = opaqueColor(foreground);
89
105
  return contrastWCAG21(fill, first) > contrastWCAG21(fill, second) ? first : second;
90
106
  }
107
+ /** Chooses readable content when the fill can be evaluated, otherwise preserves Appearance foreground. */
108
+ export function contrastingColor(value, colors) {
109
+ try {
110
+ return onColor(resolveSolidColor(value, colors), colors.background, colors.foreground);
111
+ }
112
+ catch {
113
+ return colors.foreground;
114
+ }
115
+ }
91
116
  /** Solid controls start at base; interaction shades preserve its text choice. */
92
117
  export function solidColors(base, background, foreground) {
93
118
  const fill = resolveColorLevel(base, "base");
@@ -1,15 +1,18 @@
1
1
  import type { ComponentProps } from "react";
2
2
  import { Pressable as AriaPressable } from "react-aria-components";
3
3
  import type { MenuTriggerProps as AriaMenuTriggerProps } from "react-aria-components";
4
+ import { type PopoverContentProps } from "./popover.js";
4
5
  export type ContextMenuRootProps = Omit<AriaMenuTriggerProps, "trigger">;
5
6
  export declare function ContextMenuRoot(properties: ContextMenuRootProps): import("react").JSX.Element;
6
7
  export type ContextMenuTriggerProps = ComponentProps<typeof AriaPressable>;
7
8
  export declare const ContextMenuTrigger: import("react").ForwardRefExoticComponent<Omit<import("react-aria/Pressable").PressableProps & import("react").RefAttributes<import("@react-types/shared").FocusableElement>, "ref"> & import("react").RefAttributes<import("@react-types/shared").FocusableElement>>;
9
+ export type ContextMenuContentProps = Omit<PopoverContentProps, "isNonModal">;
10
+ export declare const ContextMenuContent: import("react").ForwardRefExoticComponent<ContextMenuContentProps & import("react").RefAttributes<HTMLElement>>;
8
11
  /** A context-requested overlay sharing the same Menu contract as DropdownMenu. */
9
12
  export declare const ContextMenu: typeof ContextMenuRoot & {
10
13
  Root: typeof ContextMenuRoot;
11
14
  Trigger: import("react").ForwardRefExoticComponent<Omit<import("react-aria/Pressable").PressableProps & import("react").RefAttributes<import("@react-types/shared").FocusableElement>, "ref"> & import("react").RefAttributes<import("@react-types/shared").FocusableElement>>;
12
- Content: import("react").ForwardRefExoticComponent<import("./popover.js").PopoverContentProps & import("react").RefAttributes<HTMLElement>>;
15
+ Content: import("react").ForwardRefExoticComponent<ContextMenuContentProps & import("react").RefAttributes<HTMLElement>>;
13
16
  Menu: import("./menu.js").MenuRootComponent & {
14
17
  Root: import("./menu.js").MenuRootComponent;
15
18
  Item: import("./menu.js").MenuItemComponent;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { forwardRef } from "react";
3
- import { MenuTrigger as AriaMenuTrigger, Pressable as AriaPressable } from "react-aria-components";
2
+ import { forwardRef, useCallback, useContext, useEffect, useRef } from "react";
3
+ import { MenuTrigger as AriaMenuTrigger, OverlayTriggerStateContext, Pressable as AriaPressable } from "react-aria-components";
4
4
  import { Menu } from "./menu.js";
5
5
  import { PopoverContent } from "./popover.js";
6
6
  export function ContextMenuRoot(properties) {
@@ -9,10 +9,37 @@ export function ContextMenuRoot(properties) {
9
9
  export const ContextMenuTrigger = forwardRef(function ContextMenuTrigger(properties, ref) {
10
10
  return _jsx(AriaPressable, { ...properties, ref: ref });
11
11
  });
12
+ function setRef(ref, value) {
13
+ if (typeof ref === "function")
14
+ ref(value);
15
+ else if (ref)
16
+ ref.current = value;
17
+ }
18
+ export const ContextMenuContent = forwardRef(function ContextMenuContent(properties, forwardedRef) {
19
+ const state = useContext(OverlayTriggerStateContext);
20
+ const contentRef = useRef(null);
21
+ const ref = useCallback((element) => {
22
+ contentRef.current = element;
23
+ setRef(forwardedRef, element);
24
+ }, [forwardedRef]);
25
+ useEffect(() => {
26
+ if (!state?.isOpen)
27
+ return;
28
+ function dismiss(event) {
29
+ const target = event.target;
30
+ if (target instanceof Node && contentRef.current?.contains(target))
31
+ return;
32
+ state?.close();
33
+ }
34
+ document.addEventListener("pointerdown", dismiss, true);
35
+ return () => document.removeEventListener("pointerdown", dismiss, true);
36
+ }, [state]);
37
+ return _jsx(PopoverContent, { ...properties, ref: ref, isNonModal: true });
38
+ });
12
39
  /** A context-requested overlay sharing the same Menu contract as DropdownMenu. */
13
40
  export const ContextMenu = Object.assign(ContextMenuRoot, {
14
41
  Root: ContextMenuRoot,
15
42
  Trigger: ContextMenuTrigger,
16
- Content: PopoverContent,
43
+ Content: ContextMenuContent,
17
44
  Menu
18
45
  });
package/dist/control.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type RadiusProps } from "./radius.js";
3
3
  import { type ScaleLevel } from "./scale.js";
4
4
  import { solidColors } from "./color.js";
5
5
  import { type Color } from "./color.js";
6
+ import type { Transition } from "motion/react";
6
7
  /** Semantic Appearance colors accepted by interactive controls. */
7
8
  export type ControlColor = Color;
8
9
  export interface ControlProps {
@@ -22,10 +23,22 @@ export interface FieldProps {
22
23
  }
23
24
  /** Names owned by our contract, not a second set of primitive-specific aliases. */
24
25
  export type ControlOverrides = keyof ControlProps | keyof FieldProps | "children" | "isDisabled" | "isRequired" | "isInvalid";
25
- export declare const controlFontSizes: Readonly<Record<ScaleLevel, number>>;
26
+ export declare const controlFontSizes: Readonly<Record<ScaleLevel, CSSProperties["fontSize"]>>;
27
+ /** Shared interaction attenuation, derived consistently across control families. */
28
+ export declare const controlOpacity: Readonly<{
29
+ disabled: 0.46;
30
+ pending: 0.68;
31
+ secondary: 0.7;
32
+ placeholder: 0.6;
33
+ separator: 0.14;
34
+ }>;
35
+ /** Shared emphasis used by labels and control-owned headings. */
36
+ export declare const controlFontWeight = 600;
26
37
  type SolidColors = ReturnType<typeof solidColors>;
27
38
  export interface ControlTheme {
28
39
  readonly transition: CSSProperties;
40
+ readonly motionTransition: Transition;
41
+ readonly animations: boolean;
29
42
  readonly spacing: number;
30
43
  readonly foreground: string;
31
44
  readonly background: string;
@@ -38,7 +51,8 @@ export interface ControlTheme {
38
51
  readonly danger: SolidColors;
39
52
  };
40
53
  readonly radius: CSSProperties["borderRadius"];
41
- readonly fontSize: number;
54
+ readonly fontSize: CSSProperties["fontSize"];
55
+ readonly indicatorSize: number;
42
56
  readonly height: number;
43
57
  readonly gap: number;
44
58
  }
package/dist/control.js CHANGED
@@ -1,37 +1,50 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import { FieldError, Label, Text } from "react-aria-components";
4
- import { useAppearance, useThemedValue } from "./ui-provider.js";
4
+ import { useResolvedAppearance } from "./appearance-context.js";
5
5
  import { resolveRadius } from "./radius.js";
6
6
  import { scale } from "./scale.js";
7
7
  import { colorOpacity, solidColors } from "./color.js";
8
- import { useResolveSolidColor } from "./color.js";
9
- import { useVisualTransition } from "./motion-style.js";
8
+ import { resolveSolidColor } from "./color.js";
9
+ import { controlTransition, visualTransition } from "./motion-style.js";
10
10
  export const controlFontSizes = Object.freeze({
11
- xsmall: 11,
12
- small: 12,
13
- medium: 13,
14
- large: 14,
15
- xlarge: 15
11
+ xsmall: "0.6875em",
12
+ small: "0.75em",
13
+ medium: "0.8125em",
14
+ large: "0.875em",
15
+ xlarge: "0.9375em"
16
16
  });
17
+ /** Shared interaction attenuation, derived consistently across control families. */
18
+ export const controlOpacity = Object.freeze({
19
+ disabled: 0.46,
20
+ pending: 0.68,
21
+ secondary: 0.7,
22
+ placeholder: 0.6,
23
+ separator: 0.14
24
+ });
25
+ /** Shared emphasis used by labels and control-owned headings. */
26
+ export const controlFontWeight = 600;
17
27
  export function useControlTheme({ size = "medium", color, radius = "medium" }) {
18
- const appearance = useAppearance();
19
- const transition = useVisualTransition();
28
+ const resolved = useResolvedAppearance();
29
+ const { appearance, colors } = resolved;
30
+ const transition = useMemo(() => visualTransition(resolved.transaction, resolved.preferences.animations), [resolved.transaction, resolved.preferences.animations]);
31
+ const motionTransition = useMemo(() => controlTransition(resolved.transaction, resolved.preferences.animations), [resolved.transaction, resolved.preferences.animations]);
20
32
  const spacing = scale(appearance.spacing, size);
21
- const colors = useThemedValue(appearance.colors);
22
33
  const foreground = colors.foreground;
23
34
  const background = colors.background;
24
- const tint = useResolveSolidColor(color ?? "default:base");
25
- const neutral = useResolveSolidColor("default:base");
26
- const focus = useResolveSolidColor("warning:base");
27
- const danger = useResolveSolidColor("danger:base");
35
+ const tint = resolveSolidColor(color ?? "default:base", colors);
36
+ const neutral = resolveSolidColor("default:base", colors);
37
+ const focus = resolveSolidColor("warning:base", colors);
38
+ const danger = resolveSolidColor("danger:base", colors);
28
39
  const paints = useMemo(() => ({
29
40
  palette: solidColors(tint, background, foreground),
30
41
  neutral: solidColors(neutral, background, foreground),
31
42
  danger: solidColors(danger, background, foreground)
32
43
  }), [tint, neutral, background, foreground, danger]);
33
- return {
44
+ return useMemo(() => ({
34
45
  transition,
46
+ motionTransition,
47
+ animations: resolved.preferences.animations,
35
48
  spacing,
36
49
  foreground,
37
50
  background,
@@ -41,9 +54,10 @@ export function useControlTheme({ size = "medium", color, radius = "medium" }) {
41
54
  paints,
42
55
  radius: resolveRadius(radius, appearance),
43
56
  fontSize: controlFontSizes[size],
57
+ indicatorSize: Math.max(16, 16 + spacing / 4),
44
58
  height: Math.max(24, 24 + spacing),
45
59
  gap: Math.max(4, spacing / 2)
46
- };
60
+ }), [appearance, colors, transition, motionTransition, resolved.preferences.animations, spacing, foreground, background, tint, focus, danger, paints, radius, size]);
47
61
  }
48
62
  export function fieldStyle(theme, disabled = false, style) {
49
63
  return {
@@ -56,7 +70,7 @@ export function fieldStyle(theme, disabled = false, style) {
56
70
  fontFamily: "inherit",
57
71
  fontSize: theme.fontSize,
58
72
  lineHeight: 1.5,
59
- opacity: disabled ? 0.46 : 1
73
+ opacity: disabled ? controlOpacity.disabled : 1
60
74
  };
61
75
  }
62
76
  export function controlPaint(theme, focused, invalid, hovered = false) {
@@ -85,8 +99,8 @@ export function controlStyle(theme, focused, invalid, hovered = false, focusVisi
85
99
  };
86
100
  }
87
101
  export function FieldLabel({ label }) {
88
- return label == null ? null : _jsx(Label, { style: { fontWeight: 550 }, children: label });
102
+ return label == null ? null : _jsx(Label, { style: { fontWeight: controlFontWeight }, children: label });
89
103
  }
90
104
  export function FieldFeedback({ description, errorMessage, theme }) {
91
- return _jsxs(_Fragment, { children: [description != null && _jsx(Text, { slot: "description", style: { fontSize: "0.92em", opacity: 0.7 }, children: description }), _jsx(FieldError, { style: { fontSize: "0.92em", color: theme.danger }, children: errorMessage })] });
105
+ return _jsxs(_Fragment, { children: [description != null && _jsx(Text, { slot: "description", style: { fontSize: "0.92em", opacity: controlOpacity.secondary }, children: description }), _jsx(FieldError, { style: { fontSize: "0.92em", color: theme.danger }, children: errorMessage })] });
92
106
  }