@phreshos/react-ui 0.1.20 → 0.1.22

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.
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import {} from "@phreshos/core";
3
- import DocumentScrollbars from "./document-scrollbars.js";
4
3
  import MotionStyle from "./motion-style.js";
5
4
  import { AppearanceContext, ThemeContext, useAppearance, useTheme } from "./appearance-context.js";
6
5
  export { useAppearance, useTheme, useThemedValue } from "./appearance-context.js";
@@ -10,5 +9,5 @@ export function AppearanceProvider({ appearance, children, theme }) {
10
9
  const inheritedTheme = useTheme();
11
10
  const resolvedAppearance = appearance ?? inheritedAppearance;
12
11
  const resolvedTheme = theme ?? inheritedTheme;
13
- return _jsx(AppearanceContext.Provider, { value: resolvedAppearance, children: _jsxs(ThemeContext.Provider, { value: resolvedTheme, children: [_jsx(MotionStyle, {}), _jsx(DocumentScrollbars, { appearance: resolvedAppearance, theme: resolvedTheme }), children] }) });
12
+ return _jsx(AppearanceContext.Provider, { value: resolvedAppearance, children: _jsxs(ThemeContext.Provider, { value: resolvedTheme, children: [_jsx(MotionStyle, {}), children] }) });
14
13
  }
package/dist/button.js CHANGED
@@ -31,6 +31,7 @@ function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pe
31
31
  gridAutoFlow: "column",
32
32
  gridAutoColumns: "max-content",
33
33
  placeItems: "center",
34
+ justifyContent: "center",
34
35
  flexShrink: 0,
35
36
  minWidth: 0,
36
37
  height,
package/dist/color.js CHANGED
@@ -43,16 +43,16 @@ export function useColor(value) {
43
43
  /** Resolves a semantic Appearance color or preserves a direct CSS color. */
44
44
  export function useResolveColor(value = defaultColor) {
45
45
  const appearance = useAppearance();
46
- const semantic = parseSemanticColor(value);
47
- const source = useThemedValue(appearance.colors)[semantic?.name ?? "background"];
48
- return semantic ? color(source)[semantic.level] : value;
46
+ const colors = useThemedValue(appearance.colors);
47
+ const semantic = parseSemanticColor(value, colors);
48
+ return semantic ? color(semantic.source)[semantic.level] : value;
49
49
  }
50
50
  /** Resolves a color to concrete opaque paint for solid interactive states. */
51
51
  export function useResolveSolidColor(value) {
52
52
  const appearance = useAppearance();
53
- const semantic = parseSemanticColor(value);
54
- const source = useThemedValue(appearance.colors)[semantic?.name ?? "background"];
55
- return semantic ? resolveColorLevel(source, semantic.level) : opaqueColor(value);
53
+ const colors = useThemedValue(appearance.colors);
54
+ const semantic = parseSemanticColor(value, colors);
55
+ return semantic ? resolveColorLevel(semantic.source, semantic.level) : opaqueColor(value);
56
56
  }
57
57
  /** Applies material opacity without restricting the source CSS color syntax. */
58
58
  export function colorOpacity(value, opacity) {
@@ -99,17 +99,14 @@ export function solidColors(base, background, foreground) {
99
99
  pressed: paint(colorShade(fill, 0.085))
100
100
  };
101
101
  }
102
- function parseSemanticColor(value) {
102
+ function parseSemanticColor(value, colors) {
103
103
  const separator = value.indexOf(":");
104
104
  if (separator < 0 || value.indexOf(":", separator + 1) >= 0)
105
105
  return null;
106
106
  const name = value.slice(0, separator);
107
107
  const level = value.slice(separator + 1);
108
- if (!isAppearanceColor(name) || !isColorLevel(level))
108
+ const source = Reflect.get(colors, name);
109
+ if (typeof source !== "string" || !isColorLevel(level))
109
110
  return null;
110
- return { name, level };
111
- }
112
- function isAppearanceColor(value) {
113
- return value === "background" || value === "foreground" || value === "primary" || value === "secondary"
114
- || value === "success" || value === "warning" || value === "danger" || value === "info";
111
+ return { source, level };
115
112
  }
package/dist/main.d.ts CHANGED
@@ -19,6 +19,7 @@ export { Radio, RadioGroup, type RadioProps, type RadioGroupProps } from "./radi
19
19
  export { Switch, type SwitchProps } from "./switch.js";
20
20
  export { Select, type SelectProps, type SelectOption } from "./select.js";
21
21
  export { Slider, type SliderProps } from "./slider.js";
22
+ export { ScrollArea, type ScrollAreaAxis, type ScrollAreaProps } from "./scroll-area.js";
22
23
  export type { ControlColor } from "./control.js";
23
24
  export type { LayoutAlignment, LayoutGap, LayoutJustification } from "./layout.js";
24
25
  export { resolveRadius, type Radius, type RadiusProps } from "./radius.js";
package/dist/main.js CHANGED
@@ -19,6 +19,7 @@ export { Radio, RadioGroup } from "./radio.js";
19
19
  export { Switch } from "./switch.js";
20
20
  export { Select } from "./select.js";
21
21
  export { Slider } from "./slider.js";
22
+ export { ScrollArea } from "./scroll-area.js";
22
23
  export { resolveRadius } from "./radius.js";
23
24
  export { resolveSpacing } from "./spacing.js";
24
25
  export { useColor } from "./color.js";
@@ -9,5 +9,6 @@ export declare function useControlTransition(reduced?: boolean): Transition;
9
9
  /** Per-overlay variables consumed by the shared entrance keyframes. */
10
10
  export declare function useOverlayTransition(): CSSProperties;
11
11
  export declare const overlayMotionClass = "phreshos-ui-overlay";
12
+ export declare function useTransitionTiming(): CSSProperties;
12
13
  /** React hoists and deduplicates this stylesheet, including portals. */
13
14
  export default function MotionStyle(): import("react").JSX.Element;
@@ -29,7 +29,7 @@ export function useOverlayTransition() {
29
29
  };
30
30
  }
31
31
  export const overlayMotionClass = "phreshos-ui-overlay";
32
- function useTransitionTiming() {
32
+ export function useTransitionTiming() {
33
33
  const { duration, easing } = useAppearance().transaction;
34
34
  return {
35
35
  transitionDuration: `${duration}ms`,
@@ -0,0 +1,10 @@
1
+ import type { ComponentPropsWithoutRef, Ref, ReactNode, UIEventHandler } from "react";
2
+ export type ScrollAreaAxis = "vertical" | "horizontal" | "both";
3
+ export interface ScrollAreaProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "onScroll"> {
4
+ readonly axis?: ScrollAreaAxis;
5
+ readonly children?: ReactNode;
6
+ readonly onScroll?: UIEventHandler<HTMLDivElement>;
7
+ readonly viewportRef?: Ref<HTMLDivElement>;
8
+ }
9
+ /** A native scrolling viewport with locally owned React UI scrollbars. */
10
+ export declare const ScrollArea: import("react").ForwardRefExoticComponent<ScrollAreaProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area";
3
+ import { forwardRef, useState } from "react";
4
+ import { useAppearance, useThemedValue } from "./appearance-provider.js";
5
+ import { colorOpacity } from "./color.js";
6
+ import { useTransitionTiming } from "./motion-style.js";
7
+ /** A native scrolling viewport with locally owned React UI scrollbars. */
8
+ export const ScrollArea = forwardRef(function ScrollArea({ axis = "vertical", children, onScroll, style, viewportRef, ...properties }, ref) {
9
+ const appearance = useAppearance();
10
+ const foreground = useThemedValue(appearance.colors).foreground;
11
+ const radius = Math.min(appearance.radius, 8);
12
+ const vertical = axis === "vertical" || axis === "both";
13
+ const horizontal = axis === "horizontal" || axis === "both";
14
+ return _jsxs(BaseScrollArea.Root, { ...properties, "data-phreshos-scroll-area": "", ref: ref, style: {
15
+ ...style,
16
+ position: style?.position ?? "relative",
17
+ minWidth: style?.minWidth ?? 0,
18
+ minHeight: style?.minHeight ?? 0,
19
+ overflow: "hidden"
20
+ }, children: [_jsx(BaseScrollArea.Viewport, { "data-phreshos-scroll-area-viewport": "", ref: viewportRef, onScroll: onScroll, style: {
21
+ width: "100%",
22
+ height: "100%",
23
+ overflowX: horizontal ? "scroll" : "hidden",
24
+ overflowY: vertical ? "scroll" : "hidden"
25
+ }, children: _jsx(BaseScrollArea.Content, { "data-phreshos-scroll-area-content": "", style: vertical && !horizontal ? { minWidth: "100%", width: "100%" } : undefined, children: children }) }), vertical && _jsx(Scrollbar, { orientation: "vertical", foreground: foreground, radius: radius }), horizontal && _jsx(Scrollbar, { orientation: "horizontal", foreground: foreground, radius: radius }), vertical && horizontal && _jsx(BaseScrollArea.Corner, { style: { background: "transparent" } })] });
26
+ });
27
+ function Scrollbar({ orientation, foreground, radius }) {
28
+ const [hovered, setHovered] = useState(false);
29
+ const transition = useTransitionTiming();
30
+ const vertical = orientation === "vertical";
31
+ return _jsx(BaseScrollArea.Scrollbar, { "data-phreshos-scroll-area-scrollbar": "", orientation: orientation, keepMounted: true, onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), style: state => {
32
+ const present = vertical ? state.hasOverflowY : state.hasOverflowX;
33
+ return {
34
+ boxSizing: "border-box",
35
+ width: vertical ? 16 : undefined,
36
+ height: vertical ? undefined : 16,
37
+ padding: 5,
38
+ opacity: present && (state.hovering || state.scrolling) ? 1 : 0,
39
+ pointerEvents: present && (state.hovering || state.scrolling) ? "auto" : "none",
40
+ transitionDuration: transition.transitionDuration,
41
+ transitionTimingFunction: transition.transitionTimingFunction,
42
+ transitionProperty: "opacity"
43
+ };
44
+ }, children: _jsx(BaseScrollArea.Thumb, { "data-phreshos-scroll-area-thumb": "", style: {
45
+ borderRadius: radius,
46
+ backgroundColor: colorOpacity(foreground, hovered ? 0.2 : 0.1),
47
+ transitionDuration: transition.transitionDuration,
48
+ transitionTimingFunction: transition.transitionTimingFunction,
49
+ transitionProperty: "background-color"
50
+ } }) });
51
+ }
package/dist/select.js CHANGED
@@ -5,6 +5,7 @@ import { Select as AriaSelect, Button, SelectValue, Popover, ListBox, ListBoxIte
5
5
  import { controlPaint, controlStyle, FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
6
6
  import { SurfaceButton } from "./control-surface.js";
7
7
  import { Surface } from "./surface.js";
8
+ import { ScrollArea } from "./scroll-area.js";
8
9
  import { overlayMotionClass, useControlTransition, useOverlayTransition } from "./motion-style.js";
9
10
  /** Single selection from string-valued options; keyboard navigation and typeahead stay native to React Aria. */
10
11
  export const Select = forwardRef(function Select({ label, description, errorMessage, disabled, required, invalid, options, value, defaultValue, onChange, size, color, radius, style, material, ...properties }, ref) {
@@ -16,15 +17,15 @@ export const Select = forwardRef(function Select({ label, description, errorMess
16
17
  ...controlStyle(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered, button.isFocusVisible),
17
18
  display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
18
19
  cursor: disabled ? "not-allowed" : "pointer", textAlign: "start"
19
- }), children: [_jsx(SelectValue, { style: ({ isPlaceholder }) => ({ opacity: isPlaceholder ? 0.6 : 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }) }), _jsx(motion.svg, { "aria-hidden": "true", width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", initial: false, animate: { rotate: state.isOpen ? 180 : 0 }, transition: transition, style: { flexShrink: 0 }, children: _jsx("path", { d: "m2 4 4 4 4-4" }) })] }), _jsx(Popover, { className: overlayMotionClass, placement: "bottom start", offset: theme.gap, maxHeight: 280 + theme.gap * 2, style: { ...overlayTransition, width: "var(--trigger-width)", maxWidth: "calc(100vw - 16px)" }, children: _jsx(Surface, { style: { padding: theme.gap, display: "flex", flexDirection: "column", maxHeight: "inherit", boxSizing: "border-box" }, children: _jsx(ListBox, { items: options, style: { display: "grid", gap: theme.gap, minHeight: 0, overflow: "auto", outline: "none", fontSize: theme.fontSize, color: theme.foreground }, children: option => _jsx(ListBoxItem, { id: option.value, textValue: option.label, style: item => ({
20
- ...theme.transition,
21
- display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
22
- minHeight: theme.height, paddingInline: Math.max(8, theme.spacing), boxSizing: "border-box",
23
- borderRadius: theme.radius,
24
- outline: item.isFocused && !item.isSelected ? `2px solid ${theme.foreground}` : "none", outlineOffset: -2,
25
- cursor: item.isDisabled ? "not-allowed" : "pointer",
26
- opacity: item.isDisabled ? 0.46 : 1,
27
- ...(item.isSelected ? item.isFocused ? theme.paints.palette.hover : theme.paints.palette.rest
28
- : { background: "transparent", color: theme.foreground })
29
- }), children: item => _jsxs(_Fragment, { children: [option.label, _jsx("span", { "aria-hidden": "true", children: item.isSelected ? "✓" : null })] }) }) }) }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] }) });
20
+ }), children: [_jsx(SelectValue, { style: ({ isPlaceholder }) => ({ opacity: isPlaceholder ? 0.6 : 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }) }), _jsx(motion.svg, { "aria-hidden": "true", width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", initial: false, animate: { rotate: state.isOpen ? 180 : 0 }, transition: transition, style: { flexShrink: 0 }, children: _jsx("path", { d: "m2 4 4 4 4-4" }) })] }), _jsx(Popover, { className: overlayMotionClass, placement: "bottom start", offset: theme.gap, maxHeight: 280 + theme.gap * 2, style: { ...overlayTransition, width: "var(--trigger-width)", maxWidth: "calc(100vw - 16px)" }, children: _jsx(Surface, { style: { padding: theme.gap, display: "flex", flexDirection: "column", maxHeight: "inherit", boxSizing: "border-box" }, children: _jsx(ScrollArea, { style: { flex: "1 1 auto", minHeight: 0 }, children: _jsx(ListBox, { items: options, style: { display: "grid", gap: theme.gap, outline: "none", fontSize: theme.fontSize, color: theme.foreground }, children: option => _jsx(ListBoxItem, { id: option.value, textValue: option.label, style: item => ({
21
+ ...theme.transition,
22
+ display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
23
+ minHeight: theme.height, paddingInline: Math.max(8, theme.spacing), boxSizing: "border-box",
24
+ borderRadius: theme.radius,
25
+ outline: item.isFocused && !item.isSelected ? `2px solid ${theme.foreground}` : "none", outlineOffset: -2,
26
+ cursor: item.isDisabled ? "not-allowed" : "pointer",
27
+ opacity: item.isDisabled ? 0.46 : 1,
28
+ ...(item.isSelected ? item.isFocused ? theme.paints.palette.hover : theme.paints.palette.rest
29
+ : { background: "transparent", color: theme.foreground })
30
+ }), children: item => _jsxs(_Fragment, { children: [option.label, _jsx("span", { "aria-hidden": "true", children: item.isSelected ? "✓" : null })] }) }) }) }) }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] }) });
30
31
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "React components for coherent PhreshOS Program interfaces.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -55,13 +55,13 @@
55
55
  "react-dom": "^19.2.0"
56
56
  },
57
57
  "dependencies": {
58
- "@phreshos/core": "^0.1.47",
58
+ "@base-ui/react": "^1.7.0",
59
+ "@phreshos/core": "^0.1.49",
59
60
  "colorjs.io": "^0.7.1",
60
61
  "motion": "^13.2.0",
61
62
  "react-aria-components": "^1.20.0"
62
63
  },
63
64
  "devDependencies": {
64
- "@base-ui/react": "^1.7.0",
65
65
  "@testing-library/dom": "^10.4.1",
66
66
  "@testing-library/react": "^16.3.0",
67
67
  "@testing-library/user-event": "^14.6.1",
@@ -1,6 +0,0 @@
1
- import type { Appearance, Theme } from "@phreshos/core";
2
- /** Applies one Appearance to the complete owning document without rendering. */
3
- export default function DocumentScrollbars({ appearance, theme }: Readonly<{
4
- appearance: Appearance;
5
- theme: Theme;
6
- }>): null;
@@ -1,164 +0,0 @@
1
- import { useInsertionEffect, useRef } from "react";
2
- import { colorOpacity } from "./color.js";
3
- const properties = {
4
- thumb: "--phreshos-scrollbar-thumb",
5
- thumbHover: "--phreshos-scrollbar-thumb-hover",
6
- size: "--phreshos-scrollbar-size",
7
- padding: "--phreshos-scrollbar-padding",
8
- radius: "--phreshos-scrollbar-radius"
9
- };
10
- const stylesheet = `
11
- @supports not selector(::-webkit-scrollbar) {
12
- * {
13
- scrollbar-color: var(${properties.thumb}) transparent;
14
- scrollbar-width: thin;
15
- }
16
-
17
- @media (hover: hover) and (pointer: fine) {
18
- * {
19
- scrollbar-color: transparent transparent;
20
- }
21
-
22
- *:hover {
23
- scrollbar-color: var(${properties.thumb}) transparent;
24
- }
25
- }
26
-
27
- @media (forced-colors: active) {
28
- *,
29
- *:hover {
30
- scrollbar-color: auto;
31
- }
32
- }
33
- }
34
-
35
- @supports selector(::-webkit-scrollbar) {
36
- *::-webkit-scrollbar {
37
- width: var(${properties.size});
38
- height: var(${properties.size});
39
- background: transparent;
40
- }
41
-
42
- *::-webkit-scrollbar-track,
43
- *::-webkit-scrollbar-corner {
44
- background: transparent;
45
- }
46
-
47
- *::-webkit-scrollbar-button {
48
- display: none;
49
- }
50
-
51
- *::-webkit-scrollbar-thumb {
52
- border: var(${properties.padding}) solid transparent;
53
- border-radius: var(${properties.radius});
54
- background-color: var(${properties.thumb});
55
- background-clip: padding-box;
56
- }
57
-
58
- @media (hover: hover) and (pointer: fine) {
59
- *:hover {
60
- --phreshos-scrollbar-repaint: ;
61
- }
62
-
63
- *::-webkit-scrollbar-thumb {
64
- background-color: transparent;
65
- }
66
-
67
- *:hover::-webkit-scrollbar-thumb {
68
- background-color: var(${properties.thumb});
69
- }
70
-
71
- *::-webkit-scrollbar-thumb:hover {
72
- background-color: var(${properties.thumbHover});
73
- }
74
- }
75
-
76
- @media (forced-colors: active) {
77
- *::-webkit-scrollbar-thumb,
78
- *:hover::-webkit-scrollbar-thumb,
79
- *::-webkit-scrollbar-thumb:hover {
80
- background-color: ButtonText;
81
- }
82
- }
83
- }
84
- `;
85
- const documents = new WeakMap();
86
- /** Applies one Appearance to the complete owning document without rendering. */
87
- export default function DocumentScrollbars({ appearance, theme }) {
88
- const identity = useRef(Symbol("AppearanceProvider")).current;
89
- const foreground = appearance.colors[theme].foreground;
90
- const padding = 5;
91
- const thumbSize = 6;
92
- const size = thumbSize + padding * 2;
93
- const values = {
94
- thumb: colorOpacity(foreground, 0.1),
95
- thumbHover: colorOpacity(foreground, 0.2),
96
- size: `${size}px`,
97
- padding: `${padding}px`,
98
- radius: `${Math.min(appearance.radius, padding + thumbSize / 2)}px`
99
- };
100
- useInsertionEffect(function () {
101
- if (typeof document === "undefined")
102
- return;
103
- return register(document, identity, values);
104
- }, []);
105
- useInsertionEffect(function () {
106
- if (typeof document !== "undefined")
107
- update(document, identity, values);
108
- }, [values.thumb, values.thumbHover, values.size, values.padding, values.radius]);
109
- return null;
110
- }
111
- function register(owner, identity, values) {
112
- const state = documents.get(owner) ?? create(owner);
113
- state.providers.set(identity, values);
114
- apply(state);
115
- return function () {
116
- state.providers.delete(identity);
117
- if (state.providers.size) {
118
- apply(state);
119
- return;
120
- }
121
- state.style.remove();
122
- restore(state);
123
- documents.delete(owner);
124
- };
125
- }
126
- function update(owner, identity, values) {
127
- const state = documents.get(owner);
128
- if (!state?.providers.has(identity))
129
- return;
130
- state.providers.set(identity, values);
131
- apply(state);
132
- }
133
- function create(owner) {
134
- const root = owner.documentElement;
135
- const style = owner.createElement("style");
136
- const original = new Map(Object.values(properties).map(property => [property, {
137
- value: root.style.getPropertyValue(property),
138
- priority: root.style.getPropertyPriority(property)
139
- }]));
140
- style.dataset.phreshosScrollbars = "";
141
- style.textContent = stylesheet;
142
- owner.head.append(style);
143
- const state = { root, style, original, providers: new Map() };
144
- documents.set(owner, state);
145
- return state;
146
- }
147
- function apply(state) {
148
- const values = [...state.providers.values()].at(-1);
149
- if (!values)
150
- return;
151
- state.root.style.setProperty(properties.thumb, values.thumb);
152
- state.root.style.setProperty(properties.thumbHover, values.thumbHover);
153
- state.root.style.setProperty(properties.size, values.size);
154
- state.root.style.setProperty(properties.padding, values.padding);
155
- state.root.style.setProperty(properties.radius, values.radius);
156
- }
157
- function restore(state) {
158
- for (const [property, original] of state.original) {
159
- if (original.value)
160
- state.root.style.setProperty(property, original.value, original.priority);
161
- else
162
- state.root.style.removeProperty(property);
163
- }
164
- }