feat: add Sidebar onNavigate callback and DataTable fillHeight prop
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:
hsiegeln
2026-03-28 16:28:49 +01:00
parent 384ee97643
commit f359a2ba3d
5 changed files with 52 additions and 19 deletions

View File

@@ -12,6 +12,23 @@
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 {
overflow-x: auto;
}
@@ -35,6 +52,9 @@
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
transition: color 0.12s;
position: sticky;
top: 0;
z-index: 1;
}
.th.sortable {

View File

@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
rowAccent,
expandedContent,
flush = false,
fillHeight = false,
onSortChange,
}: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null)
@@ -81,7 +82,7 @@ export function DataTable<T extends { id: string }>({
}))
return (
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
<div className={styles.scroll}>
<table className={styles.table}>
<thead>

View File

@@ -20,6 +20,10 @@ export interface DataTableProps<T extends { id: string }> {
expandedContent?: (row: T) => ReactNode | null
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
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.
* When provided, the component skips client-side sorting — the caller is
* responsible for providing `data` in the desired order. */

View File

@@ -34,6 +34,7 @@ export interface SidebarAgent {
interface SidebarProps {
apps: SidebarApp[]
className?: string
onNavigate?: (path: string) => void
}
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -246,7 +247,7 @@ function StarredGroup({
// ── Sidebar ──────────────────────────────────────────────────────────────────
export function Sidebar({ apps, className }: SidebarProps) {
export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
const [search, setSearch] = useState('')
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-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
})
}
const navigate = useNavigate()
const routerNavigate = useNavigate()
const nav = onNavigate ?? routerNavigate
const location = useLocation()
const { starredIds, isStarred, toggleStar } = useStarred()
@@ -330,7 +332,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
return (
<aside className={`${styles.sidebar} ${className ?? ''}`}>
{/* 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} />
<div>
<span className={styles.brand}>cameleer</span>
@@ -381,10 +383,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/apps')}
onClick={() => nav('/apps')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
>
Applications
</span>
@@ -398,6 +400,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
filterQuery={search}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
onNavigate={onNavigate}
/>
)}
</div>
@@ -415,10 +418,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/agents')}
onClick={() => nav('/agents')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
>
Agents
</span>
@@ -432,6 +435,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
filterQuery={search}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
onNavigate={onNavigate}
/>
)}
</div>
@@ -449,10 +453,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/routes')}
onClick={() => nav('/routes')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
>
Routes
</span>
@@ -466,6 +470,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
filterQuery={search}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
onNavigate={onNavigate}
/>
)}
</div>
@@ -486,7 +491,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
<StarredGroup
label="Applications"
items={starredApps}
onNavigate={navigate}
onNavigate={nav}
onRemove={toggleStar}
/>
)}
@@ -494,7 +499,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
<StarredGroup
label="Routes"
items={starredRoutes}
onNavigate={navigate}
onNavigate={nav}
onRemove={toggleStar}
/>
)}
@@ -502,7 +507,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
<StarredGroup
label="Agents"
items={starredAgents}
onNavigate={navigate}
onNavigate={nav}
onRemove={toggleStar}
/>
)}
@@ -510,7 +515,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
<StarredGroup
label="Routes"
items={starredRouteStats}
onNavigate={navigate}
onNavigate={nav}
onRemove={toggleStar}
/>
)}
@@ -526,10 +531,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
styles.bottomItem,
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/admin')}
onClick={() => nav('/admin')}
role="button"
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>
<div className={styles.itemInfo}>
@@ -541,10 +546,10 @@ export function Sidebar({ apps, className }: SidebarProps) {
styles.bottomItem,
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/api-docs')}
onClick={() => nav('/api-docs')}
role="button"
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>
<div className={styles.itemInfo}>

View File

@@ -34,6 +34,7 @@ export interface SidebarTreeProps {
filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
onNavigate?: (path: string) => void
}
// ── Star icons ───────────────────────────────────────────────────────────────
@@ -134,8 +135,10 @@ export function SidebarTree({
filterQuery,
persistKey,
autoRevealPath,
onNavigate,
}: SidebarTreeProps) {
const navigate = useNavigate()
const routerNavigate = useNavigate()
const navigate = onNavigate ?? routerNavigate
// Expand/collapse state — optionally persisted to sessionStorage
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(