firebase-os 1.1.4 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FirebaseOS.d.ts +15 -0
- package/dist/firebase-os.cjs.js +5 -20
- package/dist/firebase-os.es.js +95 -90
- package/dist/index.d.ts +3 -0
- package/dist/lib/ConfigContext.d.ts +12 -0
- package/package.json +3 -2
- package/scripts/postinstall.js +86 -15
- package/src/App.css +184 -0
- package/src/App.tsx +214 -0
- package/src/FirebaseOS.tsx +81 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/AdminNotifications.test.tsx +98 -0
- package/src/components/AdminNotifications.tsx +194 -0
- package/src/components/Button.test.tsx +22 -0
- package/src/components/Button.tsx +53 -0
- package/src/components/ConfirmModal.test.tsx +98 -0
- package/src/components/ConfirmModal.tsx +73 -0
- package/src/components/ContactPopup.test.tsx +98 -0
- package/src/components/ContactPopup.tsx +437 -0
- package/src/components/CustomSelect.test.tsx +47 -0
- package/src/components/CustomSelect.tsx +89 -0
- package/src/components/DashboardNav.test.tsx +98 -0
- package/src/components/DashboardNav.tsx +281 -0
- package/src/components/Input.test.tsx +33 -0
- package/src/components/Input.tsx +61 -0
- package/src/components/JsonEditor.tsx +579 -0
- package/src/components/Navbar.test.tsx +98 -0
- package/src/components/Navbar.tsx +563 -0
- package/src/configs/forms/contactForm.config.ts +15 -0
- package/src/configs/forms/index.ts +29 -0
- package/src/configs/forms/pubForm.config.ts +11 -0
- package/src/configs/forms/supportForm.config.ts +14 -0
- package/src/configs/forms/userForm.config.ts +11 -0
- package/src/configs/pages/admin.config.ts +29 -0
- package/src/configs/pages/contact.config.ts +6 -0
- package/src/configs/pages/home.config.ts +18 -0
- package/src/configs/pages/mem.config.ts +2 -0
- package/src/configs/pages/menuOrders.config.ts +11 -0
- package/src/configs/pages/pub.config.ts +11 -0
- package/src/configs/pages/shared.config.ts +29 -0
- package/src/configs/pages/support.config.ts +7 -0
- package/src/configs/pages/tabOrders.config.ts +33 -0
- package/src/configs/pages/user.config.ts +29 -0
- package/src/configs/theme.config.ts +93 -0
- package/src/index.css +403 -0
- package/src/index.ts +22 -0
- package/src/lib/AuthContext.test.tsx +88 -0
- package/src/lib/AuthContext.tsx +191 -0
- package/src/lib/ConfigContext.tsx +45 -0
- package/src/lib/ThemeContext.tsx +233 -0
- package/src/lib/firebase.ts +91 -0
- package/src/main.tsx +22 -0
- package/src/microcomponents/AdminExampleContent.tsx +44 -0
- package/src/microcomponents/PrivateExampleContent.tsx +39 -0
- package/src/microcomponents/Public.tsx +126 -0
- package/src/microcomponents/SharedExampleContent.tsx +53 -0
- package/src/pages/Dashboard.test.tsx +98 -0
- package/src/pages/Dashboard.tsx +60 -0
- package/src/pages/DynamicPage.tsx +237 -0
- package/src/pages/FormsAdmin.test.tsx +98 -0
- package/src/pages/FormsAdmin.tsx +459 -0
- package/src/pages/Home.test.tsx +98 -0
- package/src/pages/Home.tsx +144 -0
- package/src/pages/Login.test.tsx +98 -0
- package/src/pages/Login.tsx +108 -0
- package/src/pages/PagesAdmin.test.tsx +98 -0
- package/src/pages/PagesAdmin.tsx +1022 -0
- package/src/pages/Profile.test.tsx +98 -0
- package/src/pages/Profile.tsx +319 -0
- package/src/pages/Register.test.tsx +98 -0
- package/src/pages/Register.tsx +116 -0
- package/src/pages/Requests.test.tsx +95 -0
- package/src/pages/Requests.tsx +422 -0
- package/src/pages/ResetPassword.test.tsx +98 -0
- package/src/pages/ResetPassword.tsx +92 -0
- package/src/pages/Settings.test.tsx +98 -0
- package/src/pages/Settings.tsx +393 -0
- package/src/pages/Setup.tsx +407 -0
- package/src/pages/StorageAdmin.test.tsx +150 -0
- package/src/pages/StorageAdmin.tsx +769 -0
- package/src/pages/Submissions.test.tsx +95 -0
- package/src/pages/Submissions.tsx +378 -0
- package/src/pages/Templates.test.tsx +98 -0
- package/src/pages/Templates.tsx +103 -0
- package/src/pages/ThemeAdmin.test.tsx +144 -0
- package/src/pages/ThemeAdmin.tsx +1000 -0
- package/src/pages/Users.test.tsx +95 -0
- package/src/pages/Users.tsx +334 -0
- package/src/pages/Verify.test.tsx +98 -0
- package/src/pages/Verify.tsx +95 -0
- package/src/prompts/index.ts +13 -0
- package/src/prompts/pages/publicPage.ts +44 -0
- package/src/prompts/sharedConstants.ts +12 -0
- package/src/prompts/tabs/board/adminboard.ts +32 -0
- package/src/prompts/tabs/board/privateboard.ts +36 -0
- package/src/prompts/tabs/board/publicboard.ts +36 -0
- package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
- package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
- package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
- package/src/prompts/tabs/crud/admin.ts +54 -0
- package/src/prompts/tabs/crud/private.ts +55 -0
- package/src/prompts/tabs/crud/shared.ts +53 -0
- package/src/prompts/tabs/table/admintable.ts +32 -0
- package/src/prompts/tabs/table/privatetable.ts +36 -0
- package/src/prompts/tabs/table/publictable.ts +36 -0
- package/src/setupTests.ts +1 -0
- package/src/templates/AdminPageTemplate.tsx +678 -0
- package/src/templates/PrivatePageTemplate.tsx +594 -0
- package/src/templates/PublicPageTemplate.tsx +92 -0
- package/src/templates/SharedPageTemplate.tsx +551 -0
- package/src/templates/TemplateBoard.test.tsx +106 -0
- package/src/templates/TemplateBoard.tsx +642 -0
- package/src/templates/TemplateCalendar.test.tsx +106 -0
- package/src/templates/TemplateCalendar.tsx +848 -0
- package/src/templates/TemplateConfirmation.test.tsx +106 -0
- package/src/templates/TemplateConfirmation.tsx +145 -0
- package/src/templates/TemplateInlineForm.test.tsx +106 -0
- package/src/templates/TemplateInlineForm.tsx +129 -0
- package/src/templates/TemplatePopupForm.test.tsx +106 -0
- package/src/templates/TemplatePopupForm.tsx +174 -0
- package/src/templates/TemplateTable.test.tsx +106 -0
- package/src/templates/TemplateTable.tsx +675 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
2
|
+
import { useAuth } from '../lib/AuthContext';
|
|
3
|
+
import { Users, Settings, Mail, HelpCircle, User as UserIcon, HardDrive, Palette, Layers, FormInput, MoreVertical } from 'lucide-react';
|
|
4
|
+
import { useState, useRef, useEffect } from 'react';
|
|
5
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
6
|
+
import * as IconoirIcons from 'iconoir-react';
|
|
7
|
+
import { collection, onSnapshot, doc } from 'firebase/firestore';
|
|
8
|
+
import { db } from '../lib/firebase';
|
|
9
|
+
import type { OrderItem } from '../configs/pages/tabOrders.config';
|
|
10
|
+
import { parseOrderItems, defaultTabOrder } from '../configs/pages/tabOrders.config';
|
|
11
|
+
|
|
12
|
+
interface TabItem {
|
|
13
|
+
id: string;
|
|
14
|
+
path: string;
|
|
15
|
+
label: string;
|
|
16
|
+
icon: any;
|
|
17
|
+
roles: string[];
|
|
18
|
+
exact?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// System tab definitions — fallback when Firestore has no override record
|
|
22
|
+
const systemTabDefaults: Record<string, Omit<TabItem, 'id'>> = {
|
|
23
|
+
pages: { path: '/pages', label: 'Pages', icon: Layers, roles: ['admin'] },
|
|
24
|
+
forms: { path: '/forms', label: 'Forms', icon: FormInput, roles: ['admin'] },
|
|
25
|
+
drive: { path: '/drive', label: 'Drive', icon: HardDrive, roles: ['admin', 'user', 'editor', 'guest'] },
|
|
26
|
+
users: { path: '/users', label: 'Users', icon: Users, roles: ['admin'] },
|
|
27
|
+
theme: { path: '/theme', label: 'Theme', icon: Palette, roles: ['admin'] },
|
|
28
|
+
submissions: { path: '/submissions', label: 'Submissions', icon: Mail, roles: ['admin'] },
|
|
29
|
+
requests: { path: '/requests', label: 'Requests', icon: HelpCircle, roles: ['admin'] },
|
|
30
|
+
settings: { path: '/settings', label: 'Settings', icon: Settings, roles: ['admin'] },
|
|
31
|
+
profile: { path: '/profile', label: 'Profile', icon: UserIcon, roles: ['admin', 'user', 'editor', 'guest'] },
|
|
32
|
+
support: { path: '/support', label: 'Support', icon: HelpCircle, roles: ['user', 'editor', 'guest'] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function DashboardNav({ customTabs = [] }: { customTabs?: Omit<TabItem, 'id'>[] }) {
|
|
36
|
+
const { pathname } = useLocation();
|
|
37
|
+
const { userRole } = useAuth();
|
|
38
|
+
const [moreOpen, setMoreOpen] = useState(false);
|
|
39
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
|
|
41
|
+
// Firestore: tab overrides (label / icon / role customisation) — NOT visibility
|
|
42
|
+
const [tabOverrides, setTabOverrides] = useState<Record<string, any>>({});
|
|
43
|
+
// Firestore: tab order + hidden flags (single source of truth for visibility)
|
|
44
|
+
const [tabOrder, setTabOrder] = useState<OrderItem[]>(defaultTabOrder);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const unsubTabs = onSnapshot(collection(db, 'sys_tabs'), (snap) => {
|
|
48
|
+
const overrides: Record<string, any> = {};
|
|
49
|
+
snap.forEach(d => { overrides[d.id] = d.data(); });
|
|
50
|
+
setTabOverrides(overrides);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const unsubOrder = onSnapshot(doc(db, 'sys_configs', 'tab_order'), (d) => {
|
|
54
|
+
setTabOrder(
|
|
55
|
+
d.exists() && Array.isArray(d.data().order)
|
|
56
|
+
? parseOrderItems(d.data().order)
|
|
57
|
+
: defaultTabOrder
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return () => { unsubTabs(); unsubOrder(); };
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
function handleClickOutside(e: MouseEvent) {
|
|
66
|
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
67
|
+
setMoreOpen(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
71
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
// Build the ordered, visibility-filtered tab list
|
|
75
|
+
const buildTabs = (): TabItem[] => {
|
|
76
|
+
// Build a map of all known tab definitions
|
|
77
|
+
const tabMap: Record<string, TabItem> = {};
|
|
78
|
+
|
|
79
|
+
// 1. Start from system defaults
|
|
80
|
+
Object.entries(systemTabDefaults).forEach(([id, def]) => {
|
|
81
|
+
tabMap[id] = { id, ...def, exact: true };
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 2. Merge Firestore overrides (label / icon / roles — not visibility)
|
|
85
|
+
Object.entries(tabOverrides).forEach(([id, override]) => {
|
|
86
|
+
const existing = tabMap[id];
|
|
87
|
+
|
|
88
|
+
let icon = existing?.icon ?? Layers;
|
|
89
|
+
if (override?.iconName) {
|
|
90
|
+
const cleanName = override.iconName.trim();
|
|
91
|
+
const cap = cleanName.charAt(0).toUpperCase() + cleanName.slice(1);
|
|
92
|
+
if ((IconoirIcons as any)[cap]) icon = (IconoirIcons as any)[cap];
|
|
93
|
+
else if ((IconoirIcons as any)[cleanName]) icon = (IconoirIcons as any)[cleanName];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let roles = existing?.roles ?? ['admin'];
|
|
97
|
+
if (override?.pageType === 'admin') roles = ['admin'];
|
|
98
|
+
else if (override?.pageType === 'shared') roles = ['admin', 'editor', 'user'];
|
|
99
|
+
else if (override?.pageType === 'private') roles = ['user'];
|
|
100
|
+
else if (override?.pageType === 'public') roles = ['admin', 'user', 'editor', 'guest'];
|
|
101
|
+
|
|
102
|
+
const path = override?.route
|
|
103
|
+
? (override.route.startsWith('/') ? override.route : '/' + override.route)
|
|
104
|
+
: existing?.path ?? '/' + id;
|
|
105
|
+
|
|
106
|
+
const label = override?.tabName || override?.pageTitle || override?.pageName || existing?.label || id;
|
|
107
|
+
|
|
108
|
+
tabMap[id] = { id, path, label, icon, roles, exact: true };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 3. Iterate tabOrder — include only non-hidden items
|
|
112
|
+
const ordered: TabItem[] = [];
|
|
113
|
+
const placed = new Set<string>();
|
|
114
|
+
|
|
115
|
+
tabOrder.forEach(orderItem => {
|
|
116
|
+
if (orderItem.hidden) return; // hidden → skip
|
|
117
|
+
if (tabMap[orderItem.id]) {
|
|
118
|
+
ordered.push(tabMap[orderItem.id]);
|
|
119
|
+
placed.add(orderItem.id);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 4. Insert custom/new tabs at the beginning (before 'pages') — admin/shared tabs show first
|
|
124
|
+
const newTabs: TabItem[] = [];
|
|
125
|
+
Object.keys(tabOverrides).forEach(id => {
|
|
126
|
+
if (!placed.has(id) && !tabOrder.some(o => o.id === id) && tabMap[id]) {
|
|
127
|
+
newTabs.push(tabMap[id]);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
if (newTabs.length > 0) {
|
|
131
|
+
ordered.unshift(...newTabs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return ordered;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const allSystemTabs = buildTabs();
|
|
138
|
+
|
|
139
|
+
// Append prop-passed custom tabs (always visible)
|
|
140
|
+
const allTabs = [
|
|
141
|
+
...allSystemTabs,
|
|
142
|
+
...customTabs.map(t => ({ ...t, id: t.path })),
|
|
143
|
+
].filter(t => {
|
|
144
|
+
return t.roles.includes(userRole || 'guest');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── Responsive overflow: measure real tab widths via hidden row ────────────
|
|
148
|
+
const navRef = useRef<HTMLDivElement>(null);
|
|
149
|
+
const measureRef = useRef<HTMLDivElement>(null);
|
|
150
|
+
const [maxVisible, setMaxVisible] = useState(allTabs.length);
|
|
151
|
+
// Stable key to force re-measure when tabs change
|
|
152
|
+
const tabKey = allTabs.map(t => t.id + t.label).join('|');
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const measure = () => {
|
|
156
|
+
const nav = navRef.current;
|
|
157
|
+
const measureRow = measureRef.current;
|
|
158
|
+
if (!nav || !measureRow) return;
|
|
159
|
+
|
|
160
|
+
const containerWidth = nav.clientWidth;
|
|
161
|
+
const moreButtonWidth = 52;
|
|
162
|
+
const gap = 40; // gap-10 = 2.5rem ≈ 40px
|
|
163
|
+
const tabEls = measureRow.querySelectorAll<HTMLElement>('[data-measure-tab]');
|
|
164
|
+
if (tabEls.length === 0) { setMaxVisible(allTabs.length); return; }
|
|
165
|
+
|
|
166
|
+
// Check if ALL tabs fit (no overflow button needed)
|
|
167
|
+
let checkWidth = 0;
|
|
168
|
+
let allFit = true;
|
|
169
|
+
for (let i = 0; i < tabEls.length; i++) {
|
|
170
|
+
checkWidth += tabEls[i].offsetWidth + (i > 0 ? gap : 0);
|
|
171
|
+
if (checkWidth > containerWidth) { allFit = false; break; }
|
|
172
|
+
}
|
|
173
|
+
if (allFit) { setMaxVisible(allTabs.length); return; }
|
|
174
|
+
|
|
175
|
+
// Not all fit — find how many fit WITH the more button
|
|
176
|
+
let totalWidth = 0;
|
|
177
|
+
let fitCount = 0;
|
|
178
|
+
for (let i = 0; i < tabEls.length; i++) {
|
|
179
|
+
const tabWidth = tabEls[i].offsetWidth;
|
|
180
|
+
const neededWidth = totalWidth + tabWidth + (i > 0 ? gap : 0);
|
|
181
|
+
if (neededWidth + moreButtonWidth > containerWidth) break;
|
|
182
|
+
totalWidth = neededWidth;
|
|
183
|
+
fitCount++;
|
|
184
|
+
}
|
|
185
|
+
setMaxVisible(Math.max(fitCount, 1));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const raf = requestAnimationFrame(measure);
|
|
189
|
+
if (typeof ResizeObserver === 'undefined') return () => cancelAnimationFrame(raf);
|
|
190
|
+
const ro = new ResizeObserver(() => requestAnimationFrame(measure));
|
|
191
|
+
if (navRef.current) ro.observe(navRef.current);
|
|
192
|
+
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
|
|
193
|
+
}, [tabKey, allTabs.length]);
|
|
194
|
+
|
|
195
|
+
const visibleTabs = allTabs.slice(0, maxVisible);
|
|
196
|
+
const hiddenTabs = allTabs.slice(maxVisible);
|
|
197
|
+
|
|
198
|
+
const isActive = (tab: TabItem) =>
|
|
199
|
+
tab.exact ? pathname === tab.path : pathname.startsWith(tab.path);
|
|
200
|
+
|
|
201
|
+
const isMoreActive = hiddenTabs.some(t => isActive(t));
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<>
|
|
205
|
+
{/* Hidden measurement row — renders all tabs off-screen for width calculation */}
|
|
206
|
+
<div
|
|
207
|
+
ref={measureRef}
|
|
208
|
+
aria-hidden="true"
|
|
209
|
+
className="flex items-center gap-6 md:gap-10 whitespace-nowrap px-1"
|
|
210
|
+
style={{ position: 'fixed', top: -9999, left: 0, right: 0, visibility: 'hidden', height: 0, overflow: 'hidden', pointerEvents: 'none' }}
|
|
211
|
+
>
|
|
212
|
+
{allTabs.map(tab => {
|
|
213
|
+
const Icon = tab.icon;
|
|
214
|
+
return (
|
|
215
|
+
<span key={tab.path || tab.id} data-measure-tab className="flex items-center gap-2 text-[14.5px] font-semibold shrink-0 pb-3">
|
|
216
|
+
<Icon className="w-[18px] h-[18px]" />
|
|
217
|
+
{tab.label}
|
|
218
|
+
</span>
|
|
219
|
+
);
|
|
220
|
+
})}
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{/* Visible nav */}
|
|
224
|
+
<div ref={navRef} className="w-full flex items-center gap-6 md:gap-10 mb-8 border-b border-[var(--panel-border)] px-1 whitespace-nowrap relative">
|
|
225
|
+
{visibleTabs.map(tab => {
|
|
226
|
+
const active = isActive(tab);
|
|
227
|
+
const Icon = tab.icon;
|
|
228
|
+
return (
|
|
229
|
+
<Link
|
|
230
|
+
key={tab.path || tab.id}
|
|
231
|
+
to={tab.path || '/'}
|
|
232
|
+
className={`flex items-center gap-2 text-[14.5px] font-semibold transition-all duration-300 pb-3 -mb-[1px] border-b-2 shrink-0 ${active ? 'text-accent border-accent' : 'text-foreground/50 hover:text-foreground/80 border-transparent'}`}
|
|
233
|
+
>
|
|
234
|
+
<Icon className="w-[18px] h-[18px]" />
|
|
235
|
+
{tab.label}
|
|
236
|
+
</Link>
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
239
|
+
|
|
240
|
+
{hiddenTabs.length > 0 && (
|
|
241
|
+
<div className="relative shrink-0 ml-auto" ref={dropdownRef}>
|
|
242
|
+
<button
|
|
243
|
+
onClick={() => setMoreOpen(!moreOpen)}
|
|
244
|
+
className={`flex items-center justify-center transition-all duration-300 pb-3 -mb-[1px] border-b-2 cursor-pointer ${isMoreActive ? 'text-accent border-accent' : 'text-foreground/50 hover:text-foreground/80 border-transparent'}`}
|
|
245
|
+
title="More items"
|
|
246
|
+
>
|
|
247
|
+
<MoreVertical className="w-5 h-5" />
|
|
248
|
+
</button>
|
|
249
|
+
|
|
250
|
+
<AnimatePresence>
|
|
251
|
+
{moreOpen && (
|
|
252
|
+
<motion.div
|
|
253
|
+
initial={{ opacity: 0, y: 10 }}
|
|
254
|
+
animate={{ opacity: 1, y: 0 }}
|
|
255
|
+
exit={{ opacity: 0, y: 10 }}
|
|
256
|
+
className="absolute top-[100%] right-0 mt-2 min-w-[200px] bg-background/95 backdrop-blur-xl border border-[var(--panel-border)] rounded-2xl shadow-xl z-50 flex flex-col py-2 overflow-hidden"
|
|
257
|
+
>
|
|
258
|
+
{hiddenTabs.map(tab => {
|
|
259
|
+
const active = isActive(tab);
|
|
260
|
+
const Icon = tab.icon;
|
|
261
|
+
return (
|
|
262
|
+
<Link
|
|
263
|
+
key={tab.path || tab.id}
|
|
264
|
+
to={tab.path || '/'}
|
|
265
|
+
onClick={() => setMoreOpen(false)}
|
|
266
|
+
className={`flex items-center gap-3 px-5 py-3 text-[14px] font-medium transition-colors ${active ? 'text-accent bg-accent/5' : 'text-foreground/70 hover:text-foreground hover:bg-foreground/5'}`}
|
|
267
|
+
>
|
|
268
|
+
<Icon className="w-4 h-4" />
|
|
269
|
+
{tab.label}
|
|
270
|
+
</Link>
|
|
271
|
+
);
|
|
272
|
+
})}
|
|
273
|
+
</motion.div>
|
|
274
|
+
)}
|
|
275
|
+
</AnimatePresence>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { Input } from '../components/Input';
|
|
4
|
+
|
|
5
|
+
describe('Input Component', () => {
|
|
6
|
+
it('renders correctly with default props', () => {
|
|
7
|
+
render(<Input placeholder="Test Input" />);
|
|
8
|
+
const inputElement = screen.getByPlaceholderText('Test Input');
|
|
9
|
+
expect(inputElement).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('handles onChange events', () => {
|
|
13
|
+
const handleChange = vi.fn();
|
|
14
|
+
render(<Input placeholder="Type here" onChange={handleChange} />);
|
|
15
|
+
const inputElement = screen.getByPlaceholderText('Type here');
|
|
16
|
+
|
|
17
|
+
fireEvent.change(inputElement, { target: { value: 'hello' } });
|
|
18
|
+
expect(handleChange).toHaveBeenCalled();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders with label if provided', () => {
|
|
22
|
+
render(<Input label="Username" id="username-id" />);
|
|
23
|
+
const labelElement = screen.getByText('Username');
|
|
24
|
+
expect(labelElement).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('displays error message when error prop is passed', () => {
|
|
28
|
+
render(<Input error="This field is required" />);
|
|
29
|
+
const errorElement = screen.getByText('This field is required');
|
|
30
|
+
expect(errorElement).toBeInTheDocument();
|
|
31
|
+
expect(errorElement).toHaveClass('text-red-500');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
4
|
+
import { twMerge } from 'tailwind-merge';
|
|
5
|
+
|
|
6
|
+
function cn(...inputs: ClassValue[]) {
|
|
7
|
+
return twMerge(clsx(inputs));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
11
|
+
label?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
currencySymbol?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
17
|
+
({ className, label, error, currencySymbol, type, ...props }, ref) => {
|
|
18
|
+
const isCurrency = type === 'currency';
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex flex-col w-full relative">
|
|
21
|
+
{label && (
|
|
22
|
+
<label className="text-[13px] font-medium text-foreground/50 select-none mb-1.5 ml-1">
|
|
23
|
+
{label}
|
|
24
|
+
</label>
|
|
25
|
+
)}
|
|
26
|
+
<div className="relative flex items-center">
|
|
27
|
+
{isCurrency && currencySymbol && (
|
|
28
|
+
<span className="absolute left-4 font-extrabold text-foreground z-10 pointer-events-none text-[15px]">
|
|
29
|
+
{currencySymbol}
|
|
30
|
+
</span>
|
|
31
|
+
)}
|
|
32
|
+
<input
|
|
33
|
+
ref={ref}
|
|
34
|
+
type={isCurrency ? 'number' : type}
|
|
35
|
+
className={cn(
|
|
36
|
+
'block h-12 w-full text-[15px] text-foreground py-2',
|
|
37
|
+
isCurrency && currencySymbol ? 'pl-8 pr-4' : 'px-4',
|
|
38
|
+
'transition-all duration-300 placeholder:text-foreground/40',
|
|
39
|
+
'focus:outline-none focus:border-accent glow-focus',
|
|
40
|
+
'disabled:cursor-not-allowed disabled:opacity-50 glass-panel',
|
|
41
|
+
error && 'border-red-500/50 focus:border-red-500/50',
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
style={{ borderRadius: 'var(--input-radius, 0.75rem)' }}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
{error && (
|
|
49
|
+
<motion.p
|
|
50
|
+
initial={{ opacity: 0, y: -5 }}
|
|
51
|
+
animate={{ opacity: 1, y: 0 }}
|
|
52
|
+
className="text-xs text-red-500 font-medium"
|
|
53
|
+
>
|
|
54
|
+
{error}
|
|
55
|
+
</motion.p>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
Input.displayName = 'Input';
|