@kawaiininja/layouts 1.0.3

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 ADDED
@@ -0,0 +1,110 @@
1
+ # @onyx/layouts
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.
4
+
5
+ ## ✨ Features
6
+
7
+ - **Gesture-Driven Navigation**: Horizontal tab swiping and vertical pull-to-refresh logic.
8
+ - **Radial Quick Actions**: Long-press navigation keys to trigger a radial menu for rapid access to actions.
9
+ - **Dynamic Headers**: Auto-updating header titles based on navigation state, with support for custom actions.
10
+ - **Modular Drawers**: Easily integrate side menus and custom bottom sheets/panels.
11
+ - **Integrated Theme Support**: Built-in dark/light mode toggle with persistence and system-wide CSS variable injection.
12
+ - **Hardware Back Button Support**: Sophisticated history management for closing overlays and panels seamlessly on mobile devices.
13
+
14
+ ## 🚀 Installation
15
+
16
+ ```bash
17
+ npm install @onyx/layouts
18
+ ```
19
+
20
+ Note: This package requires `framer-motion`, `lucide-react`, and `react` >= 18.0.0.
21
+
22
+ ## 📦 Usage
23
+
24
+ ```jsx
25
+ import { OnyxMobileLayout } from "@onyx/layouts";
26
+ import { Home, Settings, PenTool, RefreshCw } from "lucide-react";
27
+
28
+ const MyLayout = () => {
29
+ const tabs = [
30
+ {
31
+ id: "home",
32
+ icon: Home,
33
+ label: "Home",
34
+ navTitle: "My Feed",
35
+ subTabs: [{ label: "Overview", icon: Grid, view: MyViewComponent }],
36
+ quickActions: [
37
+ {
38
+ icon: PenTool,
39
+ label: "Create",
40
+ onClick: ({ openDrawer }) => openDrawer("editor"),
41
+ },
42
+ ],
43
+ onRefresh: () => fetchData(),
44
+ isRefreshing: loadingState,
45
+ },
46
+ ];
47
+
48
+ return (
49
+ <OnyxMobileLayout
50
+ tabs={tabs}
51
+ user={{ name: "Alex", handle: "@alex", avatar: "url" }}
52
+ drawers={{
53
+ editor: MyEditorPanel,
54
+ }}
55
+ />
56
+ );
57
+ };
58
+ ```
59
+
60
+ ## 🛠️ Configuration API
61
+
62
+ ### OnyxMobileLayoutProps
63
+
64
+ | Prop | Type | Description |
65
+ | :------------- | :-------------------------- | :------------------------------------------------------ |
66
+ | `tabs` | `TabConfig[]` | Array of main navigation tabs (right rail). |
67
+ | `user` | `UserConfig` | User profile data for the side drawer. |
68
+ | `drawers` | `Record<string, Component>` | Dictionary of custom panels/bottom sheets. |
69
+ | `drawerItems` | `DrawerItemConfig[]` | Custom links for the main side drawer. |
70
+ | `onSignOut` | `() => void` | Callback triggered when the sign-out button is clicked. |
71
+ | `onRefresh` | `() => void` | (Global) Callback for pull-to-refresh. |
72
+ | `isRefreshing` | `boolean` | (Global) Refreshing state. |
73
+ | `initialTab` | `string` | ID of the tab to show on mount (default: 'home'). |
74
+
75
+ ### TabConfig
76
+
77
+ | Field | Type | Description |
78
+ | :------------- | :-------------------- | :------------------------------------------------ |
79
+ | `id` | `string` | Unique identifier for navigation. |
80
+ | `icon` | `LucideIcon` | Icon for the navigation rail. |
81
+ | `label` | `string` | Label for the navigation rail. |
82
+ | `navTitle` | `string` | (Optional) Title shown in the header when active. |
83
+ | `subTabs` | `SubTabConfig[]` | Array of nested horizontal tabs. |
84
+ | `quickActions` | `QuickActionConfig[]` | Actions triggered by long-press on the nav key. |
85
+ | `onRefresh` | `() => void` | Tab-specific refresh callback. |
86
+ | `isRefreshing` | `boolean` | Tab-specific refresh state. |
87
+
88
+ ### QuickActionConfig / DrawerItemConfig
89
+
90
+ Quick actions and Drawer items support an `onClick` callback that receives a context object:
91
+
92
+ ```typescript
93
+ onClick: ({ openDrawer: (id: string) => void }) => void
94
+ ```
95
+
96
+ ## 🎨 Theme Variables
97
+
98
+ The layout relies on the following CSS variables for its design system:
99
+
100
+ - `--bg-main`: Page background
101
+ - `--bg-surface`: Header/Tab background
102
+ - `--bg-elevated`: Drawer/Overlay background
103
+ - `--color-accent`: Highlight/Active color
104
+ - `--text-primary`: Main text
105
+ - `--text-muted`: Secondary text
106
+ - `--color-border-subtle`: Borders and dividers
107
+
108
+ ## ⚖️ License
109
+
110
+ MIT © Vinay
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ import { OnyxMobileLayoutProps } from "./types";
3
+ export declare const ThemeProvider: ({ children }: {
4
+ children: React.ReactNode;
5
+ }) => import("react/jsx-runtime").JSX.Element;
6
+ export declare const OnyxMobileLayout: (props: OnyxMobileLayoutProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,391 @@
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, { 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
+ };
34
+ // --- Sub-components (Generic Versions) ---
35
+ 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
+ const RailNavButton = ({ title, icon: Icon, active, onClick, onRegisterRef, onTouchStart, onTouchMove, onTouchEnd, }) => (_jsxs("button", { ref: onRegisterRef, type: "button", role: "tab", 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 ${active
37
+ ? "text-[rgb(var(--color-accent))]"
38
+ : "text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]"}`, children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-[0.2em] [writing-mode:vertical-rl] rotate-180 transition-all duration-300 ${active ? "scale-110 opacity-100" : "scale-100 opacity-60"}`, children: title }), Icon && (_jsx(Icon, { size: 20, className: `transition-all duration-300 -rotate-90 ${active ? "scale-110 opacity-100" : "scale-100 opacity-60"}` }))] }));
39
+ const RailNavThemeToggle = () => {
40
+ const { isDark, toggleTheme } = useTheme();
41
+ return (_jsx("button", { onClick: toggleTheme, className: "mt-auto mb-4 mx-auto p-2 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", 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))]" })) }));
42
+ };
43
+ const QuickMenu = ({ visible, items, positionY, selectedIndex }) => {
44
+ if (!visible || !items)
45
+ return null;
46
+ return (_jsxs("aside", { className: "fixed right-20 z-[100] min-w-[180px] 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: {
47
+ top: positionY,
48
+ transform: "translateY(-50%) scale(1)",
49
+ animation: "popIn 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)",
50
+ }, children: [items.map((item, idx) => (_jsxs("button", { className: `flex items-center gap-3 p-3 rounded-xl transition-all duration-150 w-full text-left ${selectedIndex === idx
51
+ ? "bg-[rgb(var(--color-accent))] text-white scale-105 shadow-lg"
52
+ : "text-[rgb(var(--text-secondary))] hover:bg-[rgb(var(--bg-tertiary))]"}`, 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: `
53
+ @keyframes popIn {
54
+ from { opacity: 0; transform: translateY(-50%) scale(0.8) translateX(20px); }
55
+ to { opacity: 1; transform: translateY(-50%) scale(1) translateX(0); }
56
+ }
57
+ ` })] }));
58
+ };
59
+ const Header = ({ title, onMenuClick, rightAction }) => (_jsxs("header", { className: "sticky top-0 z-20 bg-[rgb(var(--bg-surface))]/90 backdrop-blur-md border-b border-[rgb(var(--color-border-subtle))] px-4 py-3 flex justify-between items-center shadow-sm select-none", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("button", { onClick: onMenuClick, className: "p-1 -ml-1 text-[rgb(var(--text-secondary))] active:text-[rgb(var(--text-primary))]", children: _jsx(Menu, { size: 24 }) }), _jsx("h1", { className: "text-xl font-bold text-[rgb(var(--text-primary))] tracking-tight", children: title })] }), rightAction] }));
60
+ 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-4 gap-6 overflow-x-auto no-scrollbar", children: tabs.map((tab) => (_jsxs("button", { onClick: () => onChange(tab.label), className: `relative py-3 flex items-center gap-2 text-sm font-medium transition-colors whitespace-nowrap ${active === tab.label
61
+ ? "text-[rgb(var(--text-primary))]"
62
+ : "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))) }) }));
63
+ // --- Hook: useRailNavScroll ---
64
+ function useRailNavScroll(active, navKeys) {
65
+ const activeIndex = Math.max(0, navKeys.indexOf(active));
66
+ const scrollContainerRef = useRef(null);
67
+ const buttonRefs = useRef([]);
68
+ const [measurements, setMeasurements] = useState({
69
+ heights: [],
70
+ offsets: [],
71
+ });
72
+ useLayoutEffect(() => {
73
+ const heights = buttonRefs.current.map((btn) => btn?.offsetHeight || 0);
74
+ let currentOffset = 0;
75
+ const offsets = [];
76
+ for (let i = 0; i < heights.length; i++) {
77
+ offsets.push(currentOffset);
78
+ currentOffset += heights[i];
79
+ }
80
+ setMeasurements({ heights, offsets });
81
+ }, [navKeys]);
82
+ useEffect(() => {
83
+ if (!scrollContainerRef.current ||
84
+ measurements.offsets.length === 0 ||
85
+ measurements.offsets.length <= activeIndex)
86
+ return;
87
+ const container = scrollContainerRef.current;
88
+ const targetY = measurements.offsets[activeIndex];
89
+ const targetHeight = measurements.heights[activeIndex];
90
+ const paddingOffset = 24;
91
+ const absoluteTargetY = targetY + paddingOffset;
92
+ const scrollTarget = absoluteTargetY - container.clientHeight / 2 + targetHeight / 2;
93
+ container.scrollTo({ top: scrollTarget, behavior: "smooth" });
94
+ }, [activeIndex, measurements]);
95
+ const registerButtonRef = (index, el) => {
96
+ buttonRefs.current[index] = el;
97
+ };
98
+ return {
99
+ scrollContainerRef,
100
+ activeHeight: measurements.heights[activeIndex] || 0,
101
+ activeOffset: measurements.offsets[activeIndex] || 0,
102
+ registerButtonRef,
103
+ };
104
+ }
105
+ // --- Main Layout Component ---
106
+ const OnyxMobileLayoutBase = ({ tabs, user, drawers = {}, onSignOut, onRefresh, isRefreshing: externalRefreshing, rightAction, initialTab = "home", drawerItems = [], }) => {
107
+ const [activeTab, setActiveTab] = useState(initialTab);
108
+ const [isOpen, setIsOpen] = useState(false);
109
+ const [isDragging, setIsDragging] = useState(false);
110
+ const [activeDrawer, setActiveDrawer] = useState(null);
111
+ const [pullY, setPullY] = useState(0);
112
+ const [internalRefreshing, setInternalRefreshing] = useState(false);
113
+ const currentTabConfig = tabs.find((t) => t.id === activeTab);
114
+ const isRefreshing = currentTabConfig?.isRefreshing ?? externalRefreshing ?? internalRefreshing;
115
+ const subTabs = currentTabConfig?.subTabs || [];
116
+ const [subTab, setSubTab] = useState(subTabs[0]?.label || "");
117
+ const navKeys = useMemo(() => tabs.map((t) => t.id), [tabs]);
118
+ const { scrollContainerRef, activeHeight, activeOffset, registerButtonRef } = useRailNavScroll(activeTab, navKeys);
119
+ const DRAWER_WIDTH = 220;
120
+ const startX = useRef(0);
121
+ const startY = useRef(0);
122
+ const dragXRef = useRef(0);
123
+ const lastDxRef = useRef(0);
124
+ const isGesturing = useRef(false);
125
+ const isHorizontal = useRef(false);
126
+ const isVerticalPull = useRef(false);
127
+ const isTabSwipe = useRef(false);
128
+ const mainScrollRef = useRef(null);
129
+ const [renderDragX, setRenderDragX] = useState(0);
130
+ const [quickMenu, setQuickMenu] = useState({
131
+ visible: false,
132
+ items: [],
133
+ positionY: 0,
134
+ selectedIndex: -1,
135
+ tabId: "",
136
+ });
137
+ const holdTimer = useRef(null);
138
+ const startYRef = useRef(0);
139
+ const isDraggingMenu = useRef(false);
140
+ const isClosingViaBack = useRef(false);
141
+ const isAnyOverlayOpen = isOpen || !!activeDrawer;
142
+ // Handle Hardware Back Button for Drawer and Active Panels
143
+ useEffect(() => {
144
+ if (!isAnyOverlayOpen)
145
+ return;
146
+ const marker = "onyx-overlay-" + Math.random().toString(36).substring(7);
147
+ window.history.pushState({ onyxMarker: marker }, "");
148
+ const onPopState = () => {
149
+ isClosingViaBack.current = true;
150
+ setIsOpen(false);
151
+ setActiveDrawer(null);
152
+ };
153
+ window.addEventListener("popstate", onPopState);
154
+ return () => {
155
+ window.removeEventListener("popstate", onPopState);
156
+ if (!isClosingViaBack.current) {
157
+ if (window.history.state?.onyxMarker === marker) {
158
+ window.history.back();
159
+ }
160
+ }
161
+ isClosingViaBack.current = false;
162
+ };
163
+ }, [isAnyOverlayOpen]);
164
+ // Navigation Logic
165
+ useEffect(() => {
166
+ if (currentTabConfig && currentTabConfig.subTabs.length > 0) {
167
+ const isValid = currentTabConfig.subTabs.some((st) => st.label === subTab);
168
+ if (!isValid)
169
+ setSubTab(currentTabConfig.subTabs[0].label);
170
+ }
171
+ }, [activeTab, tabs, subTab, currentTabConfig]);
172
+ const [[prevActive, direction], setDir] = useState([activeTab, 0]);
173
+ if (activeTab !== prevActive) {
174
+ const currentIdx = navKeys.indexOf(activeTab);
175
+ const prevIdx = navKeys.indexOf(prevActive);
176
+ setDir([activeTab, currentIdx > prevIdx ? 1 : -1]);
177
+ }
178
+ // Animation variants
179
+ const variants = {
180
+ enter: (d) => ({
181
+ y: d.direction > 0 ? "100%" : "-100%",
182
+ scaleY: 0.7,
183
+ opacity: 0,
184
+ }),
185
+ center: {
186
+ y: 0,
187
+ scaleY: 1,
188
+ opacity: 1,
189
+ transition: {
190
+ duration: 0.55,
191
+ ease: [0.8, 0, 0.1, 1],
192
+ scaleY: {
193
+ duration: 0.55,
194
+ ease: [0.8, 0, 0.1, 1],
195
+ },
196
+ },
197
+ },
198
+ exit: (d) => ({
199
+ y: d.direction > 0 ? "-100%" : "100%",
200
+ scaleY: 1.2,
201
+ opacity: 0,
202
+ transition: { duration: 0.4, ease: [0.8, 0, 0.1, 1] },
203
+ }),
204
+ };
205
+ const Drawer = () => (_jsxs("aside", { className: "fixed inset-y-0 left-0 w-[220px] bg-[var(--drawer-bg,rgb(18,18,18))] text-[var(--drawer-text,rgb(255,255,255))] z-0 flex flex-col pt-12 pb-6 px-6 overflow-hidden", children: [_jsxs("div", { className: "mb-8 pl-2", children: [_jsx("div", { className: "w-16 h-16 rounded-full bg-gradient-to-tr from-[rgb(var(--color-secondary))] to-[rgb(var(--color-accent))] mb-4 p-0.5", children: _jsx("img", { src: user.avatar, className: "w-full h-full rounded-full object-cover border-2 border-[rgb(var(--bg-main))]" }) }), _jsx("h2", { className: "text-xl font-bold whitespace-nowrap", children: user.name }), _jsx("p", { className: "text-sm text-[rgb(var(--text-muted))]", children: user.handle })] }), _jsx("div", { className: "flex-1 space-y-1", children: (drawerItems.length > 0
206
+ ? drawerItems
207
+ : (tabs[0]?.subTabs || []).map((st) => ({
208
+ label: st.label,
209
+ icon: st.icon,
210
+ targetTab: tabs[0].id,
211
+ targetSubTab: st.label,
212
+ }))).map((item) => (_jsxs("button", { onClick: () => {
213
+ if (item.targetTab)
214
+ setActiveTab(item.targetTab);
215
+ if (item.targetSubTab)
216
+ setSubTab(item.targetSubTab);
217
+ if (item.onClick)
218
+ item.onClick({
219
+ openDrawer: (id) => setActiveDrawer(id),
220
+ });
221
+ setIsOpen(false);
222
+ }, 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" })] }) })] }));
223
+ const handleTouchStart = (e) => {
224
+ if (quickMenu.visible || isDraggingMenu.current)
225
+ return;
226
+ startX.current = e.touches[0].clientX;
227
+ startY.current = e.touches[0].clientY;
228
+ dragXRef.current = isOpen ? DRAWER_WIDTH : 0;
229
+ setRenderDragX(dragXRef.current);
230
+ isGesturing.current = true;
231
+ isHorizontal.current = false;
232
+ isVerticalPull.current = false;
233
+ isTabSwipe.current = false;
234
+ };
235
+ const handleTouchMove = (e) => {
236
+ if (!isGesturing.current || quickMenu.visible || isDraggingMenu.current)
237
+ return;
238
+ const dx = e.touches[0].clientX - startX.current;
239
+ const dy = e.touches[0].clientY - startY.current;
240
+ lastDxRef.current = dx;
241
+ if (!isHorizontal.current && !isVerticalPull.current && !isDragging) {
242
+ if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10) {
243
+ if (!isOpen && dx > 0 && subTabs[0]?.label === subTab) {
244
+ isHorizontal.current = true;
245
+ setIsDragging(true);
246
+ }
247
+ else {
248
+ isTabSwipe.current = true;
249
+ }
250
+ }
251
+ else if (dy > 0 &&
252
+ Math.abs(dy) > 10 &&
253
+ mainScrollRef.current?.scrollTop <= 0 &&
254
+ !isRefreshing) {
255
+ isVerticalPull.current = true;
256
+ }
257
+ }
258
+ if (isHorizontal.current) {
259
+ const newX = Math.max(0, Math.min((isOpen ? DRAWER_WIDTH : 0) + dx, DRAWER_WIDTH));
260
+ dragXRef.current = newX;
261
+ setRenderDragX(newX);
262
+ }
263
+ if (isVerticalPull.current) {
264
+ setPullY(Math.pow(Math.max(0, dy), 0.85));
265
+ if (e.cancelable)
266
+ e.preventDefault();
267
+ }
268
+ };
269
+ const handleTouchEnd = () => {
270
+ isGesturing.current = false;
271
+ if (isHorizontal.current) {
272
+ setIsDragging(false);
273
+ isHorizontal.current = false;
274
+ const shouldOpen = dragXRef.current > DRAWER_WIDTH / 2;
275
+ setIsOpen(shouldOpen);
276
+ setRenderDragX(shouldOpen ? DRAWER_WIDTH : 0);
277
+ }
278
+ if (isTabSwipe.current) {
279
+ isTabSwipe.current = false;
280
+ if (Math.abs(lastDxRef.current) > 50) {
281
+ const idx = subTabs.findIndex((t) => t.label === subTab);
282
+ if (lastDxRef.current < 0 && idx < subTabs.length - 1)
283
+ setSubTab(subTabs[idx + 1].label);
284
+ else if (lastDxRef.current > 0 && idx > 0)
285
+ setSubTab(subTabs[idx - 1].label);
286
+ }
287
+ }
288
+ if (isVerticalPull.current) {
289
+ if (pullY > 60) {
290
+ const refreshHandler = currentTabConfig?.onRefresh || onRefresh;
291
+ if (refreshHandler) {
292
+ refreshHandler();
293
+ setPullY(60);
294
+ }
295
+ else {
296
+ setInternalRefreshing(true);
297
+ setPullY(60);
298
+ setTimeout(() => {
299
+ setInternalRefreshing(false);
300
+ setPullY(0);
301
+ }, 2000);
302
+ }
303
+ }
304
+ else {
305
+ setPullY(0);
306
+ }
307
+ }
308
+ };
309
+ // Reset pull if tab changes while refreshing
310
+ useEffect(() => {
311
+ setPullY(0);
312
+ }, [activeTab]);
313
+ useEffect(() => {
314
+ if (!isRefreshing && pullY === 60) {
315
+ setPullY(0);
316
+ }
317
+ }, [isRefreshing]);
318
+ // Quick Menu Logic
319
+ const handleNavTouchStart = (e, tab) => {
320
+ if (isDraggingMenu.current)
321
+ return;
322
+ const y = e.touches[0].clientY;
323
+ startYRef.current = y;
324
+ holdTimer.current = setTimeout(() => {
325
+ isDraggingMenu.current = true;
326
+ if (navigator.vibrate)
327
+ navigator.vibrate(50);
328
+ setQuickMenu({
329
+ visible: true,
330
+ items: tab.quickActions,
331
+ positionY: y,
332
+ selectedIndex: -1,
333
+ tabId: tab.id,
334
+ });
335
+ }, 400);
336
+ };
337
+ const handleNavTouchMove = (e) => {
338
+ const y = e.touches[0].clientY;
339
+ const diff = y - startYRef.current;
340
+ if (!isDraggingMenu.current) {
341
+ if (Math.abs(diff) > 10)
342
+ clearTimeout(holdTimer.current);
343
+ return;
344
+ }
345
+ // Lock the event and prevent parent scrolling
346
+ if (e.cancelable)
347
+ e.preventDefault();
348
+ e.stopPropagation();
349
+ const ITEM_HEIGHT = 48;
350
+ const offset = (quickMenu.items.length - 1) / 2;
351
+ const idx = Math.round(diff / ITEM_HEIGHT + offset);
352
+ const clampedIdx = Math.max(0, Math.min(idx, quickMenu.items.length - 1));
353
+ setQuickMenu((prev) => ({ ...prev, selectedIndex: clampedIdx }));
354
+ };
355
+ const handleNavTouchEnd = (tabId) => {
356
+ clearTimeout(holdTimer.current);
357
+ if (isDraggingMenu.current) {
358
+ isDraggingMenu.current = false;
359
+ if (quickMenu.selectedIndex !== -1 &&
360
+ quickMenu.items[quickMenu.selectedIndex]?.onClick) {
361
+ quickMenu.items[quickMenu.selectedIndex].onClick({
362
+ openDrawer: (id) => setActiveDrawer(id),
363
+ });
364
+ }
365
+ setQuickMenu((prev) => ({ ...prev, visible: false, selectedIndex: -1 }));
366
+ }
367
+ else {
368
+ setActiveTab(tabId);
369
+ }
370
+ };
371
+ const currentTranslate = isDragging ? renderDragX : isOpen ? DRAWER_WIDTH : 0;
372
+ const progress = currentTranslate / DRAWER_WIDTH;
373
+ return (_jsxs("div", { className: "w-full bg-[rgb(var(--bg-main))] overflow-hidden relative h-full", 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: {
374
+ transform: `translateX(${currentTranslate}px) scale(${1 - progress * 0.05})`,
375
+ borderRadius: `${progress * 24}px`,
376
+ transition: isDragging || isVerticalPull.current
377
+ ? "none"
378
+ : "transform 0.3s cubic-bezier(0.32, 0.72, 0, 1), border-radius 0.3s",
379
+ overflow: "hidden",
380
+ }, children: [isOpen && !isDragging && (_jsx("div", { className: "absolute inset-0 z-50 bg-black/20", onClick: () => setIsOpen(false) })), _jsxs("div", { className: "flex-1 flex flex-col h-full mr-12 relative bg-[rgb(var(--bg-main))]", children: [_jsx(Header, { title: currentTabConfig?.navTitle || currentTabConfig?.label || "App", onMenuClick: () => setIsOpen(!isOpen), rightAction: currentTabConfig?.rightAction || rightAction }), _jsx(HorizontalTabs, { tabs: subTabs, active: subTab, onChange: setSubTab }), _jsxs("main", { ref: mainScrollRef, className: "flex-1 overflow-y-auto no-scrollbar overscroll-contain relative mr-4", children: [_jsx("div", { className: "absolute top-0 left-0 right-0 flex justify-center items-center pointer-events-none z-0", style: {
381
+ height: `${pullY}px`,
382
+ opacity: Math.min(pullY / 40, 1),
383
+ }, children: _jsx("div", { className: `p-2 bg-[rgb(var(--bg-surface))] rounded-full shadow-md border border-[rgb(var(--color-border-subtle))] ${isRefreshing
384
+ ? "animate-spin text-[rgb(var(--color-accent))]"
385
+ : "text-[rgb(var(--text-secondary))]"}`, children: _jsx(Loader2, { size: 20 }) }) }), _jsx(AnimatePresence, { mode: "popLayout", custom: { direction }, initial: false, children: _jsx(motion.div, { custom: { direction }, variants: variants, initial: "enter", animate: "center", exit: "exit", className: "w-full min-h-full", children: _jsx("div", { style: {
386
+ transform: `translateY(${pullY}px)`,
387
+ minHeight: "100%",
388
+ }, children: subTabs.find((st) => st.label === subTab)?.view &&
389
+ React.createElement(subTabs.find((st) => st.label === subTab).view, { onOpenDrawer: setActiveDrawer }) }) }, activeTab) })] })] }), _jsxs("nav", { className: "absolute right-0 top-0 bottom-0 w-12 bg-[rgb(var(--bg-tertiary))] 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: () => handleNavTouchEnd(tab.id), onTouchStart: (e) => handleNavTouchStart(e, tab), onTouchMove: handleNavTouchMove, onTouchEnd: () => handleNavTouchEnd(tab.id), onRegisterRef: (el) => registerButtonRef(idx, el) }, tab.id)))] }), _jsx(RailNavThemeToggle, {})] })] }), _jsx(QuickMenu, { ...quickMenu }), Object.entries(drawers).map(([key, DrawerComp]) => (_jsx(DrawerComp, { isOpen: activeDrawer === key, onClose: () => setActiveDrawer(null) }, key))), _jsx("style", { children: `.no-scrollbar::-webkit-scrollbar { display: none; }` })] }));
390
+ };
391
+ export const OnyxMobileLayout = (props) => (_jsx(ThemeProvider, { children: _jsx(OnyxMobileLayoutBase, { ...props }) }));
@@ -0,0 +1,2 @@
1
+ export * from "./MobileLayout";
2
+ export * from "./types";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./MobileLayout";
2
+ export * from "./types";
@@ -0,0 +1,50 @@
1
+ import { ComponentType, ReactNode } from "react";
2
+ export interface SubTabConfig {
3
+ label: string;
4
+ icon: ComponentType<any>;
5
+ view: ComponentType<any>;
6
+ }
7
+ export interface QuickActionConfig {
8
+ label: string;
9
+ icon: ComponentType<any>;
10
+ onClick?: () => void;
11
+ }
12
+ export interface TabConfig {
13
+ id: string;
14
+ label: string;
15
+ icon: ComponentType<any>;
16
+ navTitle?: string;
17
+ subTabs: SubTabConfig[];
18
+ quickActions: QuickActionConfig[];
19
+ rightAction?: ReactNode;
20
+ onRefresh?: () => void;
21
+ isRefreshing?: boolean;
22
+ }
23
+ export interface DrawerItemConfig {
24
+ label: string;
25
+ icon: any;
26
+ targetTab?: string;
27
+ targetSubTab?: string;
28
+ onClick?: () => void;
29
+ }
30
+ export interface UserConfig {
31
+ name: string;
32
+ handle: string;
33
+ avatar: string;
34
+ }
35
+ export interface OnyxMobileLayoutProps {
36
+ tabs: TabConfig[];
37
+ user: UserConfig;
38
+ drawers?: {
39
+ [key: string]: ComponentType<{
40
+ isOpen: boolean;
41
+ onClose: () => void;
42
+ }>;
43
+ };
44
+ onSignOut?: () => void;
45
+ onRefresh?: () => void;
46
+ isRefreshing?: boolean;
47
+ rightAction?: ReactNode;
48
+ initialTab?: string;
49
+ drawerItems?: DrawerItemConfig[];
50
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@kawaiininja/layouts",
3
+ "version": "1.0.3",
4
+ "description": "High-performance, premium mobile-first layouts for the Onyx Framework, featuring gesture-driven navigation, radial quick actions, and integrated theme support.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "keywords": [
17
+ "onyx",
18
+ "layouts",
19
+ "react",
20
+ "mobile-ui",
21
+ "gestures",
22
+ "framer-motion",
23
+ "premium-design"
24
+ ],
25
+ "author": "Vinay",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/kawaiininja/onyx-framework.git",
30
+ "directory": "layouts"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/kawaiininja/onyx-framework/issues"
34
+ },
35
+ "homepage": "https://github.com/kawaiininja/onyx-framework#readme",
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js",
40
+ "require": "./dist/index.js",
41
+ "default": "./dist/index.js"
42
+ }
43
+ },
44
+ "scripts": {
45
+ "build": "tsc",
46
+ "clean": "if exist dist rmdir /s /q dist",
47
+ "prepublishOnly": "npm run clean && npm run build"
48
+ },
49
+ "peerDependencies": {
50
+ "framer-motion": ">=10.0.0",
51
+ "lucide-react": ">=0.284.0",
52
+ "react": ">=18.0.0",
53
+ "react-dom": ">=18.0.0"
54
+ },
55
+ "devDependencies": {
56
+ "@types/react": "^18.0.0",
57
+ "@types/react-dom": "^18.0.0",
58
+ "framer-motion": "latest",
59
+ "lucide-react": "latest",
60
+ "react": "latest",
61
+ "react-dom": "latest",
62
+ "typescript": "^5.0.0"
63
+ }
64
+ }