@phreshos/react-ui 0.1.38 → 0.1.40

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 (45) hide show
  1. package/dist/alert.js +3 -4
  2. package/dist/app-layout.d.ts +2 -0
  3. package/dist/app-layout.js +54 -27
  4. package/dist/avatar.d.ts +23 -0
  5. package/dist/avatar.js +42 -0
  6. package/dist/badge.d.ts +20 -0
  7. package/dist/badge.js +31 -0
  8. package/dist/breadcrumbs.d.ts +20 -0
  9. package/dist/breadcrumbs.js +43 -0
  10. package/dist/control/control.d.ts +2 -0
  11. package/dist/control/control.js +4 -0
  12. package/dist/control/meaning.d.ts +5 -0
  13. package/dist/control/meaning.js +10 -0
  14. package/dist/dialog.js +2 -2
  15. package/dist/foundation/motion-style.d.ts +8 -0
  16. package/dist/foundation/motion-style.js +13 -0
  17. package/dist/link.d.ts +18 -0
  18. package/dist/link.js +32 -0
  19. package/dist/loading.d.ts +20 -0
  20. package/dist/loading.js +66 -0
  21. package/dist/main.d.ts +13 -1
  22. package/dist/main.js +12 -0
  23. package/dist/meter.d.ts +26 -0
  24. package/dist/meter.js +29 -0
  25. package/dist/progress-bar.d.ts +3 -0
  26. package/dist/progress-bar.js +7 -3
  27. package/dist/readiness.d.ts +46 -0
  28. package/dist/readiness.js +78 -0
  29. package/dist/search-field.d.ts +13 -0
  30. package/dist/search-field.js +27 -0
  31. package/dist/skeleton.d.ts +15 -0
  32. package/dist/skeleton.js +29 -0
  33. package/dist/spinner.d.ts +2 -0
  34. package/dist/spinner.js +5 -2
  35. package/dist/surface/surface.d.ts +11 -0
  36. package/dist/surface/surface.js +17 -7
  37. package/dist/tag-group.d.ts +21 -0
  38. package/dist/tag-group.js +52 -0
  39. package/dist/toast.d.ts +32 -0
  40. package/dist/toast.js +62 -0
  41. package/dist/typography.d.ts +49 -0
  42. package/dist/typography.js +71 -0
  43. package/dist/window.d.ts +4 -1
  44. package/dist/window.js +2 -0
  45. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -40,6 +40,18 @@ export { Spinner } from "./spinner.js";
40
40
  export { NumberField } from "./number-field.js";
41
41
  export { SegmentedControl } from "./segmented-control.js";
42
42
  export { Alert } from "./alert.js";
43
+ export { Link } from "./link.js";
44
+ export { Heading, Text, Kbd, Code } from "./typography.js";
45
+ export { Badge } from "./badge.js";
46
+ export { Meter } from "./meter.js";
47
+ export { SearchField } from "./search-field.js";
48
+ export { Breadcrumbs } from "./breadcrumbs.js";
49
+ export { Avatar } from "./avatar.js";
50
+ export { TagGroup } from "./tag-group.js";
51
+ export { Skeleton } from "./skeleton.js";
52
+ export { toast, ToastRegion } from "./toast.js";
53
+ export { Readiness, useReadiness, useRequirement } from "./readiness.js";
54
+ export { Loading } from "./loading.js";
43
55
  export { FileTrigger } from "./file-trigger.js";
44
56
  export { DropZone } from "./drop-zone.js";
45
57
  export { Fieldset } from "./fieldset.js";
@@ -0,0 +1,26 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { type MeterProps as AriaMeterProps } from "react-aria-components";
3
+ import { type Color } from "./foundation/color.js";
4
+ import type { ScaleLevel } from "./foundation/scale.js";
5
+ import type { MaterialOverrides } from "./surface/material-options.js";
6
+ type NativeMeterProps = Omit<AriaMeterProps, "children" | "className" | "style">;
7
+ export interface MeterProps extends NativeMeterProps, MaterialOverrides {
8
+ readonly className?: string;
9
+ /** Color of the measured portion while no threshold is reached. */
10
+ readonly color?: Color;
11
+ /** From this share of the range, between 0 and 1, the measure turns `warning`. */
12
+ readonly warning?: number;
13
+ /** From this share of the range, between 0 and 1, the measure turns `danger`. */
14
+ readonly danger?: number;
15
+ /** Visible label associated with the measured value. */
16
+ readonly label?: ReactNode;
17
+ readonly size?: ScaleLevel;
18
+ readonly style?: CSSProperties;
19
+ }
20
+ /**
21
+ * A quantity within a known range, such as disk usage. Unlike a ProgressBar it
22
+ * does not move toward an end: it reads a level, and turns `warning` or
23
+ * `danger` once it crosses the thresholds it is given.
24
+ */
25
+ export declare const Meter: import("react").ForwardRefExoticComponent<MeterProps & import("react").RefAttributes<HTMLDivElement>>;
26
+ export {};
package/dist/meter.js ADDED
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { Label, Meter as AriaMeter } from "react-aria-components";
4
+ import { controlFontWeight, controlOpacity, transition, useControlMetrics } from "./control/control.js";
5
+ import { fieldStyle } from "./control/field.js";
6
+ import { resolveColor } from "./foundation/color.js";
7
+ import { railThickness } from "./progress-bar.js";
8
+ import { SurfaceView } from "./surface/surface.js";
9
+ /**
10
+ * A quantity within a known range, such as disk usage. Unlike a ProgressBar it
11
+ * does not move toward an end: it reads a level, and turns `warning` or
12
+ * `danger` once it crosses the thresholds it is given.
13
+ */
14
+ export const Meter = forwardRef(function Meter({ className, color = "info", warning, danger, label, material, size, style, value = 0, minValue = 0, maxValue = 100, ...properties }, ref) {
15
+ const metrics = useControlMetrics(size);
16
+ const share = maxValue === minValue ? 0 : (value - minValue) / (maxValue - minValue);
17
+ const role = danger !== undefined && share >= danger ? "danger" : warning !== undefined && share >= warning ? "warning" : color;
18
+ const fill = resolveColor(role, metrics.visual.colors);
19
+ return _jsx(AriaMeter, { ...properties, ref: ref, className: className, value: value, minValue: minValue, maxValue: maxValue, style: fieldStyle(metrics, { width: "100%", ...style }), children: state => _jsxs(_Fragment, { children: [(label != null || state.valueText != null) && _jsxs("span", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: metrics.gap }, children: [label != null && _jsx(Label, { style: { fontWeight: controlFontWeight }, children: label }), state.valueText != null && _jsx("span", { style: { fontVariantNumeric: "tabular-nums", opacity: controlOpacity.secondary }, children: state.valueText })] }), _jsx(SurfaceView, { as: "span", "aria-hidden": "true", "data-meter-track": "", color: "background", depth: "recessed", material: material, radius: "full", style: { display: "block", height: railThickness(metrics), overflow: "hidden" }, children: _jsx("span", { "data-meter-fill": "", style: {
20
+ ...transition(metrics.visual, "width, background-color", 1.5),
21
+ position: "absolute",
22
+ insetBlock: 0,
23
+ insetInlineStart: 0,
24
+ display: "block",
25
+ width: `${state.percentage}%`,
26
+ borderRadius: "inherit",
27
+ background: fill
28
+ } }) })] }) });
29
+ });
@@ -1,5 +1,6 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
2
  import type { ProgressBarProps as AriaProgressBarProps } from "react-aria-components";
3
+ import { useControlMetrics } from "./control/control.js";
3
4
  import { type Color } from "./foundation/color.js";
4
5
  import type { ScaleLevel } from "./foundation/scale.js";
5
6
  import type { MaterialOverrides } from "./surface/material-options.js";
@@ -15,6 +16,8 @@ export interface ProgressBarProps extends NativeProgressBarProps, MaterialOverri
15
16
  readonly size?: ScaleLevel;
16
17
  readonly style?: CSSProperties;
17
18
  }
19
+ /** The thickness of a recessed value rail, shared by ProgressBar and Meter. */
20
+ export declare function railThickness(metrics: ReturnType<typeof useControlMetrics>): number;
18
21
  /** Progress over time: a recessed rail filled in the owner's color. */
19
22
  export declare const ProgressBar: import("react").ForwardRefExoticComponent<ProgressBarProps & import("react").RefAttributes<HTMLDivElement>>;
20
23
  export {};
@@ -4,12 +4,16 @@ import { Label, ProgressBar as AriaProgressBar } from "react-aria-components";
4
4
  import { controlFontWeight, controlOpacity, transition, useControlMetrics } from "./control/control.js";
5
5
  import { fieldStyle } from "./control/field.js";
6
6
  import { resolveColor } from "./foundation/color.js";
7
- import MotionStyle from "./foundation/motion-style.js";
7
+ import MotionStyle, { loopDuration } from "./foundation/motion-style.js";
8
8
  import { SurfaceView } from "./surface/surface.js";
9
+ /** The thickness of a recessed value rail, shared by ProgressBar and Meter. */
10
+ export function railThickness(metrics) {
11
+ return Math.max(4, Math.round(metrics.spacing / 2));
12
+ }
9
13
  /** Progress over time: a recessed rail filled in the owner's color. */
10
14
  export const ProgressBar = forwardRef(function ProgressBar({ className, color = "primary", indeterminate = false, label, material, size, style, value, minValue = 0, maxValue = 100, ...properties }, ref) {
11
15
  const metrics = useControlMetrics(size);
12
- const thickness = Math.max(4, Math.round(metrics.spacing / 2));
16
+ const thickness = railThickness(metrics);
13
17
  const fill = resolveColor(color, metrics.visual.colors);
14
18
  const moving = indeterminate && metrics.visual.duration > 0;
15
19
  return _jsx(AriaProgressBar, { ...properties, ref: ref, className: className, value: value, minValue: minValue, maxValue: maxValue, isIndeterminate: indeterminate, style: fieldStyle(metrics, { width: "100%", ...style }), children: state => {
@@ -24,7 +28,7 @@ export const ProgressBar = forwardRef(function ProgressBar({ className, color =
24
28
  width: state.isIndeterminate ? "40%" : `${state.percentage ?? 0}%`,
25
29
  borderRadius: "inherit",
26
30
  background: fill,
27
- animation: moving ? `phreshos-ui-sweep ${Math.max(900, metrics.visual.appearance.transaction.duration * 8)}ms ${metrics.visual.easing} infinite` : undefined
31
+ animation: moving ? `phreshos-ui-sweep ${loopDuration(metrics.visual)}ms ${metrics.visual.easing} infinite` : undefined
28
32
  } }) })] });
29
33
  } });
30
34
  });
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from "react";
2
+ /** One requirement of a boundary, as it is now. */
3
+ export interface Requirement<Detail = unknown> {
4
+ readonly ready: boolean;
5
+ readonly detail: Detail;
6
+ }
7
+ /** What a boundary knows: whether it is ready, and every requirement declared in it. */
8
+ export interface ReadinessState<Detail = unknown> {
9
+ readonly ready: boolean;
10
+ readonly requirements: readonly Requirement<Detail>[];
11
+ }
12
+ export interface ReadinessProps<Detail = unknown> {
13
+ /** The interface that waits. Held while the boundary is not ready. */
14
+ readonly children?: ReactNode;
15
+ /**
16
+ * Interface that shows the waiting. Rendered next to the children, outside
17
+ * the held area, so it stays interactive. A function receives the same
18
+ * value `useReadiness()` returns.
19
+ */
20
+ readonly status?: ReactNode | ((readiness: ReadinessState<Detail>) => ReactNode);
21
+ /** Milliseconds the boundary stays held after its requirements are ready, for work they do not see. */
22
+ readonly delay?: number;
23
+ }
24
+ /**
25
+ * Holds interaction with its children until every requirement declared in it
26
+ * is ready. It draws nothing: the children are wrapped in one
27
+ * `display: contents` element that carries `inert` while waiting.
28
+ *
29
+ * A requirement belongs to its nearest boundary only, so a boundary that
30
+ * starts waiting inside a ready one holds just its own part.
31
+ *
32
+ * The boundary starts held and is released only after the first commit, once
33
+ * every requirement mounted with it has registered. Server HTML is held too.
34
+ */
35
+ export declare function Readiness<Detail = unknown>({ children, status, delay }: ReadinessProps<Detail>): import("react").JSX.Element;
36
+ /**
37
+ * Declares one requirement of the nearest boundary for as long as the calling
38
+ * component is mounted. It follows `ready` both ways. `detail` is carried as-is
39
+ * and compared by identity, so pass a stable value such as a string.
40
+ */
41
+ export declare function useRequirement(ready: boolean, detail?: unknown): void;
42
+ /**
43
+ * Reads the nearest boundary. Inside `status`, that is the boundary the status
44
+ * belongs to. `Detail` states what the boundary's requirements pass as detail.
45
+ */
46
+ export declare function useReadiness<Detail = unknown>(): ReadinessState<Detail>;
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react";
3
+ /** Where `useRequirement` declares. Status content sees the parent's, not its own boundary's. */
4
+ const RegistryContext = createContext(null);
5
+ /** What `useReadiness` reads. */
6
+ const StateContext = createContext(null);
7
+ /**
8
+ * Holds interaction with its children until every requirement declared in it
9
+ * is ready. It draws nothing: the children are wrapped in one
10
+ * `display: contents` element that carries `inert` while waiting.
11
+ *
12
+ * A requirement belongs to its nearest boundary only, so a boundary that
13
+ * starts waiting inside a ready one holds just its own part.
14
+ *
15
+ * The boundary starts held and is released only after the first commit, once
16
+ * every requirement mounted with it has registered. Server HTML is held too.
17
+ */
18
+ export function Readiness({ children, status, delay = 0 }) {
19
+ const entries = useRef(new Map());
20
+ const [revision, invalidate] = useReducer((value) => value + 1, 0);
21
+ const [mounted, setMounted] = useState(false);
22
+ const [delayed, setDelayed] = useState(false);
23
+ const registry = useMemo(() => ({
24
+ set(identity, requirement) {
25
+ const current = entries.current.get(identity);
26
+ if (current !== undefined && current.ready === requirement.ready && Object.is(current.detail, requirement.detail))
27
+ return;
28
+ entries.current.set(identity, requirement);
29
+ invalidate();
30
+ },
31
+ delete(identity) {
32
+ if (entries.current.delete(identity))
33
+ invalidate();
34
+ }
35
+ }), []);
36
+ // Descendant layout effects run first, so every requirement mounted with the boundary is known here.
37
+ useLayoutEffect(() => setMounted(true), []);
38
+ const requirements = useMemo(() => [...entries.current.values()], [revision]);
39
+ const met = mounted && requirements.every(requirement => requirement.ready);
40
+ // The delay starts again whenever a requirement stops being ready.
41
+ if (!met && delayed)
42
+ setDelayed(false);
43
+ useEffect(() => {
44
+ if (!met || delay === 0)
45
+ return;
46
+ const timer = setTimeout(() => setDelayed(true), delay);
47
+ return () => clearTimeout(timer);
48
+ }, [met, delay]);
49
+ const ready = met && (delay === 0 || delayed);
50
+ const state = useMemo(() => ({ ready, requirements }), [ready, requirements]);
51
+ return _jsxs(StateContext.Provider, { value: state, children: [_jsx(RegistryContext.Provider, { value: registry, children: _jsx("div", { "data-readiness": "", inert: !ready, style: { display: "contents" }, children: children }) }), typeof status === "function" ? status(state) : status] });
52
+ }
53
+ /**
54
+ * Declares one requirement of the nearest boundary for as long as the calling
55
+ * component is mounted. It follows `ready` both ways. `detail` is carried as-is
56
+ * and compared by identity, so pass a stable value such as a string.
57
+ */
58
+ export function useRequirement(ready, detail) {
59
+ const registry = useContext(RegistryContext);
60
+ if (registry === null)
61
+ throw new Error("useRequirement must be called inside a Readiness boundary.");
62
+ // The call site is the identity: one object for this mounted component.
63
+ const [identity] = useState(() => ({}));
64
+ useLayoutEffect(() => {
65
+ registry.set(identity, { ready, detail });
66
+ }, [registry, identity, ready, detail]);
67
+ useLayoutEffect(() => () => registry.delete(identity), [registry, identity]);
68
+ }
69
+ /**
70
+ * Reads the nearest boundary. Inside `status`, that is the boundary the status
71
+ * belongs to. `Detail` states what the boundary's requirements pass as detail.
72
+ */
73
+ export function useReadiness() {
74
+ const state = useContext(StateContext);
75
+ if (state === null)
76
+ throw new Error("useReadiness must be called inside a Readiness boundary.");
77
+ return state;
78
+ }
@@ -0,0 +1,13 @@
1
+ import type { SearchFieldProps as AriaSearchFieldProps } from "react-aria-components";
2
+ import { type ControlOverrides, type ControlProps, type FieldProps } from "./control/control.js";
3
+ import type { RadiusProps } from "./foundation/radius.js";
4
+ import type { MaterialOverrides } from "./surface/material-options.js";
5
+ export interface SearchFieldProps extends Omit<AriaSearchFieldProps, ControlOverrides | "className" | "style">, ControlProps, FieldProps, RadiusProps, MaterialOverrides {
6
+ readonly placeholder?: string;
7
+ readonly readOnly?: boolean;
8
+ }
9
+ /**
10
+ * A text field for searching. It leads with a search mark, and a clear button
11
+ * appears once it holds text; Escape clears it too, and Enter calls `onSubmit`.
12
+ */
13
+ export declare const SearchField: import("react").ForwardRefExoticComponent<SearchFieldProps & import("react").RefAttributes<HTMLInputElement>>;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { Group, Input as AriaInput, SearchField as AriaSearchField } from "react-aria-components";
4
+ import { Search, X } from "lucide-react";
5
+ import { controlOpacity, useControlMetrics } from "./control/control.js";
6
+ import { FieldButton } from "./control/field-button.js";
7
+ import { FieldFeedback, FieldLabel, fieldStyle } from "./control/field.js";
8
+ import { iconProps } from "./control/icon.js";
9
+ import { surfaceRender } from "./control/surface-render.js";
10
+ import { nativeTextStyle } from "./control/text-control.js";
11
+ import MotionStyle, { textControlClass } from "./foundation/motion-style.js";
12
+ import { dimmedClass } from "./surface/surface.js";
13
+ /**
14
+ * A text field for searching. It leads with a search mark, and a clear button
15
+ * appears once it holds text; Escape clears it too, and Enter calls `onSubmit`.
16
+ */
17
+ export const SearchField = forwardRef(function SearchField({ label, description, errorMessage, disabled, readOnly, required, invalid, size, color = "background", radius, style, className, placeholder, material, ...properties }, ref) {
18
+ const metrics = useControlMetrics(size, radius);
19
+ const icon = Math.round(metrics.height / 2);
20
+ return _jsx(AriaSearchField, { ...properties, className: dimmedClass(disabled ?? false, className), isDisabled: disabled, isReadOnly: readOnly, isRequired: required, isInvalid: invalid, style: fieldStyle(metrics, style), children: field => _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsx(FieldLabel, { label: label }), _jsxs(Group, { render: surfaceRender("div", group => ({
21
+ color,
22
+ depth: "recessed",
23
+ material,
24
+ radius: metrics.radius,
25
+ interaction: { hovered: group.isHovered, focusVisible: group.isFocusWithin, invalid: group.isInvalid, disabled: group.isDisabled }
26
+ })), style: { display: "grid", gridTemplateColumns: "auto minmax(0, 1fr) auto", alignItems: "center", minWidth: 0, height: metrics.height, paddingInlineEnd: 4 }, children: [_jsx(Search, { ...iconProps(icon), style: { marginInlineStart: metrics.inset, opacity: controlOpacity.placeholder } }), _jsx(AriaInput, { ref: ref, placeholder: placeholder, className: textControlClass, style: { ...nativeTextStyle(metrics, false), paddingInlineStart: metrics.gap, WebkitAppearance: "none" } }), _jsx(FieldButton, { metrics: metrics, "aria-label": "Clear search", style: { visibility: field.isEmpty ? "hidden" : "visible" }, children: _jsx(X, { ...iconProps(icon) }) })] }), _jsx(FieldFeedback, { metrics: metrics, description: description, errorMessage: errorMessage })] }) });
27
+ });
@@ -0,0 +1,15 @@
1
+ import type { CSSProperties } from "react";
2
+ import { type Radius } from "./foundation/radius.js";
3
+ export interface SkeletonProps {
4
+ /** Lines of text to hold the place of. Omitted, the Skeleton is one block sized by `style`. */
5
+ readonly lines?: number;
6
+ readonly radius?: Radius;
7
+ readonly className?: string;
8
+ readonly style?: CSSProperties;
9
+ }
10
+ /**
11
+ * The place of content that is still loading, in the separator paint so it
12
+ * sits quietly on any Surface. It pulses at the pace of every repeating motion
13
+ * and stands still when animations are off.
14
+ */
15
+ export declare function Skeleton({ lines, radius, className, style }: SkeletonProps): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useControlMetrics } from "./control/control.js";
3
+ import { separatorColor } from "./control/field.js";
4
+ import MotionStyle, { loopDuration } from "./foundation/motion-style.js";
5
+ import { resolveRadius } from "./foundation/radius.js";
6
+ /** The last line of a paragraph ends short, at the golden ratio of the others. */
7
+ const lastLine = `${(1 / 1.618) * 100}%`;
8
+ /**
9
+ * The place of content that is still loading, in the separator paint so it
10
+ * sits quietly on any Surface. It pulses at the pace of every repeating motion
11
+ * and stands still when animations are off.
12
+ */
13
+ export function Skeleton({ lines, radius, className, style }) {
14
+ const metrics = useControlMetrics();
15
+ const visual = metrics.visual;
16
+ const paint = {
17
+ display: "block",
18
+ background: separatorColor(metrics),
19
+ borderRadius: resolveRadius(radius ?? "small", visual.radius),
20
+ animation: visual.duration > 0 ? `phreshos-ui-pulse ${loopDuration(visual)}ms ${visual.easing} infinite` : undefined
21
+ };
22
+ if (lines === undefined)
23
+ return _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsx("span", { "aria-hidden": "true", className: className, style: { ...paint, height: metrics.height, ...style } })] });
24
+ return _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsx("span", { "aria-hidden": "true", className: className, style: { display: "grid", gap: "0.5lh", ...style }, children: Array.from({ length: lines }, (_, index) => _jsx("span", { style: {
25
+ ...paint,
26
+ height: "1em",
27
+ width: index === lines - 1 && lines > 1 ? lastLine : "100%"
28
+ } }, index)) })] });
29
+ }
package/dist/spinner.d.ts CHANGED
@@ -21,6 +21,8 @@ type DecorativeSpinnerProps = Readonly<{
21
21
  label?: never;
22
22
  }>;
23
23
  export type SpinnerProps = SpinnerVisualProps & (NamedSpinnerProps | DecorativeSpinnerProps);
24
+ /** A standalone status stays visibly distinct from the compact control indicator. */
25
+ export declare function spinnerDiameter(spacing: number, size: ScaleLevel): number;
24
26
  /** Compact indeterminate progress, either named or explicitly decorative. */
25
27
  export declare const Spinner: import("react").ForwardRefExoticComponent<SpinnerProps & import("react").RefAttributes<HTMLDivElement>>;
26
28
  export {};
package/dist/spinner.js CHANGED
@@ -5,12 +5,15 @@ import { useControlMetrics } from "./control/control.js";
5
5
  import { colorOpacity, resolveColor } from "./foundation/color.js";
6
6
  import MotionStyle from "./foundation/motion-style.js";
7
7
  import { scale } from "./foundation/scale.js";
8
+ /** A standalone status stays visibly distinct from the compact control indicator. */
9
+ export function spinnerDiameter(spacing, size) {
10
+ return spacing + scale(spacing, size);
11
+ }
8
12
  /** Compact indeterminate progress, either named or explicitly decorative. */
9
13
  export const Spinner = forwardRef(function Spinner({ className, color = "primary", decorative = false, label, size = "medium", style, ...properties }, ref) {
10
14
  const metrics = useControlMetrics(size);
11
15
  const { colors } = metrics.visual;
12
- // A standalone status stays visibly distinct from the compact control indicator.
13
- const diameter = metrics.visual.spacing + scale(metrics.visual.spacing, size);
16
+ const diameter = spinnerDiameter(metrics.visual.spacing, size);
14
17
  const arc = color === "currentColor" ? "currentColor" : resolveColor(color, colors);
15
18
  const rootStyle = {
16
19
  display: "inline-grid",
@@ -78,3 +78,14 @@ export declare function surfacePaint(visual: Visual, color: Color, depth: Surfac
78
78
  distortion: number;
79
79
  variables: CSSProperties;
80
80
  };
81
+ /**
82
+ * How far the inward spill reaches: at most a fixed depth, and less on short
83
+ * Surfaces. At a fixed depth a small button was lit through nearly half its
84
+ * height and read as swollen, while a large panel barely changes.
85
+ *
86
+ * The limit is the height of the spill's background layer, not the gradient's
87
+ * size: browsers reject a size that mixes pixels and percentages inside
88
+ * radial-gradient(), and one rejected layer drops the whole background,
89
+ * grain included.
90
+ */
91
+ export declare const spillDepth = "min(6px, 12%)";
@@ -111,11 +111,10 @@ export function surfacePaint(visual, color, depth, options, shadowOptions, inter
111
111
  const rim = edged
112
112
  ? `linear-gradient(to right, transparent, ${colorOpacity(light, lit.side)} 18%, ${colorOpacity(light, lit.edge)} 50%, ${colorOpacity(light, lit.side)} 82%, transparent)`
113
113
  : "none";
114
- // The spill reaches at most a fixed depth, and less on short Surfaces: at a
115
- // fixed depth, a small button was lit through nearly half its height and
116
- // read as swollen, while a large panel barely changes.
114
+ // The spill fills its own background layer, whose height the stylesheet
115
+ // limits (see spillDepth).
117
116
  const spill = (edge) => edged && lit.spill > 0
118
- ? `radial-gradient(50% ${spillDepth} at 50% ${edge === "top" ? "0" : "100%"}, ${colorOpacity(light, lit.spill)}, transparent)`
117
+ ? `radial-gradient(50% 100% at 50% ${edge === "top" ? "0" : "100%"}, ${colorOpacity(light, lit.spill)}, transparent)`
119
118
  : "none";
120
119
  // A recessed Surface holds a value, so focus and validity also claim its hairline.
121
120
  const claimed = interaction?.invalid || (depth === "recessed" && interaction?.focusVisible);
@@ -148,8 +147,17 @@ export function surfacePaint(visual, color, depth, options, shadowOptions, inter
148
147
  }
149
148
  };
150
149
  }
151
- /** How far the inward spill reaches: a fixed depth, or a share of a short Surface's height. */
152
- const spillDepth = "min(6px, 12%)";
150
+ /**
151
+ * How far the inward spill reaches: at most a fixed depth, and less on short
152
+ * Surfaces. At a fixed depth a small button was lit through nearly half its
153
+ * height and read as swollen, while a large panel barely changes.
154
+ *
155
+ * The limit is the height of the spill's background layer, not the gradient's
156
+ * size: browsers reject a size that mixes pixels and percentages inside
157
+ * radial-gradient(), and one rejected layer drops the whole background,
158
+ * grain included.
159
+ */
160
+ export const spillDepth = "min(6px, 12%)";
153
161
  /** How much light each part of the rim receives, interpolated along the canvas darkness. */
154
162
  function edgeLight(depth, darkness) {
155
163
  const { light, dark } = edgeLightEnds[depth];
@@ -219,7 +227,9 @@ const stylesheet = `
219
227
  z-index: -1;
220
228
  background-color: var(--phreshos-surface-paint);
221
229
  background-image: var(--phreshos-surface-spill-top), var(--phreshos-surface-spill-bottom), var(--phreshos-surface-grain);
222
- background-size: auto, auto, ${grainSize}px ${grainSize}px;
230
+ background-size: 100% ${spillDepth}, 100% ${spillDepth}, ${grainSize}px ${grainSize}px;
231
+ background-position: center top, center bottom, 0 0;
232
+ background-repeat: no-repeat, no-repeat, repeat;
223
233
  -webkit-backdrop-filter: var(--phreshos-surface-frost);
224
234
  backdrop-filter: var(--phreshos-surface-frost);
225
235
  transition: background-color var(--phreshos-surface-duration) var(--phreshos-surface-easing);
@@ -0,0 +1,21 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import type { TagGroupProps as AriaTagGroupProps, TagProps as AriaTagProps } from "react-aria-components";
3
+ import { type FieldProps } from "./control/control.js";
4
+ import { type Color } from "./foundation/color.js";
5
+ import type { ScaleLevel } from "./foundation/scale.js";
6
+ export interface TagGroupProps extends Omit<AriaTagGroupProps, "className" | "style" | "children">, Omit<FieldProps, "required" | "invalid"> {
7
+ readonly children?: ReactNode;
8
+ /** The paint of every Tag, in its subtle level; selection uses the selection color. */
9
+ readonly color?: Color;
10
+ readonly size?: ScaleLevel;
11
+ readonly className?: string;
12
+ readonly style?: CSSProperties;
13
+ }
14
+ export interface TagGroupTagProps extends Omit<AriaTagProps, "className" | "style" | "children"> {
15
+ readonly children?: ReactNode;
16
+ }
17
+ declare function TagGroupTag({ children, ...properties }: TagGroupTagProps): import("react").JSX.Element;
18
+ export declare const TagGroup: import("react").ForwardRefExoticComponent<TagGroupProps & import("react").RefAttributes<HTMLDivElement>> & {
19
+ Tag: typeof TagGroupTag;
20
+ };
21
+ export {};
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { createContext, forwardRef, useContext } from "react";
3
+ import { Tag as AriaTag, TagGroup as AriaTagGroup, TagList } from "react-aria-components";
4
+ import { X } from "lucide-react";
5
+ import { controlFontWeight, relativeFontSize, useControlMetrics } from "./control/control.js";
6
+ import { FieldButton } from "./control/field-button.js";
7
+ import { FieldFeedback, FieldLabel, fieldStyle } from "./control/field.js";
8
+ import { iconProps } from "./control/icon.js";
9
+ import { selectionColor } from "./control/item.js";
10
+ import { surfaceRender } from "./control/surface-render.js";
11
+ import { colorLevel, resolveColor } from "./foundation/color.js";
12
+ const TagGroupContext = createContext(null);
13
+ /** Tags sit one control step below the size of their group, like Badges. */
14
+ const smaller = Object.freeze({ xsmall: "xsmall", small: "xsmall", medium: "small", large: "medium", xlarge: "large" });
15
+ /**
16
+ * A set of short labels, such as applied filters. Tags can be selected like
17
+ * collection items, and with `onRemove` each one gains a remove button.
18
+ */
19
+ const TagGroupRoot = forwardRef(function TagGroup({ label, description, errorMessage, children, color = "default", size = "medium", style, ...properties }, ref) {
20
+ const group = useControlMetrics(size);
21
+ const metrics = useControlMetrics(smaller[size]);
22
+ return _jsx(TagGroupContext.Provider, { value: { metrics, color, size }, children: _jsxs(AriaTagGroup, { ...properties, ref: ref, style: fieldStyle(group, style), children: [_jsx(FieldLabel, { label: label }), _jsx(TagList, { style: { display: "flex", flexWrap: "wrap", gap: group.gap }, children: children }), _jsx(FieldFeedback, { metrics: group, description: description, errorMessage: errorMessage })] }) });
23
+ });
24
+ function TagGroupTag({ children, ...properties }) {
25
+ const context = useContext(TagGroupContext);
26
+ if (context === null)
27
+ throw new Error("TagGroup.Tag must be inside TagGroup");
28
+ const { metrics, color, size } = context;
29
+ const { colors } = metrics.visual;
30
+ const base = resolveColor(color, colors);
31
+ return _jsx(AriaTag, { ...properties, textValue: properties.textValue ?? (typeof children === "string" ? children : undefined), render: surfaceRender("div", state => ({
32
+ color: state.isSelected ? selectionColor(base, colors) : colorLevel(base, "subtle", colors),
33
+ depth: "flat",
34
+ radius: "full",
35
+ interaction: { hovered: state.isHovered, pressed: state.isPressed, focusVisible: state.isFocusVisible, disabled: state.isDisabled }
36
+ })), style: state => ({
37
+ display: "inline-flex",
38
+ alignItems: "center",
39
+ gap: metrics.gap / 2,
40
+ height: metrics.height,
41
+ paddingInlineStart: metrics.inset,
42
+ paddingInlineEnd: state.allowsRemoving ? 4 : metrics.inset,
43
+ boxSizing: "border-box",
44
+ // The group root already applies its own text level.
45
+ fontSize: relativeFontSize(smaller[size], size),
46
+ fontWeight: controlFontWeight,
47
+ whiteSpace: "nowrap",
48
+ cursor: state.selectionMode === "none" ? "default" : "pointer",
49
+ outlineOffset: -1
50
+ }), children: state => _jsxs(_Fragment, { children: [children, state.allowsRemoving && _jsx(FieldButton, { slot: "remove", metrics: metrics, "aria-label": "Remove", children: _jsx(X, { ...iconProps(Math.round(metrics.height / 2)) }) })] }) });
51
+ }
52
+ export const TagGroup = Object.assign(TagGroupRoot, { Tag: TagGroupTag });
@@ -0,0 +1,32 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { type Color } from "./foundation/color.js";
3
+ export interface ToastContent {
4
+ readonly title: ReactNode;
5
+ readonly description?: ReactNode;
6
+ /** The meaning, as a color role: `info` by default. It sets the icon. */
7
+ readonly color?: Color;
8
+ }
9
+ export interface ToastOptions {
10
+ /**
11
+ * Milliseconds before the Toast closes itself. Omitted, it stays until
12
+ * closed. Keep at least five seconds, so it can be read.
13
+ */
14
+ readonly timeout?: number;
15
+ }
16
+ /**
17
+ * Shows a brief message that does not interrupt, such as "Saved". It appears
18
+ * in the ToastRegion and returns a key that closes it early.
19
+ */
20
+ export declare const toast: Readonly<{
21
+ show(content: ToastContent, options?: ToastOptions): string;
22
+ close(key: string): void;
23
+ }>;
24
+ export interface ToastRegionProps {
25
+ readonly className?: string;
26
+ readonly style?: CSSProperties;
27
+ }
28
+ /**
29
+ * Where Toasts appear: stacked at the end of the viewport bottom, above the
30
+ * interface, at the large spacing step from its edges. Render it once.
31
+ */
32
+ export declare function ToastRegion({ className, style }: ToastRegionProps): import("react").JSX.Element;
package/dist/toast.js ADDED
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Text, UNSTABLE_Toast as AriaToast, UNSTABLE_ToastContent as AriaToastContent, UNSTABLE_ToastQueue as AriaToastQueue, UNSTABLE_ToastRegion as AriaToastRegion } from "react-aria-components";
3
+ import { X } from "lucide-react";
4
+ import { controlFontSizes, controlFontWeight, controlOpacity, useControlMetrics } from "./control/control.js";
5
+ import { FieldButton } from "./control/field-button.js";
6
+ import { iconProps } from "./control/icon.js";
7
+ import { meaningIcon } from "./control/meaning.js";
8
+ import { colorLevel, resolveColor } from "./foundation/color.js";
9
+ import MotionStyle, { overlayLayer, overlayTransition } from "./foundation/motion-style.js";
10
+ import { resolveRadius } from "./foundation/radius.js";
11
+ import { scale } from "./foundation/scale.js";
12
+ import { Surface } from "./surface/surface.js";
13
+ import { floatingShadow } from "./surface/shadow-options.js";
14
+ const queue = new AriaToastQueue({ maxVisibleToasts: 5 });
15
+ /**
16
+ * Shows a brief message that does not interrupt, such as "Saved". It appears
17
+ * in the ToastRegion and returns a key that closes it early.
18
+ */
19
+ export const toast = Object.freeze({
20
+ show(content, options = {}) {
21
+ return queue.add(content, options.timeout === undefined ? {} : { timeout: options.timeout });
22
+ },
23
+ close(key) {
24
+ queue.close(key);
25
+ }
26
+ });
27
+ /**
28
+ * Where Toasts appear: stacked at the end of the viewport bottom, above the
29
+ * interface, at the large spacing step from its edges. Render it once.
30
+ */
31
+ export function ToastRegion({ className, style }) {
32
+ const metrics = useControlMetrics();
33
+ const visual = metrics.visual;
34
+ const inset = scale(visual.spacing, "large");
35
+ return _jsxs(_Fragment, { children: [_jsx(MotionStyle, {}), _jsx(AriaToastRegion, { queue: queue, className: className, style: {
36
+ position: "fixed",
37
+ insetBlockEnd: inset,
38
+ insetInlineEnd: inset,
39
+ zIndex: overlayLayer,
40
+ display: "flex",
41
+ flexDirection: "column-reverse",
42
+ // Each Toast is as wide as its message, up to the width of a readable line.
43
+ alignItems: "end",
44
+ gap: metrics.gap,
45
+ maxWidth: `min(28em, calc(100vw - ${inset * 2}px))`,
46
+ outline: "none",
47
+ ...style
48
+ }, children: ({ toast: item }) => {
49
+ const color = item.content.color ?? "info";
50
+ const Icon = meaningIcon(color);
51
+ return _jsx(AriaToast, { toast: item, style: { outline: "none", ...overlayTransition(visual), animation: `phreshos-ui-overlay-enter var(--phreshos-ui-motion-duration) var(--phreshos-ui-motion-easing) both` }, children: _jsxs(Surface, { material: "full", shadow: floatingShadow(visual.shadow), radius: resolveRadius("medium", visual.radius), style: {
52
+ display: "grid",
53
+ gridTemplateColumns: "auto minmax(0, 1fr) auto",
54
+ alignItems: "start",
55
+ gap: metrics.gap * 2,
56
+ padding: metrics.spacing,
57
+ paddingInlineEnd: metrics.spacing / 2,
58
+ fontSize: controlFontSizes.medium,
59
+ lineHeight: 1.45
60
+ }, children: [_jsx("span", { style: { display: "grid", height: "1.45em", alignItems: "center", color: colorLevel(resolveColor(color, visual.colors), "strong", visual.colors) }, children: _jsx(Icon, { ...iconProps(Math.round(metrics.height / 2)) }) }), _jsxs(AriaToastContent, { style: { display: "grid", gap: 2, minWidth: 0 }, children: [_jsx(Text, { slot: "title", style: { fontWeight: controlFontWeight }, children: item.content.title }), item.content.description != null && _jsx(Text, { slot: "description", style: { opacity: controlOpacity.secondary }, children: item.content.description })] }), _jsx(FieldButton, { slot: "close", metrics: metrics, "aria-label": "Close", children: _jsx(X, { ...iconProps(Math.round(metrics.height / 2)) }) })] }) });
61
+ } })] });
62
+ }