@phreshos/react-ui 0.1.27 → 0.1.29

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.
Files changed (47) hide show
  1. package/README.md +40 -21
  2. package/dist/appearance-context.d.ts +47 -16
  3. package/dist/appearance-context.js +70 -20
  4. package/dist/appearance.d.ts +6 -0
  5. package/dist/appearance.js +11 -0
  6. package/dist/button.js +6 -10
  7. package/dist/color.d.ts +7 -5
  8. package/dist/color.js +37 -12
  9. package/dist/context-menu.d.ts +4 -1
  10. package/dist/context-menu.js +30 -3
  11. package/dist/control.d.ts +16 -2
  12. package/dist/control.js +34 -20
  13. package/dist/dialog.js +47 -40
  14. package/dist/direction.d.ts +9 -1
  15. package/dist/direction.js +34 -10
  16. package/dist/field-style.js +2 -1
  17. package/dist/layout.d.ts +1 -1
  18. package/dist/main.d.ts +4 -3
  19. package/dist/main.js +3 -2
  20. package/dist/material-options.d.ts +10 -3
  21. package/dist/material-options.js +3 -6
  22. package/dist/material-paint.d.ts +3 -1
  23. package/dist/material-paint.js +35 -11
  24. package/dist/menu.js +30 -18
  25. package/dist/motion-style.d.ts +6 -8
  26. package/dist/motion-style.js +15 -31
  27. package/dist/popover.js +13 -12
  28. package/dist/preferences.d.ts +4 -0
  29. package/dist/preferences.js +0 -0
  30. package/dist/radio.js +4 -3
  31. package/dist/radius.d.ts +1 -1
  32. package/dist/scroll-area.js +17 -10
  33. package/dist/select.js +24 -36
  34. package/dist/shadow-options.d.ts +9 -3
  35. package/dist/shadow-options.js +3 -6
  36. package/dist/slider.js +7 -6
  37. package/dist/spacing.d.ts +1 -1
  38. package/dist/surface-edge.d.ts +3 -1
  39. package/dist/surface-edge.js +3 -5
  40. package/dist/surface.js +24 -19
  41. package/dist/toggle-indicator.js +2 -5
  42. package/dist/tooltip.js +16 -15
  43. package/dist/ui-provider.d.ts +2 -1
  44. package/dist/ui-provider.js +9 -7
  45. package/dist/window-header.d.ts +42 -0
  46. package/dist/window-header.js +113 -0
  47. package/package.json +2 -2
@@ -1,15 +1,13 @@
1
1
  import type { CSSProperties } from "react";
2
2
  import type { Transition } from "motion/react";
3
- /** CSS timing for values painted directly by React UI. */
4
- export declare function useVisualTransition(): CSSProperties;
5
- /** CSS timing for SVG paint owned by a Material. */
6
- export declare function usePaintTransition(): CSSProperties;
7
- /** Motion timing for interactive geometry owned by React UI. */
8
- export declare function useControlTransition(): Transition;
3
+ import type { AppearanceTransaction } from "./appearance.js";
4
+ export declare function controlTransition(transaction: AppearanceTransaction, animations: boolean): Transition;
9
5
  /** Per-overlay variables consumed by the shared entrance keyframes. */
10
- export declare function useOverlayTransition(): CSSProperties;
6
+ export declare function overlayTransition(transaction: AppearanceTransaction, animations: boolean): CSSProperties;
11
7
  export declare const overlayMotionClass = "phreshos-ui-overlay";
12
8
  export declare const backdropMotionClass = "phreshos-ui-backdrop";
13
- export declare function useTransitionTiming(): CSSProperties;
9
+ export declare function transitionTiming(transaction: AppearanceTransaction, animations: boolean): CSSProperties;
10
+ export declare function visualTransition(transaction: AppearanceTransaction, animations: boolean): CSSProperties;
11
+ export declare function paintTransition(transaction: AppearanceTransaction, animations: boolean): CSSProperties;
14
12
  /** React hoists and deduplicates this stylesheet, including portals. */
15
13
  export default function MotionStyle(): import("react").JSX.Element;
@@ -1,45 +1,34 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { defaultAppearance } from "@phreshos/core";
3
- import { useAppearance, usePreferences } from "./appearance-context.js";
4
2
  const visualProperties = "background-color, color, border-color, border-radius, box-shadow";
5
3
  const paintProperties = "fill, stroke, opacity";
6
- /** CSS timing for values painted directly by React UI. */
7
- export function useVisualTransition() {
8
- return { ...useTransitionTiming(), transitionProperty: visualProperties };
9
- }
10
- /** CSS timing for SVG paint owned by a Material. */
11
- export function usePaintTransition() {
12
- return { ...useTransitionTiming(), transitionProperty: paintProperties };
13
- }
14
- /** Motion timing for interactive geometry owned by React UI. */
15
- export function useControlTransition() {
16
- const { duration, easing } = useAppearance().transaction;
17
- const { animations } = usePreferences();
4
+ export function controlTransition(transaction, animations) {
18
5
  return {
19
6
  type: "tween",
20
- duration: animations ? duration / 1_000 : 0,
21
- ease: motionEasing(easing)
7
+ duration: animations ? transaction.duration / 1_000 : 0,
8
+ ease: motionEasing(transaction.easing)
22
9
  };
23
10
  }
24
11
  /** Per-overlay variables consumed by the shared entrance keyframes. */
25
- export function useOverlayTransition() {
26
- const { duration, easing } = useAppearance().transaction;
27
- const { animations } = usePreferences();
12
+ export function overlayTransition(transaction, animations) {
28
13
  return {
29
- "--phreshos-ui-motion-duration": `${animations ? duration : 0}ms`,
30
- "--phreshos-ui-motion-easing": cssEasing(easing)
14
+ "--phreshos-ui-motion-duration": `${animations ? transaction.duration : 0}ms`,
15
+ "--phreshos-ui-motion-easing": cssEasing(transaction.easing)
31
16
  };
32
17
  }
33
18
  export const overlayMotionClass = "phreshos-ui-overlay";
34
19
  export const backdropMotionClass = "phreshos-ui-backdrop";
35
- export function useTransitionTiming() {
36
- const { duration, easing } = useAppearance().transaction;
37
- const { animations } = usePreferences();
20
+ export function transitionTiming(transaction, animations) {
38
21
  return {
39
- transitionDuration: `${animations ? duration : 0}ms`,
40
- transitionTimingFunction: cssEasing(easing)
22
+ transitionDuration: `${animations ? transaction.duration : 0}ms`,
23
+ transitionTimingFunction: cssEasing(transaction.easing)
41
24
  };
42
25
  }
26
+ export function visualTransition(transaction, animations) {
27
+ return { ...transitionTiming(transaction, animations), transitionProperty: visualProperties };
28
+ }
29
+ export function paintTransition(transaction, animations) {
30
+ return { ...transitionTiming(transaction, animations), transitionProperty: paintProperties };
31
+ }
43
32
  function cssEasing(easing) {
44
33
  return typeof easing === "string" ? easing : `cubic-bezier(${easing.join(", ")})`;
45
34
  }
@@ -57,11 +46,6 @@ function motionEasing(easing) {
57
46
  return easing;
58
47
  }
59
48
  const stylesheet = `
60
- :root {
61
- --phreshos-ui-motion-duration: ${defaultAppearance.transaction.duration}ms;
62
- --phreshos-ui-motion-easing: ${cssEasing(defaultAppearance.transaction.easing)};
63
- }
64
-
65
49
  .phreshos-ui-overlay[data-entering] {
66
50
  animation: phreshos-ui-overlay-enter var(--phreshos-ui-motion-duration) var(--phreshos-ui-motion-easing) both;
67
51
  }
package/dist/popover.js CHANGED
@@ -1,9 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Heading as AriaHeading, Popover as AriaPopover } from "react-aria-components";
4
- import { useAppearance } from "./ui-provider.js";
4
+ import { useResolvedAppearance } from "./appearance-context.js";
5
5
  import { Button } from "./button.js";
6
- import { overlayMotionClass, useOverlayTransition } from "./motion-style.js";
6
+ import MotionStyle, { overlayMotionClass, overlayTransition } from "./motion-style.js";
7
7
  import { scale } from "./scale.js";
8
8
  import { Surface } from "./surface.js";
9
9
  import { resolveDirection, useDirection } from "./direction.js";
@@ -15,16 +15,17 @@ export const PopoverTrigger = forwardRef(function PopoverTrigger(properties, ref
15
15
  return _jsx(Button, { ...properties, ref: ref });
16
16
  });
17
17
  export const PopoverContent = forwardRef(function PopoverContent({ children, className, color, material, radius, shadow, style, offset, placement = "bottom", ...properties }, ref) {
18
- const inset = scale(useAppearance().spacing, "small");
19
- const transition = useOverlayTransition();
18
+ const resolved = useResolvedAppearance();
19
+ const inset = scale(resolved.appearance.spacing, "small");
20
+ const transition = overlayTransition(resolved.transaction, resolved.preferences.animations);
20
21
  const direction = resolveDirection(properties.dir, useDirection());
21
- return _jsx(AriaPopover, { ...properties, ref: ref, dir: direction, offset: offset ?? inset, placement: resolveDirectionalPlacement(placement, direction), className: overlayMotionClass, style: transition, children: _jsx(Surface, { dir: direction, className: className, color: color, material: material, radius: radius, shadow: shadow, style: {
22
- boxSizing: "border-box",
23
- maxWidth: `calc(100vw - ${inset * 2}px)`,
24
- maxHeight: `calc(100vh - ${inset * 2}px)`,
25
- outline: "none",
26
- ...style
27
- }, children: children }) });
22
+ return _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsx(AriaPopover, { ...properties, ref: ref, dir: direction, offset: offset ?? inset, placement: resolveDirectionalPlacement(placement, direction), className: overlayMotionClass, style: transition, children: _jsx(Surface, { dir: direction, className: className, color: color, material: material, radius: radius, shadow: shadow, style: {
23
+ boxSizing: "border-box",
24
+ maxWidth: `calc(100vw - ${inset * 2}px)`,
25
+ maxHeight: `calc(100vh - ${inset * 2}px)`,
26
+ outline: "none",
27
+ ...style
28
+ }, children: children }) })] });
28
29
  });
29
30
  export const PopoverDialog = forwardRef(function PopoverDialog({ style, ...properties }, ref) {
30
31
  return _jsx(AriaDialog, { ...properties, ref: ref, style: { minWidth: 0, outline: "none", ...style } });
@@ -0,0 +1,4 @@
1
+ import type { DesktopPreferences } from "@phreshos/core";
2
+ /** Complete environment preferences consumed by React UI. */
3
+ export type Preferences = Pick<DesktopPreferences, "theme" | "animations">;
4
+ export type Theme = Preferences["theme"];
File without changes
package/dist/radio.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Base UI accepts direction directly; React Aria derives these keys from locale.
2
3
  import { Radio as BaseRadio } from "@base-ui/react/radio";
3
4
  import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
4
5
  import { DirectionProvider as BaseDirectionProvider } from "@base-ui/react/direction-provider";
5
6
  import { createContext, forwardRef, useContext, useId, useState } from "react";
6
- import { fieldStyle, useControlTheme } from "./control.js";
7
+ import { controlFontWeight, controlOpacity, fieldStyle, useControlTheme } from "./control.js";
7
8
  import { ToggleIndicator, toggleStyle } from "./toggle-indicator.js";
8
9
  import { resolveDirection, useDirection } from "./direction.js";
9
10
  const RadioStyleContext = createContext(null);
@@ -18,7 +19,7 @@ export const RadioGroup = forwardRef(function RadioGroup({ label, description, e
18
19
  const root = _jsxs(BaseRadioGroup, { ...properties, ref: ref, className: className, value: value, defaultValue: defaultValue, onValueChange: next => {
19
20
  if (next != null)
20
21
  onChange?.(next);
21
- }, disabled: disabled, readOnly: readOnly, required: required, "aria-invalid": invalid || undefined, "aria-labelledby": ariaLabelledBy ?? (label != null ? labelId : undefined), "aria-describedby": describedBy, style: fieldStyle(theme, disabled, style), children: [label != null && _jsx("div", { id: labelId, style: { fontWeight: 550 }, children: label }), _jsx(RadioStyleContext.Provider, { value: { size, color, disabled, invalid, readOnly, material, shadow }, children: _jsx("div", { style: { display: "flex", flexDirection: orientation === "vertical" ? "column" : "row", gap: theme.gap, flexWrap: "wrap" }, children: children }) }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: 0.7 }, children: description }), invalid && errorMessage != null && _jsx("span", { id: errorId, style: { fontSize: "0.92em", color: theme.danger }, children: errorMessage })] });
22
+ }, disabled: disabled, readOnly: readOnly, required: required, "aria-invalid": invalid || undefined, "aria-labelledby": ariaLabelledBy ?? (label != null ? labelId : undefined), "aria-describedby": describedBy, style: fieldStyle(theme, disabled, style), children: [label != null && _jsx("div", { id: labelId, style: { fontWeight: controlFontWeight }, children: label }), _jsx(RadioStyleContext.Provider, { value: { size, color, disabled, invalid, readOnly, material, shadow }, children: _jsx("div", { style: { display: "flex", flexDirection: orientation === "vertical" ? "column" : "row", gap: theme.gap, flexWrap: "wrap" }, children: children }) }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: controlOpacity.secondary }, children: description }), invalid && errorMessage != null && _jsx("span", { id: errorId, style: { fontSize: "0.92em", color: theme.danger }, children: errorMessage })] });
22
23
  return _jsx(BaseDirectionProvider, { direction: direction, children: root });
23
24
  });
24
25
  /** An option in a RadioGroup. Selection and validation belong to the group. */
@@ -38,5 +39,5 @@ export const Radio = forwardRef(function Radio({ label, description, value, disa
38
39
  }, onBlur: event => {
39
40
  setFocusVisible(false);
40
41
  onBlur?.(event);
41
- }, style: state => toggleStyle(theme, state.disabled, state.readOnly), render: (native, state) => _jsxs("span", { ...native, children: [_jsx(ToggleIndicator, { kind: "radio", material: material ?? inherited.material, shadow: shadow ?? inherited.shadow, theme: theme, selected: state.checked, focused: focusVisible, invalid: inherited.invalid, hovered: !state.disabled && !state.readOnly && hovered, pressed: !state.disabled && !state.readOnly && pressed }), label] }) }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: 0.7 }, children: description })] });
42
+ }, style: state => toggleStyle(theme, state.disabled, state.readOnly), render: (native, state) => _jsxs("span", { ...native, children: [_jsx(ToggleIndicator, { kind: "radio", material: material ?? inherited.material, shadow: shadow ?? inherited.shadow, theme: theme, selected: state.checked, focused: focusVisible, invalid: inherited.invalid, hovered: !state.disabled && !state.readOnly && hovered, pressed: !state.disabled && !state.readOnly && pressed }), label] }) }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: controlOpacity.secondary }, children: description })] });
42
43
  });
package/dist/radius.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties } from "react";
2
- import type { Appearance } from "@phreshos/core";
2
+ import type { Appearance } from "./appearance.js";
3
3
  import { type ScaleLevel } from "./scale.js";
4
4
  /** An Appearance-derived level, fully rounded shape, pixel value, or explicit CSS corner radius. */
5
5
  export type Radius = ScaleLevel | "full" | number | (string & {});
@@ -2,43 +2,50 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area";
3
3
  import { DirectionProvider as BaseDirectionProvider } from "@base-ui/react/direction-provider";
4
4
  import { forwardRef, useState } from "react";
5
- import { useAppearance, useThemedValue } from "./ui-provider.js";
5
+ import { useResolvedAppearance } from "./appearance-context.js";
6
6
  import { colorOpacity } from "./color.js";
7
- import { useTransitionTiming } from "./motion-style.js";
7
+ import { transitionTiming } from "./motion-style.js";
8
8
  import { resolveDirection, useDirection } from "./direction.js";
9
9
  /** A native scrolling viewport with locally owned React UI scrollbars. */
10
10
  export const ScrollArea = forwardRef(function ScrollArea({ axis = "vertical", children, onScroll, style, viewportRef, ...properties }, ref) {
11
- const appearance = useAppearance();
11
+ const resolved = useResolvedAppearance();
12
+ const { appearance } = resolved;
12
13
  const direction = resolveDirection(properties.dir, useDirection());
13
- const foreground = useThemedValue(appearance.colors).foreground;
14
+ const foreground = resolved.colors.foreground;
14
15
  const radius = Math.min(appearance.radius, 8);
16
+ const thickness = Math.max(8, appearance.spacing);
17
+ const inset = Math.max(2, thickness / 3);
15
18
  const vertical = axis === "vertical" || axis === "both";
16
19
  const horizontal = axis === "horizontal" || axis === "both";
20
+ const transition = transitionTiming(resolved.transaction, resolved.preferences.animations);
17
21
  const root = _jsxs(BaseScrollArea.Root, { ...properties, "data-phreshos-scroll-area": "", ref: ref, style: {
18
22
  ...style,
23
+ display: "flex",
24
+ flexDirection: "column",
19
25
  position: style?.position ?? "relative",
20
26
  minWidth: style?.minWidth ?? 0,
21
27
  minHeight: style?.minHeight ?? 0,
22
28
  overflow: "hidden"
23
29
  }, children: [_jsx(BaseScrollArea.Viewport, { "data-phreshos-scroll-area-viewport": "", ref: viewportRef, onScroll: onScroll, style: {
30
+ flex: "1 1 auto",
31
+ minHeight: 0,
24
32
  width: "100%",
25
33
  height: "100%",
26
34
  overflowX: horizontal ? "scroll" : "hidden",
27
35
  overflowY: vertical ? "scroll" : "hidden"
28
- }, 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" } })] });
36
+ }, 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, thickness: thickness, inset: inset, transition: transition }), horizontal && _jsx(Scrollbar, { orientation: "horizontal", foreground: foreground, radius: radius, thickness: thickness, inset: inset, transition: transition }), vertical && horizontal && _jsx(BaseScrollArea.Corner, { style: { background: "transparent" } })] });
29
37
  return _jsx(BaseDirectionProvider, { direction: direction, children: root });
30
38
  });
31
- function Scrollbar({ orientation, foreground, radius }) {
39
+ function Scrollbar({ orientation, foreground, radius, thickness, inset, transition }) {
32
40
  const [hovered, setHovered] = useState(false);
33
- const transition = useTransitionTiming();
34
41
  const vertical = orientation === "vertical";
35
42
  return _jsx(BaseScrollArea.Scrollbar, { "data-phreshos-scroll-area-scrollbar": "", orientation: orientation, keepMounted: true, onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), style: state => {
36
43
  const present = vertical ? state.hasOverflowY : state.hasOverflowX;
37
44
  return {
38
45
  boxSizing: "border-box",
39
- width: vertical ? 16 : undefined,
40
- height: vertical ? undefined : 16,
41
- padding: 5,
46
+ width: vertical ? thickness : undefined,
47
+ height: vertical ? undefined : thickness,
48
+ padding: inset,
42
49
  opacity: present && (state.hovering || state.scrolling) ? 1 : 0,
43
50
  pointerEvents: present && (state.hovering || state.scrolling) ? "auto" : "none",
44
51
  transitionDuration: transition.transitionDuration,
package/dist/select.js CHANGED
@@ -1,48 +1,36 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { forwardRef } from "react";
3
- import { motion } from "motion/react";
4
- import { Select as AriaSelect, Button, SelectValue, Popover, ListBox, ListBoxItem } from "react-aria-components";
5
- import { controlPaint, controlStyle, FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
2
+ import { forwardRef, useMemo } from "react";
3
+ import { Select as AriaSelect, Button, SelectValue, ListBox, ListBoxItem } from "react-aria-components";
4
+ import { controlOpacity, controlPaint, controlStyle, FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
6
5
  import { SurfaceButton } from "./control-surface.js";
7
- import { Surface } from "./surface.js";
8
6
  import { ScrollArea } from "./scroll-area.js";
9
- import { overlayMotionClass, useControlTransition, useOverlayTransition } from "./motion-style.js";
7
+ import { PopoverContent } from "./popover.js";
10
8
  import { resolveDirection, useDirection } from "./direction.js";
11
- import { resolveDirectionalPlacement } from "./overlay-placement.js";
12
9
  /** Single selection from string-valued options; keyboard navigation and typeahead stay native to React Aria. */
13
10
  export const Select = forwardRef(function Select({ label, description, errorMessage, disabled, required, invalid, options, value, defaultValue, onChange, size, color, radius, style, material, shadow, ...properties }, ref) {
14
11
  const theme = useControlTheme({ size, color, radius });
15
- const transition = useControlTransition();
16
- const overlayTransition = useOverlayTransition();
17
12
  const direction = resolveDirection(properties.dir, useDirection());
18
- const optionDependencies = [
19
- theme.transition.transitionDuration,
20
- theme.transition.transitionTimingFunction,
21
- theme.transition.transitionProperty,
22
- theme.gap,
23
- theme.height,
24
- theme.spacing,
25
- theme.radius,
26
- theme.focusColor,
27
- theme.foreground,
28
- theme.paints.palette.rest.background,
29
- theme.paints.palette.rest.color,
30
- theme.paints.palette.hover.background,
31
- theme.paints.palette.hover.color
32
- ];
33
- 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, shadow: shadow, paint: controlPaint(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered) }), style: button => ({
13
+ const disabledKeys = useMemo(() => options.filter(option => option.disabled).map(option => option.value), [options]);
14
+ return _jsx(AriaSelect, { ...properties, ref: ref, value: value, defaultValue: defaultValue, onChange: key => onChange?.(key == null ? null : String(key)), disabledKeys: disabledKeys, 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, shadow: shadow, paint: controlPaint(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered) }), style: button => ({
34
15
  ...controlStyle(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered, button.isFocusVisible),
35
16
  display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
36
17
  cursor: disabled ? "not-allowed" : "pointer", textAlign: "start"
37
- }), 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, { dir: direction, className: overlayMotionClass, placement: resolveDirectionalPlacement("bottom start", direction), offset: theme.gap, maxHeight: 280 + theme.gap * 2, style: { ...overlayTransition, width: "var(--trigger-width)", maxWidth: "calc(100vw - 16px)" }, children: _jsx(Surface, { dir: direction, 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, dependencies: optionDependencies, shouldFocusOnHover: false, 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 => ({
38
- ...theme.transition,
39
- display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
40
- minHeight: theme.height, paddingInline: Math.max(8, theme.spacing), boxSizing: "border-box",
41
- borderRadius: theme.radius,
42
- outline: item.isFocused && !item.isSelected ? `1px solid ${theme.focusColor}` : "none", outlineOffset: 1,
43
- cursor: item.isDisabled ? "not-allowed" : "pointer",
44
- opacity: item.isDisabled ? 0.46 : 1,
45
- ...(item.isSelected ? item.isFocused ? theme.paints.palette.hover : theme.paints.palette.rest
46
- : { background: "transparent", color: theme.foreground })
47
- }), children: item => _jsxs(_Fragment, { children: [option.label, _jsx("span", { "aria-hidden": "true", children: item.isSelected ? "✓" : null })] }) }) }) }) }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] }) });
18
+ }), children: [_jsx(SelectValue, { style: ({ isPlaceholder }) => ({ opacity: isPlaceholder ? controlOpacity.placeholder : 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }) }), _jsx("svg", { "aria-hidden": "true", width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", style: { ...theme.transition, transitionProperty: "transform", flexShrink: 0, transform: `rotate(${state.isOpen ? 180 : 0}deg)` }, children: _jsx("path", { d: "m2 4 4 4 4-4" }) })] }), _jsx(PopoverContent, { dir: direction, placement: "bottom start", offset: theme.gap, style: {
19
+ width: "var(--trigger-width)",
20
+ maxHeight: `min(calc(100vh - ${theme.spacing * 2}px), ${theme.height * 8}px)`,
21
+ padding: theme.gap,
22
+ display: "flex",
23
+ flexDirection: "column"
24
+ }, children: _jsx(ScrollArea, { style: { flex: "1 1 auto", minHeight: 0 }, children: _jsx(ListBox, { shouldFocusOnHover: false, style: { display: "grid", gap: theme.gap, outline: "none", fontSize: theme.fontSize, color: theme.foreground }, children: options.map(option => _jsx(ListBoxItem, { id: option.value, textValue: option.label, style: item => ({
25
+ ...theme.transition,
26
+ display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
27
+ minHeight: theme.height, paddingInline: Math.max(8, theme.spacing), boxSizing: "border-box",
28
+ borderRadius: theme.radius,
29
+ outline: item.isFocusVisible && !item.isSelected ? `1px solid ${theme.focusColor}` : "none", outlineOffset: 1,
30
+ cursor: item.isDisabled ? "not-allowed" : "pointer",
31
+ opacity: item.isDisabled ? controlOpacity.disabled : 1,
32
+ ...(item.isSelected
33
+ ? item.isPressed ? theme.paints.palette.pressed : item.isHovered ? theme.paints.palette.hover : theme.paints.palette.rest
34
+ : item.isHovered ? theme.paints.neutral.hover : { background: "transparent", color: theme.foreground })
35
+ }), children: item => _jsxs(_Fragment, { children: [option.label, _jsx("span", { "aria-hidden": "true", children: item.isSelected ? "✓" : null })] }) }, option.value)) }) }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] }) });
48
36
  });
@@ -12,14 +12,20 @@ export interface ShadowOverrides {
12
12
  /** Omission or true uses Appearance defaults; false removes the shadow. */
13
13
  readonly shadow?: boolean | ShadowOptions;
14
14
  }
15
- /** Resolves shadow values from the active Appearance branch. */
16
- export declare function useShadowOptions(values?: ShadowOptions): {
15
+ /** Resolves overrides against an already selected shadow branch. */
16
+ export declare function resolveShadowOptions(values: ShadowOptions, defaults: Readonly<{
17
+ x: number;
18
+ y: number;
19
+ blur: number;
20
+ spread: number;
21
+ opacity: number;
22
+ }>): {
17
23
  x: number;
18
24
  y: number;
19
25
  blur: number;
20
26
  spread: number;
21
27
  opacity: number;
22
28
  };
23
- export type ResolvedShadow = ReturnType<typeof useShadowOptions>;
29
+ export type ResolvedShadow = ReturnType<typeof resolveShadowOptions>;
24
30
  /** Serializes one resolved neutral-black outer shadow. */
25
31
  export declare function shadowStyle({ x, y, blur, spread, opacity }: ResolvedShadow): string;
@@ -1,10 +1,7 @@
1
- import { appearanceLimits } from "@phreshos/core";
2
- import { useAppearance, useThemedValue } from "./ui-provider.js";
1
+ import { appearanceLimits } from "./appearance.js";
3
2
  import { isScaleLevel, scale } from "./scale.js";
4
- /** Resolves shadow values from the active Appearance branch. */
5
- export function useShadowOptions(values = {}) {
6
- const appearance = useAppearance();
7
- const defaults = useThemedValue(appearance.shadow);
3
+ /** Resolves overrides against an already selected shadow branch. */
4
+ export function resolveShadowOptions(values, defaults) {
8
5
  const limits = appearanceLimits.shadow;
9
6
  return {
10
7
  x: resolve(values.x, defaults.x, limits.x),
package/dist/slider.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Base UI accepts direction directly; React Aria derives these keys from locale.
2
3
  import { Slider as BaseSlider } from "@base-ui/react/slider";
3
4
  import { DirectionProvider as BaseDirectionProvider } from "@base-ui/react/direction-provider";
4
5
  import { forwardRef, useEffect, useId, useRef, useState } from "react";
5
- import { fieldStyle, useControlTheme } from "./control.js";
6
+ import { controlFontWeight, controlOpacity, fieldStyle, useControlTheme } from "./control.js";
6
7
  import { resolveDirection, useDirection } from "./direction.js";
7
8
  /** One numeric value with pointer, touch, keyboard, and form support. */
8
9
  export const Slider = forwardRef(function Slider({ label, description, name, form, disabled, size, color, style, orientation = "horizontal", value, defaultValue, minValue = 0, maxValue = 100, step = 1, formatOptions, onChange, onChangeEnd, ...properties }, ref) {
@@ -14,7 +15,7 @@ export const Slider = forwardRef(function Slider({ label, description, name, for
14
15
  const [uncontrolledValue, setUncontrolledValue] = useState(initialValue.current);
15
16
  const [hovered, setHovered] = useState(false);
16
17
  const vertical = orientation === "vertical";
17
- const diameter = theme.fontSize + 6;
18
+ const diameter = theme.indicatorSize;
18
19
  const rail = Math.max(4, Math.round(diameter / 3));
19
20
  const describedBy = [properties["aria-describedby"], description != null ? descriptionId : null].filter(Boolean).join(" ") || undefined;
20
21
  const controlled = value !== undefined;
@@ -31,7 +32,7 @@ export const Slider = forwardRef(function Slider({ label, description, name, for
31
32
  if (!controlled)
32
33
  setUncontrolledValue(next);
33
34
  onChange?.(next);
34
- }, onValueCommitted: next => onChangeEnd?.(next), style: fieldStyle(theme, disabled, style), children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: theme.gap }, children: [label != null && _jsx(BaseSlider.Label, { style: { fontWeight: 550 }, children: label }), _jsx(BaseSlider.Value, { style: { fontVariantNumeric: "tabular-nums", opacity: 0.7 } })] }), _jsx(BaseSlider.Control, { style: {
35
+ }, onValueCommitted: next => onChangeEnd?.(next), style: fieldStyle(theme, disabled, style), children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: theme.gap }, children: [label != null && _jsx(BaseSlider.Label, { style: { fontWeight: controlFontWeight }, children: label }), _jsx(BaseSlider.Value, { style: { fontVariantNumeric: "tabular-nums", opacity: controlOpacity.secondary } })] }), _jsx(BaseSlider.Control, { style: {
35
36
  position: "relative",
36
37
  display: "flex",
37
38
  alignItems: "center",
@@ -39,7 +40,7 @@ export const Slider = forwardRef(function Slider({ label, description, name, for
39
40
  touchAction: "none",
40
41
  userSelect: "none",
41
42
  width: vertical ? theme.height : "100%",
42
- height: vertical ? 160 : theme.height
43
+ height: vertical ? theme.height * 4 : theme.height
43
44
  }, children: _jsxs(BaseSlider.Track, { style: {
44
45
  position: "relative",
45
46
  width: vertical ? rail : "100%",
@@ -59,12 +60,12 @@ export const Slider = forwardRef(function Slider({ label, description, name, for
59
60
  borderRadius: "50%",
60
61
  boxSizing: "border-box",
61
62
  background: paint.color,
62
- border: `3px solid ${paint.background}`,
63
+ border: `${Math.max(1, diameter / 6)}px solid ${paint.background}`,
63
64
  outline: state.focused ? `1px solid ${theme.focusColor}` : "none",
64
65
  outlineOffset: 1,
65
66
  cursor: state.disabled ? "not-allowed" : state.dragging ? "grabbing" : "grab",
66
67
  transform: `scale(${state.dragging ? 0.92 : hovered ? 1.08 : 1})`
67
68
  };
68
- } })] }) }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: 0.7 }, children: description })] });
69
+ } })] }) }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: controlOpacity.secondary }, children: description })] });
69
70
  return _jsx(BaseDirectionProvider, { direction: direction, children: root });
70
71
  });
package/dist/spacing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties } from "react";
2
- import type { Appearance } from "@phreshos/core";
2
+ import type { Appearance } from "./appearance.js";
3
3
  import { type ScaleLevel } from "./scale.js";
4
4
  /** An Appearance-derived level, pixel value, or explicit CSS spacing value. */
5
5
  export type Spacing = ScaleLevel | number | (string & {});
@@ -1,9 +1,11 @@
1
+ import { type CSSProperties } from "react";
1
2
  type EdgeMaterial = Readonly<{
2
3
  color: string;
3
4
  opacity: number;
4
5
  }>;
5
6
  /** Paints the boundary owned by one Surface from the material it contains. */
6
- export declare function SurfaceEdge({ material }: Readonly<{
7
+ export declare function SurfaceEdge({ material, transition }: Readonly<{
7
8
  material: EdgeMaterial;
9
+ transition: CSSProperties;
8
10
  }>): import("react").JSX.Element | null;
9
11
  export {};
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment } from "react";
3
- import { useTransitionTiming } from "./motion-style.js";
4
3
  import { scale } from "./scale.js";
5
4
  const edgeLayer = {
6
5
  position: "absolute",
@@ -17,15 +16,14 @@ const illuminatedEdgeBoundary = Math.round((outerEdgeThickness + illuminatedEdge
17
16
  const horizontalIllumination = "linear-gradient(90deg, var(--phreshos-surface-edge-light-minimum) 0%, var(--phreshos-surface-edge-light-minimum) 12%, var(--phreshos-surface-edge-light-soft) 34%, var(--phreshos-surface-edge-light-peak) 50%, var(--phreshos-surface-edge-light-soft) 66%, var(--phreshos-surface-edge-light-minimum) 88%, var(--phreshos-surface-edge-light-minimum) 100%)";
18
17
  const edgeMask = "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)";
19
18
  /** Paints the boundary owned by one Surface from the material it contains. */
20
- export function SurfaceEdge({ material }) {
21
- const timing = useTransitionTiming();
19
+ export function SurfaceEdge({ material, transition }) {
22
20
  const visible = material.opacity > 0;
23
21
  if (!visible)
24
22
  return null;
25
23
  const opacity = Math.min(1, scale(material.opacity, "xlarge"));
26
24
  const peak = illumination(material.color, 92);
27
25
  return _jsxs(Fragment, { children: [_jsx("span", { "data-surface-edge-light": "", "aria-hidden": "true", style: {
28
- ...timing,
26
+ ...transition,
29
27
  ...edgeLayer,
30
28
  transitionProperty: "opacity",
31
29
  padding: illuminatedEdgeBoundary,
@@ -39,7 +37,7 @@ export function SurfaceEdge({ material }) {
39
37
  "--phreshos-surface-edge-light-soft": illumination(material.color, 56),
40
38
  "--phreshos-surface-edge-light-minimum": illumination(material.color, 24)
41
39
  } }), _jsx("span", { "data-surface-edge": "", "aria-hidden": "true", style: {
42
- ...timing,
40
+ ...transition,
43
41
  ...edgeLayer,
44
42
  transitionProperty: "border-color, opacity",
45
43
  borderStyle: "solid",
package/dist/surface.js CHANGED
@@ -1,21 +1,23 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createElement, forwardRef, useId } from "react";
3
- import { useAppearance, useThemedValue } from "./ui-provider.js";
4
- import { useResolveColor } from "./color.js";
5
- import { useMaterialOptions } from "./material-options.js";
3
+ import { useResolvedAppearance } from "./appearance-context.js";
4
+ import { contrastingColor, defaultColor, resolveColor } from "./color.js";
5
+ import { resolveMaterialOptions } from "./material-options.js";
6
6
  import { MaterialPaint } from "./material-paint.js";
7
- import MotionStyle, { useVisualTransition } from "./motion-style.js";
7
+ import { paintTransition, transitionTiming, visualTransition } from "./motion-style.js";
8
8
  import { resolveRadius } from "./radius.js";
9
- import { shadowStyle, useShadowOptions } from "./shadow-options.js";
9
+ import { resolveShadowOptions, shadowStyle } from "./shadow-options.js";
10
10
  import { SurfaceEdge } from "./surface-edge.js";
11
11
  /** One material- and shadow-owning element. The host is a div unless `as` selects another element. */
12
12
  const SurfaceRoot = forwardRef(function Surface({ as: Element = "div", color, material: options, shadow: shadowOptions, radius, children, style, ...properties }, ref) {
13
- const appearance = useAppearance();
14
- const transition = useVisualTransition();
15
- const material = useResolvedSurface(color, options);
16
- const resolvedShadow = useShadowOptions(typeof shadowOptions === "object" ? shadowOptions : undefined);
13
+ const resolved = useResolvedAppearance();
14
+ const transition = visualTransition(resolved.transaction, resolved.preferences.animations);
15
+ const material = resolveSurface(color, options, resolved);
16
+ const resolvedShadow = resolveShadowOptions(typeof shadowOptions === "object" ? shadowOptions : {}, resolved.shadow);
17
17
  const shadow = shadowOptions === false ? "none" : shadowStyle(resolvedShadow);
18
- const resolvedRadius = resolveRadius(radius ?? "medium", appearance);
18
+ const resolvedRadius = resolveRadius(radius ?? "medium", resolved.appearance);
19
+ const timing = transitionTiming(resolved.transaction, resolved.preferences.animations);
20
+ const paintTiming = paintTransition(resolved.transaction, resolved.preferences.animations);
19
21
  return createElement(Element, {
20
22
  ...properties,
21
23
  ref,
@@ -29,17 +31,17 @@ const SurfaceRoot = forwardRef(function Surface({ as: Element = "div", color, ma
29
31
  position: style?.position ?? "relative",
30
32
  isolation: "isolate"
31
33
  }
32
- }, material.enabled && _jsx(SurfaceLayers, { material: material }), material.enabled && _jsx(SurfaceEdge, { material: material }), children);
34
+ }, material.enabled && _jsx(SurfaceLayers, { material: material, paintTransition: paintTiming }), material.enabled && _jsx(SurfaceEdge, { material: material, transition: timing }), children);
33
35
  });
34
36
  export const Surface = SurfaceRoot;
35
- function useResolvedSurface(color, options) {
36
- const appearance = useAppearance();
37
- const material = useMaterialOptions(typeof options === "object" ? options : undefined);
37
+ function resolveSurface(color, options, resolved) {
38
+ const material = resolveMaterialOptions(typeof options === "object" ? options : {}, resolved.material);
39
+ const fill = resolveColor(color, resolved.colors);
38
40
  return {
39
41
  ...material,
40
42
  enabled: options !== false,
41
- color: useResolveColor(color),
42
- foreground: useThemedValue(appearance.colors).foreground
43
+ color: fill,
44
+ foreground: contrastingColor(color ?? defaultColor, resolved.colors)
43
45
  };
44
46
  }
45
47
  const layerStyle = {
@@ -48,14 +50,17 @@ const layerStyle = {
48
50
  borderRadius: "inherit",
49
51
  pointerEvents: "none"
50
52
  };
51
- function SurfaceLayers({ material }) {
52
- const { backdrop, saturation, distortion } = material;
53
+ function SurfaceLayers({ material, paintTransition }) {
54
+ const filtersVisible = material.opacity < 1;
55
+ const backdrop = filtersVisible ? material.backdrop : 0;
56
+ const saturation = filtersVisible ? material.saturation : 1;
57
+ const distortion = filtersVisible ? material.distortion : 0;
53
58
  const frost = [
54
59
  backdrop === 0 ? "" : `blur(${backdrop}px)`,
55
60
  saturation === 1 ? "" : `saturate(${saturation})`
56
61
  ].filter(Boolean).join(" ");
57
62
  const identity = `phresh-material-${useId().replaceAll(":", "")}`;
58
- return _jsxs("span", { "data-material": "", "aria-hidden": "true", style: layerStyle, children: [_jsx(MotionStyle, {}), distortion > 0 && _jsx(BackdropLayer, { name: "refraction", filter: `url("#${identity}-distortion")`, zIndex: -3 }), frost && _jsx(BackdropLayer, { name: "frost", filter: frost, zIndex: -2 }), _jsx(MaterialPaint, { identity: identity, distortion: distortion, color: material.color, grain: material.grain, grainAmount: material.grainAmount, opacity: material.opacity })] });
63
+ return _jsxs("span", { "data-material": "", "aria-hidden": "true", style: layerStyle, children: [distortion > 0 && _jsx(BackdropLayer, { name: "refraction", filter: `url("#${identity}-distortion")`, zIndex: -3 }), frost && _jsx(BackdropLayer, { name: "frost", filter: frost, zIndex: -2 }), _jsx(MaterialPaint, { identity: identity, distortion: distortion, color: material.color, grain: material.grain, grainAmount: material.grainAmount, opacity: material.opacity, transition: paintTransition })] });
59
64
  }
60
65
  /** Keep refraction and native frost in independent compositor passes. */
61
66
  function BackdropLayer({ filter, name, zIndex }) {
@@ -1,13 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { motion } from "motion/react";
3
- import { useControlTransition } from "./motion-style.js";
4
- import { usePreferences } from "./ui-provider.js";
5
3
  import { Surface } from "./surface.js";
6
4
  /** Shared paint only. Selection, focus, validation, and native input behavior belong to React Aria. */
7
5
  export function ToggleIndicator({ kind, direction = "ltr", selected, indeterminate = false, focused, invalid = false, hovered = false, pressed = false, theme, material: options, shadow }) {
8
- const { animations } = usePreferences();
9
- const transition = useControlTransition();
10
- const diameter = theme.fontSize + 6;
6
+ const { animations, motionTransition: transition } = theme;
7
+ const diameter = theme.indicatorSize;
11
8
  const active = selected || indeterminate;
12
9
  const paints = invalid ? theme.paints.danger : active ? theme.paints.palette : theme.paints.neutral;
13
10
  const paint = pressed ? paints.pressed : hovered ? paints.hover : paints.rest;