@phreshos/react-ui 0.1.15 → 0.1.17

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 (53) hide show
  1. package/README.md +200 -35
  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 +40 -0
  9. package/dist/color.js +99 -4
  10. package/dist/control-material.d.ts +20 -0
  11. package/dist/control-material.js +14 -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 +14 -2
  22. package/dist/main.js +11 -0
  23. package/dist/material-options.d.ts +20 -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/material.d.ts +27 -0
  28. package/dist/material.js +45 -0
  29. package/dist/motion-style.d.ts +21 -0
  30. package/dist/motion-style.js +45 -0
  31. package/dist/panel.d.ts +9 -0
  32. package/dist/panel.js +27 -0
  33. package/dist/radio.d.ts +14 -0
  34. package/dist/radio.js +17 -0
  35. package/dist/select.d.ts +17 -0
  36. package/dist/select.js +28 -0
  37. package/dist/slider.d.ts +7 -0
  38. package/dist/slider.js +36 -0
  39. package/dist/surface-edge.d.ts +7 -0
  40. package/dist/surface-edge.js +59 -0
  41. package/dist/surface.d.ts +7 -44
  42. package/dist/surface.js +18 -98
  43. package/dist/switch.d.ts +10 -0
  44. package/dist/switch.js +10 -0
  45. package/dist/text-control.d.ts +9 -0
  46. package/dist/text-control.js +0 -0
  47. package/dist/textarea.d.ts +6 -0
  48. package/dist/textarea.js +17 -0
  49. package/dist/toggle-indicator.d.ts +16 -0
  50. package/dist/toggle-indicator.js +51 -0
  51. package/package.json +4 -3
  52. package/dist/surface-material.d.ts +0 -13
  53. package/dist/surface-material.js +0 -102
@@ -0,0 +1,21 @@
1
+ /** The same fixed timing for Motion-driven control movements. */
2
+ export declare const controlTransition: {
3
+ readonly type: "tween";
4
+ readonly duration: 0.12;
5
+ readonly ease: "easeOut";
6
+ };
7
+ /** Host opacity and filters are excluded: either can change backdrop sampling. */
8
+ export declare const visualTransition: {
9
+ transitionProperty: "background-color, color, border-color, border-radius";
10
+ transitionDuration: "var(--phreshos-ui-motion-duration, 120ms)";
11
+ transitionTimingFunction: "ease-out";
12
+ };
13
+ /** Radius is inherited from the host; only the painted values transition here. */
14
+ export declare const paintTransition: {
15
+ transitionProperty: "fill, stroke, opacity";
16
+ transitionDuration: "var(--phreshos-ui-motion-duration, 120ms)";
17
+ transitionTimingFunction: "ease-out";
18
+ };
19
+ export declare const overlayMotionClass = "phreshos-ui-overlay";
20
+ /** React hoists and deduplicates this stylesheet, including nested providers and portals. */
21
+ export default function MotionStyle(): import("react").JSX.Element;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ const timing = {
3
+ transitionDuration: "var(--phreshos-ui-motion-duration, 120ms)",
4
+ transitionTimingFunction: "ease-out"
5
+ };
6
+ /** The same fixed timing for Motion-driven control movements. */
7
+ export const controlTransition = { type: "tween", duration: 0.12, ease: "easeOut" };
8
+ /** Host opacity and filters are excluded: either can change backdrop sampling. */
9
+ export const visualTransition = {
10
+ ...timing,
11
+ transitionProperty: "background-color, color, border-color, border-radius"
12
+ };
13
+ /** Radius is inherited from the host; only the painted values transition here. */
14
+ export const paintTransition = {
15
+ ...timing,
16
+ transitionProperty: "fill, stroke, opacity"
17
+ };
18
+ export const overlayMotionClass = "phreshos-ui-overlay";
19
+ const stylesheet = `
20
+ :root { --phreshos-ui-motion-duration: 120ms; }
21
+
22
+ .phreshos-ui-overlay { --phreshos-ui-overlay-x: 0px; --phreshos-ui-overlay-y: -4px; }
23
+ .phreshos-ui-overlay[data-placement="top"] { --phreshos-ui-overlay-y: 4px; }
24
+ .phreshos-ui-overlay[data-placement="left"] { --phreshos-ui-overlay-x: 4px; --phreshos-ui-overlay-y: 0px; }
25
+ .phreshos-ui-overlay[data-placement="right"] { --phreshos-ui-overlay-x: -4px; --phreshos-ui-overlay-y: 0px; }
26
+ .phreshos-ui-overlay[data-entering] {
27
+ animation: phreshos-ui-overlay-enter var(--phreshos-ui-motion-duration, 120ms) ease-out both;
28
+ }
29
+ .phreshos-ui-overlay[data-exiting] {
30
+ animation: phreshos-ui-overlay-enter var(--phreshos-ui-motion-duration, 120ms) ease-out reverse both;
31
+ pointer-events: none;
32
+ }
33
+ @keyframes phreshos-ui-overlay-enter {
34
+ from { opacity: 0; translate: var(--phreshos-ui-overlay-x) var(--phreshos-ui-overlay-y); }
35
+ to { opacity: 1; translate: 0 0; }
36
+ }
37
+ @media (prefers-reduced-motion: reduce) {
38
+ :root { --phreshos-ui-motion-duration: 0ms; }
39
+ .phreshos-ui-overlay[data-entering], .phreshos-ui-overlay[data-exiting] { animation: none; }
40
+ }
41
+ `;
42
+ /** React hoists and deduplicates this stylesheet, including nested providers and portals. */
43
+ export default function MotionStyle() {
44
+ return _jsx("style", { href: "phreshos-react-ui-motion", precedence: "phreshos", children: stylesheet });
45
+ }
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ import { type SurfaceProps } from "./surface.js";
3
+ /** A Surface with an optional header and an inset content Surface. */
4
+ export interface PanelProps extends SurfaceProps {
5
+ readonly header?: ReactNode;
6
+ readonly contentProps?: Omit<SurfaceProps, "children">;
7
+ }
8
+ /** Owns the shared shell layout, not positioning, interaction, or lifecycle. */
9
+ export declare const Panel: import("react").ForwardRefExoticComponent<Omit<PanelProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
package/dist/panel.js ADDED
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { useAppearance, useResolveTheme } from "./appearance-provider.js";
4
+ import { scale } from "./scale.js";
5
+ import { Surface } from "./surface.js";
6
+ /** Owns the shared shell layout, not positioning, interaction, or lifecycle. */
7
+ export const Panel = forwardRef(function Panel({ header, children, contentProps, style, ...properties }, ref) {
8
+ const appearance = useAppearance();
9
+ const inset = scale(useResolveTheme(appearance.spacing), "small");
10
+ const hasHeader = header !== undefined && header !== null && header !== false;
11
+ return _jsxs(Surface, { ...properties, ref: ref, style: {
12
+ display: "grid",
13
+ gridTemplateRows: hasHeader ? "auto minmax(0, 1fr)" : "minmax(0, 1fr)",
14
+ minWidth: 0,
15
+ minHeight: 0,
16
+ maxHeight: "inherit",
17
+ ...style
18
+ }, children: [header, _jsx(Surface, { ...contentProps, style: {
19
+ position: "relative",
20
+ minWidth: 0,
21
+ minHeight: 0,
22
+ margin: inset,
23
+ marginTop: hasHeader ? 0 : inset,
24
+ overflow: "hidden",
25
+ ...contentProps?.style
26
+ }, children: children })] });
27
+ });
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RadioGroupProps as AriaRadioGroupProps, RadioFieldProps } from "react-aria-components";
3
+ import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
4
+ import type { MaterialOverrides } from "./material.js";
5
+ export interface RadioGroupProps extends Omit<AriaRadioGroupProps, ControlOverrides | "isReadOnly">, ControlProps, FieldProps, MaterialOverrides {
6
+ readonly children: ReactNode;
7
+ readonly readOnly?: boolean;
8
+ }
9
+ export interface RadioProps extends Omit<RadioFieldProps, ControlOverrides>, ControlProps, Pick<FieldProps, "label" | "description">, MaterialOverrides {
10
+ }
11
+ /** One string value selected from its Radio children, with native arrow-key navigation. */
12
+ export declare const RadioGroup: import("react").ForwardRefExoticComponent<RadioGroupProps & import("react").RefAttributes<HTMLDivElement>>;
13
+ /** An option in a RadioGroup. Selection and validation belong to the group. */
14
+ export declare const Radio: import("react").ForwardRefExoticComponent<RadioProps & import("react").RefAttributes<HTMLDivElement>>;
package/dist/radio.js ADDED
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { createContext, forwardRef, useContext } from "react";
3
+ import { RadioGroup as AriaRadioGroup, RadioField, RadioButton, Text } from "react-aria-components";
4
+ import { FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
5
+ import { ToggleIndicator, toggleStyle } from "./toggle-indicator.js";
6
+ const RadioStyle = createContext({});
7
+ /** One string value selected from its Radio children, with native arrow-key navigation. */
8
+ export const RadioGroup = forwardRef(function RadioGroup({ label, description, errorMessage, children, disabled, readOnly, required, invalid, size, color, style, material, orientation = "vertical", ...properties }, ref) {
9
+ const theme = useControlTheme({ size, color });
10
+ return _jsxs(AriaRadioGroup, { ...properties, ref: ref, isDisabled: disabled, isReadOnly: readOnly, isRequired: required, isInvalid: invalid, orientation: orientation, style: fieldStyle(theme, disabled, style), children: [_jsx(FieldLabel, { label: label }), _jsx(RadioStyle.Provider, { value: { size, color, disabled, material }, children: _jsx("div", { style: { display: "flex", flexDirection: orientation === "vertical" ? "column" : "row", gap: theme.gap, flexWrap: "wrap" }, children: children }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] });
11
+ });
12
+ /** An option in a RadioGroup. Selection and validation belong to the group. */
13
+ export const Radio = forwardRef(function Radio({ label, description, disabled, size, color, style, material, ...properties }, ref) {
14
+ const inherited = useContext(RadioStyle);
15
+ const theme = useControlTheme({ size: size ?? inherited.size, color: color ?? inherited.color });
16
+ return _jsxs(RadioField, { ...properties, ref: ref, isDisabled: disabled, style: state => fieldStyle(theme, state.isDisabled && !inherited.disabled, style), children: [_jsx(RadioButton, { style: state => toggleStyle(theme, state.isDisabled, state.isReadOnly), children: state => _jsxs(_Fragment, { children: [_jsx(ToggleIndicator, { kind: "radio", material: material ?? inherited.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] }) }), description != null && _jsx(Text, { slot: "description", style: { fontSize: "0.92em", opacity: 0.7 }, children: description })] });
17
+ });
@@ -0,0 +1,17 @@
1
+ import type { SelectProps as AriaSelectProps } 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.js";
5
+ export interface SelectOption {
6
+ readonly value: string;
7
+ readonly label: string;
8
+ readonly disabled?: boolean;
9
+ }
10
+ export interface SelectProps extends Omit<AriaSelectProps<SelectOption>, ControlOverrides | "value" | "defaultValue" | "onChange" | "selectedKey" | "defaultSelectedKey" | "onSelectionChange" | "disabledKeys" | "selectionMode">, ControlProps, FieldProps, RadiusProps, MaterialOverrides {
11
+ readonly options: readonly SelectOption[];
12
+ readonly value?: string | null;
13
+ readonly defaultValue?: string | null;
14
+ readonly onChange?: (value: string | null) => void;
15
+ }
16
+ /** Single selection from string-valued options; keyboard navigation and typeahead stay native to React Aria. */
17
+ export declare const Select: import("react").ForwardRefExoticComponent<SelectProps & import("react").RefAttributes<HTMLDivElement>>;
package/dist/select.js ADDED
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { motion, useReducedMotion } 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";
6
+ import { MaterialButton } from "./control-material.js";
7
+ import { Surface } from "./surface.js";
8
+ import { controlTransition, overlayMotionClass, visualTransition } from "./motion-style.js";
9
+ /** Single selection from string-valued options; keyboard navigation and typeahead stay native to React Aria. */
10
+ export const Select = forwardRef(function Select({ label, description, errorMessage, disabled, required, invalid, options, value, defaultValue, onChange, size, color, radius, style, material, ...properties }, ref) {
11
+ const theme = useControlTheme({ size, color, radius });
12
+ const reduced = useReducedMotion();
13
+ 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(MaterialButton, { native: native, material: material, paint: controlPaint(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered) }), style: button => ({
14
+ ...controlStyle(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered, button.isFocusVisible),
15
+ display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
16
+ cursor: disabled ? "not-allowed" : "pointer", textAlign: "start"
17
+ }), 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: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: { flexShrink: 0 }, children: _jsx("path", { d: "m2 4 4 4 4-4" }) })] }), _jsx(Popover, { className: overlayMotionClass, placement: "bottom start", offset: theme.gap, maxHeight: 280 + theme.gap * 2, style: { width: "var(--trigger-width)", maxWidth: "calc(100vw - 16px)" }, children: _jsx(Surface, { style: { padding: theme.gap, display: "flex", flexDirection: "column", maxHeight: "inherit", boxSizing: "border-box" }, children: _jsx(ListBox, { items: options, style: { display: "grid", gap: theme.gap, minHeight: 0, overflow: "auto", outline: "none", fontSize: theme.fontSize, color: theme.foreground }, children: option => _jsx(ListBoxItem, { id: option.value, textValue: option.label, style: item => ({
18
+ ...visualTransition,
19
+ display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
20
+ minHeight: theme.height, paddingInline: Math.max(8, theme.spacing), boxSizing: "border-box",
21
+ borderRadius: theme.radius,
22
+ outline: item.isFocused && !item.isSelected ? `2px solid ${theme.foreground}` : "none", outlineOffset: -2,
23
+ cursor: item.isDisabled ? "not-allowed" : "pointer",
24
+ opacity: item.isDisabled ? 0.46 : 1,
25
+ ...(item.isSelected ? item.isFocused ? theme.paints.palette.hover : theme.paints.palette.rest
26
+ : { background: "transparent", color: theme.foreground })
27
+ }), children: item => _jsxs(_Fragment, { children: [option.label, _jsx("span", { "aria-hidden": "true", children: item.isSelected ? "✓" : null })] }) }) }) }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] }) });
28
+ });
@@ -0,0 +1,7 @@
1
+ import type { SliderProps as AriaSliderProps } from "react-aria-components";
2
+ import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
3
+ export interface SliderProps extends Omit<AriaSliderProps<number>, ControlOverrides>, ControlProps, Pick<FieldProps, "label" | "description"> {
4
+ readonly name?: string;
5
+ }
6
+ /** One numeric value with pointer, touch, keyboard, and form support. */
7
+ export declare const Slider: import("react").ForwardRefExoticComponent<SliderProps & import("react").RefAttributes<HTMLDivElement>>;
package/dist/slider.js ADDED
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId } from "react";
3
+ import { motion, useReducedMotion } from "motion/react";
4
+ import { Slider as AriaSlider, SliderTrack, SliderThumb, SliderFill, SliderOutput } from "react-aria-components";
5
+ import { FieldLabel, fieldStyle, useControlTheme } from "./control.js";
6
+ import { controlTransition, visualTransition } from "./motion-style.js";
7
+ /** One numeric value with pointer, touch, keyboard, and form support. */
8
+ export const Slider = forwardRef(function Slider({ label, description, name, disabled, size, color, style, orientation = "horizontal", ...properties }, ref) {
9
+ const theme = useControlTheme({ size, color });
10
+ const descriptionId = useId();
11
+ const vertical = orientation === "vertical";
12
+ const diameter = theme.fontSize + 6;
13
+ const reduced = useReducedMotion();
14
+ const rail = Math.max(4, Math.round(diameter / 3));
15
+ return _jsxs(AriaSlider, { ...properties, ref: ref, isDisabled: disabled, orientation: orientation, style: fieldStyle(theme, disabled, style), "aria-describedby": [properties["aria-describedby"], description != null ? descriptionId : null].filter(Boolean).join(" ") || undefined, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: theme.gap }, children: [_jsx(FieldLabel, { label: label }), _jsx(SliderOutput, { style: { fontVariantNumeric: "tabular-nums", opacity: 0.7 } })] }), _jsxs(SliderTrack, { style: {
16
+ position: "relative", touchAction: "none",
17
+ width: vertical ? theme.height : "100%", height: vertical ? 160 : theme.height
18
+ }, children: [_jsx("div", { "aria-hidden": "true", style: {
19
+ ...visualTransition,
20
+ position: "absolute", borderRadius: rail,
21
+ ...(vertical ? { width: rail, height: "100%", left: "50%", transform: "translateX(-50%)" } : { height: rail, width: "100%", top: "50%", transform: "translateY(-50%)" }),
22
+ background: theme.paints.neutral.rest.background
23
+ } }), _jsx(SliderFill, { style: {
24
+ ...visualTransition,
25
+ position: "absolute", background: theme.paints.palette.rest.background, borderRadius: rail,
26
+ ...(vertical ? { width: rail, left: "50%", transform: "translateX(-50%)" } : { height: rail, top: "50%", transform: "translateY(-50%)" })
27
+ } }), _jsx(SliderThumb, { name: name, style: state => ({
28
+ width: diameter, height: diameter, borderRadius: "50%", boxSizing: "border-box",
29
+ ...(vertical ? { left: "50%" } : { top: "50%" }),
30
+ outline: state.isFocusVisible ? `2px solid ${theme.foreground}` : "none", outlineOffset: 2,
31
+ cursor: state.isDisabled ? "not-allowed" : state.isDragging ? "grabbing" : "grab"
32
+ }), children: state => _jsx(motion.span, { "aria-hidden": "true", initial: false, animate: { scale: reduced || state.isDisabled ? 1 : state.isDragging ? 0.92 : state.isHovered ? 1.08 : 1 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: { ...visualTransition, position: "absolute", inset: 0, borderRadius: "inherit", pointerEvents: "none",
33
+ background: (state.isDragging ? theme.paints.palette.pressed : state.isHovered ? theme.paints.palette.hover : theme.paints.palette.rest).color,
34
+ border: `3px solid ${(state.isDragging ? theme.paints.palette.pressed : state.isHovered ? theme.paints.palette.hover : theme.paints.palette.rest).background}`,
35
+ boxSizing: "border-box" } }) })] }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: 0.7 }, children: description })] });
36
+ });
@@ -0,0 +1,7 @@
1
+ import type { ResolvedMaterial } from "./material.js";
2
+ type EdgeMaterial = Pick<ResolvedMaterial, "color" | "foreground" | "opacity">;
3
+ /** Paints the boundary owned by one Surface from the material it contains. */
4
+ export declare function SurfaceEdge({ material }: Readonly<{
5
+ material: EdgeMaterial;
6
+ }>): import("react").JSX.Element | null;
7
+ 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,7 @@
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 } from "react";
2
+ import { type MaterialProps } from "./material.js";
3
+ import { type RadiusProps } from "./radius.js";
4
+ /** A div that hosts one Material and owns its geometry. */
5
+ export interface SurfaceProps extends Omit<ComponentPropsWithRef<"div">, "color">, MaterialProps, RadiusProps {
6
+ }
7
+ export declare const Surface: import("react").ForwardRefExoticComponent<Omit<SurfaceProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
package/dist/surface.js CHANGED
@@ -1,101 +1,21 @@
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";
6
- import { useAppearance, useResolveTheme } from "./appearance-provider.js";
7
- const layerStyle = {
8
- position: "absolute",
9
- inset: 0,
10
- borderRadius: "inherit",
11
- pointerEvents: "none"
12
- };
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) {
2
+ import { forwardRef } from "react";
3
+ import { useAppearance } from "./appearance-provider.js";
4
+ import { MaterialLayer, useResolvedMaterial } from "./material.js";
5
+ import { visualTransition } from "./motion-style.js";
6
+ import { resolveRadius } from "./radius.js";
7
+ import { SurfaceEdge } from "./surface-edge.js";
8
+ export const Surface = forwardRef(function Surface({ color, material: options, radius, children, style, ...properties }, ref) {
15
9
  const appearance = useAppearance();
16
- const background = useResolveTheme(appearance.background);
17
- const foreground = useResolveTheme(appearance.foreground);
18
- const radius = useResolveTheme(appearance.radius);
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, ...resolved.material }), children] });
10
+ const material = useResolvedMaterial({ color, material: options });
11
+ const resolvedRadius = resolveRadius(radius ?? "medium", appearance);
12
+ return _jsxs("div", { ...properties, ref: ref, style: {
13
+ ...visualTransition,
14
+ background: "transparent",
15
+ color: material.foreground,
16
+ ...style,
17
+ borderRadius: radius === undefined ? style?.borderRadius ?? resolvedRadius : resolvedRadius,
18
+ position: style?.position ?? "relative",
19
+ isolation: "isolate"
20
+ }, children: [_jsx(MaterialLayer, { material: material }), _jsx(SurfaceEdge, { material: material }), children] });
40
21
  });
41
- /** Keeps refraction and native frost in independent compositor passes. */
42
- function BackdropLayer({ filter, name, zIndex }) {
43
- return _jsx("div", { "data-surface-backdrop": name, "aria-hidden": "true", style: {
44
- ...layerStyle,
45
- zIndex,
46
- backdropFilter: filter,
47
- WebkitBackdropFilter: filter
48
- } });
49
- }
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.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.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 { MaterialField } from "./control-material.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(MaterialField, { 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;