feat: add Sidebar onNavigate callback and DataTable fillHeight prop
All checks were successful
Build & Publish / publish (push) Successful in 1m3s
All checks were successful
Build & Publish / publish (push) Successful in 1m3s
Sidebar: add optional onNavigate prop so consuming apps can intercept and remap navigation paths instead of relying on internal React Router links. DataTable: add fillHeight prop for flex-fill layouts with scrolling body. Make the table header sticky by default so it stays visible during vertical scroll. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,23 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fillHeight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -35,6 +52,9 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: color 0.12s;
|
transition: color 0.12s;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable {
|
.th.sortable {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
rowAccent,
|
rowAccent,
|
||||||
expandedContent,
|
expandedContent,
|
||||||
flush = false,
|
flush = false,
|
||||||
|
fillHeight = false,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
@@ -81,7 +82,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
|
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
|
||||||
<div className={styles.scroll}>
|
<div className={styles.scroll}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface DataTableProps<T extends { id: string }> {
|
|||||||
expandedContent?: (row: T) => ReactNode | null
|
expandedContent?: (row: T) => ReactNode | null
|
||||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||||
flush?: boolean
|
flush?: boolean
|
||||||
|
/** Make the table fill remaining vertical space in a flex parent.
|
||||||
|
* The table body scrolls while the header stays sticky and the
|
||||||
|
* pagination footer stays pinned at the bottom. */
|
||||||
|
fillHeight?: boolean
|
||||||
/** Controlled sort: called when the user clicks a sortable column header.
|
/** Controlled sort: called when the user clicks a sortable column header.
|
||||||
* When provided, the component skips client-side sorting — the caller is
|
* When provided, the component skips client-side sorting — the caller is
|
||||||
* responsible for providing `data` in the desired order. */
|
* responsible for providing `data` in the desired order. */
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface SidebarAgent {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
apps: SidebarApp[]
|
apps: SidebarApp[]
|
||||||
className?: string
|
className?: string
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -246,7 +247,7 @@ function StarredGroup({
|
|||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Sidebar({ apps, className }: SidebarProps) {
|
export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||||
@@ -275,7 +276,8 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const nav = onNavigate ?? routerNavigate
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
|
|
||||||
@@ -330,7 +332,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
|
<div className={styles.logo} onClick={() => nav('/apps')} style={{ cursor: 'pointer' }}>
|
||||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
||||||
<div>
|
<div>
|
||||||
<span className={styles.brand}>cameleer</span>
|
<span className={styles.brand}>cameleer</span>
|
||||||
@@ -381,10 +383,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/apps')}
|
onClick={() => nav('/apps')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
|
||||||
>
|
>
|
||||||
Applications
|
Applications
|
||||||
</span>
|
</span>
|
||||||
@@ -398,6 +400,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:apps"
|
persistKey="cameleer:expanded:apps"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -415,10 +418,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/agents')}
|
onClick={() => nav('/agents')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
|
||||||
>
|
>
|
||||||
Agents
|
Agents
|
||||||
</span>
|
</span>
|
||||||
@@ -432,6 +435,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:agents"
|
persistKey="cameleer:expanded:agents"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -449,10 +453,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/routes')}
|
onClick={() => nav('/routes')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
|
||||||
>
|
>
|
||||||
Routes
|
Routes
|
||||||
</span>
|
</span>
|
||||||
@@ -466,6 +470,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:routes"
|
persistKey="cameleer:expanded:routes"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -486,7 +491,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Applications"
|
label="Applications"
|
||||||
items={starredApps}
|
items={starredApps}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -494,7 +499,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Routes"
|
label="Routes"
|
||||||
items={starredRoutes}
|
items={starredRoutes}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -502,7 +507,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Agents"
|
label="Agents"
|
||||||
items={starredAgents}
|
items={starredAgents}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -510,7 +515,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Routes"
|
label="Routes"
|
||||||
items={starredRouteStats}
|
items={starredRouteStats}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -526,10 +531,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => nav('/admin')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/admin') }}
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}><Settings size={14} /></span>
|
<span className={styles.bottomIcon}><Settings size={14} /></span>
|
||||||
<div className={styles.itemInfo}>
|
<div className={styles.itemInfo}>
|
||||||
@@ -541,10 +546,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/api-docs')}
|
onClick={() => nav('/api-docs')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/api-docs') }}
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}><FileText size={14} /></span>
|
<span className={styles.bottomIcon}><FileText size={14} /></span>
|
||||||
<div className={styles.itemInfo}>
|
<div className={styles.itemInfo}>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface SidebarTreeProps {
|
|||||||
filterQuery?: string
|
filterQuery?: string
|
||||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||||
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Star icons ───────────────────────────────────────────────────────────────
|
// ── Star icons ───────────────────────────────────────────────────────────────
|
||||||
@@ -134,8 +135,10 @@ export function SidebarTree({
|
|||||||
filterQuery,
|
filterQuery,
|
||||||
persistKey,
|
persistKey,
|
||||||
autoRevealPath,
|
autoRevealPath,
|
||||||
|
onNavigate,
|
||||||
}: SidebarTreeProps) {
|
}: SidebarTreeProps) {
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const navigate = onNavigate ?? routerNavigate
|
||||||
|
|
||||||
// Expand/collapse state — optionally persisted to sessionStorage
|
// Expand/collapse state — optionally persisted to sessionStorage
|
||||||
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
||||||
|
|||||||
Reference in New Issue
Block a user