@phreshos/react-ui 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +158 -8
  2. package/dist/appearance-provider.d.ts +6 -8
  3. package/dist/appearance-provider.js +34 -19
  4. package/dist/button.d.ts +11 -5
  5. package/dist/button.js +28 -36
  6. package/dist/checkbox.d.ts +11 -0
  7. package/dist/checkbox.js +10 -0
  8. package/dist/color.d.ts +33 -0
  9. package/dist/color.js +84 -5
  10. package/dist/control-surface.d.ts +20 -0
  11. package/dist/control-surface.js +11 -0
  12. package/dist/control.d.ts +55 -0
  13. package/dist/control.js +87 -0
  14. package/dist/document-scrollbars.js +1 -1
  15. package/dist/field-style.d.ts +3 -0
  16. package/dist/field-style.js +11 -0
  17. package/dist/flex.js +2 -2
  18. package/dist/grid.js +2 -2
  19. package/dist/input.d.ts +4 -0
  20. package/dist/input.js +11 -0
  21. package/dist/main.d.ts +13 -3
  22. package/dist/main.js +9 -0
  23. package/dist/material-options.d.ts +24 -0
  24. package/dist/material-options.js +22 -0
  25. package/dist/material-paint.d.ts +11 -0
  26. package/dist/material-paint.js +76 -0
  27. package/dist/motion-style.d.ts +21 -0
  28. package/dist/motion-style.js +45 -0
  29. package/dist/panel.d.ts +1 -1
  30. package/dist/radio.d.ts +14 -0
  31. package/dist/radio.js +17 -0
  32. package/dist/select.d.ts +17 -0
  33. package/dist/select.js +28 -0
  34. package/dist/slider.d.ts +7 -0
  35. package/dist/slider.js +36 -0
  36. package/dist/surface-edge.d.ts +10 -0
  37. package/dist/surface-edge.js +59 -0
  38. package/dist/surface.d.ts +23 -44
  39. package/dist/surface.js +47 -86
  40. package/dist/switch.d.ts +10 -0
  41. package/dist/switch.js +10 -0
  42. package/dist/text-control.d.ts +9 -0
  43. package/dist/text-control.js +0 -0
  44. package/dist/textarea.d.ts +6 -0
  45. package/dist/textarea.js +17 -0
  46. package/dist/toggle-indicator.d.ts +16 -0
  47. package/dist/toggle-indicator.js +48 -0
  48. package/package.json +3 -3
  49. package/dist/surface-material.d.ts +0 -14
  50. package/dist/surface-material.js +0 -128
@@ -0,0 +1,10 @@
1
+ type EdgeMaterial = Readonly<{
2
+ color: string;
3
+ foreground: string;
4
+ opacity: number;
5
+ }>;
6
+ /** Paints the boundary owned by one Surface from the material it contains. */
7
+ export declare function SurfaceEdge({ material }: Readonly<{
8
+ material: EdgeMaterial;
9
+ }>): import("react").JSX.Element | null;
10
+ export {};
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment, useCallback, useLayoutEffect, useRef, useState } from "react";
3
+ import { colorLightness, colorOpacity, orderColors } from "./color.js";
4
+ import { paintTransition } from "./motion-style.js";
5
+ import { scale } from "./scale.js";
6
+ /** Paints the boundary owned by one Surface from the material it contains. */
7
+ export function SurfaceEdge({ material }) {
8
+ const visible = material.opacity > 0;
9
+ const palette = useRef(null);
10
+ const [colors, setColors] = useState(null);
11
+ const updatePalette = useCallback(() => {
12
+ const base = palette.current;
13
+ const view = base?.ownerDocument.defaultView;
14
+ if (!base || !view)
15
+ return;
16
+ const computed = view.getComputedStyle(base);
17
+ const background = computed.fill;
18
+ const foreground = computed.color;
19
+ setColors(previous => previous?.background === background && previous.foreground === foreground ? previous : {
20
+ ...orderColors(background, foreground),
21
+ background,
22
+ foreground,
23
+ lightness: Math.max(0, Math.min(1, colorLightness(background)))
24
+ });
25
+ }, []);
26
+ useLayoutEffect(updatePalette);
27
+ useLayoutEffect(() => {
28
+ const base = palette.current;
29
+ if (!base)
30
+ return;
31
+ const complete = (event) => {
32
+ if (event.target === base && (event.propertyName === "fill" || event.propertyName === "color"))
33
+ updatePalette();
34
+ };
35
+ base.addEventListener("transitionend", complete);
36
+ return () => base.removeEventListener("transitionend", complete);
37
+ }, [updatePalette, visible]);
38
+ if (!visible)
39
+ return null;
40
+ return _jsxs(Fragment, { children: [_jsx("svg", { "data-surface-palette": "", "aria-hidden": "true", width: "0", height: "0", style: { position: "absolute", pointerEvents: "none" }, children: _jsx("rect", { ref: palette, "data-surface-palette-base": "", style: { ...paintTransition, fill: material.color, color: material.foreground } }) }), _jsx("span", { "data-surface-edge": "", "aria-hidden": "true", style: {
41
+ ...paintTransition,
42
+ position: "absolute",
43
+ zIndex: 1,
44
+ inset: 0,
45
+ padding: 1,
46
+ borderRadius: "inherit",
47
+ pointerEvents: "none",
48
+ opacity: Math.min(1, scale(material.opacity, "xlarge")) * (colors?.lightness ?? 0),
49
+ background: colors ? glassEdge(colors.lighter, colors.darker) : undefined,
50
+ WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
51
+ WebkitMaskComposite: "xor",
52
+ mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
53
+ maskComposite: "exclude"
54
+ } })] });
55
+ }
56
+ function glassEdge(light, dark) {
57
+ const edge = `color-mix(in oklch, ${dark} 20%, ${light})`;
58
+ return `linear-gradient(145deg, ${colorOpacity(light, 0.92)}, ${colorOpacity(light, 0.4)} 35%, ${colorOpacity(edge, 0.18)} 55%, ${colorOpacity(light, 0.6)} 85%, ${colorOpacity(light, 0.3)})`;
59
+ }
package/dist/surface.d.ts CHANGED
@@ -1,44 +1,23 @@
1
- import type { ComponentPropsWithoutRef } from "react";
2
- import { type ScaleLevel } from "./scale.js";
3
- /** Native div properties plus controls for the locally owned material. */
4
- export type SurfaceProps = Omit<ComponentPropsWithoutRef<"div">, "opacity"> & Readonly<{
5
- /** Appearance-derived level or direct grain intensity from zero to one. */
6
- grain?: ScaleLevel | number;
7
- /** Appearance-derived level or direct retained grain amount from zero to one. */
8
- grainAmount?: ScaleLevel | number;
9
- /** Appearance-derived level or direct backdrop blur from zero to 24 CSS pixels. */
10
- backdrop?: ScaleLevel | number;
11
- /** Appearance-derived level or direct material opacity from zero to one. */
12
- opacity?: ScaleLevel | number;
13
- /** Appearance-derived level or direct organic displacement from zero to 140 pixels. */
14
- distortion?: ScaleLevel | number;
15
- /** Appearance-derived level or direct directional displacement from zero to 40 pixels. */
16
- waves?: ScaleLevel | number;
17
- /** Appearance-derived level or direct ripple displacement from zero to 40 pixels. */
18
- ripples?: ScaleLevel | number;
19
- /** Appearance-derived level or direct backdrop saturation multiplier. */
20
- saturation?: ScaleLevel | number;
21
- /** Appearance-derived level or direct backdrop brightness multiplier. */
22
- brightness?: ScaleLevel | number;
23
- }>;
24
- /** Contains content above locally owned Surface material layers. */
25
- export declare const Surface: import("react").ForwardRefExoticComponent<Omit<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">, "opacity"> & Readonly<{
26
- /** Appearance-derived level or direct grain intensity from zero to one. */
27
- grain?: ScaleLevel | number;
28
- /** Appearance-derived level or direct retained grain amount from zero to one. */
29
- grainAmount?: ScaleLevel | number;
30
- /** Appearance-derived level or direct backdrop blur from zero to 24 CSS pixels. */
31
- backdrop?: ScaleLevel | number;
32
- /** Appearance-derived level or direct material opacity from zero to one. */
33
- opacity?: ScaleLevel | number;
34
- /** Appearance-derived level or direct organic displacement from zero to 140 pixels. */
35
- distortion?: ScaleLevel | number;
36
- /** Appearance-derived level or direct directional displacement from zero to 40 pixels. */
37
- waves?: ScaleLevel | number;
38
- /** Appearance-derived level or direct ripple displacement from zero to 40 pixels. */
39
- ripples?: ScaleLevel | number;
40
- /** Appearance-derived level or direct backdrop saturation multiplier. */
41
- saturation?: ScaleLevel | number;
42
- /** Appearance-derived level or direct backdrop brightness multiplier. */
43
- brightness?: ScaleLevel | number;
44
- }> & import("react").RefAttributes<HTMLDivElement>>;
1
+ import type { ComponentPropsWithRef, CSSProperties, ElementType, ReactElement, ReactNode } from "react";
2
+ import { type Color } from "./color.js";
3
+ import { type MaterialOptions } from "./material-options.js";
4
+ import { type RadiusProps } from "./radius.js";
5
+ export type { MaterialOptions } from "./material-options.js";
6
+ export interface SurfaceOwnProps extends MaterialOptions, RadiusProps {
7
+ readonly color?: Color;
8
+ }
9
+ /**
10
+ * The structural properties a component must preserve when it hosts a Surface.
11
+ * The component must apply `style` and render `children` on the same host element,
12
+ * and it must forward its ref to that element.
13
+ */
14
+ export interface SurfaceHostProps {
15
+ readonly children?: ReactNode;
16
+ readonly style?: CSSProperties;
17
+ }
18
+ export type SurfaceHost = ElementType<SurfaceHostProps>;
19
+ export type SurfaceProps<As extends SurfaceHost = "div"> = SurfaceOwnProps & Readonly<{
20
+ as?: As;
21
+ }> & Omit<ComponentPropsWithRef<As>, keyof SurfaceOwnProps | "as" | "color">;
22
+ export type SurfaceComponent = <As extends SurfaceHost = "div">(properties: SurfaceProps<As>) => ReactElement | null;
23
+ export declare const Surface: SurfaceComponent;
package/dist/surface.js CHANGED
@@ -1,101 +1,62 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useCallback, useId, useLayoutEffect, useRef } from "react";
3
- import { appearanceLimits } from "@phreshos/core";
4
- import { isScaleLevel, scale, scaleMultiplier } from "./scale.js";
5
- import { SurfaceMaterial } from "./surface-material.js";
2
+ import { createElement, forwardRef, useId } from "react";
6
3
  import { useAppearance, useResolveTheme } from "./appearance-provider.js";
4
+ import { useResolveColor } from "./color.js";
5
+ import { useMaterialOptions } from "./material-options.js";
6
+ import { MaterialPaint } from "./material-paint.js";
7
+ import MotionStyle, { visualTransition } from "./motion-style.js";
8
+ import { resolveRadius } from "./radius.js";
9
+ import { SurfaceEdge } from "./surface-edge.js";
10
+ /** One material-owning element. The host is a div unless `as` selects another element. */
11
+ const SurfaceRoot = forwardRef(function Surface({ as: Element = "div", color, opacity, backdrop, grain, grainAmount, distortion, saturation, radius, children, style, ...properties }, ref) {
12
+ const appearance = useAppearance();
13
+ const material = useResolvedSurface(color, { opacity, backdrop, grain, grainAmount, distortion, saturation });
14
+ const resolvedRadius = resolveRadius(radius ?? "medium", appearance);
15
+ return createElement(Element, {
16
+ ...properties,
17
+ ref,
18
+ style: {
19
+ ...visualTransition,
20
+ ...style,
21
+ background: "transparent",
22
+ color: style?.color ?? material.foreground,
23
+ borderRadius: radius === undefined ? style?.borderRadius ?? resolvedRadius : resolvedRadius,
24
+ position: style?.position ?? "relative",
25
+ isolation: "isolate"
26
+ }
27
+ }, _jsx(SurfaceLayers, { material: material }), _jsx(SurfaceEdge, { material: material }), children);
28
+ });
29
+ export const Surface = SurfaceRoot;
30
+ function useResolvedSurface(color, options) {
31
+ const appearance = useAppearance();
32
+ const material = useMaterialOptions(options);
33
+ return {
34
+ ...material,
35
+ color: useResolveColor(color),
36
+ foreground: useResolveTheme(appearance.colors.foreground)
37
+ };
38
+ }
7
39
  const layerStyle = {
8
40
  position: "absolute",
9
41
  inset: 0,
10
42
  borderRadius: "inherit",
11
43
  pointerEvents: "none"
12
44
  };
13
- /** Contains content above locally owned Surface material layers. */
14
- export const Surface = forwardRef(function Surface({ backdrop, brightness, children, distortion, grain, grainAmount, opacity, ripples, saturation, style, waves, ...properties }, forwardedRef) {
15
- const appearance = useAppearance();
16
- const background = useResolveTheme(appearance.background);
17
- const foreground = useResolveTheme(appearance.foreground);
18
- const radius = scale(useResolveTheme(appearance.radius), "medium");
19
- const surface = useResolveTheme(appearance.surface);
20
- const identity = `phresh-surface-${useId().replaceAll(":", "")}`;
21
- const element = useRef(null);
22
- const capture = useCallback((node) => {
23
- element.current = node;
24
- if (typeof forwardedRef === "function")
25
- forwardedRef(node);
26
- else if (forwardedRef)
27
- forwardedRef.current = node;
28
- }, [forwardedRef]);
29
- const resolved = resolveSurface({ backdrop, brightness, distortion, grain, grainAmount, opacity, ripples, saturation, waves }, background, surface);
30
- useLayoutEffect(() => {
31
- const surface = element.current;
32
- if (surface)
33
- return prepareSurfaceLayout(surface);
34
- });
35
- return _jsxs("div", { ...properties, ref: capture, style: {
36
- borderRadius: radius,
37
- color: foreground,
38
- ...style
39
- }, children: [resolved.refracts && _jsx(BackdropLayer, { name: "refraction", filter: `url("#${identity}-distortion")`, zIndex: -3 }), resolved.frost && _jsx(BackdropLayer, { name: "frost", filter: resolved.frost, zIndex: -2 }), _jsx(SurfaceMaterial, { identity: identity, foreground: foreground, ...resolved.material }), children] });
40
- });
41
- /** Keeps refraction and native frost in independent compositor passes. */
45
+ function SurfaceLayers({ material }) {
46
+ const { backdrop, saturation, distortion } = material;
47
+ const frost = [
48
+ backdrop === 0 ? "" : `blur(${backdrop}px)`,
49
+ saturation === 1 ? "" : `saturate(${saturation})`
50
+ ].filter(Boolean).join(" ");
51
+ const identity = `phresh-material-${useId().replaceAll(":", "")}`;
52
+ 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 })] });
53
+ }
54
+ /** Keep refraction and native frost in independent compositor passes. */
42
55
  function BackdropLayer({ filter, name, zIndex }) {
43
- return _jsx("div", { "data-surface-backdrop": name, "aria-hidden": "true", style: {
56
+ return _jsx("span", { "data-material-backdrop": name, "aria-hidden": "true", style: {
44
57
  ...layerStyle,
45
58
  zIndex,
46
59
  backdropFilter: filter,
47
60
  WebkitBackdropFilter: filter
48
61
  } });
49
62
  }
50
- function resolveSurface(values, background, surface) {
51
- const material = {
52
- color: background,
53
- distortion: resolveScale(values.distortion, surface.distortion, appearanceLimits.surface.distortion),
54
- grain: resolveScale(values.grain, surface.grain, appearanceLimits.surface.grain),
55
- grainAmount: resolveScale(values.grainAmount, surface.grainAmount, appearanceLimits.surface.grainAmount),
56
- opacity: resolveScale(values.opacity, surface.opacity, appearanceLimits.surface.opacity),
57
- ripples: resolveScale(values.ripples, surface.ripples, appearanceLimits.surface.ripples),
58
- waves: resolveScale(values.waves, surface.waves, appearanceLimits.surface.waves)
59
- };
60
- const backdrop = resolveScale(values.backdrop, surface.backdrop, appearanceLimits.surface.backdrop);
61
- const saturation = resolveMultiplier(values.saturation, surface.saturation, appearanceLimits.surface.saturation);
62
- const brightness = resolveMultiplier(values.brightness, surface.brightness, appearanceLimits.surface.brightness);
63
- const frost = [
64
- backdrop === 0 ? "" : `blur(${backdrop}px)`,
65
- saturation === 1 ? "" : `saturate(${saturation})`,
66
- brightness === 1 ? "" : `brightness(${brightness})`
67
- ].filter(Boolean).join(" ");
68
- return {
69
- material,
70
- frost,
71
- refracts: material.distortion > 0 || material.waves > 0 || material.ripples > 0
72
- };
73
- }
74
- function resolveScale(value, base, range) {
75
- const resolved = isScaleLevel(value) ? scale(base, value) : value ?? base;
76
- const finite = Number.isFinite(resolved) ? resolved : base;
77
- return Math.min(range.maximum, Math.max(range.minimum, finite));
78
- }
79
- function resolveMultiplier(value, base, range) {
80
- const resolved = isScaleLevel(value) ? scaleMultiplier(base, value) : value ?? base;
81
- const finite = Number.isFinite(resolved) ? resolved : base;
82
- return Math.min(range.maximum, Math.max(range.minimum, finite));
83
- }
84
- function prepareSurfaceLayout(element) {
85
- const view = element.ownerDocument.defaultView;
86
- const computed = view?.getComputedStyle(element);
87
- const position = element.style.position;
88
- const isolation = element.style.isolation;
89
- const ownsPosition = computed?.position === "static";
90
- const ownsIsolation = computed?.isolation !== "isolate";
91
- if (ownsPosition)
92
- element.style.position = "relative";
93
- if (ownsIsolation)
94
- element.style.isolation = "isolate";
95
- return () => {
96
- if (ownsPosition && element.style.position === "relative")
97
- element.style.position = position;
98
- if (ownsIsolation && element.style.isolation === "isolate")
99
- element.style.isolation = isolation;
100
- };
101
- }
@@ -0,0 +1,10 @@
1
+ import type { SwitchFieldProps } from "react-aria-components";
2
+ import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
3
+ import type { MaterialOverrides } from "./material-options.js";
4
+ export interface SwitchProps extends Omit<SwitchFieldProps, ControlOverrides | "isReadOnly" | "isSelected" | "defaultSelected">, ControlProps, FieldProps, MaterialOverrides {
5
+ readonly checked?: boolean;
6
+ readonly defaultChecked?: boolean;
7
+ readonly readOnly?: boolean;
8
+ }
9
+ /** An on/off setting. onChange receives the next boolean value. */
10
+ export declare const Switch: import("react").ForwardRefExoticComponent<SwitchProps & import("react").RefAttributes<HTMLDivElement>>;
package/dist/switch.js ADDED
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { SwitchField, SwitchButton } from "react-aria-components";
4
+ import { FieldFeedback, fieldStyle, useControlTheme } from "./control.js";
5
+ import { ToggleIndicator, toggleStyle } from "./toggle-indicator.js";
6
+ /** An on/off setting. onChange receives the next boolean value. */
7
+ export const Switch = forwardRef(function Switch({ label, description, errorMessage, disabled, required, invalid, readOnly, checked, defaultChecked, size, color, style, material, ...properties }, ref) {
8
+ const theme = useControlTheme({ size, color });
9
+ return _jsxs(SwitchField, { ...properties, ref: ref, isDisabled: disabled, isRequired: required, isInvalid: invalid, isReadOnly: readOnly, isSelected: checked, defaultSelected: defaultChecked, style: state => fieldStyle(theme, state.isDisabled, style), children: [_jsx(SwitchButton, { style: state => toggleStyle(theme, state.isDisabled, state.isReadOnly), children: state => _jsxs(_Fragment, { children: [_jsx(ToggleIndicator, { kind: "switch", material: material, theme: theme, selected: state.isSelected, focused: state.isFocusVisible, invalid: state.isInvalid, hovered: !state.isDisabled && !state.isReadOnly && state.isHovered, pressed: !state.isDisabled && !state.isReadOnly && state.isPressed }), label] }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] });
10
+ });
@@ -0,0 +1,9 @@
1
+ import type { TextFieldProps } from "react-aria-components";
2
+ import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
3
+ import type { RadiusProps } from "./radius.js";
4
+ import type { MaterialOverrides } from "./material-options.js";
5
+ /** Shared contract for native text-entry controls. */
6
+ export interface TextControlProps extends Omit<TextFieldProps, ControlOverrides | "isReadOnly">, ControlProps, FieldProps, RadiusProps, MaterialOverrides {
7
+ readonly placeholder?: string;
8
+ readonly readOnly?: boolean;
9
+ }
File without changes
@@ -0,0 +1,6 @@
1
+ import type { TextControlProps } from "./text-control.js";
2
+ export interface TextareaProps extends Omit<TextControlProps, "type" | "pattern"> {
3
+ readonly rows?: number;
4
+ }
5
+ /** A labeled multiline text control, vertically resizable without escaping its width. */
6
+ export declare const Textarea: import("react").ForwardRefExoticComponent<TextareaProps & import("react").RefAttributes<HTMLTextAreaElement>>;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { TextArea as AriaTextarea, TextField } from "react-aria-components";
4
+ import { controlPaint, controlStyle, FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
5
+ import { SurfaceField } from "./control-surface.js";
6
+ import FieldStyle, { textControlClass } from "./field-style.js";
7
+ /** A labeled multiline text control, vertically resizable without escaping its width. */
8
+ export const Textarea = forwardRef(function Textarea({ label, description, errorMessage, disabled, readOnly, required, invalid, size, color, radius, style, className, placeholder, rows = 4, material, ...properties }, ref) {
9
+ const theme = useControlTheme({ size, color, radius });
10
+ return _jsxs(TextField, { ...properties, className: className, isDisabled: disabled, isReadOnly: readOnly, isRequired: required, isInvalid: invalid, style: fieldStyle(theme, disabled, style), children: [_jsx(FieldStyle, {}), _jsx(FieldLabel, { label: label }), _jsx(AriaTextarea, { ref: ref, placeholder: placeholder, rows: rows, className: textControlClass, render: (native, state) => _jsx(SurfaceField, { material: material, radius: theme.radius, paint: controlPaint(theme, state.isFocused, state.isInvalid, state.isHovered), children: _jsx("textarea", { ...native, style: { ...native.style, borderRadius: "inherit", background: "transparent" } }) }), style: state => ({
11
+ ...controlStyle(theme, state.isFocused, state.isInvalid, state.isHovered, state.isFocusVisible),
12
+ height: "auto",
13
+ minHeight: theme.height,
14
+ paddingBlock: Math.max(6, theme.spacing / 2),
15
+ resize: "vertical"
16
+ }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] });
17
+ });
@@ -0,0 +1,16 @@
1
+ import type { CSSProperties } from "react";
2
+ import type { ControlTheme } from "./control.js";
3
+ import type { MaterialOptions } from "./material-options.js";
4
+ /** Shared paint only. Selection, focus, validation, and native input behavior belong to React Aria. */
5
+ export declare function ToggleIndicator({ kind, selected, indeterminate, focused, invalid, hovered, pressed, theme, material: options }: Readonly<{
6
+ kind: "checkbox" | "radio" | "switch";
7
+ selected: boolean;
8
+ indeterminate?: boolean;
9
+ focused: boolean;
10
+ invalid?: boolean;
11
+ hovered?: boolean;
12
+ pressed?: boolean;
13
+ theme: ControlTheme;
14
+ material?: MaterialOptions;
15
+ }>): import("react").JSX.Element;
16
+ export declare function toggleStyle(theme: ControlTheme, disabled: boolean, readOnly: boolean): CSSProperties;
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { motion, useReducedMotion } from "motion/react";
3
+ import { useLocale } from "react-aria-components";
4
+ import { controlTransition, visualTransition } from "./motion-style.js";
5
+ import { Surface } from "./surface.js";
6
+ /** Shared paint only. Selection, focus, validation, and native input behavior belong to React Aria. */
7
+ export function ToggleIndicator({ kind, selected, indeterminate = false, focused, invalid = false, hovered = false, pressed = false, theme, material: options }) {
8
+ const reduced = useReducedMotion();
9
+ const { direction } = useLocale();
10
+ const diameter = theme.fontSize + 6;
11
+ const active = selected || indeterminate;
12
+ const paints = invalid ? theme.paints.danger : active ? theme.paints.palette : theme.paints.neutral;
13
+ const paint = pressed ? paints.pressed : hovered ? paints.hover : paints.rest;
14
+ const radius = kind === "checkbox" ? Math.min(diameter / 4, typeof theme.radius === "number" ? theme.radius : diameter / 4) : diameter;
15
+ const switching = kind === "switch";
16
+ const thumbWidth = diameter - 6 + (pressed && !reduced ? 3 : 0);
17
+ const travel = (diameter * 2 - thumbWidth) / 2 - 3;
18
+ return _jsx(Surface, { as: motion.span, ...options, color: paint.background, "aria-hidden": "true", initial: false, animate: { scale: reduced || switching ? 1 : pressed ? 0.94 : hovered ? 1.03 : 1 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: {
19
+ color: paint.color,
20
+ ...visualTransition,
21
+ display: "inline-grid",
22
+ placeItems: "center",
23
+ position: "relative",
24
+ boxSizing: "border-box",
25
+ flexShrink: 0,
26
+ width: switching ? diameter * 2 : diameter,
27
+ height: diameter,
28
+ borderRadius: radius,
29
+ border: "none",
30
+ outline: focused ? `2px solid ${theme.foreground}` : "none",
31
+ outlineOffset: 2,
32
+ }, children: switching ? _jsx(motion.span, { initial: false, animate: { width: thumbWidth, x: (selected ? travel : -travel) * (direction === "rtl" ? -1 : 1) }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: {
33
+ ...visualTransition,
34
+ height: diameter - 6, borderRadius: diameter,
35
+ background: paint.color
36
+ } }) : _jsx(motion.span, { initial: false, animate: { opacity: active ? 1 : 0, scale: active ? 1 : 0.7 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: { display: "grid", placeItems: "center" }, children: kind === "radio" ? _jsx("span", { style: { ...visualTransition, width: diameter / 2, height: diameter / 2, borderRadius: "50%", background: "currentColor" } })
37
+ : _jsxs("svg", { width: diameter - 4, height: diameter - 4, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx(motion.path, { d: "m3 8 3 3 7-7", initial: false, animate: { pathLength: selected && !indeterminate ? 1 : 0, opacity: selected && !indeterminate ? 1 : 0 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration } }), _jsx(motion.path, { d: "M4 8h8", initial: false, animate: { pathLength: indeterminate ? 1 : 0, opacity: indeterminate ? 1 : 0 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration } })] }) }) });
38
+ }
39
+ export function toggleStyle(theme, disabled, readOnly) {
40
+ return {
41
+ display: "flex",
42
+ alignItems: "center",
43
+ gap: theme.gap,
44
+ minHeight: theme.height,
45
+ width: "fit-content",
46
+ cursor: disabled ? "not-allowed" : readOnly ? "default" : "pointer"
47
+ };
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "React components for coherent PhreshOS Program interfaces.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -52,17 +52,17 @@
52
52
  "prepack": "node --run test && node --run build"
53
53
  },
54
54
  "peerDependencies": {
55
- "@phreshos/core": "^0.1.41",
56
55
  "react": "^19.2.0",
57
56
  "react-dom": "^19.2.0"
58
57
  },
59
58
  "dependencies": {
59
+ "@phreshos/core": "^0.1.42",
60
60
  "colorjs.io": "^0.7.1",
61
+ "motion": "^13.2.0",
61
62
  "react-aria-components": "^1.20.0"
62
63
  },
63
64
  "devDependencies": {
64
65
  "@base-ui/react": "^1.7.0",
65
- "@phreshos/core": "^0.1.41",
66
66
  "@testing-library/dom": "^10.4.1",
67
67
  "@testing-library/react": "^16.3.0",
68
68
  "@testing-library/user-event": "^14.6.1",
@@ -1,14 +0,0 @@
1
- interface SurfaceMaterialProps {
2
- readonly color: string;
3
- readonly distortion: number;
4
- readonly foreground: string;
5
- readonly grain: number;
6
- readonly grainAmount: number;
7
- readonly identity: string;
8
- readonly opacity: number;
9
- readonly ripples: number;
10
- readonly waves: number;
11
- }
12
- /** The locally owned fill, refraction definition, and glass rim inside one Surface. */
13
- export declare function SurfaceMaterial({ color, distortion, foreground, grain, grainAmount, identity, opacity, ripples, waves }: SurfaceMaterialProps): import("react").JSX.Element | null;
14
- export {};
@@ -1,128 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useLayoutEffect, useMemo, useRef, useState } from "react";
3
- import { colorLightness, colorOpacity, orderColors } from "./color.js";
4
- import { scale } from "./scale.js";
5
- /** The locally owned fill, refraction definition, and glass rim inside one Surface. */
6
- export function SurfaceMaterial({ color, distortion, foreground, grain, grainAmount, identity, opacity, ripples, waves }) {
7
- const element = useRef(null);
8
- const [colors, setColors] = useState(null);
9
- const seed = useMemo(() => seedFrom(identity), [identity]);
10
- const hasPaint = opacity > 0;
11
- const hasGrain = hasPaint && grain > 0 && grainAmount > 0;
12
- const hasDistortion = distortion > 0 || waves > 0 || ripples > 0;
13
- const initial = useMemo(() => hasGrain ? grainPaths(seed, grainAmount) : [], [grainAmount, hasGrain, seed]);
14
- // Resolve variables, currentColor, and CSS expressions in this Surface's
15
- // own scope before comparing colors. Layout effects settle before paint.
16
- useLayoutEffect(() => {
17
- const material = element.current;
18
- const view = material?.ownerDocument.defaultView;
19
- if (!material || !view || !hasPaint)
20
- return;
21
- const computed = view.getComputedStyle(material);
22
- const background = computed.fill;
23
- const foreground = computed.color;
24
- if (colors?.background === background && colors.foreground === foreground)
25
- return;
26
- setColors({ ...orderColors(background, foreground), background, foreground, lightness: Math.max(0, Math.min(1, colorLightness(background))) });
27
- });
28
- if (!hasPaint && !hasDistortion)
29
- return null;
30
- return _jsxs(Fragment, { children: [_jsxs("svg", { "data-surface-material": "", "aria-hidden": "true", focusable: "false", style: {
31
- position: "absolute",
32
- zIndex: -1,
33
- inset: 0,
34
- display: "block",
35
- width: "100%",
36
- height: "100%",
37
- overflow: "hidden",
38
- borderRadius: "inherit",
39
- boxShadow: hasPaint && colors ? `inset 0 1px 2px ${colorOpacity(colors.lighter, 0.28)}, 0 8px 24px ${colorOpacity(colors.darker, 0.04)}` : undefined,
40
- pointerEvents: "none"
41
- }, children: [(hasGrain || hasDistortion) && _jsxs("defs", { children: [hasDistortion && _jsx(DistortionFilter, { distortion: distortion, identity: identity, ripples: ripples, seed: seed, waves: waves }), hasGrain && _jsx("pattern", { id: `${identity}-grain`, width: patternSize, height: patternSize, patternUnits: "userSpaceOnUse", children: initial.map((path, tone) => _jsx("path", { "data-surface-grain-tone": tone, d: path, fill: grainTone(color, tone, grain), shapeRendering: "crispEdges" }, tone)) })] }), hasPaint && _jsxs("g", { "data-surface-paint": "", opacity: opacity, children: [_jsx("rect", { ref: element, "data-surface-base": "", width: "100%", height: "100%", style: { fill: color, color: foreground } }), hasGrain && _jsx("rect", { "data-surface-grain": "", width: "100%", height: "100%", fill: `url(#${identity}-grain)`, shapeRendering: "crispEdges" })] })] }), hasPaint && _jsx("div", { "data-surface-border": "", "aria-hidden": "true", style: {
42
- position: "absolute",
43
- // The material belongs behind content; its rim belongs above it.
44
- // Opaque children must not erase the Surface's rounded edge.
45
- zIndex: 1,
46
- inset: 0,
47
- padding: 1,
48
- borderRadius: "inherit",
49
- pointerEvents: "none",
50
- opacity: Math.min(1, scale(opacity, "xlarge")) * (colors?.lightness ?? 0),
51
- background: colors ? glassRim(colors.lighter, colors.darker) : undefined,
52
- WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
53
- WebkitMaskComposite: "xor",
54
- mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
55
- maskComposite: "exclude"
56
- } })] });
57
- }
58
- function glassRim(light, dark) {
59
- const edge = `color-mix(in oklch, ${dark} 20%, ${light})`;
60
- return `linear-gradient(145deg, ${colorOpacity(light, 0.92)}, ${colorOpacity(light, 0.4)} 35%, ${colorOpacity(edge, 0.18)} 55%, ${colorOpacity(light, 0.6)} 85%, ${colorOpacity(light, 0.3)})`;
61
- }
62
- function DistortionFilter({ distortion, identity, ripples, seed, waves }) {
63
- const fields = [
64
- distortion > 0 && { name: "organic", strength: distortion, x: "R" },
65
- waves > 0 && { name: "waves", strength: waves, x: "R" },
66
- ripples > 0 && { name: "ripples", strength: ripples, x: "B" }
67
- ].filter((field) => Boolean(field));
68
- const scale = fields.reduce((total, field) => total + field.strength, 0);
69
- const weighted = fields.map(field => `${identity}-${field.name}-weighted`);
70
- const map = `${identity}-distortion-map`;
71
- return _jsxs("filter", { id: `${identity}-distortion`, "data-surface-distortion": "", x: "-20%", y: "-20%", width: "140%", height: "140%", colorInterpolationFilters: "sRGB", children: [distortion > 0 && _jsxs(Fragment, { children: [_jsx("feTurbulence", { "data-surface-distortion-noise": "", "data-surface-distortion-field": "organic", type: "fractalNoise", baseFrequency: "0.008 0.008", numOctaves: 2, seed: 92, result: `${identity}-organic-noise` }), _jsx("feGaussianBlur", { in: `${identity}-organic-noise`, stdDeviation: 2, result: `${identity}-organic-noise-blurred` })] }), waves > 0 && _jsx(Fragment, { children: _jsx("feTurbulence", { "data-surface-distortion-field": "waves", type: "turbulence", baseFrequency: "0.006 0.045", numOctaves: 1, seed: seed + 17, result: `${identity}-waves-noise` }) }), ripples > 0 && _jsx(Fragment, { children: _jsx("feTurbulence", { "data-surface-distortion-field": "ripples", type: "turbulence", baseFrequency: "0.055", numOctaves: 2, seed: seed + 31, result: `${identity}-ripples-noise` }) }), fields.map(field => _jsx("feColorMatrix", { "data-surface-distortion-weight": field.name, in: `${identity}-${field.name}-noise${field.name === "organic" ? "-blurred" : ""}`, type: "matrix", values: weightMatrix(field.x, field.strength / scale), result: `${identity}-${field.name}-weighted` }, field.name)), weighted.slice(1).map((field, index) => _jsx("feComposite", { "data-surface-distortion-combine": "", in: index === 0 ? weighted[0] : `${identity}-distortion-sum-${index}`, in2: field, operator: "arithmetic", k2: 1, k3: 1, result: index === weighted.length - 2 ? map : `${identity}-distortion-sum-${index + 1}` }, field)), _jsx("feDisplacementMap", { "data-surface-distortion-stage": "combined", in: "SourceGraphic", in2: weighted.length === 1 ? weighted[0] : map, scale: scale, xChannelSelector: "R", yChannelSelector: "G" })] });
72
- }
73
- function weightMatrix(x, weight) {
74
- const red = x === "R" ? `${weight} 0 0 0 0` : `0 0 ${weight} 0 0`;
75
- return `${red}
76
- 0 ${weight} 0 0 0
77
- 0 0 0 0 0
78
- 0 0 0 1 0`;
79
- }
80
- function grainTone(color, tone, intensity) {
81
- const channel = Math.round(tone / (toneCount - 1) * 255);
82
- const percentage = Math.round(intensity * 10_000) / 100;
83
- return `color-mix(in srgb, ${color} ${100 - percentage}%, rgb(${channel} ${channel} ${channel}) ${percentage}%)`;
84
- }
85
- function grainPaths(seed, amount) {
86
- const tones = Array.from({ length: toneCount }, () => []);
87
- for (let y = 0; y < patternSize; y += 1) {
88
- for (let x = 0; x < patternSize; x += 1) {
89
- const pointX = x + seed * 41;
90
- const pointY = y + seed * 17;
91
- const presence = shaderHash(pointX + 71.9, pointY + 13.7);
92
- if (presence > amount)
93
- continue;
94
- const fine = shaderHash(Math.floor(pointX * 1.18), Math.floor(pointY * 1.18));
95
- const clustered = shaderHash(Math.floor(pointX * 0.47) + 31.7, Math.floor(pointY * 0.47) + 31.7);
96
- const value = clamp(fine * 0.8 + clustered * 0.2, 0, 1);
97
- const tone = Math.min(toneCount - 1, Math.floor(value * toneCount));
98
- tones[tone]?.push(`M${x} ${y}h1v1h-1z`);
99
- }
100
- }
101
- return tones.map(tone => tone.join(""));
102
- }
103
- function shaderHash(x, y) {
104
- let red = fract(x * 0.1031);
105
- let green = fract(y * 0.1031);
106
- let blue = fract(x * 0.1031);
107
- const product = red * (green + 33.33) + green * (blue + 33.33) + blue * (red + 33.33);
108
- red += product;
109
- green += product;
110
- blue += product;
111
- return fract((red + green) * blue);
112
- }
113
- function seedFrom(value) {
114
- let seed = 2166136261;
115
- for (const character of value) {
116
- seed ^= character.codePointAt(0) ?? 0;
117
- seed = Math.imul(seed, 16777619);
118
- }
119
- return (seed >>> 0) % 997 + 1;
120
- }
121
- function fract(value) {
122
- return value - Math.floor(value);
123
- }
124
- function clamp(value, minimum, maximum) {
125
- return Math.min(maximum, Math.max(minimum, value));
126
- }
127
- const patternSize = 64;
128
- const toneCount = 16;