@kawaiininja/layouts 1.0.13 β 1.2.0
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 +40 -28
- package/dist/MobileLayout.d.ts +0 -4
- package/dist/MobileLayout.js +15 -42
- package/dist/PcLayout.d.ts +2 -0
- package/dist/PcLayout.js +260 -0
- package/dist/ResponsiveLayout.d.ts +2 -0
- package/dist/ResponsiveLayout.js +21 -0
- package/dist/ThemeContext.d.ts +12 -0
- package/dist/ThemeContext.js +30 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @kawaiininja/layouts
|
|
2
2
|
|
|
3
|
-
High-performance, premium
|
|
3
|
+
High-performance, premium responsive layouts for the Onyx Framework. Designed with a focus on fluid gestures, adaptive sidebar navigation, and state-of-the-art aesthetics that switch seamlessly between Mobile and PC.
|
|
4
4
|
|
|
5
5
|
[**π Read the Full Developer Guide**](./GUIDE.md) | [**π¨ Explore Examples**](./examples/)
|
|
6
6
|
|
|
@@ -8,7 +8,13 @@ High-performance, premium mobile-first layouts for the Onyx Framework. Designed
|
|
|
8
8
|
|
|
9
9
|
## β¨ Features
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### π» Responsive Engine (Adaptive Mode)
|
|
12
|
+
|
|
13
|
+
- **Layout Swapping**: Automatically switches between `OnyxMobileLayout` and `OnyxPcLayout` at the 1024px breakpoint.
|
|
14
|
+
- **Adaptive Navigation**: Mobile uses a bottom rail and popup drawer; PC uses a fixed sidebar and hover-based quick menus.
|
|
15
|
+
- **Single Config**: Define your navigation onceβthe framework handles the physics and layout changes for all screen sizes.
|
|
16
|
+
|
|
17
|
+
### ποΈ Gesture & Mouse Interactions
|
|
12
18
|
|
|
13
19
|
- **Horizontal Tab Swiping**: Seamlessly transition between sub-tabs with natural swipe gestures. Built on a custom gesture engine for zero-lag response.
|
|
14
20
|
- **Vertical Pull-to-Refresh**: Granular refresh logic at the global, tab, or individual sub-tab level with integrated spring physics and haptic feedback support.
|
|
@@ -51,10 +57,10 @@ Note: This package requires `framer-motion`, `lucide-react`, and `react` >= 18.0
|
|
|
51
57
|
|
|
52
58
|
## π¦ Usage
|
|
53
59
|
|
|
54
|
-
### Integrated
|
|
60
|
+
### Integrated Routing Support (Responsive)
|
|
55
61
|
|
|
56
62
|
```jsx
|
|
57
|
-
import {
|
|
63
|
+
import { OnyxResponsiveLayout } from "@kawaiininja/layouts";
|
|
58
64
|
import { BrowserRouter, useNavigate, useLocation } from "react-router-dom";
|
|
59
65
|
import { Home, Grid, PenTool } from "lucide-react";
|
|
60
66
|
|
|
@@ -92,19 +98,12 @@ const App = () => {
|
|
|
92
98
|
|
|
93
99
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
94
100
|
|
|
95
|
-
const handleRefresh = async () => {
|
|
96
|
-
setIsRefreshing(true);
|
|
97
|
-
await fetchMyData(); // Framework handles the 12s safety timeout automatically
|
|
98
|
-
setIsRefreshing(false);
|
|
99
|
-
};
|
|
100
|
-
|
|
101
101
|
return (
|
|
102
|
-
<
|
|
102
|
+
<OnyxResponsiveLayout
|
|
103
103
|
tabs={tabs}
|
|
104
104
|
user={{ name: "Alex", handle: "@alex", avatar: "..." }}
|
|
105
105
|
activeTab={activeTabId}
|
|
106
106
|
onNavigate={(path) => navigate(path)}
|
|
107
|
-
onRefresh={handleRefresh}
|
|
108
107
|
isRefreshing={isRefreshing}
|
|
109
108
|
drawers={{
|
|
110
109
|
editor: MyEditorPanel,
|
|
@@ -156,10 +155,35 @@ If your view components are **Lazy Loaded** (using `React.lazy`), you **must** w
|
|
|
156
155
|
|
|
157
156
|
```jsx
|
|
158
157
|
<Suspense fallback={<MyLoader />}>
|
|
159
|
-
<
|
|
158
|
+
<OnyxResponsiveLayout ... />
|
|
160
159
|
</Suspense>
|
|
161
160
|
```
|
|
162
161
|
|
|
162
|
+
### 4. Sidemenu Deep-Linking (Global Navigation)
|
|
163
|
+
|
|
164
|
+
The sidebar (PC) and Drawer (Mobile) can navigate to specific tabs and sub-tabs using the `drawerItems` prop. This is the preferred way to create a "Global Menu" that crosses category boundaries.
|
|
165
|
+
|
|
166
|
+
```javascript
|
|
167
|
+
const drawerItems = [
|
|
168
|
+
{
|
|
169
|
+
label: "Inventory Hub",
|
|
170
|
+
icon: Activity,
|
|
171
|
+
targetTab: "search", // The ID of the parent tab
|
|
172
|
+
targetSubTab: "Inventory", // The Label of the specific sub-tab
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
label: "Account Settings",
|
|
176
|
+
icon: User,
|
|
177
|
+
targetTab: "settings", // Defaults to first sub-tab of settings
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
// ... inside render
|
|
182
|
+
<OnyxResponsiveLayout tabs={tabs} drawerItems={drawerItems} />;
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The framework will automatically calculate the correct path from your `TabConfig` and trigger `onNavigate`.
|
|
186
|
+
|
|
163
187
|
---
|
|
164
188
|
|
|
165
189
|
## π Data Refresh & Safety
|
|
@@ -193,23 +217,11 @@ To prevent your UI from getting "stuck" due to forgotten state updates or hangin
|
|
|
193
217
|
|
|
194
218
|
---
|
|
195
219
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
### 4. Capacitor & Safe Areas
|
|
199
|
-
|
|
200
|
-
The header and side rail automatically handle notches and status bars using `env(safe-area-inset-*)`. For the best native experience, ensure your app container uses the dynamic viewport height:
|
|
201
|
-
|
|
202
|
-
```css
|
|
203
|
-
.app-container {
|
|
204
|
-
height: 100dvh; /* Prevents layout shifting on mobile */
|
|
205
|
-
}
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
---
|
|
220
|
+
### π οΈ Configuration API
|
|
209
221
|
|
|
210
|
-
|
|
222
|
+
### `OnyxMobileLayoutProps` & `OnyxPcLayoutProps`
|
|
211
223
|
|
|
212
|
-
|
|
224
|
+
Both versions of the layout use almost identical properties. For automatic detection, use `OnyxResponsiveLayout`.
|
|
213
225
|
|
|
214
226
|
| Prop | Type | Description |
|
|
215
227
|
| :------------- | :---------------------------- | :------------------------------------------------------------ |
|
package/dist/MobileLayout.d.ts
CHANGED
|
@@ -1,6 +1,2 @@
|
|
|
1
|
-
import React from "react";
|
|
2
1
|
import { OnyxMobileLayoutProps } from "./types";
|
|
3
|
-
export declare const ThemeProvider: ({ children }: {
|
|
4
|
-
children: React.ReactNode;
|
|
5
|
-
}) => import("react/jsx-runtime").JSX.Element;
|
|
6
2
|
export declare const OnyxMobileLayout: (props: OnyxMobileLayoutProps) => import("react/jsx-runtime").JSX.Element;
|
package/dist/MobileLayout.js
CHANGED
|
@@ -1,36 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { AnimatePresence, motion } from "framer-motion";
|
|
3
3
|
import { Loader2, LogOut, Menu, Moon, Sun } from "lucide-react";
|
|
4
|
-
import React, {
|
|
5
|
-
|
|
6
|
-
const ThemeContext = createContext(null);
|
|
7
|
-
export const ThemeProvider = ({ children }) => {
|
|
8
|
-
const [isDark, setIsDark] = useState(() => {
|
|
9
|
-
if (typeof window !== "undefined") {
|
|
10
|
-
const saved = localStorage.getItem("theme");
|
|
11
|
-
return saved ? saved === "dark" : true;
|
|
12
|
-
}
|
|
13
|
-
return true;
|
|
14
|
-
});
|
|
15
|
-
useEffect(() => {
|
|
16
|
-
const themeValue = isDark ? "dark" : "light";
|
|
17
|
-
localStorage.setItem("theme", themeValue);
|
|
18
|
-
if (!isDark) {
|
|
19
|
-
document.documentElement.dataset.theme = "light";
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
document.documentElement.removeAttribute("data-theme");
|
|
23
|
-
}
|
|
24
|
-
}, [isDark]);
|
|
25
|
-
const toggleTheme = () => setIsDark((prev) => !prev);
|
|
26
|
-
return (_jsx(ThemeContext.Provider, { value: { isDark, toggleTheme }, children: children }));
|
|
27
|
-
};
|
|
28
|
-
const useTheme = () => {
|
|
29
|
-
const context = useContext(ThemeContext);
|
|
30
|
-
if (!context)
|
|
31
|
-
throw new Error("useTheme must be used within a ThemeProvider");
|
|
32
|
-
return context;
|
|
33
|
-
};
|
|
4
|
+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
5
|
+
import { ThemeProvider, useTheme } from "./ThemeContext";
|
|
34
6
|
// --- Sub-components (Generic Versions) ---
|
|
35
7
|
const RailNavIndicator = ({ height, offset, }) => (_jsx("div", { className: "absolute left-2 right-2 rounded-[24px] bg-[rgb(var(--bg-elevated))] transition-all duration-300 ease-out z-0 pointer-events-none border border-[rgb(var(--color-border-subtle))]/30", style: { height: `${height}px`, transform: `translateY(${offset}px)` } }));
|
|
36
8
|
const RailNavButton = ({ title, icon: Icon, active, onClick, onRegisterRef, onTouchStart, onTouchMove, onTouchEnd, }) => (_jsxs("button", { ref: onRegisterRef, type: "button", role: "tab", "aria-label": title, onClick: onClick, onTouchStart: onTouchStart, onTouchMove: onTouchMove, onTouchEnd: onTouchEnd, onContextMenu: (e) => e.preventDefault(), className: `flex flex-col shrink-0 items-center justify-center w-full py-8 px-2 min-h-[120px] gap-4 transition-colors relative z-10 select-none ${active
|
|
@@ -306,10 +278,9 @@ const OnyxMobileLayoutBase = ({ tabs, user, drawers = {}, onSignOut, onRefresh,
|
|
|
306
278
|
setIsOpen(false);
|
|
307
279
|
}, className: "w-full flex items-center gap-4 p-3 text-[rgb(var(--text-muted))] hover:text-[rgb(var(--text-primary))] hover:bg-[rgb(var(--bg-tertiary))] rounded-xl transition-all group font-medium", children: [_jsx(item.icon, { size: 20, className: "group-hover:scale-110 transition-transform" }), _jsx("span", { children: item.label })] }, item.label))) }), _jsx("div", { className: "mt-auto", children: _jsxs("button", { onClick: onSignOut, className: "flex items-center gap-3 text-[rgb(var(--status-error))] font-medium p-3 w-full hover:bg-[rgb(var(--bg-tertiary))] rounded-xl transition-colors", children: [_jsx(LogOut, { size: 20 }), _jsx("span", { children: "Sign Out" })] }) })] }));
|
|
308
280
|
const handleTouchStart = (e) => {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
startY.current = e.touches[0].clientY;
|
|
281
|
+
const zoom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--ui-zoom") || "1");
|
|
282
|
+
startX.current = e.touches[0].clientX / zoom;
|
|
283
|
+
startY.current = e.touches[0].clientY / zoom;
|
|
313
284
|
dragXRef.current = isOpen ? DRAWER_WIDTH : 0;
|
|
314
285
|
setRenderDragX(dragXRef.current);
|
|
315
286
|
isGesturing.current = true;
|
|
@@ -320,8 +291,9 @@ const OnyxMobileLayoutBase = ({ tabs, user, drawers = {}, onSignOut, onRefresh,
|
|
|
320
291
|
const handleTouchMove = (e) => {
|
|
321
292
|
if (!isGesturing.current || quickMenu.visible || isDraggingMenu.current)
|
|
322
293
|
return;
|
|
323
|
-
const
|
|
324
|
-
const
|
|
294
|
+
const zoom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--ui-zoom") || "1");
|
|
295
|
+
const dx = e.touches[0].clientX / zoom - startX.current;
|
|
296
|
+
const dy = e.touches[0].clientY / zoom - startY.current;
|
|
325
297
|
lastDxRef.current = dx;
|
|
326
298
|
if (!isHorizontal.current && !isVerticalPull.current && !isDragging) {
|
|
327
299
|
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10) {
|
|
@@ -425,9 +397,8 @@ const OnyxMobileLayoutBase = ({ tabs, user, drawers = {}, onSignOut, onRefresh,
|
|
|
425
397
|
}, [isRefreshing]);
|
|
426
398
|
// Quick Menu Logic
|
|
427
399
|
const handleNavTouchStart = (e, tab) => {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const y = e.touches[0].clientY;
|
|
400
|
+
const zoom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--ui-zoom") || "1");
|
|
401
|
+
const y = e.touches[0].clientY / zoom;
|
|
431
402
|
startYRef.current = y;
|
|
432
403
|
holdTimer.current = setTimeout(() => {
|
|
433
404
|
isDraggingMenu.current = true;
|
|
@@ -443,7 +414,8 @@ const OnyxMobileLayoutBase = ({ tabs, user, drawers = {}, onSignOut, onRefresh,
|
|
|
443
414
|
}, 400);
|
|
444
415
|
};
|
|
445
416
|
const handleNavTouchMove = (e) => {
|
|
446
|
-
const
|
|
417
|
+
const zoom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--ui-zoom") || "1");
|
|
418
|
+
const y = e.touches[0].clientY / zoom;
|
|
447
419
|
const diff = y - startYRef.current;
|
|
448
420
|
if (!isDraggingMenu.current) {
|
|
449
421
|
if (Math.abs(diff) > 10)
|
|
@@ -478,8 +450,9 @@ const OnyxMobileLayoutBase = ({ tabs, user, drawers = {}, onSignOut, onRefresh,
|
|
|
478
450
|
};
|
|
479
451
|
const currentTranslate = isDragging ? renderDragX : isOpen ? DRAWER_WIDTH : 0;
|
|
480
452
|
const progress = currentTranslate / DRAWER_WIDTH;
|
|
481
|
-
return (_jsxs("div", { className: "
|
|
482
|
-
height: "100dvh",
|
|
453
|
+
return (_jsxs("div", { className: "bg-[rgb(var(--bg-main))] overflow-hidden relative safe-top safe-bottom", style: {
|
|
454
|
+
height: "calc(100dvh / var(--ui-zoom, 1))",
|
|
455
|
+
width: "calc(100vw / var(--ui-zoom, 1))",
|
|
483
456
|
}, onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, children: [_jsx(Drawer, {}), _jsxs("div", { className: "absolute inset-0 z-10 bg-[rgb(var(--bg-main))] flex shadow-2xl origin-left", style: {
|
|
484
457
|
transform: `translateX(${currentTranslate}px) scale(${1 - progress * 0.05})`,
|
|
485
458
|
borderRadius: `${progress * 24}px`,
|
package/dist/PcLayout.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
3
|
+
import { Loader2, LogOut, Menu, Moon, Sun } from "lucide-react";
|
|
4
|
+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
5
|
+
import { ThemeProvider, useTheme } from "./ThemeContext";
|
|
6
|
+
// --- Sub-components (Generic Versions) ---
|
|
7
|
+
const RailNavIndicator = ({ height, offset, }) => (_jsx("div", { className: "absolute left-2 right-2 rounded-[24px] bg-[rgb(var(--bg-elevated))] transition-all duration-300 ease-out z-0 pointer-events-none border border-[rgb(var(--color-border-subtle))]/30", style: { height: `${height}px`, transform: `translateY(${offset}px)` } }));
|
|
8
|
+
const RailNavButton = ({ title, icon: Icon, active, onClick, onRegisterRef, onMouseEnter, onMouseLeave, }) => (_jsxs("button", { ref: onRegisterRef, type: "button", role: "tab", "aria-label": title, onClick: onClick, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, className: `flex flex-col shrink-0 items-center justify-center w-full py-8 px-2 min-h-[120px] gap-4 transition-colors relative z-10 select-none group focus:outline-none ${active
|
|
9
|
+
? "text-[rgb(var(--color-accent))]"
|
|
10
|
+
: "text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]"}`, children: [_jsx("span", { className: `text-[10px] font-bold uppercase tracking-[0.2em] [writing-mode:vertical-rl] rotate-180 transition-all duration-300 pointer-events-none ${active
|
|
11
|
+
? "scale-110 opacity-100"
|
|
12
|
+
: "scale-100 opacity-60 group-hover:opacity-100"}`, children: title }), Icon && (_jsx(Icon, { size: 20, className: `transition-all duration-300 -rotate-90 pointer-events-none ${active
|
|
13
|
+
? "scale-110 opacity-100"
|
|
14
|
+
: "scale-100 opacity-60 group-hover:opacity-100"}` }))] }));
|
|
15
|
+
const RailNavThemeToggle = () => {
|
|
16
|
+
const { isDark, toggleTheme } = useTheme();
|
|
17
|
+
return (_jsx("button", { onClick: toggleTheme, className: "mt-auto mb-6 mx-auto p-3 rounded-xl text-[rgb(var(--text-tertiary))] hover:text-[rgb(var(--text-primary))] hover:bg-[rgb(var(--bg-tertiary))] transition-all active:scale-90 group", title: isDark ? "Switch to Light Mode" : "Switch to Dark Mode", children: isDark ? (_jsx(Sun, { size: 20, className: "duration-500 group-hover:rotate-180 text-[rgb(var(--color-accent))]" })) : (_jsx(Moon, { size: 20, className: "duration-300 group-hover:-rotate-12 text-[rgb(var(--color-secondary))]" })) }));
|
|
18
|
+
};
|
|
19
|
+
const QuickMenu = ({ visible, items, positionY, selectedIndex, onSelect, }) => {
|
|
20
|
+
if (!visible || !items || items.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
return (_jsxs("aside", { className: "fixed right-20 z-[100] min-w-[200px] bg-[rgb(var(--bg-elevated))]/95 backdrop-blur-xl rounded-2xl shadow-2xl p-2 border border-[rgb(var(--color-border))] flex flex-col gap-1 transition-all duration-200 ease-out origin-right", style: {
|
|
23
|
+
top: positionY,
|
|
24
|
+
transform: "translateY(-50%) scale(1)",
|
|
25
|
+
animation: "popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)",
|
|
26
|
+
}, children: [items.map((item, idx) => (_jsxs("button", { onClick: () => onSelect?.(idx), className: `flex items-center gap-3 p-3 rounded-xl transition-all duration-150 w-full text-left ${selectedIndex === idx
|
|
27
|
+
? "bg-[rgb(var(--color-accent))] text-white scale-[1.02] shadow-lg"
|
|
28
|
+
: "text-[rgb(var(--text-secondary))] hover:bg-[rgb(var(--bg-tertiary))] hover:text-[rgb(var(--text-primary))]"}`, children: [item.icon && _jsx(item.icon, { size: 18 }), _jsx("span", { className: "text-sm font-medium", children: item.label })] }, idx))), _jsx("div", { className: "absolute top-1/2 -right-2 w-4 h-4 bg-[rgb(var(--bg-elevated))]/95 rotate-45 border-r border-t border-[rgb(var(--color-border))] -translate-y-1/2" }), _jsx("style", { children: `
|
|
29
|
+
@keyframes popIn {
|
|
30
|
+
from { opacity: 0; transform: translateY(-50%) scale(0.8) translateX(20px); }
|
|
31
|
+
to { opacity: 1; transform: translateY(-50%) scale(1) translateX(0); }
|
|
32
|
+
}
|
|
33
|
+
` })] }));
|
|
34
|
+
};
|
|
35
|
+
const Header = ({ title, onMenuClick, rightAction }) => (_jsxs("header", { className: "sticky top-0 z-20 bg-[rgb(var(--bg-surface))]/80 backdrop-blur-xl border-b border-[rgb(var(--color-border-subtle))] px-6 h-16 flex justify-between items-center select-none", children: [_jsxs("div", { className: "flex items-center gap-4", children: [_jsx("button", { onClick: onMenuClick, className: "p-2 rounded-lg text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))] hover:bg-[rgb(var(--bg-tertiary))] transition-colors", children: _jsx(Menu, { size: 20 }) }), _jsx("h1", { className: "text-xl font-bold text-[rgb(var(--text-primary))] tracking-tight", children: title })] }), rightAction] }));
|
|
36
|
+
const HorizontalTabs = ({ tabs, active, onChange }) => (_jsx("div", { className: "w-full bg-[rgb(var(--bg-main))] border-b border-[rgb(var(--color-border-subtle))] z-10 shrink-0", children: _jsx("div", { className: "flex px-6 gap-8 overflow-x-auto no-scrollbar", children: tabs.map((tab) => (_jsxs("button", { onClick: () => onChange(tab.label), className: `relative py-4 flex items-center gap-2 text-sm font-medium transition-all hover:text-[rgb(var(--text-primary))] whitespace-nowrap ${active === tab.label
|
|
37
|
+
? "text-[rgb(var(--text-primary))]"
|
|
38
|
+
: "text-[rgb(var(--text-muted))]"}`, children: [tab.icon && _jsx(tab.icon, { size: 16 }), tab.label, active === tab.label && (_jsx(motion.div, { layoutId: "activeTabIndicator", className: "absolute bottom-0 left-0 right-0 h-0.5 bg-[rgb(var(--color-accent))]", transition: { type: "spring", stiffness: 300, damping: 30 } }))] }, tab.label))) }) }));
|
|
39
|
+
// --- Hook: useRailNavScroll ---
|
|
40
|
+
function useRailNavScroll(active, navKeys) {
|
|
41
|
+
const activeIndex = Math.max(0, navKeys.indexOf(active));
|
|
42
|
+
const scrollContainerRef = useRef(null);
|
|
43
|
+
const buttonRefs = useRef([]);
|
|
44
|
+
const [measurements, setMeasurements] = useState({
|
|
45
|
+
heights: [],
|
|
46
|
+
offsets: [],
|
|
47
|
+
});
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
const heights = buttonRefs.current.map((btn) => btn?.offsetHeight || 0);
|
|
50
|
+
let currentOffset = 0;
|
|
51
|
+
const offsets = [];
|
|
52
|
+
for (let i = 0; i < heights.length; i++) {
|
|
53
|
+
offsets.push(currentOffset);
|
|
54
|
+
currentOffset += heights[i];
|
|
55
|
+
}
|
|
56
|
+
setMeasurements({ heights, offsets });
|
|
57
|
+
}, [navKeys]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!scrollContainerRef.current ||
|
|
60
|
+
measurements.offsets.length === 0 ||
|
|
61
|
+
measurements.offsets.length <= activeIndex)
|
|
62
|
+
return;
|
|
63
|
+
const container = scrollContainerRef.current;
|
|
64
|
+
const targetY = measurements.offsets[activeIndex];
|
|
65
|
+
const targetHeight = measurements.heights[activeIndex];
|
|
66
|
+
const paddingOffset = 16; // Adjusted for PC
|
|
67
|
+
const absoluteTargetY = targetY + paddingOffset;
|
|
68
|
+
const scrollTarget = absoluteTargetY - container.clientHeight / 2 + targetHeight / 2;
|
|
69
|
+
container.scrollTo({ top: scrollTarget, behavior: "smooth" });
|
|
70
|
+
}, [activeIndex, measurements]);
|
|
71
|
+
const registerButtonRef = (index, el) => {
|
|
72
|
+
buttonRefs.current[index] = el;
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
scrollContainerRef,
|
|
76
|
+
activeHeight: measurements.heights[activeIndex] || 0,
|
|
77
|
+
activeOffset: measurements.offsets[activeIndex] || 0,
|
|
78
|
+
registerButtonRef,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// --- Main Layout Component ---
|
|
82
|
+
const OnyxPcLayoutBase = ({ tabs, user, drawers = {}, onSignOut, onRefresh, isRefreshing: externalRefreshing, rightAction, initialTab = "home", activeTab: externalActiveTab, activeSubTab: externalActiveSubTab, onNavigate, drawerItems = [], }) => {
|
|
83
|
+
// --- Safety Validations ---
|
|
84
|
+
if (!tabs || !Array.isArray(tabs) || tabs.length === 0) {
|
|
85
|
+
throw new Error("[OnyxPcLayout] Critical Error: 'tabs' prop is required and must be a non-empty array.");
|
|
86
|
+
}
|
|
87
|
+
if (!user) {
|
|
88
|
+
throw new Error("[OnyxPcLayout] Critical Error: 'user' prop is required to display profile information.");
|
|
89
|
+
}
|
|
90
|
+
const [internalActiveTab, setInternalActiveTab] = useState(initialTab);
|
|
91
|
+
const activeTab = externalActiveTab || internalActiveTab;
|
|
92
|
+
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
|
93
|
+
const [activeDrawer, setActiveDrawer] = useState(null);
|
|
94
|
+
const [internalRefreshing, setInternalRefreshing] = useState(false);
|
|
95
|
+
const currentTabConfig = tabs.find((t) => t.id === activeTab);
|
|
96
|
+
const subTabs = currentTabConfig?.subTabs ?? [];
|
|
97
|
+
const [internalSubTab, setInternalSubTab] = useState(subTabs[0]?.label || "");
|
|
98
|
+
const subTab = externalActiveSubTab || internalSubTab;
|
|
99
|
+
const navigateTo = (tabId, subTabLabel) => {
|
|
100
|
+
let nextTab = tabId || activeTab;
|
|
101
|
+
let nextSub = subTabLabel || subTab;
|
|
102
|
+
if (tabId) {
|
|
103
|
+
setInternalActiveTab(tabId);
|
|
104
|
+
const targetTab = tabs.find((t) => t.id === tabId);
|
|
105
|
+
const isValid = (targetTab?.subTabs || []).some((st) => st.label === nextSub);
|
|
106
|
+
if (!isValid && targetTab?.subTabs?.[0]) {
|
|
107
|
+
nextSub = targetTab.subTabs[0].label;
|
|
108
|
+
setInternalSubTab(nextSub);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (subTabLabel) {
|
|
112
|
+
nextSub = subTabLabel;
|
|
113
|
+
setInternalSubTab(subTabLabel);
|
|
114
|
+
}
|
|
115
|
+
if (onNavigate) {
|
|
116
|
+
const targetTab = tabs.find((t) => t.id === nextTab);
|
|
117
|
+
let path = targetTab?.path || "";
|
|
118
|
+
const st = (targetTab?.subTabs || []).find((s) => s.label === nextSub);
|
|
119
|
+
if (st?.path) {
|
|
120
|
+
if (st.path.startsWith("/")) {
|
|
121
|
+
path = st.path;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const base = path.endsWith("/") ? path : path + "/";
|
|
125
|
+
path = base + st.path;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (path)
|
|
129
|
+
onNavigate(path);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const currentSubTabConfig = subTabs.find((st) => st.label === subTab);
|
|
133
|
+
const isRefreshing = !!(currentSubTabConfig?.isRefreshing ||
|
|
134
|
+
currentTabConfig?.isRefreshing ||
|
|
135
|
+
externalRefreshing ||
|
|
136
|
+
internalRefreshing);
|
|
137
|
+
const navKeys = useMemo(() => tabs.map((t) => t.id), [tabs]);
|
|
138
|
+
const { scrollContainerRef, activeHeight, activeOffset, registerButtonRef } = useRailNavScroll(activeTab, navKeys);
|
|
139
|
+
const [quickMenu, setQuickMenu] = useState({
|
|
140
|
+
visible: false,
|
|
141
|
+
items: [],
|
|
142
|
+
positionY: 0,
|
|
143
|
+
selectedIndex: -1,
|
|
144
|
+
tabId: "",
|
|
145
|
+
});
|
|
146
|
+
const sidebarWidth = isSidebarOpen ? 260 : 0;
|
|
147
|
+
// Sync subtab if tab changes
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (currentTabConfig && currentTabConfig.subTabs.length > 0) {
|
|
150
|
+
const isValid = currentTabConfig.subTabs.some((st) => st.label === subTab);
|
|
151
|
+
if (!isValid && currentTabConfig.subTabs.length > 0)
|
|
152
|
+
navigateTo(undefined, currentTabConfig.subTabs[0].label);
|
|
153
|
+
}
|
|
154
|
+
}, [activeTab, tabs, subTab, currentTabConfig]);
|
|
155
|
+
const [motionData, setMotionData] = useState({
|
|
156
|
+
tab: activeTab,
|
|
157
|
+
sub: subTab,
|
|
158
|
+
dir: 0,
|
|
159
|
+
type: "tab",
|
|
160
|
+
});
|
|
161
|
+
if (activeTab !== motionData.tab) {
|
|
162
|
+
const prevIdx = navKeys.indexOf(motionData.tab);
|
|
163
|
+
const nextIdx = navKeys.indexOf(activeTab);
|
|
164
|
+
setMotionData({
|
|
165
|
+
tab: activeTab,
|
|
166
|
+
sub: subTab,
|
|
167
|
+
dir: nextIdx > prevIdx ? 1 : -1,
|
|
168
|
+
type: "tab",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
else if (subTab !== motionData.sub) {
|
|
172
|
+
const subLabels = subTabs.map((st) => st.label);
|
|
173
|
+
const prevIdx = subLabels.indexOf(motionData.sub);
|
|
174
|
+
const nextIdx = subLabels.indexOf(subTab);
|
|
175
|
+
if (prevIdx !== -1 && nextIdx !== -1) {
|
|
176
|
+
setMotionData({
|
|
177
|
+
tab: activeTab,
|
|
178
|
+
sub: subTab,
|
|
179
|
+
dir: nextIdx > prevIdx ? 1 : -1,
|
|
180
|
+
type: "subtab",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
setMotionData({ ...motionData, sub: subTab, dir: 0 });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const variants = {
|
|
188
|
+
enter: (d) => ({
|
|
189
|
+
x: d.type === "subtab" ? (d.dir > 0 ? 40 : -40) : 0,
|
|
190
|
+
y: d.type === "tab" ? (d.dir > 0 ? 100 : -100) : 0,
|
|
191
|
+
opacity: 0,
|
|
192
|
+
scale: 0.98,
|
|
193
|
+
}),
|
|
194
|
+
center: {
|
|
195
|
+
x: 0,
|
|
196
|
+
y: 0,
|
|
197
|
+
opacity: 1,
|
|
198
|
+
scale: 1,
|
|
199
|
+
transition: {
|
|
200
|
+
duration: 0.4,
|
|
201
|
+
ease: [0.16, 1, 0.3, 1],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
exit: (d) => ({
|
|
205
|
+
x: d.type === "subtab" ? (d.dir > 0 ? -40 : 40) : 0,
|
|
206
|
+
y: d.type === "tab" ? (d.dir > 0 ? -100 : 100) : 0,
|
|
207
|
+
opacity: 0,
|
|
208
|
+
scale: 1.02,
|
|
209
|
+
transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] },
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
const Sidebar = () => (_jsxs("aside", { className: "h-full bg-[rgb(var(--bg-surface))] border-r border-[rgb(var(--color-border-subtle))] flex flex-col z-30 transition-all duration-300 ease-in-out relative", style: {
|
|
213
|
+
width: `${sidebarWidth}px`,
|
|
214
|
+
opacity: isSidebarOpen ? 1 : 0,
|
|
215
|
+
pointerEvents: isSidebarOpen ? "auto" : "none",
|
|
216
|
+
}, children: [_jsxs("div", { className: "p-8 flex flex-col items-center text-center", children: [_jsxs("div", { className: "relative group cursor-pointer mb-4", children: [_jsx("div", { className: "w-20 h-20 rounded-full bg-gradient-to-tr from-[rgb(var(--color-secondary))] to-[rgb(var(--color-accent))] p-1 transition-transform group-hover:scale-105", children: _jsx("img", { src: user.avatar, className: "w-full h-full rounded-full object-cover border-4 border-[rgb(var(--bg-surface))]" }) }), _jsx("div", { className: "absolute -bottom-1 -right-1 w-6 h-6 bg-[rgb(var(--color-success))] border-4 border-[rgb(var(--bg-surface))] rounded-full" })] }), _jsx("h2", { className: "text-lg font-bold text-[rgb(var(--text-primary))]", children: user.name }), _jsx("p", { className: "text-sm text-[rgb(var(--text-muted))]", children: user.handle })] }), _jsx("div", { className: "flex-1 px-4 space-y-1 overflow-y-auto no-scrollbar", children: (drawerItems.length > 0
|
|
217
|
+
? drawerItems
|
|
218
|
+
: (tabs[0]?.subTabs || []).map((st) => ({
|
|
219
|
+
label: st.label,
|
|
220
|
+
icon: st.icon,
|
|
221
|
+
targetTab: tabs[0].id,
|
|
222
|
+
targetSubTab: st.label,
|
|
223
|
+
}))).map((item) => (_jsxs("button", { onClick: () => {
|
|
224
|
+
navigateTo(item.targetTab, item.targetSubTab);
|
|
225
|
+
if (item.onClick)
|
|
226
|
+
item.onClick({
|
|
227
|
+
openDrawer: (id) => setActiveDrawer(id),
|
|
228
|
+
});
|
|
229
|
+
}, className: "w-full flex items-center gap-3 px-4 py-3 text-[rgb(var(--text-muted))] hover:text-[rgb(var(--text-primary))] hover:bg-[rgb(var(--bg-tertiary))] rounded-xl transition-all group font-medium", children: [_jsx(item.icon, { size: 18, className: "group-hover:scale-110 transition-transform" }), _jsx("span", { className: "text-sm", children: item.label })] }, item.label))) }), _jsx("div", { className: "p-4 mt-auto border-t border-[rgb(var(--color-border-subtle))]", children: _jsxs("button", { onClick: onSignOut, className: "flex items-center gap-3 text-[rgb(var(--status-error))] font-medium px-4 py-3 w-full hover:bg-[rgb(var(--bg-tertiary))] rounded-xl transition-colors", children: [_jsx(LogOut, { size: 18 }), _jsx("span", { className: "text-sm", children: "Sign Out" })] }) })] }));
|
|
230
|
+
const handleNavClick = (tabId) => {
|
|
231
|
+
navigateTo(tabId);
|
|
232
|
+
setQuickMenu((p) => ({ ...p, visible: false }));
|
|
233
|
+
};
|
|
234
|
+
const handleQuickAction = (idx) => {
|
|
235
|
+
const item = quickMenu.items[idx];
|
|
236
|
+
if (item?.onClick) {
|
|
237
|
+
item.onClick({
|
|
238
|
+
openDrawer: (id) => setActiveDrawer(id),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
setQuickMenu((p) => ({ ...p, visible: false }));
|
|
242
|
+
};
|
|
243
|
+
return (_jsxs("div", { className: "bg-[rgb(var(--bg-main))] flex h-screen w-full overflow-hidden text-[rgb(var(--text-primary))]", children: [_jsx(Sidebar, {}), _jsxs("div", { className: "flex-1 flex flex-col min-w-0 relative bg-[rgb(var(--bg-main))]", children: [_jsx(Header, { title: currentTabConfig?.navTitle || currentTabConfig?.label || "App", onMenuClick: () => setIsSidebarOpen(!isSidebarOpen), rightAction: currentTabConfig?.rightAction || rightAction }), _jsx(HorizontalTabs, { tabs: subTabs, active: subTab, onChange: (label) => navigateTo(undefined, label) }), _jsxs("main", { className: "flex-1 overflow-y-auto no-scrollbar relative min-h-0", children: [isRefreshing && (_jsx("div", { className: "absolute top-4 left-1/2 -translate-x-1/2 z-10 p-2 bg-[rgb(var(--bg-surface))] rounded-full shadow-xl border border-[rgb(var(--color-border-subtle))] animate-spin text-[rgb(var(--color-accent))]", children: _jsx(Loader2, { size: 18 }) })), _jsx(AnimatePresence, { mode: "popLayout", custom: motionData, initial: false, children: _jsx(motion.div, { custom: motionData, variants: variants, initial: "enter", animate: "center", exit: "exit", className: "w-full min-h-full", children: _jsx("div", { className: "p-6 md:p-8 max-w-7xl mx-auto", children: subTabs.find((st) => st.label === subTab)?.view &&
|
|
244
|
+
React.createElement(subTabs.find((st) => st.label === subTab).view, { onOpenDrawer: setActiveDrawer }) }) }, `${activeTab}-${subTab}`) })] })] }), _jsxs("nav", { className: "w-16 bg-[rgb(var(--bg-surface))] border-l border-[rgb(var(--color-border-subtle))] flex flex-col z-50", children: [_jsxs("div", { ref: scrollContainerRef, className: "flex-1 overflow-y-auto no-scrollbar py-4 w-full relative", children: [_jsx(RailNavIndicator, { height: activeHeight, offset: activeOffset }), tabs.map((tab, idx) => (_jsx(RailNavButton, { title: tab.label, icon: tab.icon, active: activeTab === tab.id, onClick: () => handleNavClick(tab.id), onMouseEnter: (e) => {
|
|
245
|
+
if (tab.quickActions?.length > 0) {
|
|
246
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
247
|
+
setQuickMenu({
|
|
248
|
+
visible: true,
|
|
249
|
+
items: tab.quickActions,
|
|
250
|
+
positionY: rect.top + rect.height / 2,
|
|
251
|
+
selectedIndex: -1,
|
|
252
|
+
tabId: tab.id,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}, onRegisterRef: (el) => registerButtonRef(idx, el) }, tab.id)))] }), _jsx(RailNavThemeToggle, {})] }), quickMenu.visible && (_jsx("div", { className: "fixed inset-0 z-[90]", onClick: () => setQuickMenu((p) => ({ ...p, visible: false })) })), _jsx(QuickMenu, { ...quickMenu, onSelect: handleQuickAction }), Object.entries(drawers || {}).map(([key, DrawerComp]) => (_jsx(DrawerComp, { isOpen: activeDrawer === key, onClose: () => setActiveDrawer(null) }, key))), _jsx("style", { children: `
|
|
256
|
+
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
257
|
+
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
|
258
|
+
` })] }));
|
|
259
|
+
};
|
|
260
|
+
export const OnyxPcLayout = (props) => (_jsx(ThemeProvider, { children: _jsx(OnyxPcLayoutBase, { ...props }) }));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { OnyxMobileLayout } from "./MobileLayout";
|
|
4
|
+
import { OnyxPcLayout } from "./PcLayout";
|
|
5
|
+
export const OnyxResponsiveLayout = (props) => {
|
|
6
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const checkMobile = () => {
|
|
9
|
+
// 1024px is a common breakpoint for tablets/desktops (LG in Tailwind)
|
|
10
|
+
setIsMobile(window.innerWidth < 1024);
|
|
11
|
+
};
|
|
12
|
+
// Initial check
|
|
13
|
+
checkMobile();
|
|
14
|
+
window.addEventListener("resize", checkMobile);
|
|
15
|
+
return () => window.removeEventListener("resize", checkMobile);
|
|
16
|
+
}, []);
|
|
17
|
+
if (isMobile) {
|
|
18
|
+
return _jsx(OnyxMobileLayout, { ...props });
|
|
19
|
+
}
|
|
20
|
+
return _jsx(OnyxPcLayout, { ...props });
|
|
21
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export declare const ThemeContext: React.Context<{
|
|
3
|
+
isDark: boolean;
|
|
4
|
+
toggleTheme: () => void;
|
|
5
|
+
} | null>;
|
|
6
|
+
export declare const ThemeProvider: ({ children }: {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export declare const useTheme: () => {
|
|
10
|
+
isDark: boolean;
|
|
11
|
+
toggleTheme: () => void;
|
|
12
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
3
|
+
export const ThemeContext = createContext(null);
|
|
4
|
+
export const ThemeProvider = ({ children }) => {
|
|
5
|
+
const [isDark, setIsDark] = useState(() => {
|
|
6
|
+
if (typeof window !== "undefined") {
|
|
7
|
+
const saved = localStorage.getItem("theme");
|
|
8
|
+
return saved ? saved === "dark" : true;
|
|
9
|
+
}
|
|
10
|
+
return true;
|
|
11
|
+
});
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const themeValue = isDark ? "dark" : "light";
|
|
14
|
+
localStorage.setItem("theme", themeValue);
|
|
15
|
+
if (!isDark) {
|
|
16
|
+
document.documentElement.dataset.theme = "light";
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
document.documentElement.removeAttribute("data-theme");
|
|
20
|
+
}
|
|
21
|
+
}, [isDark]);
|
|
22
|
+
const toggleTheme = () => setIsDark((prev) => !prev);
|
|
23
|
+
return (_jsx(ThemeContext.Provider, { value: { isDark, toggleTheme }, children: children }));
|
|
24
|
+
};
|
|
25
|
+
export const useTheme = () => {
|
|
26
|
+
const context = useContext(ThemeContext);
|
|
27
|
+
if (!context)
|
|
28
|
+
throw new Error("useTheme must be used within a ThemeProvider");
|
|
29
|
+
return context;
|
|
30
|
+
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kawaiininja/layouts",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "High-performance, premium mobile-first layouts for the Onyx Framework, featuring gesture-driven navigation, radial quick actions, and integrated theme support.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"framer-motion",
|
|
23
23
|
"premium-design"
|
|
24
24
|
],
|
|
25
|
-
"author": "
|
|
25
|
+
"author": "Tristan",
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"repository": {
|
|
28
28
|
"type": "git",
|