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; 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 {

View File

@@ -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>

View File

@@ -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. */

View File

@@ -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}>

View File

@@ -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>>(