Compare commits

...

2 Commits

Author SHA1 Message Date
hsiegeln
f359a2ba3d 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>
2026-03-28 16:28:49 +01:00
hsiegeln
384ee97643 chore: npm audit fix
All checks were successful
Build & Publish / publish (push) Successful in 1m39s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:33:37 +01:00
6 changed files with 64 additions and 31 deletions

24
package-lock.json generated
View File

@@ -1918,9 +1918,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/language-core/node_modules/brace-expansion": { "node_modules/@vue/language-core/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2099,9 +2099,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.4", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2520,9 +2520,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/happy-dom": { "node_modules/happy-dom": {
"version": "20.8.4", "version": "20.8.9",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==", "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2882,9 +2882,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

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