@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @kawaiininja/layouts
2
2
 
3
- High-performance, premium mobile-first layouts for the Onyx Framework. Designed with a focus on fluid gestures, modularity, and state-of-the-art aesthetics.
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
- ### πŸ–οΈ Gesture-Driven Navigation (Fluid Flow)
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 with React Router
60
+ ### Integrated Routing Support (Responsive)
55
61
 
56
62
  ```jsx
57
- import { OnyxMobileLayout } from "@kawaiininja/layouts";
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
- <OnyxMobileLayout
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
- <OnyxMobileLayout ... />
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
- ## πŸ› οΈ Configuration API
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
- ## πŸ› οΈ Configuration API
222
+ ### `OnyxMobileLayoutProps` & `OnyxPcLayoutProps`
211
223
 
212
- ### `OnyxMobileLayoutProps`
224
+ Both versions of the layout use almost identical properties. For automatic detection, use `OnyxResponsiveLayout`.
213
225
 
214
226
  | Prop | Type | Description |
215
227
  | :------------- | :---------------------------- | :------------------------------------------------------------ |
@@ -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;
@@ -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, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
5
- // --- Theme Context & Hook ---
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
- if (quickMenu.visible || isDraggingMenu.current)
310
- return;
311
- startX.current = e.touches[0].clientX;
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 dx = e.touches[0].clientX - startX.current;
324
- const dy = e.touches[0].clientY - startY.current;
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
- if (isDraggingMenu.current)
429
- return;
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 y = e.touches[0].clientY;
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: "w-full bg-[rgb(var(--bg-main))] overflow-hidden relative h-full safe-top safe-bottom", style: {
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`,
@@ -0,0 +1,2 @@
1
+ import { OnyxPcLayoutProps } from "./types";
2
+ export declare const OnyxPcLayout: (props: OnyxPcLayoutProps) => import("react/jsx-runtime").JSX.Element;
@@ -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,2 @@
1
+ import { OnyxPcLayoutProps } from "./types";
2
+ export declare const OnyxResponsiveLayout: (props: OnyxPcLayoutProps) => import("react/jsx-runtime").JSX.Element;
@@ -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
@@ -1,2 +1,5 @@
1
1
  export * from "./MobileLayout";
2
+ export * from "./PcLayout";
3
+ export * from "./ResponsiveLayout";
4
+ export * from "./ThemeContext";
2
5
  export * from "./types";
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
1
  export * from "./MobileLayout";
2
+ export * from "./PcLayout";
3
+ export * from "./ResponsiveLayout";
4
+ export * from "./ThemeContext";
2
5
  export * from "./types";
package/dist/types.d.ts CHANGED
@@ -59,3 +59,5 @@ export interface OnyxMobileLayoutProps {
59
59
  onNavigate?: (path: string) => void;
60
60
  drawerItems?: DrawerItemConfig[];
61
61
  }
62
+ export interface OnyxPcLayoutProps extends OnyxMobileLayoutProps {
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kawaiininja/layouts",
3
- "version": "1.0.13",
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": "Vinay",
25
+ "author": "Tristan",
26
26
  "license": "MIT",
27
27
  "repository": {
28
28
  "type": "git",