feat: Add vertical tab layout to Coupon form
Implemented VerticalTabForm component for better UX Created Components: 1. VerticalTabForm.tsx - Reusable vertical tab layout - Left sidebar with navigation (250px on desktop) - Right content area (scrollable) - Scroll spy - auto-highlights active section - Click to scroll to section - Smooth scrolling behavior - Icons support for tabs 2. FormSection component - Wrapper for form sections - Proper ref forwarding - Section ID tracking Updated CouponForm: - Added vertical tab navigation - 3 sections: General, Usage restrictions, Usage limits - Icons: Settings, ShieldCheck, BarChart3 - Narrower content area (better readability) - Desktop-only (lg:block) - mobile keeps original layout Features: ✅ Scroll spy - active tab follows scroll ✅ Click navigation - smooth scroll to section ✅ Visual hierarchy - clear section separation ✅ Better space utilization ✅ Reduced form width for readability ✅ Professional UI like Shopify/Stripe Layout: - Desktop: 250px sidebar + remaining content - Content: max-h-[calc(100vh-200px)] scrollable - Sticky sidebar (top-4) - Active state: bg-primary text-primary-foreground - Hover state: bg-muted hover:text-foreground Next: Apply same pattern to Products form
This commit is contained in:
132
admin-spa/src/components/VerticalTabForm.tsx
Normal file
132
admin-spa/src/components/VerticalTabForm.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface VerticalTab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface VerticalTabFormProps {
|
||||
tabs: VerticalTab[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VerticalTabForm({ tabs, children, className }: VerticalTabFormProps) {
|
||||
const [activeTab, setActiveTab] = useState(tabs[0]?.id || '');
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const sectionRefs = useRef<{ [key: string]: HTMLElement }>({});
|
||||
|
||||
// Scroll spy - update active tab based on scroll position
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
const scrollPosition = contentRef.current.scrollTop + 100; // Offset for better UX
|
||||
|
||||
// Find which section is currently in view
|
||||
for (const tab of tabs) {
|
||||
const section = sectionRefs.current[tab.id];
|
||||
if (section) {
|
||||
const { offsetTop, offsetHeight } = section;
|
||||
if (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
|
||||
setActiveTab(tab.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const content = contentRef.current;
|
||||
if (content) {
|
||||
content.addEventListener('scroll', handleScroll);
|
||||
return () => content.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
}, [tabs]);
|
||||
|
||||
// Register section refs
|
||||
const registerSection = (id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
sectionRefs.current[id] = element;
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll to section
|
||||
const scrollToSection = (id: string) => {
|
||||
const section = sectionRefs.current[id];
|
||||
if (section && contentRef.current) {
|
||||
const offsetTop = section.offsetTop - 20; // Small offset from top
|
||||
contentRef.current.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
setActiveTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-6', className)}>
|
||||
{/* Vertical Tabs Sidebar */}
|
||||
<div className="hidden lg:block w-56 flex-shrink-0">
|
||||
<div className="sticky top-4 space-y-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => scrollToSection(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors',
|
||||
'flex items-center gap-3',
|
||||
activeTab === tab.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.icon && <span className="w-4 h-4">{tab.icon}</span>}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex-1 overflow-y-auto max-h-[calc(100vh-200px)] pr-2"
|
||||
>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child) && child.props['data-section-id']) {
|
||||
const sectionId = child.props['data-section-id'];
|
||||
return React.cloneElement(child as React.ReactElement<any>, {
|
||||
ref: (el: HTMLElement) => registerSection(sectionId, el),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Section wrapper component for easier usage
|
||||
interface SectionProps {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FormSection = React.forwardRef<HTMLDivElement, SectionProps>(
|
||||
({ id, children, className }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-section-id={id}
|
||||
className={cn('mb-6 scroll-mt-4', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FormSection.displayName = 'FormSection';
|
||||
Reference in New Issue
Block a user