refactor: decompose TopBar into composable shell with children slot

TopBar was a monolith that hardcoded server-specific controls (status
filters, time range, auto-refresh, search trigger). Extract these into
standalone SearchTrigger and AutoRefreshToggle components. TopBar now
accepts children for the center slot, letting consumers compose their
own controls. Fixes cameleer/cameleer-saas#53.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 17:00:57 +02:00
parent 4a6e6dea96
commit b443fc787e
5 changed files with 61 additions and 64 deletions

View File

@@ -0,0 +1,22 @@
import styles from './TopBar.module.css'
interface AutoRefreshToggleProps {
active: boolean
onChange: (active: boolean) => void
className?: string
}
export function AutoRefreshToggle({ active, onChange, className }: AutoRefreshToggleProps) {
return (
<button
className={`${styles.liveToggle} ${active ? styles.liveToggleActive : ''} ${className ?? ''}`}
onClick={() => onChange(!active)}
type="button"
aria-label={active ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={active ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{active ? 'AUTO' : 'MANUAL'}
</button>
)
}

View File

@@ -0,0 +1,24 @@
import { Search } from 'lucide-react'
import styles from './TopBar.module.css'
interface SearchTriggerProps {
onClick: () => void
className?: string
}
export function SearchTrigger({ onClick, className }: SearchTriggerProps) {
return (
<button
className={`${styles.search} ${className ?? ''}`}
onClick={onClick}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<Search size={13} />
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
)
}

View File

@@ -1,14 +1,9 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { Search, Moon, Sun, Power } from 'lucide-react' import { Moon, Sun, Power } from 'lucide-react'
import styles from './TopBar.module.css' import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb' import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Dropdown } from '../../composites/Dropdown/Dropdown' import { Dropdown } from '../../composites/Dropdown/Dropdown'
import { Avatar } from '../../primitives/Avatar/Avatar' import { Avatar } from '../../primitives/Avatar/Avatar'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider' import { useTheme } from '../../providers/ThemeProvider'
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider' import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider' import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
@@ -20,15 +15,9 @@ interface TopBarProps {
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[] userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
onLogout?: () => void onLogout?: () => void
className?: string className?: string
children?: ReactNode
} }
const STATUS_ITEMS: ButtonGroupItem[] = [
{ value: 'completed', label: 'OK', color: 'var(--success)' },
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ value: 'failed', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]
export function TopBar({ export function TopBar({
breadcrumb, breadcrumb,
environment, environment,
@@ -36,9 +25,8 @@ export function TopBar({
userMenuItems, userMenuItems,
onLogout, onLogout,
className, className,
children,
}: TopBarProps) { }: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const breadcrumbOverride = useBreadcrumbOverride() const breadcrumbOverride = useBreadcrumbOverride()
@@ -47,54 +35,11 @@ export function TopBar({
{/* Left: Breadcrumb */} {/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} /> <Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
{/* Search trigger */} {/* Center: consumer-provided controls */}
<button {children}
className={styles.search}
onClick={() => commandPalette.setOpen(true)}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<Search size={13} />
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
{/* Status filter group */} {/* Right: theme toggle, env badge, user */}
<ButtonGroup
items={STATUS_ITEMS}
value={globalFilters.statusFilters}
onChange={(selected) => {
// Sync with global filter by toggling the diff
const current = globalFilters.statusFilters
for (const v of selected) {
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/>
{/* Time range pills */}
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
<div className={styles.right}> <div className={styles.right}>
<button
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
type="button"
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
</button>
<button <button
className={styles.themeToggle} className={styles.themeToggle}
onClick={toggleTheme} onClick={toggleTheme}

View File

@@ -4,3 +4,5 @@ export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree' export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred' export { useStarred } from './Sidebar/useStarred'
export { TopBar } from './TopBar/TopBar' export { TopBar } from './TopBar/TopBar'
export { SearchTrigger } from './TopBar/SearchTrigger'
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'

View File

@@ -5,6 +5,8 @@ import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/Side
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot' import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
import { Box, Settings, FileText, ChevronRight } from 'lucide-react' import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
import { TopBar } from '../../../design-system/layout/TopBar/TopBar' import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
import { SearchTrigger } from '../../../design-system/layout/TopBar/SearchTrigger'
import { AutoRefreshToggle } from '../../../design-system/layout/TopBar/AutoRefreshToggle'
// ── DemoCard helper ────────────────────────────────────────────────────────── // ── DemoCard helper ──────────────────────────────────────────────────────────
@@ -122,7 +124,7 @@ export function LayoutSection() {
<DemoCard <DemoCard
id="topbar" id="topbar"
title="TopBar" title="TopBar"
description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar." description="Composable top navigation bar. Consumers pass children for the center slot (search, filters, etc.). Shell provides breadcrumb, theme toggle, env badge, and user menu."
> >
<div className={styles.topbarPreview}> <div className={styles.topbarPreview}>
<TopBar <TopBar
@@ -132,9 +134,11 @@ export function LayoutSection() {
{ label: 'order-ingest' }, { label: 'order-ingest' },
]} ]}
environment="production" environment="production"
user={{ name: 'Hendrik' }} user={{ name: 'Hendrik' }}
/> >
<SearchTrigger onClick={() => {}} />
<AutoRefreshToggle active={true} onChange={() => {}} />
</TopBar>
</div> </div>
</DemoCard> </DemoCard>
</section> </section>