50 .tsx components and 6 .ts hook files use React client APIs
(createContext, useState, useEffect, useRef, useMemo, useCallback,
forwardRef, etc.) but lacked the "use client" directive at the top
of the source file. tsc-emitted dist files therefore also lacked it.
Older Next.js / Turbopack versions traversed the import graph and
implicitly treated the imports as client when their consumer was a
client component. Next.js 16's stricter Turbopack rejects this: any
file that touches client-only React APIs must declare "use client"
explicitly, regardless of where it is imported from.
Symptom in consumers (gscSupport build, 2026-05-17):
./templates/limitless-ui/dist/theme/ThemeProvider.js:2:10
You're importing a module that depends on `createContext` into a
React Server Component module. This API is only available in
Client Components. To fix, mark the file (or its parent) with
the `"use client"` directive.
Adding the directive at source-file level cascades through tsc into
the emitted dist/ — verified gscSupport + gscCRM build cleanly after
this change.
Affected files (50 total):
src/theme/ThemeProvider.tsx
src/hooks/useDisclosure.ts
src/components/{Accordion,Carousel,DualListBox,Form,Wizard,…}.tsx
src/validation/hooks/{useValidation,useFieldValidation,…}.ts
src/genui/hooks/{useGenUI,useWebMCP}.ts
... and more — every component that touches createContext / a
React hook now self-declares as client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
322 lines
9.9 KiB
TypeScript
322 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from 'react';
|
|
|
|
export type SidebarNavItem = {
|
|
type?: 'link' | 'header' | 'divider' | 'submenu';
|
|
label?: React.ReactNode;
|
|
href?: string;
|
|
icon?: React.ReactNode;
|
|
iconClass?: string;
|
|
badge?: React.ReactNode;
|
|
active?: boolean;
|
|
disabled?: boolean;
|
|
children?: SidebarNavItem[];
|
|
onClick?: () => void;
|
|
/** Whether submenu is open (controlled externally) */
|
|
isOpen?: boolean;
|
|
/** Callback to toggle submenu open state */
|
|
onToggle?: () => void;
|
|
/** Custom link component (e.g., NavLink from Remix) */
|
|
LinkComponent?: React.ComponentType<any>;
|
|
};
|
|
|
|
export type SidebarUserInfo = {
|
|
name: string;
|
|
subtitle?: string;
|
|
avatar?: string;
|
|
menuItems?: SidebarNavItem[];
|
|
};
|
|
|
|
export type SidebarProps = {
|
|
/** Sidebar variant: 'main' | 'secondary' | 'right' */
|
|
variant?: 'main' | 'secondary' | 'right';
|
|
/** Light or dark color scheme */
|
|
color?: 'light' | 'dark';
|
|
/** User info block (Layout 3 material style) */
|
|
user?: SidebarUserInfo;
|
|
/** Optional header content at top of sidebar */
|
|
header?: React.ReactNode;
|
|
/** Navigation items */
|
|
items: SidebarNavItem[];
|
|
/** Additional className */
|
|
className?: string;
|
|
/** Mobile toggler title */
|
|
mobileTitle?: string;
|
|
/** Sidebar expand breakpoint */
|
|
expandBreakpoint?: 'sm' | 'md' | 'lg' | 'xl';
|
|
/** Whether sidebar is collapsed */
|
|
collapsed?: boolean;
|
|
/** Callback when mobile sidebar close is clicked */
|
|
onMobileClose?: () => void;
|
|
/** Custom link component for all nav items (e.g., NavLink from Remix) */
|
|
LinkComponent?: React.ComponentType<any>;
|
|
/** Current pathname for active state detection */
|
|
pathname?: string;
|
|
};
|
|
|
|
/**
|
|
* Layout 3 detached sidebar with nav-sidebar navigation.
|
|
* Supports user menu, headers, dividers, and multi-level submenus.
|
|
*/
|
|
export function Sidebar({
|
|
variant = 'main',
|
|
color = 'light',
|
|
user,
|
|
header,
|
|
items,
|
|
className = '',
|
|
mobileTitle = 'Main sidebar',
|
|
expandBreakpoint = 'lg',
|
|
collapsed = false,
|
|
onMobileClose,
|
|
LinkComponent,
|
|
pathname = '',
|
|
}: SidebarProps) {
|
|
const variantClass = variant === 'main' ? 'sidebar-main' : variant === 'secondary' ? 'sidebar-secondary' : 'sidebar-right';
|
|
const colorClass = color === 'dark' ? 'sidebar-dark' : 'sidebar-light';
|
|
const expandClass = `sidebar-expand-${expandBreakpoint}`;
|
|
|
|
return (
|
|
<div className={`sidebar ${colorClass} ${variantClass} ${expandClass} align-self-start ${className}`.trim()}>
|
|
{/* Sidebar mobile toggler */}
|
|
<div className="sidebar-mobile-toggler text-center">
|
|
<a href="#" className="sidebar-mobile-main-toggle" onClick={(e) => { e.preventDefault(); onMobileClose?.(); }}>
|
|
<i className="ph-arrow-left"></i>
|
|
</a>
|
|
<span className="fw-semibold">{mobileTitle}</span>
|
|
<a href="#" className="sidebar-mobile-expand">
|
|
<i className="ph-arrows-out-simple"></i>
|
|
</a>
|
|
</div>
|
|
|
|
{/* Sidebar content */}
|
|
<div className="sidebar-content">
|
|
{header && <div className="sidebar-section">{header}</div>}
|
|
{/* User menu (material style) */}
|
|
{user && <SidebarUserMenu user={user} />}
|
|
|
|
{/* Navigation */}
|
|
<div className="card card-sidebar-mobile">
|
|
<div className="card-body p-0">
|
|
<ul className="nav nav-sidebar" data-nav-type="accordion">
|
|
{items.map((item, idx) => (
|
|
<SidebarNavNode key={item.href || item.label?.toString() || idx} item={item} collapsed={collapsed} LinkComponent={LinkComponent} pathname={pathname} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarUserMenu({ user }: { user: SidebarUserInfo }) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="sidebar-user-material">
|
|
<div className="sidebar-user-material-body card-img-top">
|
|
<div className="card-body text-center">
|
|
<a href="#">
|
|
{user.avatar ? (
|
|
<img src={user.avatar} className="img-fluid rounded-circle shadow-2 mb-3" width="80" height="80" alt="" />
|
|
) : (
|
|
<div className="rounded-circle bg-secondary d-inline-flex align-items-center justify-content-center mb-3" style={{ width: 80, height: 80 }}>
|
|
<span className="text-white h4 mb-0">{user.name.charAt(0).toUpperCase()}</span>
|
|
</div>
|
|
)}
|
|
</a>
|
|
<h6 className="mb-0 text-white text-shadow-dark">{user.name}</h6>
|
|
{user.subtitle && <span className="font-size-sm text-white text-shadow-dark">{user.subtitle}</span>}
|
|
</div>
|
|
|
|
<div className="sidebar-user-material-footer">
|
|
<a
|
|
href="#user-nav"
|
|
className="d-flex justify-content-between align-items-center text-shadow-dark dropdown-toggle"
|
|
onClick={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
|
|
>
|
|
<span>My account</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`collapse ${isOpen ? 'show' : ''}`} id="user-nav">
|
|
{user.menuItems && (
|
|
<ul className="nav nav-sidebar">
|
|
{user.menuItems.map((item, idx) => (
|
|
<SidebarNavNode key={idx} item={item} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarNavNode({
|
|
item,
|
|
level = 0,
|
|
collapsed = false,
|
|
LinkComponent,
|
|
pathname = '',
|
|
}: {
|
|
item: SidebarNavItem;
|
|
level?: number;
|
|
collapsed?: boolean;
|
|
LinkComponent?: React.ComponentType<any>;
|
|
pathname?: string;
|
|
}) {
|
|
// Check if any child is active (current path matches)
|
|
const hasActiveChild = item.children?.some(child => child.href && pathname.startsWith(child.href)) || false;
|
|
// Use external isOpen if provided, otherwise fall back to local state
|
|
const [localIsOpen, setLocalIsOpen] = useState(hasActiveChild);
|
|
const isOpen = item.isOpen !== undefined ? item.isOpen : localIsOpen;
|
|
|
|
if (item.type === 'header') {
|
|
if (collapsed) return null;
|
|
return (
|
|
<li className="nav-item-header">
|
|
<div className="text-uppercase font-size-xs line-height-xs">{item.label}</div>
|
|
{item.icon && <i className="ph-list" title={String(item.label)}></i>}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
if (item.type === 'divider') {
|
|
return <li className="nav-item-divider"></li>;
|
|
}
|
|
|
|
const hasChildren = item.children && item.children.length > 0;
|
|
const isSubmenu = item.type === 'submenu' || hasChildren;
|
|
|
|
// Determine if item is active based on pathname
|
|
const isItemActive = item.active ?? (item.href
|
|
? pathname.startsWith(item.href)
|
|
: item.children?.some(child => child.href && pathname.startsWith(child.href)) || false);
|
|
|
|
const linkClasses = [
|
|
'nav-link',
|
|
'd-flex',
|
|
'align-items-center',
|
|
isItemActive ? 'active' : '',
|
|
item.disabled ? 'disabled' : '',
|
|
collapsed ? 'justify-content-center' : '',
|
|
].filter(Boolean).join(' ');
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
if (isSubmenu) {
|
|
e.preventDefault();
|
|
if (item.onToggle) {
|
|
item.onToggle();
|
|
} else {
|
|
setLocalIsOpen(!isOpen);
|
|
}
|
|
}
|
|
if (item.onClick) {
|
|
item.onClick();
|
|
}
|
|
};
|
|
|
|
// Render icon
|
|
const renderIcon = () => {
|
|
if (item.icon) {
|
|
return <span className={collapsed ? '' : 'me-2'}>{item.icon}</span>;
|
|
}
|
|
if (item.iconClass) {
|
|
return <i className={`${item.iconClass} ${collapsed ? '' : 'me-2'}`}></i>;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (isSubmenu) {
|
|
// Use button for submenu toggle to avoid anchor navigation issues
|
|
const handleToggle = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (collapsed) {
|
|
if (item.onClick) {
|
|
item.onClick();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (item.onToggle) {
|
|
item.onToggle();
|
|
} else {
|
|
setLocalIsOpen(prev => !prev);
|
|
}
|
|
if (item.onClick) {
|
|
item.onClick();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<li className={`nav-item nav-item-submenu ${isOpen ? 'nav-item-open' : ''}`}>
|
|
<button
|
|
type="button"
|
|
className={`${linkClasses} w-100 text-start border-0 bg-transparent`}
|
|
title={collapsed ? String(item.label) : undefined}
|
|
aria-expanded={isOpen}
|
|
onClick={handleToggle}
|
|
>
|
|
{renderIcon()}
|
|
{!collapsed && (
|
|
<>
|
|
<span className="flex-grow-1">{item.label}</span>
|
|
<i className={`ph-caret-${isOpen ? 'up' : 'down'} ms-auto`}></i>
|
|
</>
|
|
)}
|
|
</button>
|
|
<ul
|
|
className="nav nav-group-sub"
|
|
data-submenu-title={String(item.label)}
|
|
>
|
|
{item.children!.map((child, idx) => (
|
|
<SidebarNavNode
|
|
key={child.href || child.label?.toString() || idx}
|
|
item={child}
|
|
level={level + 1}
|
|
collapsed={collapsed}
|
|
LinkComponent={LinkComponent}
|
|
pathname={pathname}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
// Use custom LinkComponent if provided, otherwise use <a>
|
|
const Link = LinkComponent || 'a';
|
|
const linkProps = LinkComponent
|
|
? {
|
|
to: item.href || '#',
|
|
className: linkClasses,
|
|
title: collapsed ? String(item.label) : undefined,
|
|
onClick: (e: React.MouseEvent) => {
|
|
// Stop propagation to prevent parent handlers from interfering
|
|
e.stopPropagation();
|
|
if (item.onClick) {
|
|
item.onClick();
|
|
}
|
|
// Don't prevent default - let navigation proceed
|
|
},
|
|
}
|
|
: { href: item.href || '#', className: linkClasses, onClick: handleClick, title: collapsed ? String(item.label) : undefined };
|
|
|
|
return (
|
|
<li className="nav-item">
|
|
<Link {...linkProps}>
|
|
{renderIcon()}
|
|
{!collapsed && <span>{item.label}</span>}
|
|
{!collapsed && item.badge && <span className="badge ms-auto align-self-center">{item.badge}</span>}
|
|
</Link>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
// Re-export for backwards compatibility
|
|
export type SidebarItem = SidebarNavItem;
|