@phreshos/react-ui 0.1.34 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.d.ts CHANGED
@@ -36,6 +36,8 @@ export { Table, type TableProps, type TableNoSelectionProps, type TableSingleSel
36
36
  export { Tree, type TreeProps, type TreeNoSelectionProps, type TreeSingleSelectionProps, type TreeMultipleSelectionProps, type TreeMultipleValue, type TreeItemProps, type TreeContentProps, type TreeCollectionProps } from "./tree.js";
37
37
  export { Slider, type SliderProps } from "./slider.js";
38
38
  export { ProgressBar, type ProgressBarProps } from "./progress-bar.js";
39
+ export { Spinner, type SpinnerProps } from "./spinner.js";
40
+ export { Readiness, useReadiness, useRequirement, type ReadinessFallback, type ReadinessProps, type ReadinessRequirement, type ReadinessRequirementProps } from "./readiness.js";
39
41
  export { ScrollArea, type ScrollAreaAxis, type ScrollAreaProps } from "./scroll-area.js";
40
42
  export { Toolbar, type ToolbarProps, type ToolbarGroupProps, type ToolbarSeparatorProps } from "./toolbar.js";
41
43
  export { Disclosure, type DisclosureProps, type DisclosureTriggerProps, type DisclosureContentProps } from "./disclosure.js";
package/dist/main.js CHANGED
@@ -34,6 +34,8 @@ export { Table } from "./table.js";
34
34
  export { Tree } from "./tree.js";
35
35
  export { Slider } from "./slider.js";
36
36
  export { ProgressBar } from "./progress-bar.js";
37
+ export { Spinner } from "./spinner.js";
38
+ export { Readiness, useReadiness, useRequirement } from "./readiness.js";
37
39
  export { ScrollArea } from "./scroll-area.js";
38
40
  export { Toolbar } from "./toolbar.js";
39
41
  export { Disclosure } from "./disclosure.js";
@@ -0,0 +1,37 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ /** One requirement observed during the boundary's initial readiness cycle. */
3
+ export interface ReadinessRequirement {
4
+ readonly message: string;
5
+ readonly createdAt: Date;
6
+ readonly readyAt: Date | null;
7
+ }
8
+ export interface ReadinessProps {
9
+ /** Interface prepared behind this initial readiness boundary. */
10
+ readonly children: ReactNode;
11
+ /** Optional presentation rendered while initial requirements remain pending. */
12
+ readonly fallback?: (requirements: readonly ReadinessRequirement[]) => ReactNode;
13
+ /** Message used when the oldest pending requirement has none of its own. */
14
+ readonly message?: string;
15
+ }
16
+ /** Prevents interaction with one React subtree until its initial requirements are ready. */
17
+ export declare function ReadinessRoot({ children, fallback, message }: ReadinessProps): import("react").JSX.Element;
18
+ export interface ReadinessRequirementProps {
19
+ /** Message exposed while this mounted requirement remains pending. */
20
+ readonly message?: string;
21
+ }
22
+ /** One declarative requirement whose pending lifetime matches this component's mount. */
23
+ export declare function ReadinessRequirement({ message }: ReadinessRequirementProps): null;
24
+ /** Registers one requirement and returns its stable, idempotent completion. */
25
+ export declare function useRequirement(message?: string): () => void;
26
+ /** Reads the nearest boundary's current initial-readiness state. */
27
+ export declare function useReadiness(): Readonly<{
28
+ ready: boolean;
29
+ requirements: readonly ReadinessRequirement[];
30
+ }>;
31
+ interface ReadinessComponent {
32
+ (properties: ReadinessProps): ReactElement | null;
33
+ readonly Requirement: typeof ReadinessRequirement;
34
+ }
35
+ export declare const Readiness: ReadinessComponent;
36
+ export type ReadinessFallback = NonNullable<ReadinessProps["fallback"]>;
37
+ export {};
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react";
3
+ const ReadinessContext = createContext(null);
4
+ /** Prevents interaction with one React subtree until its initial requirements are ready. */
5
+ export function ReadinessRoot({ children, fallback, message = "Loading…" }) {
6
+ const parent = useContext(ReadinessContext);
7
+ const requirements = useRef(new Map());
8
+ const revealedRef = useRef(false);
9
+ const [revealed, setRevealed] = useState(false);
10
+ const [revision, invalidate] = useReducer(value => value + 1, 0);
11
+ const registry = useMemo(() => ({
12
+ register(identity, nextMessage) {
13
+ if (revealedRef.current)
14
+ return;
15
+ const current = requirements.current.get(identity);
16
+ if (current != null) {
17
+ if (current.readyAt == null)
18
+ return;
19
+ // React Strict Mode restores mounted effects before the initial tree settles.
20
+ requirements.current.set(identity, {
21
+ ...current,
22
+ message: nextMessage,
23
+ readyAt: null
24
+ });
25
+ invalidate();
26
+ return;
27
+ }
28
+ requirements.current.set(identity, {
29
+ message: nextMessage,
30
+ createdAt: new Date(),
31
+ readyAt: null
32
+ });
33
+ invalidate();
34
+ },
35
+ update(identity, nextMessage) {
36
+ if (revealedRef.current)
37
+ return;
38
+ const current = requirements.current.get(identity);
39
+ if (current == null || current.message === nextMessage)
40
+ return;
41
+ requirements.current.set(identity, { ...current, message: nextMessage });
42
+ invalidate();
43
+ },
44
+ complete(identity) {
45
+ if (revealedRef.current)
46
+ return;
47
+ const current = requirements.current.get(identity);
48
+ if (current == null || current.readyAt != null)
49
+ return;
50
+ requirements.current.set(identity, { ...current, readyAt: new Date() });
51
+ invalidate();
52
+ },
53
+ release(identity) {
54
+ if (revealedRef.current)
55
+ return;
56
+ const current = requirements.current.get(identity);
57
+ if (current == null || current.readyAt != null)
58
+ return;
59
+ requirements.current.set(identity, { ...current, readyAt: new Date() });
60
+ invalidate();
61
+ }
62
+ }), []);
63
+ const currentRequirements = useMemo(() => {
64
+ return [...requirements.current.values()].map(requirement => ({
65
+ message: requirement.message ?? message,
66
+ createdAt: new Date(requirement.createdAt),
67
+ readyAt: requirement.readyAt == null ? null : new Date(requirement.readyAt)
68
+ }));
69
+ }, [message, revision]);
70
+ const currentMessage = currentRequirements.find(requirement => requirement.readyAt == null)?.message ?? message;
71
+ const completeParent = useManagedRequirement(parent?.registry ?? null, currentMessage);
72
+ useLayoutEffect(() => {
73
+ if (revealedRef.current
74
+ || [...requirements.current.values()].some(requirement => requirement.readyAt == null))
75
+ return;
76
+ // Descendant layout effects register first, so an empty set here represents a settled commit.
77
+ revealedRef.current = true;
78
+ setRevealed(true);
79
+ }, [revision]);
80
+ useLayoutEffect(() => {
81
+ if (revealed)
82
+ completeParent();
83
+ }, [completeParent, revealed]);
84
+ const context = useMemo(() => ({
85
+ ready: revealed,
86
+ requirements: currentRequirements,
87
+ registry
88
+ }), [currentRequirements, registry, revealed]);
89
+ return _jsxs(ReadinessContext.Provider, { value: context, children: [_jsx("div", { "data-readiness": "", "data-ready": revealed ? "true" : "false", inert: revealed ? undefined : true, style: { display: "contents" }, children: children }), !revealed && fallback?.(currentRequirements)] });
90
+ }
91
+ /** One declarative requirement whose pending lifetime matches this component's mount. */
92
+ export function ReadinessRequirement({ message }) {
93
+ useRequirement(message);
94
+ return null;
95
+ }
96
+ /** Registers one requirement and returns its stable, idempotent completion. */
97
+ export function useRequirement(message) {
98
+ const context = useContext(ReadinessContext);
99
+ if (context == null)
100
+ throw new Error("useRequirement must be used inside Readiness");
101
+ return useManagedRequirement(context.registry, message);
102
+ }
103
+ /** Reads the nearest boundary's current initial-readiness state. */
104
+ export function useReadiness() {
105
+ const context = useContext(ReadinessContext);
106
+ if (context == null)
107
+ throw new Error("useReadiness must be used inside Readiness");
108
+ return useMemo(() => ({
109
+ ready: context.ready,
110
+ requirements: context.requirements
111
+ }), [context.ready, context.requirements]);
112
+ }
113
+ function useManagedRequirement(registry, message) {
114
+ const identity = useRef({});
115
+ const completed = useRef(false);
116
+ useLayoutEffect(() => {
117
+ if (!completed.current)
118
+ registry?.register(identity.current, message);
119
+ return () => registry?.release(identity.current);
120
+ }, [registry]);
121
+ useLayoutEffect(() => {
122
+ if (!completed.current)
123
+ registry?.update(identity.current, message);
124
+ }, [message, registry]);
125
+ return useCallback(() => {
126
+ if (completed.current)
127
+ return;
128
+ completed.current = true;
129
+ registry?.complete(identity.current);
130
+ }, [registry]);
131
+ }
132
+ export const Readiness = Object.assign(ReadinessRoot, {
133
+ Requirement: ReadinessRequirement
134
+ });
@@ -0,0 +1,29 @@
1
+ import type { CSSProperties } from "react";
2
+ import type { ProgressBarProps as AriaProgressBarProps } from "react-aria-components";
3
+ import type { ControlColor } from "./control.js";
4
+ import { type ScaleLevel } from "./scale.js";
5
+ type NativeSpinnerProps = Omit<AriaProgressBarProps, "aria-hidden" | "aria-label" | "aria-labelledby" | "children" | "className" | "formatOptions" | "isIndeterminate" | "maxValue" | "minValue" | "slot" | "style" | "value" | "valueLabel">;
6
+ interface SpinnerVisualProps extends NativeSpinnerProps {
7
+ /** Native class name applied without replacing the component contract. */
8
+ readonly className?: string;
9
+ /** Color of the moving indicator. */
10
+ readonly color?: ControlColor;
11
+ /** Derives the indicator diameter from Appearance. */
12
+ readonly size?: ScaleLevel;
13
+ /** Native styles applied after Spinner defaults. */
14
+ readonly style?: CSSProperties;
15
+ }
16
+ type NamedSpinnerProps = Readonly<{
17
+ /** Accessible operation name announced with indeterminate progress semantics. */
18
+ label: string;
19
+ decorative?: false;
20
+ }>;
21
+ type DecorativeSpinnerProps = Readonly<{
22
+ /** Hides the Spinner when surrounding content already communicates progress. */
23
+ decorative: true;
24
+ label?: never;
25
+ }>;
26
+ export type SpinnerProps = SpinnerVisualProps & (NamedSpinnerProps | DecorativeSpinnerProps);
27
+ /** Compact indeterminate progress, either named or explicitly decorative. */
28
+ export declare const Spinner: import("react").ForwardRefExoticComponent<SpinnerProps & import("react").RefAttributes<HTMLDivElement>>;
29
+ export {};
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { ProgressBar as AriaProgressBar } from "react-aria-components";
4
+ import { motion } from "motion/react";
5
+ import { useAppearance } from "./appearance-context.js";
6
+ import { colorOpacity } from "./color.js";
7
+ import { useControlTheme } from "./control.js";
8
+ import { scale } from "./scale.js";
9
+ /** Compact indeterminate progress, either named or explicitly decorative. */
10
+ export const Spinner = forwardRef(function Spinner({ className, color = "default:base", decorative = false, label, size = "medium", style, ...properties }, ref) {
11
+ const appearance = useAppearance();
12
+ const theme = useControlTheme({ color, size });
13
+ // Spinner is a standalone status, so its scale must remain visibly distinct
14
+ // instead of inheriting the compact indicator size used inside controls.
15
+ const diameter = appearance.spacing + scale(appearance.spacing, size);
16
+ const rootStyle = {
17
+ display: "inline-grid",
18
+ placeItems: "center",
19
+ boxSizing: "border-box",
20
+ flexShrink: 0,
21
+ width: diameter,
22
+ height: diameter,
23
+ verticalAlign: "middle",
24
+ ...style
25
+ };
26
+ const indicator = _jsx(SpinnerIndicator, { contextualColor: color === "currentColor", theme: theme });
27
+ if (decorative)
28
+ return _jsx("div", { ...properties, ref: ref, "aria-hidden": "true", className: className, style: rootStyle, children: indicator });
29
+ return _jsx(AriaProgressBar, { ...properties, ref: ref, "aria-label": label, className: className, isIndeterminate: true, style: rootStyle, children: indicator });
30
+ });
31
+ function SpinnerIndicator({ contextualColor, theme }) {
32
+ const duration = typeof theme.motionTransition.duration === "number"
33
+ ? Math.max(0.6, theme.motionTransition.duration * 4)
34
+ : 0.8;
35
+ // currentColor belongs to the surrounding surface, whose canvas is not the
36
+ // Appearance canvas. Alpha preserves contrast against that actual owner.
37
+ const track = contextualColor
38
+ ? colorOpacity("currentColor", 0.25)
39
+ : theme.paints.subtle.rest.background;
40
+ const fill = contextualColor ? "currentColor" : theme.paints.palette.rest.background;
41
+ return _jsxs(motion.svg, { "data-spinner-indicator": "", "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", width: "100%", height: "100%", fill: "none", initial: false, animate: { rotate: theme.animations ? [0, 360] : 0 }, transition: theme.animations ? { duration, repeat: Infinity, ease: "linear" } : { duration: 0 }, style: { display: "block", transformOrigin: "center" }, children: [_jsx("circle", { "data-spinner-track": "", cx: "12", cy: "12", r: "9", stroke: track, strokeWidth: "3" }), _jsx("circle", { "data-spinner-fill": "", cx: "12", cy: "12", r: "9", pathLength: "100", stroke: fill, strokeWidth: "3", strokeLinecap: "round", strokeDasharray: "68 32" })] });
42
+ }
@@ -35,6 +35,10 @@ export function ToggleIndicator({ kind, direction = "ltr", selected, indetermina
35
35
  }
36
36
  export function toggleStyle(theme, disabled, readOnly) {
37
37
  return {
38
+ // React Aria's focusable native input is visually hidden with absolute
39
+ // positioning. Anchor it here so focus cannot scroll an ancestor viewport
40
+ // toward a position outside the visible toggle.
41
+ position: "relative",
38
42
  display: "flex",
39
43
  alignItems: "center",
40
44
  gap: theme.gap,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "React components for coherent PhreshOS Program interfaces.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -57,7 +57,7 @@
57
57
  "dependencies": {
58
58
  "@base-ui/react": "^1.7.0",
59
59
  "@internationalized/date": "^3.12.3",
60
- "@phreshos/core": "^0.1.59",
60
+ "@phreshos/core": "^0.1.60",
61
61
  "colorjs.io": "^0.7.1",
62
62
  "motion": "^13.2.0",
63
63
  "react-aria": "^3.51.0",