@phreshos/react-ui 0.1.49 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -231,7 +231,7 @@ component-owned.
231
231
  Text fields, Select, ComboBox, and the date fields recess the `background` by
232
232
  default. Checkbox, Switch, and RadioGroup indicators are recessed beds that rise
233
233
  in their color when selected. Slider and ProgressBar fill a recessed rail with
234
- their color. Motion follows `appearance.transaction`; Preferences with
234
+ their color. Motion is derived from what moves, how far, and `appearance.tempo` (`timing`, `useTiming`); Preferences with
235
235
  animations disabled make every change immediate without changing Appearance.
236
236
 
237
237
  | Component | Value contract | Purpose |
package/dist/button.d.ts CHANGED
@@ -25,7 +25,10 @@ interface ButtonOwnProps extends ControlProps, RadiusProps, MaterialOverrides, S
25
25
  /** A Button that acts: a native `<button>`. */
26
26
  export interface ButtonActionProps extends NativeButtonProps, ButtonOwnProps {
27
27
  readonly href?: undefined;
28
- /** Prevents activation while keeping the Button focusable. */
28
+ /**
29
+ * Work the Button started is under way: a Spinner shows in its place, the Button keeps its size
30
+ * and its name, and it stays focusable without activating.
31
+ */
29
32
  readonly pending?: boolean;
30
33
  }
31
34
  /**
package/dist/button.js CHANGED
@@ -1,8 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
4
- import { controlOpacity, transition, useControlMetrics } from "./control/control.js";
4
+ import { transition, useControlMetrics } from "./control/control.js";
5
5
  import { surfaceRender } from "./control/surface-render.js";
6
+ import { Spinner } from "./spinner.js";
6
7
  /** A raised Surface you act on, or follow when it has an `href`. */
7
8
  export const Button = forwardRef(function Button(properties, ref) {
8
9
  const { children, color = "default", depth, disabled, iconOnly = false, radius, size, style, className, material, shadow, ...rest } = properties;
@@ -33,7 +34,7 @@ export const Button = forwardRef(function Button(properties, ref) {
33
34
  const { pending = false, type = "button", ...action } = rest;
34
35
  // Disabled state is read from React Aria, so a Button placed in a slot, such
35
36
  // as a calendar's month buttons, follows the owner that disables it.
36
- return _jsx(AriaButton, { ...action, ref: ref, type: type, className: className, isDisabled: disabled, isPending: pending, render: surfaceRender("button", state => surface(state, pending)), style: state => buttonStyle(metrics, iconOnly, !state.isDisabled && !pending && state.isPressed, state.isDisabled, pending, style), children: children });
37
+ return _jsx(AriaButton, { ...action, ref: ref, type: type, className: className, isDisabled: disabled, isPending: pending, render: surfaceRender("button", state => surface(state, pending)), style: state => buttonStyle(metrics, iconOnly, !state.isDisabled && !pending && state.isPressed, state.isDisabled, pending, style), children: pending ? _jsxs(_Fragment, { children: [_jsx("span", { style: { display: "inline-flex", alignItems: "center", gap: metrics.gap, opacity: 0 }, children: children }), _jsx("span", { style: { position: "absolute", inset: 0, display: "grid", placeItems: "center" }, children: _jsx(Spinner, { decorative: true, color: "currentColor", style: { width: metrics.indicator, height: metrics.indicator } }) })] }) : children });
37
38
  });
38
39
  function buttonStyle(metrics, iconOnly, pressed, disabled, pending, style) {
39
40
  return {
@@ -62,7 +63,7 @@ function buttonStyle(metrics, iconOnly, pressed, disabled, pending, style) {
62
63
  WebkitTapHighlightColor: "transparent",
63
64
  cursor: disabled ? "not-allowed" : pending ? "progress" : "pointer",
64
65
  scale: pressed ? "0.97" : "1",
65
- ...(pending ? { opacity: controlOpacity.pending } : {}),
66
+ ...(pending ? { position: "relative" } : {}),
66
67
  ...style
67
68
  };
68
69
  }
@@ -36,7 +36,6 @@ export declare const controlLineHeight = 1.45;
36
36
  export declare const controlFontWeight = 600;
37
37
  /** Attenuation shared by every control family. */
38
38
  export declare const controlOpacity: Readonly<{
39
- pending: 0.68;
40
39
  secondary: 0.66;
41
40
  placeholder: 0.55;
42
41
  }>;
@@ -62,5 +61,5 @@ export declare function controlMetrics(visual: Visual, size: ScaleLevel, radius:
62
61
  * Appearance radius itself.
63
62
  */
64
63
  export declare function proportionalRadius(visual: Visual, height: number, level?: ScaleLevel): number;
65
- /** A CSS transition for the given properties using the Appearance transaction. */
64
+ /** A CSS transition for the given properties moving as a change in place. */
66
65
  export declare function transition(visual: Visual, properties: string, factor?: number): CSSProperties;
@@ -21,7 +21,6 @@ export const controlLineHeight = 1.45;
21
21
  export const controlFontWeight = 600;
22
22
  /** Attenuation shared by every control family. */
23
23
  export const controlOpacity = Object.freeze({
24
- pending: 0.68,
25
24
  secondary: 0.66,
26
25
  placeholder: 0.55
27
26
  });
@@ -70,7 +69,7 @@ export function proportionalRadius(visual, height, level = "medium") {
70
69
  const medium = controlHeight(visual.spacing);
71
70
  return Math.round(scale(visual.radius, level) * height / medium * 100) / 100;
72
71
  }
73
- /** A CSS transition for the given properties using the Appearance transaction. */
72
+ /** A CSS transition for the given properties moving as a change in place. */
74
73
  export function transition(visual, properties, factor = 1) {
75
74
  return {
76
75
  transitionProperty: properties,
@@ -0,0 +1,21 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ export interface DrawerProps {
3
+ readonly open: boolean;
4
+ /** Called for a press outside the Drawer or for Escape. */
5
+ readonly onClose: () => void;
6
+ /** Shown at its top, such as the name of what it belongs to. */
7
+ readonly title?: ReactNode;
8
+ readonly "aria-label"?: string;
9
+ readonly className?: string;
10
+ readonly style?: CSSProperties;
11
+ readonly children: ReactNode;
12
+ }
13
+ /**
14
+ * A narrow layout's sidebar, over its content from the start side, such as the navigation a narrow
15
+ * window gives up to its content. It is placed in its nearest positioned container. A press
16
+ * outside or Escape closes it. It slides in and back out along the same path, timed by the
17
+ * motion of a change in place, the way out its own animation so its end is seen; without motion it
18
+ * simply shows and goes. Only its position moves:
19
+ * fading it would switch off the blur it draws of what is behind it.
20
+ */
21
+ export declare function Drawer({ open, onClose, title, className, style, children, ...properties }: DrawerProps): import("react").JSX.Element | null;
package/dist/drawer.js ADDED
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { controlFontSizes, controlFontWeight } from "./control/control.js";
4
+ import MotionStyle, { overlayTransition } from "./foundation/motion-style.js";
5
+ import { scale } from "./foundation/scale.js";
6
+ import { useVisual } from "./foundation/visual.js";
7
+ import { Surface } from "./surface/surface.js";
8
+ /**
9
+ * A narrow layout's sidebar, over its content from the start side, such as the navigation a narrow
10
+ * window gives up to its content. It is placed in its nearest positioned container. A press
11
+ * outside or Escape closes it. It slides in and back out along the same path, timed by the
12
+ * motion of a change in place, the way out its own animation so its end is seen; without motion it
13
+ * simply shows and goes. Only its position moves:
14
+ * fading it would switch off the blur it draws of what is behind it.
15
+ */
16
+ export function Drawer({ open, onClose, title, className, style, children, ...properties }) {
17
+ const visual = useVisual();
18
+ const animated = visual.duration > 0;
19
+ const inset = scale(visual.spacing, "small");
20
+ // It stays while it slides out, and leaves once the slide ends.
21
+ const [present, setPresent] = useState(open);
22
+ useEffect(() => { if (open)
23
+ setPresent(true);
24
+ else if (!animated)
25
+ setPresent(false); }, [open, animated]);
26
+ useEffect(() => {
27
+ if (!open)
28
+ return;
29
+ // Escape closes the Drawer alone, not a surface that holds it.
30
+ const close = (event) => { if (event.key === "Escape") {
31
+ event.preventDefault();
32
+ event.stopPropagation();
33
+ onClose();
34
+ } };
35
+ addEventListener("keydown", close, true);
36
+ return () => removeEventListener("keydown", close, true);
37
+ }, [open, onClose]);
38
+ if (!present)
39
+ return null;
40
+ return _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), open && _jsx("div", { "data-drawer-scrim": "", style: { position: "absolute", inset: 0, zIndex: 10 }, onPointerDown: onClose }), _jsxs(Surface, { ...properties, "data-drawer": "", "data-state": animated ? open ? "opening" : "closing" : undefined, material: "full", className: ["phreshos-ui-drawer", className].filter(Boolean).join(" "), onAnimationEnd: () => { if (!open)
41
+ setPresent(false); }, style: {
42
+ ...overlayTransition(visual),
43
+ position: "absolute",
44
+ insetBlock: inset,
45
+ insetInlineStart: inset,
46
+ zIndex: 11,
47
+ boxSizing: "border-box",
48
+ width: `min(${visual.spacing * 20}px, calc(100% - ${visual.spacing * 4}px))`,
49
+ padding: inset,
50
+ overflow: "auto",
51
+ ...style
52
+ }, children: [title != null && _jsx("div", { style: { padding: `${inset}px ${inset}px ${visual.spacing}px`, fontSize: controlFontSizes.xlarge, fontWeight: controlFontWeight }, children: title }), children] })] });
53
+ }
@@ -1,7 +1,7 @@
1
- import { type Appearance as SystemAppearance, type AppearanceColors, type AppearanceMaterial, type AppearanceShadow, type AppearanceTransaction } from "@phreshos/core";
2
- export type { AppearanceColor, AppearanceColors, AppearanceMaterial, AppearanceRange, AppearanceShadow, AppearanceTransaction, Easing, ThemedValue } from "@phreshos/core";
1
+ import { type Appearance as SystemAppearance, type AppearanceColors, type AppearanceMaterial, type AppearanceShadow } from "@phreshos/core";
2
+ export type { AppearanceColor, AppearanceColors, AppearanceMaterial, AppearanceRange, AppearanceShadow, Transaction, Easing, ThemedValue } from "@phreshos/core";
3
3
  /** The System Appearance fields consumed by React UI. */
4
- export type Appearance = Pick<SystemAppearance, "colors" | "spacing" | "radius" | "shadow" | "material" | "transaction">;
4
+ export type Appearance = Pick<SystemAppearance, "colors" | "spacing" | "radius" | "shadow" | "material" | "tempo">;
5
5
  type Themed<Value> = Readonly<{
6
6
  light?: Readonly<Partial<Value>>;
7
7
  dark?: Readonly<Partial<Value>>;
@@ -12,7 +12,7 @@ type AppearanceUpdateFields = Readonly<{
12
12
  radius?: number;
13
13
  shadow?: Themed<AppearanceShadow>;
14
14
  material?: Themed<AppearanceMaterial>;
15
- transaction?: Readonly<Partial<AppearanceTransaction>>;
15
+ tempo?: number;
16
16
  }>;
17
17
  /** At least one partial visual field merged recursively with the nearest UIProvider. */
18
18
  export type AppearanceUpdate = {
@@ -76,6 +76,10 @@ export declare const appearanceLimits: Readonly<{
76
76
  maximum: 2.6;
77
77
  }>;
78
78
  }>;
79
+ tempo: Readonly<{
80
+ minimum: number;
81
+ maximum: 4;
82
+ }>;
79
83
  }>;
80
84
  /** The visual portion of the System's default Appearance. */
81
85
  export declare const defaultAppearance: Appearance;
@@ -4,7 +4,8 @@ export const appearanceLimits = Object.freeze({
4
4
  spacing: coreAppearanceLimits.spacing,
5
5
  radius: coreAppearanceLimits.radius,
6
6
  shadow: coreAppearanceLimits.shadow,
7
- material: coreAppearanceLimits.material
7
+ material: coreAppearanceLimits.material,
8
+ tempo: coreAppearanceLimits.tempo
8
9
  });
9
10
  /** The visual portion of the System's default Appearance. */
10
11
  export const defaultAppearance = Object.freeze({
@@ -13,7 +14,7 @@ export const defaultAppearance = Object.freeze({
13
14
  radius: systemDefaultAppearance.radius,
14
15
  shadow: systemDefaultAppearance.shadow,
15
16
  material: systemDefaultAppearance.material,
16
- transaction: systemDefaultAppearance.transaction
17
+ tempo: systemDefaultAppearance.tempo
17
18
  });
18
19
  /** Applies one partial update; omitted leaves continue to inherit. */
19
20
  export function mergeAppearance(appearance, update) {
@@ -23,9 +24,7 @@ export function mergeAppearance(appearance, update) {
23
24
  radius: update.radius ?? appearance.radius,
24
25
  shadow: mergeThemed(appearance.shadow, update.shadow),
25
26
  material: mergeThemed(appearance.material, update.material),
26
- transaction: update.transaction === undefined
27
- ? appearance.transaction
28
- : Object.freeze({ ...appearance.transaction, ...update.transaction })
27
+ tempo: update.tempo ?? appearance.tempo
29
28
  });
30
29
  }
31
30
  function mergeThemed(current, update) {
@@ -4,7 +4,7 @@ import type { Visual } from "./visual.js";
4
4
  export declare function overlayTransition(visual: Visual): CSSProperties;
5
5
  /**
6
6
  * How long one pass of a repeating motion takes, such as an indeterminate
7
- * progress sweep or a skeleton pulse: eight Appearance transactions, and never
7
+ * progress sweep or a skeleton pulse: eight changes in place, and never
8
8
  * quicker than a calm breath.
9
9
  */
10
10
  export declare function loopDuration(visual: Visual): number;
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { timing } from "./timing.js";
2
3
  /** Per-overlay variables consumed by the shared entrance keyframes. */
3
4
  export function overlayTransition(visual) {
4
5
  return {
@@ -8,11 +9,11 @@ export function overlayTransition(visual) {
8
9
  }
9
10
  /**
10
11
  * How long one pass of a repeating motion takes, such as an indeterminate
11
- * progress sweep or a skeleton pulse: eight Appearance transactions, and never
12
+ * progress sweep or a skeleton pulse: eight changes in place, and never
12
13
  * quicker than a calm breath.
13
14
  */
14
15
  export function loopDuration(visual) {
15
- return Math.max(900, visual.appearance.transaction.duration * 8);
16
+ return Math.max(900, timing("change", { tempo: visual.appearance.tempo }).duration * 8);
16
17
  }
17
18
  /** The stacking layer of content above an interface: dialogs and notifications. */
18
19
  export const overlayLayer = 1_000;
@@ -45,6 +46,22 @@ const stylesheet = `
45
46
  color: inherit;
46
47
  opacity: 0.55;
47
48
  }
49
+ .phreshos-ui-text-control::-webkit-search-cancel-button,
50
+ .phreshos-ui-text-control::-webkit-search-decoration {
51
+ display: none;
52
+ }
53
+ .phreshos-ui-drawer[data-state="opening"] {
54
+ animation: phreshos-ui-drawer-in var(--phreshos-ui-motion-duration) var(--phreshos-ui-motion-easing);
55
+ }
56
+ .phreshos-ui-drawer[data-state="closing"] {
57
+ animation: phreshos-ui-drawer-out var(--phreshos-ui-motion-duration) var(--phreshos-ui-motion-easing) forwards;
58
+ }
59
+ :dir(rtl).phreshos-ui-drawer[data-state="opening"] { animation-name: phreshos-ui-drawer-in-rtl; }
60
+ :dir(rtl).phreshos-ui-drawer[data-state="closing"] { animation-name: phreshos-ui-drawer-out-rtl; }
61
+ @keyframes phreshos-ui-drawer-in { from { translate: calc(-100% - 1rem) 0; } }
62
+ @keyframes phreshos-ui-drawer-out { to { translate: calc(-100% - 1rem) 0; } }
63
+ @keyframes phreshos-ui-drawer-in-rtl { from { translate: calc(100% + 1rem) 0; } }
64
+ @keyframes phreshos-ui-drawer-out-rtl { to { translate: calc(100% + 1rem) 0; } }
48
65
  @keyframes phreshos-ui-spin {
49
66
  to { rotate: 360deg; }
50
67
  }
@@ -0,0 +1,30 @@
1
+ import type { Transaction } from "@phreshos/core";
2
+ /**
3
+ * What moves, which decides how it moves:
4
+ *
5
+ * - `change`: something changing in its place, such as a color, a control's state, or an overlay
6
+ * appearing. It is short, and the same whatever the size of what changes.
7
+ * - `window`: a box carried across a distance, such as a Window moving or resizing. A light body
8
+ * answering a hand: it sets off at once and settles softly.
9
+ * - `view`: a whole scene carried across a distance, such as the view over the plane. It moves as
10
+ * a body on a spring: it arrives without passing its place, and a move taken on the way carries
11
+ * on at the speed it already has.
12
+ */
13
+ export type MotionKind = "change" | "window" | "view";
14
+ export type TimingOptions = Readonly<{
15
+ /** How far it goes, in pixels; nothing for a change in place. */
16
+ distance?: number;
17
+ /** Whether it leaves the person's sight: then it sets off softly, or it is gone in the first frames. */
18
+ leaving?: boolean;
19
+ /** The Appearance tempo, or a tempo made from it; 1 by default. */
20
+ tempo?: number;
21
+ }>;
22
+ /**
23
+ * The motion of one change: how long it takes and how it moves, from what moves, how far, and the
24
+ * tempo. The shape of every motion is the System's own; the tempo only stretches or shortens time,
25
+ * so every motion keeps its shape and its length beside every other.
26
+ *
27
+ * Across a distance, the time grows with the distance, gently, as the square root, within bounds:
28
+ * a fixed short time would cross a far distance so fast that frames show as jumps.
29
+ */
30
+ export declare function timing(kind: MotionKind, options?: TimingOptions): Transaction;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The motion of one change: how long it takes and how it moves, from what moves, how far, and the
3
+ * tempo. The shape of every motion is the System's own; the tempo only stretches or shortens time,
4
+ * so every motion keeps its shape and its length beside every other.
5
+ *
6
+ * Across a distance, the time grows with the distance, gently, as the square root, within bounds:
7
+ * a fixed short time would cross a far distance so fast that frames show as jumps.
8
+ */
9
+ export function timing(kind, options = {}) {
10
+ const tempo = options.tempo ?? 1;
11
+ // A change in place is the same for one tempo, so it is one value that effects can depend on. Only
12
+ // the few tempos in use are kept: a tempo dragged across its range leaves none of its steps behind.
13
+ if (kind === "change") {
14
+ let cached = changes.get(tempo);
15
+ if (!cached) {
16
+ if (changes.size >= 8)
17
+ changes.delete(changes.keys().next().value);
18
+ changes.set(tempo, cached = Object.freeze({ duration: Math.round(change.duration * tempo), easing: change.easing }));
19
+ }
20
+ return cached;
21
+ }
22
+ const pace = paces[kind];
23
+ const distance = options.distance ?? 0;
24
+ // Near, the time grows as the square root; a long crossing adds a little more for each pixel
25
+ // past a first stretch, so a far journey is not crossed at a rush.
26
+ const designed = Math.min(pace.maximum, Math.max(pace.minimum, pace.base + pace.growth * Math.sqrt(distance) + pace.far * Math.max(0, distance - 1000)));
27
+ return Object.freeze({ duration: Math.round(designed * tempo), easing: options.leaving ? departure : pace.easing });
28
+ }
29
+ const change = Object.freeze({ duration: 102, easing: "ease-out" });
30
+ const changes = new Map();
31
+ /** The curve of something leaving the view: soft to start, gathering speed as it goes. */
32
+ const departure = Object.freeze([0.65, 0, 0.35, 1]);
33
+ const paces = {
34
+ view: { base: 187, growth: 8.5, far: 0, minimum: 255, maximum: 850, easing: Object.freeze({ spring: Object.freeze({ bounce: 0 }) }) },
35
+ window: { base: 72, growth: 3.4, far: 0.0425, minimum: 110, maximum: 553, easing: Object.freeze([0.22, 1, 0.36, 1]) }
36
+ };
@@ -1,4 +1,6 @@
1
- import { type Appearance, type AppearanceColors, type AppearanceMaterial, type AppearanceShadow, type Easing, type ThemedValue } from "./appearance.js";
1
+ import { type Transaction } from "@phreshos/core";
2
+ import { type MotionKind, type TimingOptions } from "./timing.js";
3
+ import { type Appearance, type AppearanceColors, type AppearanceMaterial, type AppearanceShadow, type ThemedValue } from "./appearance.js";
2
4
  import type { Preferences } from "./preferences.js";
3
5
  /**
4
6
  * One Appearance resolved for one set of Preferences. Every component reads
@@ -13,9 +15,9 @@ export interface Visual {
13
15
  readonly shadow: AppearanceShadow;
14
16
  readonly spacing: number;
15
17
  readonly radius: number;
16
- /** Duration in milliseconds, already zero when animations are disabled. */
18
+ /** Duration in milliseconds of a change in place, already zero when animations are disabled. */
17
19
  readonly duration: number;
18
- /** CSS timing function for the Appearance transaction. */
20
+ /** CSS timing function of a change in place. */
19
21
  readonly easing: string;
20
22
  }
21
23
  /** Resolves and memoizes one Visual per Appearance, Theme, and animation preference. */
@@ -32,11 +34,15 @@ export declare function useAppearance(): Appearance;
32
34
  export declare function usePreferences(): Preferences;
33
35
  /** Returns the shared resolved Visual for the nearest Appearance and Preferences. */
34
36
  export declare function useVisual(): Visual;
37
+ /**
38
+ * Derives the motion of a change from the nearest Appearance's tempo: what moves, how far, and
39
+ * whether it leaves sight. With animations off, every motion takes no time.
40
+ */
41
+ export declare function useTiming(): (kind: MotionKind, options?: Omit<TimingOptions, "tempo">) => Transaction;
35
42
  /** Selects the active branch of one themed value. */
36
43
  export declare function useThemedValue<Value>(value: ThemedValue<Value>): Value;
37
44
  /** Reactively reads the browser's complete visual preferences. */
38
45
  export declare function useBrowserPreferences(): Preferences;
39
46
  /** Creates a stable source for one explicitly supplied Preferences value. */
40
47
  export declare function fixedPreferencesSource(preferences: Preferences): PreferencesSource;
41
- export declare function cssEasing(easing: Easing): string;
42
48
  export declare const PreferencesContext: import("react").Context<PreferencesSource>;
@@ -1,4 +1,6 @@
1
- import { createContext, useContext, useSyncExternalStore } from "react";
1
+ import { createContext, useCallback, useContext, useSyncExternalStore } from "react";
2
+ import { cssEasing } from "@phreshos/core";
3
+ import { timing } from "./timing.js";
2
4
  import { defaultAppearance } from "./appearance.js";
3
5
  const visuals = new WeakMap();
4
6
  /** Resolves and memoizes one Visual per Appearance, Theme, and animation preference. */
@@ -10,7 +12,8 @@ export function resolveVisual(appearance, preferences) {
10
12
  const cached = resolved.get(key);
11
13
  if (cached !== undefined)
12
14
  return cached;
13
- const duration = preferences.animations ? appearance.transaction.duration : 0;
15
+ const change = timing("change", { tempo: appearance.tempo });
16
+ const duration = preferences.animations ? change.duration : 0;
14
17
  const visual = Object.freeze({
15
18
  appearance,
16
19
  preferences,
@@ -20,7 +23,7 @@ export function resolveVisual(appearance, preferences) {
20
23
  spacing: appearance.spacing,
21
24
  radius: appearance.radius,
22
25
  duration,
23
- easing: cssEasing(appearance.transaction.easing)
26
+ easing: cssEasing(change.easing)
24
27
  });
25
28
  resolved.set(key, visual);
26
29
  return visual;
@@ -39,6 +42,18 @@ export function usePreferences() {
39
42
  export function useVisual() {
40
43
  return resolveVisual(useAppearance(), usePreferences());
41
44
  }
45
+ /**
46
+ * Derives the motion of a change from the nearest Appearance's tempo: what moves, how far, and
47
+ * whether it leaves sight. With animations off, every motion takes no time.
48
+ */
49
+ export function useTiming() {
50
+ const visual = useVisual();
51
+ return useCallback(function (kind, options = {}) {
52
+ if (!visual.preferences.animations)
53
+ return still;
54
+ return timing(kind, { ...options, tempo: visual.appearance.tempo });
55
+ }, [visual]);
56
+ }
42
57
  /** Selects the active branch of one themed value. */
43
58
  export function useThemedValue(value) {
44
59
  return value[usePreferences().theme];
@@ -55,9 +70,8 @@ export function fixedPreferencesSource(preferences) {
55
70
  subscribe: () => () => undefined
56
71
  };
57
72
  }
58
- export function cssEasing(easing) {
59
- return typeof easing === "string" ? easing : `cubic-bezier(${easing.join(", ")})`;
60
- }
73
+ // Every motion with animations off: it takes no time, so its curve never shows.
74
+ const still = Object.freeze({ duration: 0, easing: "linear" });
61
75
  const defaultPreferences = Object.freeze({ theme: "light", animations: true });
62
76
  const subscribers = new Set();
63
77
  let darkPreference;
@@ -8,6 +8,8 @@ type GridListRootBaseProps<T extends object> = Omit<AriaGridListProps<T>, "class
8
8
  className?: string;
9
9
  /** Color laid beneath selected Items. */
10
10
  color?: Color;
11
+ /** Color of Items that are not selected: `default` unless given. */
12
+ restColor?: Color;
11
13
  /** The narrowest an Item may be; the grid fits as many columns as that allows. Sixteen times the spacing by default. */
12
14
  itemWidth?: CSSProperties["width"];
13
15
  size?: ScaleLevel;
@@ -29,6 +31,8 @@ export interface GridListItemProps<T = object> extends Omit<AriaGridListItemProp
29
31
  readonly className?: string;
30
32
  /** Color laid beneath this Item when it is selected. */
31
33
  readonly color?: Color;
34
+ /** Color of this Item while it is not selected; the GridList's unless given. */
35
+ readonly restColor?: Color;
32
36
  readonly disabled?: boolean;
33
37
  readonly style?: CSSProperties;
34
38
  }
package/dist/grid-list.js CHANGED
@@ -10,12 +10,12 @@ import { resolveDirection, useDirection } from "./foundation/direction.js";
10
10
  import { collectionHeaderStyle } from "./list-box.js";
11
11
  const GridListStyleContext = createContext(null);
12
12
  const GridListRootImplementation = forwardRef(function GridList(properties, ref) {
13
- const { className, color = "primary", defaultValue: _defaultValue, itemWidth, onChange: _onChange, radius, selectionMode: _selectionMode, size, style, value: _value, ...native } = properties;
13
+ const { className, color = "primary", restColor = "default", defaultValue: _defaultValue, itemWidth, onChange: _onChange, radius, selectionMode: _selectionMode, size, style, value: _value, ...native } = properties;
14
14
  const metrics = useControlMetrics(size, radius);
15
15
  const width = itemWidth ?? metrics.visual.spacing * 16;
16
16
  // As many columns as fit, each at least the Item width; a Section repeats the same columns.
17
17
  const columns = `repeat(auto-fill, minmax(min(${typeof width === "number" ? `${width}px` : width}, 100%), 1fr))`;
18
- const context = useMemo(() => ({ color, metrics, columns }), [color, metrics, columns]);
18
+ const context = useMemo(() => ({ color, restColor, metrics, columns }), [color, restColor, metrics, columns]);
19
19
  const direction = resolveDirection(native.dir, useDirection());
20
20
  return _jsx(GridListStyleContext.Provider, { value: context, children: _jsx(AriaDirectionBoundary, { direction: direction, children: _jsx(AriaGridList, { ...native, ...ariaSelection(properties, "single"), ref: ref, dir: direction, layout: "grid", className: className, style: state => ({
21
21
  display: "grid",
@@ -33,7 +33,7 @@ const GridListRootImplementation = forwardRef(function GridList(properties, ref)
33
33
  }) }) }) });
34
34
  });
35
35
  /** One card. It holds whatever it is given, and shows a check in its corner when selected. */
36
- const GridListItemImplementation = forwardRef(function GridListItem({ children, className, color, disabled = false, style, textValue, ...properties }, ref) {
36
+ const GridListItemImplementation = forwardRef(function GridListItem({ children, className, color, restColor, disabled = false, style, textValue, ...properties }, ref) {
37
37
  const inherited = useGridListStyle();
38
38
  const itemColor = color ?? inherited.color;
39
39
  const { metrics } = inherited;
@@ -46,9 +46,9 @@ const GridListItemImplementation = forwardRef(function GridListItem({ children,
46
46
  focusVisible: state.isFocusVisible,
47
47
  disabled: state.isDisabled
48
48
  }, metrics.radius),
49
- // Unlike a list row, a card is a whole Surface: flat, in the default color at rest, in its
50
- // selection color once selected, with the default material in both.
51
- ...(state.isSelected || state.isDropTarget === true ? {} : { color: "default" }),
49
+ // Unlike a list row, a card is a whole Surface: flat, in its rest color, in its selection
50
+ // color once selected, with the default material in both.
51
+ ...(state.isSelected || state.isDropTarget === true ? {} : { color: restColor ?? inherited.restColor }),
52
52
  material: undefined
53
53
  })), style: {
54
54
  position: "relative",
package/dist/loading.d.ts CHANGED
@@ -17,7 +17,7 @@ export interface LoadingProps extends Pick<ReadinessProps, "children" | "delay">
17
17
  * requirements are waited for without being shown.
18
18
  *
19
19
  * The loading fills the nearest positioned ancestor. It appears at once; when
20
- * everything is ready it fades out with the Appearance transaction and the
20
+ * everything is ready it fades out as a change in place and the
21
21
  * interface appears whole at once.
22
22
  */
23
23
  export declare function Loading({ children, delay, steps, className, style }: LoadingProps): import("react").JSX.Element;
package/dist/loading.js CHANGED
@@ -15,7 +15,7 @@ import { Text } from "./typography.js";
15
15
  * requirements are waited for without being shown.
16
16
  *
17
17
  * The loading fills the nearest positioned ancestor. It appears at once; when
18
- * everything is ready it fades out with the Appearance transaction and the
18
+ * everything is ready it fades out as a change in place and the
19
19
  * interface appears whole at once.
20
20
  */
21
21
  export function Loading({ children, delay, steps = false, className, style }) {
package/dist/main.d.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  * Components enter this surface only after their implementation-independent
5
5
  * behavior contract has been established by the package's tests.
6
6
  */
7
- export { appearanceLimits, defaultAppearance, type Appearance, type AppearanceColor, type AppearanceColors, type AppearanceMaterial, type AppearanceRange, type AppearanceShadow, type AppearanceTransaction, type AppearanceUpdate, type Easing, type ThemedValue } from "./foundation/appearance.js";
7
+ export { appearanceLimits, defaultAppearance, type Appearance, type AppearanceColor, type AppearanceColors, type AppearanceMaterial, type AppearanceRange, type AppearanceShadow, type Transaction, type AppearanceUpdate, type Easing, type ThemedValue } from "./foundation/appearance.js";
8
8
  export { UIProvider, type UIProviderProps } from "./foundation/provider.js";
9
9
  export { DocumentTheme } from "./document-theme.js";
10
- export { useAppearance, useBrowserPreferences, usePreferences, useThemedValue } from "./foundation/visual.js";
10
+ export { useAppearance, useBrowserPreferences, usePreferences, useThemedValue, useTiming } from "./foundation/visual.js";
11
+ export { timing, type MotionKind, type TimingOptions } from "./foundation/timing.js";
11
12
  export { useDirection, useDocumentDirection, type Direction } from "./foundation/direction.js";
12
13
  export type { Preferences, PreferencesUpdate, Theme } from "./foundation/preferences.js";
13
14
  export { Flex, type FlexProps } from "./flex.js";
@@ -17,6 +18,7 @@ export { resolveColor, useColor, useContrastingColor, type Color, type ColorLeve
17
18
  export { Panel, type PanelProps, type PanelHeaderProps, type PanelContentProps } from "./panel.js";
18
19
  export { Window, type WindowProps, type WindowContentProps, type WindowHeaderProps, type WindowIdentityProps, type WindowCenterProps, type WindowActionsProps, type WindowActionProps, type WindowControlProps, type WindowMaximizeProps, type WindowCloseProps, type WindowMoveCaptureProps } from "./window.js";
19
20
  export { default as useWindowMoveHandle, type WindowMoveHandle } from "./use-window-move-handle.js";
21
+ export { Drawer, type DrawerProps } from "./drawer.js";
20
22
  export { Button, type ButtonActionProps, type ButtonColor, type ButtonLinkProps, type ButtonProps } from "./button.js";
21
23
  export { Input, type InputProps } from "./input.js";
22
24
  export { Textarea, type TextareaProps } from "./textarea.js";
package/dist/main.js CHANGED
@@ -7,7 +7,8 @@
7
7
  export { appearanceLimits, defaultAppearance } from "./foundation/appearance.js";
8
8
  export { UIProvider } from "./foundation/provider.js";
9
9
  export { DocumentTheme } from "./document-theme.js";
10
- export { useAppearance, useBrowserPreferences, usePreferences, useThemedValue } from "./foundation/visual.js";
10
+ export { useAppearance, useBrowserPreferences, usePreferences, useThemedValue, useTiming } from "./foundation/visual.js";
11
+ export { timing } from "./foundation/timing.js";
11
12
  export { useDirection, useDocumentDirection } from "./foundation/direction.js";
12
13
  export { Flex } from "./flex.js";
13
14
  export { Grid } from "./grid.js";
@@ -17,6 +18,7 @@ export { resolveColor, useColor, useContrastingColor } from "./foundation/color.
17
18
  export { Panel } from "./panel.js";
18
19
  export { Window } from "./window.js";
19
20
  export { default as useWindowMoveHandle } from "./use-window-move-handle.js";
21
+ export { Drawer } from "./drawer.js";
20
22
  export { Button } from "./button.js";
21
23
  export { Input } from "./input.js";
22
24
  export { Textarea } from "./textarea.js";
package/dist/spinner.js CHANGED
@@ -1,9 +1,9 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import { ProgressBar as AriaProgressBar } from "react-aria-components";
4
4
  import { useControlMetrics } from "./control/control.js";
5
- import { colorOpacity, resolveColor } from "./foundation/color.js";
6
- import MotionStyle from "./foundation/motion-style.js";
5
+ import { resolveColor } from "./foundation/color.js";
6
+ import MotionStyle, { loopDuration } from "./foundation/motion-style.js";
7
7
  import { scale } from "./foundation/scale.js";
8
8
  /** A standalone status stays visibly distinct from the compact control indicator. */
9
9
  export function spinnerDiameter(spacing, size) {
@@ -25,7 +25,7 @@ export const Spinner = forwardRef(function Spinner({ className, color = "primary
25
25
  verticalAlign: "middle",
26
26
  ...style
27
27
  };
28
- const indicator = _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsxs("svg", { "data-spinner-indicator": "", "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", width: "100%", height: "100%", fill: "none", style: { display: "block", animation: metrics.visual.duration > 0 ? `phreshos-ui-spin ${Math.max(600, metrics.visual.appearance.transaction.duration * 5)}ms linear infinite` : undefined }, children: [_jsx("circle", { "data-spinner-track": "", cx: "12", cy: "12", r: "9", stroke: colorOpacity(arc, 0.2), strokeWidth: "3" }), _jsx("circle", { "data-spinner-fill": "", cx: "12", cy: "12", r: "9", pathLength: "100", stroke: arc, strokeWidth: "3", strokeLinecap: "round", strokeDasharray: "30 70" })] })] });
28
+ const indicator = _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsx("svg", { "data-spinner-indicator": "", "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", width: "100%", height: "100%", fill: "none", style: { display: "block", animation: metrics.visual.duration > 0 ? `phreshos-ui-spin ${loopDuration(metrics.visual)}ms linear infinite` : undefined }, children: _jsx("circle", { "data-spinner-fill": "", cx: "12", cy: "12", r: "11", pathLength: "100", stroke: arc, strokeWidth: "2", strokeDasharray: "75 25", transform: "rotate(-90 12 12)" }) })] });
29
29
  if (decorative)
30
30
  return _jsx("div", { ...properties, ref: ref, "aria-hidden": "true", className: className, style: rootStyle, children: indicator });
31
31
  return _jsx(AriaProgressBar, { ...properties, ref: ref, "aria-label": label, className: className, isIndeterminate: true, style: rootStyle, children: indicator });
package/dist/tooltip.d.ts CHANGED
@@ -4,7 +4,9 @@ import { type ButtonActionProps } from "./button.js";
4
4
  import { type AriaOverlayInternals, type OverlayRootProps } from "./control/open-state.js";
5
5
  import { type Direction } from "./foundation/direction.js";
6
6
  import { type SurfaceOwnProps } from "./surface/surface.js";
7
- export type TooltipRootProps = OverlayRootProps & Readonly<Pick<AriaTooltipTriggerProps, "delay" | "closeDelay" | "trigger">> & Readonly<{
7
+ export type TooltipRootProps = OverlayRootProps & Readonly<Pick<AriaTooltipTriggerProps, "closeDelay" | "trigger">> & Readonly<{
8
+ /** Milliseconds the pointer rests on the trigger before the Tooltip opens: four changes in place by default. */
9
+ delay?: number;
8
10
  /** Whether the Tooltip is prevented from opening. */
9
11
  disabled?: boolean;
10
12
  /** Whether pressing the trigger closes the Tooltip. Defaults to `true`. */
@@ -18,6 +20,8 @@ export interface TooltipContentProps extends Omit<AriaTooltipProps, AriaOverlayI
18
20
  readonly children?: ReactNode;
19
21
  readonly className?: string;
20
22
  readonly dir?: Direction;
23
+ /** DOM container that owns the positioned overlay's coordinate space. */
24
+ readonly portalContainer?: Element;
21
25
  readonly style?: CSSProperties;
22
26
  }
23
27
  export declare const TooltipContent: import("react").ForwardRefExoticComponent<TooltipContentProps & import("react").RefAttributes<HTMLDivElement>>;
package/dist/tooltip.js CHANGED
@@ -9,20 +9,23 @@ import MotionStyle, { overlayMotionClass, overlayTransition } from "./foundation
9
9
  import { resolveDirectionalPlacement } from "./foundation/overlay-placement.js";
10
10
  import { scale } from "./foundation/scale.js";
11
11
  import { useVisual } from "./foundation/visual.js";
12
+ import { timing } from "./foundation/timing.js";
12
13
  import { floatingShadow } from "./surface/shadow-options.js";
13
14
  import { Surface } from "./surface/surface.js";
14
15
  export function TooltipRoot({ children, delay, closeDelay, trigger, disabled, closeOnPress, ...state }) {
15
- return _jsx(AriaTooltipTrigger, { ...ariaOpenState(state), delay: delay, closeDelay: closeDelay, trigger: trigger, isDisabled: disabled, shouldCloseOnPress: closeOnPress, children: children });
16
+ // A pointer resting on a trigger for four changes in place is asking what it is.
17
+ const rest = timing("change", { tempo: useVisual().appearance.tempo }).duration * 4;
18
+ return _jsx(AriaTooltipTrigger, { ...ariaOpenState(state), delay: delay ?? rest, closeDelay: closeDelay, trigger: trigger, isDisabled: disabled, shouldCloseOnPress: closeOnPress, children: children });
16
19
  }
17
20
  export const TooltipTrigger = forwardRef(function TooltipTrigger(properties, ref) {
18
21
  return _jsx(Button, { ...properties, ref: ref });
19
22
  });
20
- export const TooltipContent = forwardRef(function TooltipContent({ children, className, color, material, radius, shadow, style, offset, placement = "top", dir, ...attributes }, ref) {
23
+ export const TooltipContent = forwardRef(function TooltipContent({ children, className, color, material, radius, shadow, style, offset, placement = "top", dir, portalContainer, ...attributes }, ref) {
21
24
  const visual = useVisual();
22
25
  const inset = scale(visual.spacing, "small");
23
26
  const transition = overlayTransition(visual);
24
27
  const direction = resolveDirection(dir, useDirection());
25
- return _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsx(AriaTooltip, { ...attributes, ref: ref, dir: direction, offset: offset ?? inset, placement: resolveDirectionalPlacement(placement, direction), className: overlayMotionClass, style: transition, children: _jsx(Surface, { dir: direction, className: className, color: color ?? "foreground", material: material, radius: radius, shadow: shadow ?? floatingShadow(visual.shadow), style: {
28
+ return _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsx(AriaTooltip, { ...attributes, ref: ref, dir: direction, offset: offset ?? inset, placement: resolveDirectionalPlacement(placement, direction), UNSTABLE_portalContainer: portalContainer, className: overlayMotionClass, style: transition, children: _jsx(Surface, { dir: direction, className: className, color: color ?? "foreground", material: material, radius: radius, shadow: shadow ?? floatingShadow(visual.shadow), style: {
26
29
  boxSizing: "border-box",
27
30
  maxWidth: "28em",
28
31
  paddingBlock: inset,
@@ -1,4 +1,4 @@
1
- import type { BeginWindowMoveGesture } from "@phreshos/core";
1
+ import type { BeginPresentationMoveGesture } from "@phreshos/core";
2
2
  import { type PointerEventHandler } from "react";
3
3
  export interface WindowMoveHandle {
4
4
  onPointerDown: PointerEventHandler<HTMLElement>;
@@ -8,4 +8,4 @@ export interface WindowMoveHandle {
8
8
  onLostPointerCapture: PointerEventHandler<HTMLElement>;
9
9
  }
10
10
  /** Turns one DOM element into a move handle for any compatible host gesture. */
11
- export default function useWindowMoveHandle(beginMoveGesture?: BeginWindowMoveGesture, onError?: (error: unknown) => void): WindowMoveHandle;
11
+ export default function useWindowMoveHandle(beginMoveGesture?: BeginPresentationMoveGesture, onError?: (error: unknown) => void): WindowMoveHandle;
package/dist/window.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { HTMLAttributes, ReactNode } from "react";
2
- import type { BeginWindowMoveGesture } from "@phreshos/core";
2
+ import type { BeginPresentationMoveGesture } from "@phreshos/core";
3
3
  import { type ButtonActionProps } from "./button.js";
4
4
  import { type Color } from "./foundation/color.js";
5
5
  import { type SurfaceProps } from "./surface/surface.js";
@@ -16,7 +16,7 @@ export interface WindowHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>,
16
16
  /** Base color inherited by header-owned text and icons. */
17
17
  readonly color?: Color;
18
18
  /** Hands an intentional header drag to its host after the pointer threshold. */
19
- readonly beginMoveGesture?: BeginWindowMoveGesture;
19
+ readonly beginMoveGesture?: BeginPresentationMoveGesture;
20
20
  /** Whether the represented window is currently maximized. */
21
21
  readonly maximized?: boolean;
22
22
  /** Toggles the represented window between maximized and restored states. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
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.66",
60
+ "@phreshos/core": "^0.1.69",
61
61
  "colorjs.io": "^0.7.1",
62
62
  "lucide-react": "^1.48.0",
63
63
  "react-aria": "^3.51.0",