@checkstack/ui 1.3.4 → 1.3.6
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @checkstack/ui
|
|
2
2
|
|
|
3
|
+
## 1.3.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4b0934d: Refactored UserMenu to use a responsive grid layout, improved menu item alignment, and implemented a full-screen scrollable portal for mobile devices. Fixed an issue where the UserMenu would instantly close and reopen when clicking the trigger while the menu was open.
|
|
8
|
+
|
|
9
|
+
## 1.3.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 286491a: Added automatic FPS detection that enables "Low Power Mode" once for devices running below 50 FPS, ensuring smooth performance even for users unaware of the manual toggle.
|
|
14
|
+
|
|
3
15
|
## 1.3.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/ui",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"dependencies": {
|
|
@@ -21,18 +21,20 @@
|
|
|
21
21
|
"monaco-editor": "^0.55.1",
|
|
22
22
|
"react": "^18.2.0",
|
|
23
23
|
"react-day-picker": "^9.13.0",
|
|
24
|
+
"react-dom": "^19.2.5",
|
|
24
25
|
"react-markdown": "^10.1.0",
|
|
25
26
|
"react-router-dom": "^6.20.0",
|
|
26
27
|
"recharts": "^3.6.0",
|
|
27
28
|
"tailwind-merge": "^2.2.0"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
|
-
"
|
|
31
|
-
"@types/react": "^18.2.0",
|
|
32
|
-
"@testing-library/react": "^16.0.0",
|
|
31
|
+
"@checkstack/scripts": "0.1.2",
|
|
33
32
|
"@checkstack/test-utils-frontend": "0.0.4",
|
|
34
33
|
"@checkstack/tsconfig": "0.0.5",
|
|
35
|
-
"@
|
|
34
|
+
"@testing-library/react": "^16.0.0",
|
|
35
|
+
"@types/react": "^18.2.0",
|
|
36
|
+
"@types/react-dom": "^19.2.3",
|
|
37
|
+
"typescript": "^5.0.0"
|
|
36
38
|
},
|
|
37
39
|
"scripts": {
|
|
38
40
|
"typecheck": "tsc --noEmit",
|
|
@@ -1,11 +1,43 @@
|
|
|
1
|
-
import React, { useRef, useEffect } from "react";
|
|
1
|
+
import React, { useRef, useEffect, createContext, useContext, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { X } from "lucide-react";
|
|
2
4
|
import { cn } from "../utils";
|
|
3
5
|
import { usePerformance } from "./PerformanceProvider";
|
|
4
6
|
|
|
7
|
+
export const DropdownMenuContext = createContext<{ onClose?: () => void }>({});
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Custom hook to detect mobile viewport.
|
|
11
|
+
*/
|
|
12
|
+
export function useIsMobile() {
|
|
13
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
// Initial check
|
|
17
|
+
const mql = globalThis.matchMedia("(max-width: 640px)");
|
|
18
|
+
setIsMobile(mql.matches);
|
|
19
|
+
|
|
20
|
+
// Listener for changes
|
|
21
|
+
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
|
22
|
+
mql.addEventListener("change", handler);
|
|
23
|
+
return () => mql.removeEventListener("change", handler);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
return isMobile;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export const DropdownMenuRootContext = createContext<{ rootRef?: React.RefObject<HTMLDivElement> }>({});
|
|
31
|
+
|
|
5
32
|
export const DropdownMenu: React.FC<{ children: React.ReactNode }> = ({
|
|
6
33
|
children,
|
|
7
34
|
}) => {
|
|
8
|
-
|
|
35
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
return (
|
|
37
|
+
<DropdownMenuRootContext.Provider value={{ rootRef }}>
|
|
38
|
+
<div ref={rootRef} className="relative inline-block text-left">{children}</div>
|
|
39
|
+
</DropdownMenuRootContext.Provider>
|
|
40
|
+
);
|
|
9
41
|
};
|
|
10
42
|
|
|
11
43
|
export const DropdownMenuTrigger: React.FC<{
|
|
@@ -25,44 +57,83 @@ export const DropdownMenuContent: React.FC<{
|
|
|
25
57
|
isOpen: boolean;
|
|
26
58
|
onClose: () => void;
|
|
27
59
|
className?: string;
|
|
28
|
-
|
|
60
|
+
innerClassName?: string;
|
|
61
|
+
}> = ({ children, isOpen, onClose, className, innerClassName }) => {
|
|
29
62
|
const { isLowPower } = usePerformance();
|
|
30
63
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
64
|
+
const { rootRef } = useContext(DropdownMenuRootContext);
|
|
65
|
+
const isMobile = useIsMobile();
|
|
31
66
|
|
|
32
67
|
useEffect(() => {
|
|
33
68
|
const handleClickOutside = (event: MouseEvent) => {
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
69
|
+
// Don't auto-close if clicking outside on mobile (full screen modal style)
|
|
70
|
+
if (isMobile) return;
|
|
71
|
+
|
|
72
|
+
const target = event.target as Node;
|
|
73
|
+
|
|
74
|
+
// Don't close if clicking inside the menu content
|
|
75
|
+
if (contentRef.current && contentRef.current.contains(target)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Don't close if clicking inside the wrapper (e.g. the trigger button)
|
|
80
|
+
// This allows the trigger's onClick to handle toggling the menu without conflict.
|
|
81
|
+
if (rootRef?.current && rootRef.current.contains(target)) {
|
|
82
|
+
return;
|
|
39
83
|
}
|
|
84
|
+
|
|
85
|
+
onClose();
|
|
40
86
|
};
|
|
41
87
|
|
|
42
88
|
if (isOpen) {
|
|
43
89
|
document.addEventListener("mousedown", handleClickOutside);
|
|
90
|
+
// Lock body scroll on mobile
|
|
91
|
+
if (isMobile) {
|
|
92
|
+
document.body.style.overflow = "hidden";
|
|
93
|
+
}
|
|
44
94
|
}
|
|
45
95
|
return () => {
|
|
46
96
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
97
|
+
if (isMobile) {
|
|
98
|
+
document.body.style.overflow = "";
|
|
99
|
+
}
|
|
47
100
|
};
|
|
48
|
-
}, [isOpen, onClose]);
|
|
101
|
+
}, [isOpen, onClose, isMobile, rootRef]);
|
|
49
102
|
|
|
50
103
|
if (!isOpen) return <React.Fragment />;
|
|
51
104
|
|
|
52
|
-
|
|
105
|
+
const content = (
|
|
53
106
|
<div
|
|
54
107
|
ref={contentRef}
|
|
55
108
|
className={cn(
|
|
56
|
-
|
|
109
|
+
isMobile
|
|
110
|
+
? "fixed inset-0 z-[100] bg-background w-full h-full p-4 overflow-y-auto"
|
|
111
|
+
: "absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-popover shadow-lg ring-1 ring-border focus:outline-none z-[100] max-h-[calc(100vh-4rem)] overflow-y-auto",
|
|
57
112
|
!isLowPower && "animate-in fade-in zoom-in-95 duration-100",
|
|
58
113
|
className
|
|
59
114
|
)}
|
|
60
115
|
>
|
|
61
|
-
<
|
|
62
|
-
{
|
|
63
|
-
|
|
116
|
+
<DropdownMenuContext.Provider value={{ onClose }}>
|
|
117
|
+
<div className={cn(isMobile ? "flex flex-col pt-4 pb-12" : "py-1", innerClassName)} role="none">
|
|
118
|
+
{isMobile && (
|
|
119
|
+
<div className="flex justify-between items-center mb-6 col-span-full px-2">
|
|
120
|
+
<h2 className="text-xl font-bold text-foreground">Menu</h2>
|
|
121
|
+
<button
|
|
122
|
+
onClick={onClose}
|
|
123
|
+
className="p-2 rounded-full hover:bg-accent text-muted-foreground transition-colors"
|
|
124
|
+
aria-label="Close menu"
|
|
125
|
+
>
|
|
126
|
+
<X className="w-6 h-6" />
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
{children}
|
|
131
|
+
</div>
|
|
132
|
+
</DropdownMenuContext.Provider>
|
|
64
133
|
</div>
|
|
65
134
|
);
|
|
135
|
+
|
|
136
|
+
return isMobile ? createPortal(content, document.body) : content;
|
|
66
137
|
};
|
|
67
138
|
|
|
68
139
|
export const DropdownMenuItem: React.FC<{
|
|
@@ -70,30 +141,47 @@ export const DropdownMenuItem: React.FC<{
|
|
|
70
141
|
onClick?: () => void;
|
|
71
142
|
className?: string;
|
|
72
143
|
icon?: React.ReactNode;
|
|
73
|
-
|
|
144
|
+
description?: React.ReactNode;
|
|
145
|
+
closeOnClick?: boolean;
|
|
146
|
+
}> = ({ children, onClick, className, icon, description, closeOnClick = true }) => {
|
|
147
|
+
const { onClose } = useContext(DropdownMenuContext);
|
|
148
|
+
|
|
149
|
+
const handleClick = () => {
|
|
150
|
+
if (onClick) onClick();
|
|
151
|
+
if (closeOnClick && onClose) onClose();
|
|
152
|
+
};
|
|
153
|
+
|
|
74
154
|
return (
|
|
75
155
|
<button
|
|
76
|
-
onClick={
|
|
156
|
+
onClick={handleClick}
|
|
77
157
|
className={cn(
|
|
78
|
-
"flex items-
|
|
158
|
+
"flex flex-col items-start w-full px-4 py-2 text-sm text-popover-foreground hover:bg-accent hover:text-accent-foreground transition-colors rounded-sm overflow-hidden",
|
|
79
159
|
className
|
|
80
160
|
)}
|
|
81
161
|
role="menuitem"
|
|
82
162
|
>
|
|
83
|
-
|
|
84
|
-
|
|
163
|
+
<div className="flex items-center w-full overflow-hidden">
|
|
164
|
+
{icon && <span className="mr-3 text-muted-foreground shrink-0">{icon}</span>}
|
|
165
|
+
<span className="flex-1 text-left truncate">{children}</span>
|
|
166
|
+
</div>
|
|
167
|
+
{description && (
|
|
168
|
+
<span className={cn("text-[10px] text-muted-foreground mt-1 leading-tight text-left truncate w-full", icon ? "pl-7" : "")}>
|
|
169
|
+
{description}
|
|
170
|
+
</span>
|
|
171
|
+
)}
|
|
85
172
|
</button>
|
|
86
173
|
);
|
|
87
174
|
};
|
|
88
175
|
|
|
89
|
-
export const DropdownMenuSeparator: React.FC = () => (
|
|
90
|
-
<div className="my-1 h-px bg-border" />
|
|
176
|
+
export const DropdownMenuSeparator: React.FC<{ className?: string }> = ({ className }) => (
|
|
177
|
+
<div className={cn("my-1 h-px bg-border col-span-full", className)} />
|
|
91
178
|
);
|
|
92
179
|
|
|
93
|
-
export const DropdownMenuLabel: React.FC<{ children: React.ReactNode }> = ({
|
|
180
|
+
export const DropdownMenuLabel: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
|
94
181
|
children,
|
|
182
|
+
className
|
|
95
183
|
}) => (
|
|
96
|
-
<div className="px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
184
|
+
<div className={cn("px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider col-span-full", className)}>
|
|
97
185
|
{children}
|
|
98
186
|
</div>
|
|
99
187
|
);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
2
|
+
import { useToast } from "./ToastProvider";
|
|
2
3
|
|
|
3
4
|
const STORAGE_KEY = "checkstack-low-power";
|
|
4
5
|
|
|
5
6
|
interface PerformanceContextValue {
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
7
8
|
* Whether the application should run in low-power mode.
|
|
8
9
|
* Derived from (manualLowPower || prefersReducedMotion).
|
|
9
10
|
*/
|
|
@@ -38,9 +39,13 @@ interface PerformanceProviderProps {
|
|
|
38
39
|
* Supports manual override (persisted to localStorage) and respects OS-level
|
|
39
40
|
* Reduced Motion preferences.
|
|
40
41
|
*/
|
|
41
|
-
export const PerformanceProvider: React.FC<PerformanceProviderProps> = ({
|
|
42
|
+
export const PerformanceProvider: React.FC<PerformanceProviderProps> = ({
|
|
43
|
+
children,
|
|
44
|
+
}) => {
|
|
45
|
+
const toast = useToast();
|
|
42
46
|
const [manualLowPower, setManualLowPower] = useState<boolean>(false);
|
|
43
|
-
const [prefersReducedMotion, setPrefersReducedMotion] =
|
|
47
|
+
const [prefersReducedMotion, setPrefersReducedMotion] =
|
|
48
|
+
useState<boolean>(false);
|
|
44
49
|
const [isLoaded, setIsLoaded] = useState<boolean>(false);
|
|
45
50
|
|
|
46
51
|
useEffect(() => {
|
|
@@ -51,11 +56,14 @@ export const PerformanceProvider: React.FC<PerformanceProviderProps> = ({ childr
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
// 2. Initialize Reduced Motion Detection
|
|
54
|
-
const mediaQuery = globalThis.matchMedia(
|
|
59
|
+
const mediaQuery = globalThis.matchMedia(
|
|
60
|
+
"(prefers-reduced-motion: reduce)",
|
|
61
|
+
);
|
|
55
62
|
setPrefersReducedMotion(mediaQuery.matches);
|
|
56
63
|
|
|
57
64
|
// 3. Listen for changes in OS-level settings
|
|
58
|
-
const listener = (e: MediaQueryListEvent) =>
|
|
65
|
+
const listener = (e: MediaQueryListEvent) =>
|
|
66
|
+
setPrefersReducedMotion(e.matches);
|
|
59
67
|
mediaQuery.addEventListener("change", listener);
|
|
60
68
|
|
|
61
69
|
setIsLoaded(true);
|
|
@@ -63,6 +71,61 @@ export const PerformanceProvider: React.FC<PerformanceProviderProps> = ({ childr
|
|
|
63
71
|
return () => mediaQuery.removeEventListener("change", listener);
|
|
64
72
|
}, []);
|
|
65
73
|
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
// 4. Automatic FPS Detection (Runs once per device)
|
|
76
|
+
const stored = globalThis.localStorage?.getItem(STORAGE_KEY);
|
|
77
|
+
if (stored !== null) return; // User already has a saved preference, don't auto-detect
|
|
78
|
+
|
|
79
|
+
let rAFId: number;
|
|
80
|
+
let isCancelled = false;
|
|
81
|
+
|
|
82
|
+
// Wait 1 second for initial render and hydration to settle
|
|
83
|
+
const warmupTimer = setTimeout(() => {
|
|
84
|
+
if (isCancelled) return;
|
|
85
|
+
|
|
86
|
+
let frameCount = 0;
|
|
87
|
+
let startTime = performance.now();
|
|
88
|
+
|
|
89
|
+
const measure = (time: DOMHighResTimeStamp) => {
|
|
90
|
+
if (isCancelled) return;
|
|
91
|
+
if (frameCount === 0) startTime = time;
|
|
92
|
+
frameCount++;
|
|
93
|
+
const elapsed = time - startTime;
|
|
94
|
+
|
|
95
|
+
if (elapsed >= 1000) {
|
|
96
|
+
const fps = (frameCount * 1000) / elapsed;
|
|
97
|
+
if (fps < 50) {
|
|
98
|
+
setManualLowPower(true);
|
|
99
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, "true");
|
|
100
|
+
toast.info(
|
|
101
|
+
"Low power mode was automatically activated to improve performance. Hardware acceleration appears to be disabled.",
|
|
102
|
+
8000
|
|
103
|
+
);
|
|
104
|
+
console.warn(
|
|
105
|
+
`[PerformanceProvider] Auto-enabled Low Power Mode. Detected FPS: ${fps.toFixed(1)}`,
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
// Save 'false' to prevent future auto-detection checks
|
|
109
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, "false");
|
|
110
|
+
console.log(
|
|
111
|
+
`[PerformanceProvider] Performance OK. Detected FPS: ${fps.toFixed(1)}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
rAFId = globalThis.requestAnimationFrame(measure);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
rAFId = globalThis.requestAnimationFrame(measure);
|
|
120
|
+
}, 1000);
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
isCancelled = true;
|
|
124
|
+
clearTimeout(warmupTimer);
|
|
125
|
+
if (rAFId) globalThis.cancelAnimationFrame(rAFId);
|
|
126
|
+
};
|
|
127
|
+
}, [toast]);
|
|
128
|
+
|
|
66
129
|
const toggleManualLowPower = () => {
|
|
67
130
|
setManualLowPower((prev) => {
|
|
68
131
|
const next = !prev;
|
|
@@ -60,7 +60,12 @@ export const UserMenu: React.FC<UserMenuProps> = ({
|
|
|
60
60
|
</button>
|
|
61
61
|
</DropdownMenuTrigger>
|
|
62
62
|
|
|
63
|
-
<DropdownMenuContent
|
|
63
|
+
<DropdownMenuContent
|
|
64
|
+
isOpen={isOpen}
|
|
65
|
+
onClose={() => setIsOpen(false)}
|
|
66
|
+
className="w-full sm:w-[400px] md:w-[460px]"
|
|
67
|
+
innerClassName="grid grid-cols-1 sm:grid-cols-2 p-2 gap-2 sm:gap-1"
|
|
68
|
+
>
|
|
64
69
|
<DropdownMenuLabel>
|
|
65
70
|
<div className="flex flex-col">
|
|
66
71
|
<span className="text-sm font-bold text-foreground truncate">
|