feat: redesign Sidebar with hierarchical trees, starring, and collapsible sections
Replace flat app/route/agent lists with expandable tree navigation. Apps contain their routes and agents hierarchically. Add localStorage- backed starring with composite keys for uniqueness. Persist expand state to sessionStorage across page navigations. Add collapsible section headers, remove button on starred items, and parent app context labels. Create stub pages for /apps/:id, /agents/:id, /admin, /api-docs. Consolidate duplicated sidebar data into shared mock. Widen sidebar from 220px to 260px. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
429
src/design-system/layout/Sidebar/SidebarTree.tsx
Normal file
429
src/design-system/layout/Sidebar/SidebarTree.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styles from './Sidebar.module.css'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SidebarTreeNode {
|
||||
id: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
badge?: string
|
||||
path?: string
|
||||
starrable?: boolean
|
||||
starKey?: string // unique key for starring (defaults to id)
|
||||
children?: SidebarTreeNode[]
|
||||
}
|
||||
|
||||
export interface SidebarTreeProps {
|
||||
nodes: SidebarTreeNode[]
|
||||
selectedPath?: string // current URL path — matches against node.path
|
||||
isStarred: (id: string) => boolean
|
||||
onToggleStar: (id: string) => void
|
||||
className?: string
|
||||
filterQuery?: string
|
||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||
}
|
||||
|
||||
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
||||
|
||||
function StarOutline() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StarFilled() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Persistent expand state ──────────────────────────────────────────────────
|
||||
|
||||
function readExpandState(key: string): Set<string> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key)
|
||||
if (raw) {
|
||||
const arr = JSON.parse(raw)
|
||||
if (Array.isArray(arr)) return new Set(arr)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return new Set()
|
||||
}
|
||||
|
||||
function writeExpandState(key: string, ids: Set<string>): void {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify([...ids]))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Flat node for keyboard nav ───────────────────────────────────────────────
|
||||
|
||||
interface FlatNode {
|
||||
node: SidebarTreeNode
|
||||
depth: number
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
function flattenVisible(
|
||||
nodes: SidebarTreeNode[],
|
||||
expandedIds: Set<string>,
|
||||
depth = 0,
|
||||
parentId: string | null = null,
|
||||
): FlatNode[] {
|
||||
const result: FlatNode[] = []
|
||||
for (const node of nodes) {
|
||||
result.push({ node, depth, parentId })
|
||||
if (node.children && node.children.length > 0 && expandedIds.has(node.id)) {
|
||||
result.push(...flattenVisible(node.children, expandedIds, depth + 1, node.id))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Filter logic ─────────────────────────────────────────────────────────────
|
||||
|
||||
function filterNodes(
|
||||
nodes: SidebarTreeNode[],
|
||||
query: string,
|
||||
): { filtered: SidebarTreeNode[]; matchedParentIds: Set<string> } {
|
||||
if (!query) return { filtered: nodes, matchedParentIds: new Set() }
|
||||
const q = query.toLowerCase()
|
||||
const matchedParentIds = new Set<string>()
|
||||
|
||||
function walk(nodeList: SidebarTreeNode[]): SidebarTreeNode[] {
|
||||
const result: SidebarTreeNode[] = []
|
||||
for (const node of nodeList) {
|
||||
const childResults = node.children ? walk(node.children) : []
|
||||
const selfMatches = node.label.toLowerCase().includes(q)
|
||||
|
||||
if (selfMatches || childResults.length > 0) {
|
||||
if (childResults.length > 0) {
|
||||
matchedParentIds.add(node.id)
|
||||
}
|
||||
result.push({
|
||||
...node,
|
||||
children: childResults.length > 0
|
||||
? childResults
|
||||
: node.children?.filter((c) => c.label.toLowerCase().includes(q)),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return { filtered: walk(nodes), matchedParentIds }
|
||||
}
|
||||
|
||||
// ── SidebarTree ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function SidebarTree({
|
||||
nodes,
|
||||
selectedPath,
|
||||
isStarred,
|
||||
onToggleStar,
|
||||
className,
|
||||
filterQuery,
|
||||
persistKey,
|
||||
}: SidebarTreeProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Expand/collapse state — optionally persisted to sessionStorage
|
||||
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
||||
() => persistKey ? readExpandState(persistKey) : new Set(),
|
||||
)
|
||||
|
||||
// Filter
|
||||
const { filtered, matchedParentIds } = useMemo(
|
||||
() => filterNodes(nodes, filterQuery ?? ''),
|
||||
[nodes, filterQuery],
|
||||
)
|
||||
|
||||
// Effective expanded set: user toggles + auto-expanded from search
|
||||
const expandedSet = useMemo(() => {
|
||||
if (filterQuery) {
|
||||
return new Set([...userExpandedIds, ...matchedParentIds])
|
||||
}
|
||||
return userExpandedIds
|
||||
}, [userExpandedIds, matchedParentIds, filterQuery])
|
||||
|
||||
function handleToggle(id: string) {
|
||||
setUserExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
if (persistKey) writeExpandState(persistKey, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null)
|
||||
const treeRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
const visibleNodes = useMemo(
|
||||
() => flattenVisible(filtered, expandedSet),
|
||||
[filtered, expandedSet],
|
||||
)
|
||||
|
||||
const getFocusedIndex = useCallback(() => {
|
||||
if (focusedId === null) return -1
|
||||
return visibleNodes.findIndex((fn) => fn.node.id === focusedId)
|
||||
}, [focusedId, visibleNodes])
|
||||
|
||||
function focusNode(id: string) {
|
||||
const el = treeRef.current?.querySelector(`[data-nodeid="${CSS.escape(id)}"]`) as HTMLElement | null
|
||||
if (el) {
|
||||
el.focus()
|
||||
} else {
|
||||
setFocusedId(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLUListElement>) => {
|
||||
const currentIndex = getFocusedIndex()
|
||||
const current = visibleNodes[currentIndex]
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
const prev = visibleNodes[currentIndex - 1]
|
||||
if (prev) focusNode(prev.node.id)
|
||||
break
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault()
|
||||
if (!current) break
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (hasChildren) {
|
||||
if (!expandedSet.has(current.node.id)) {
|
||||
handleToggle(current.node.id)
|
||||
} else {
|
||||
const next = visibleNodes[currentIndex + 1]
|
||||
if (next) focusNode(next.node.id)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault()
|
||||
if (!current) break
|
||||
const hasChildren = current.node.children && current.node.children.length > 0
|
||||
if (hasChildren && expandedSet.has(current.node.id)) {
|
||||
handleToggle(current.node.id)
|
||||
} else if (current.parentId !== null) {
|
||||
focusNode(current.parentId)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
if (current?.node.path) {
|
||||
navigate(current.node.path)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Home': {
|
||||
e.preventDefault()
|
||||
if (visibleNodes.length > 0) {
|
||||
focusNode(visibleNodes[0].node.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'End': {
|
||||
e.preventDefault()
|
||||
if (visibleNodes.length > 0) {
|
||||
focusNode(visibleNodes[visibleNodes.length - 1].node.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[visibleNodes, expandedSet, focusedId],
|
||||
)
|
||||
|
||||
return (
|
||||
<ul
|
||||
ref={treeRef}
|
||||
role="tree"
|
||||
className={`${styles.tree} ${className ?? ''}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{filtered.map((node) => (
|
||||
<SidebarTreeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
expandedSet={expandedSet}
|
||||
selectedPath={selectedPath}
|
||||
focusedId={focusedId}
|
||||
isStarred={isStarred}
|
||||
onToggle={handleToggle}
|
||||
onToggleStar={onToggleStar}
|
||||
onFocus={setFocusedId}
|
||||
navigate={navigate}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Row ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SidebarTreeRowProps {
|
||||
node: SidebarTreeNode
|
||||
depth: number
|
||||
expandedSet: Set<string>
|
||||
selectedPath?: string
|
||||
focusedId: string | null
|
||||
isStarred: (id: string) => boolean
|
||||
onToggle: (id: string) => void
|
||||
onToggleStar: (id: string) => void
|
||||
onFocus: (id: string) => void
|
||||
navigate: (path: string) => void
|
||||
}
|
||||
|
||||
function SidebarTreeRow({
|
||||
node,
|
||||
depth,
|
||||
expandedSet,
|
||||
selectedPath,
|
||||
focusedId,
|
||||
isStarred,
|
||||
onToggle,
|
||||
onToggleStar,
|
||||
onFocus,
|
||||
navigate,
|
||||
}: SidebarTreeRowProps) {
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isExpanded = expandedSet.has(node.id)
|
||||
const isSelected = Boolean(node.path && selectedPath === node.path)
|
||||
const isFocused = focusedId === node.id
|
||||
const effectiveStarKey = node.starKey ?? node.id
|
||||
const starred = isStarred(effectiveStarKey)
|
||||
|
||||
function handleRowClick() {
|
||||
if (node.path) {
|
||||
navigate(node.path)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChevronClick(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
onToggle(node.id)
|
||||
}
|
||||
|
||||
function handleStarClick(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
onToggleStar(effectiveStarKey)
|
||||
}
|
||||
|
||||
const rowClass = [
|
||||
styles.treeRow,
|
||||
isSelected ? styles.treeRowActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<li role="none">
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
aria-selected={isSelected}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
data-nodeid={node.id}
|
||||
className={rowClass}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={handleRowClick}
|
||||
onFocus={() => onFocus(node.id)}
|
||||
>
|
||||
{/* Chevron */}
|
||||
<span className={styles.treeChevronSlot}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className={styles.treeChevron}
|
||||
onClick={handleChevronClick}
|
||||
tabIndex={-1}
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
{/* Icon (health dot, arrow, etc.) */}
|
||||
{node.icon && (
|
||||
<span className={styles.treeIcon} aria-hidden="true">
|
||||
{node.icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<span className={styles.treeLabel}>{node.label}</span>
|
||||
|
||||
{/* Badge */}
|
||||
{node.badge && (
|
||||
<span className={styles.treeBadge}>{node.badge}</span>
|
||||
)}
|
||||
|
||||
{/* Star */}
|
||||
{node.starrable && (
|
||||
<button
|
||||
className={`${styles.treeStar} ${starred ? styles.treeStarActive : ''}`}
|
||||
onClick={handleStarClick}
|
||||
tabIndex={-1}
|
||||
aria-label={starred ? 'Remove from starred' : 'Add to starred'}
|
||||
>
|
||||
{starred ? <StarFilled /> : <StarOutline />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{hasChildren && isExpanded && (
|
||||
<ul role="group" className={styles.treeGroup}>
|
||||
{node.children!.map((child) => (
|
||||
<SidebarTreeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expandedSet={expandedSet}
|
||||
selectedPath={selectedPath}
|
||||
focusedId={focusedId}
|
||||
isStarred={isStarred}
|
||||
onToggle={onToggle}
|
||||
onToggleStar={onToggleStar}
|
||||
onFocus={onFocus}
|
||||
navigate={navigate}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user