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:
22
src/design-system/layout/TopBar/AutoRefreshToggle.tsx
Normal file
22
src/design-system/layout/TopBar/AutoRefreshToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/design-system/layout/TopBar/SearchTrigger.tsx
Normal file
24
src/design-system/layout/TopBar/SearchTrigger.tsx
Normal 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... ⌘K</span>
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
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 { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||
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 { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||
@@ -20,15 +15,9 @@ interface TopBarProps {
|
||||
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
|
||||
onLogout?: () => void
|
||||
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({
|
||||
breadcrumb,
|
||||
environment,
|
||||
@@ -36,9 +25,8 @@ export function TopBar({
|
||||
userMenuItems,
|
||||
onLogout,
|
||||
className,
|
||||
children,
|
||||
}: TopBarProps) {
|
||||
const globalFilters = useGlobalFilters()
|
||||
const commandPalette = useCommandPalette()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const breadcrumbOverride = useBreadcrumbOverride()
|
||||
|
||||
@@ -47,54 +35,11 @@ export function TopBar({
|
||||
{/* Left: Breadcrumb */}
|
||||
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
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... ⌘K</span>
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
</button>
|
||||
{/* Center: consumer-provided controls */}
|
||||
{children}
|
||||
|
||||
{/* Status filter group */}
|
||||
<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 */}
|
||||
{/* Right: theme toggle, env badge, user */}
|
||||
<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
|
||||
className={styles.themeToggle}
|
||||
onClick={toggleTheme}
|
||||
|
||||
@@ -4,3 +4,5 @@ export { SidebarTree } from './Sidebar/SidebarTree'
|
||||
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||
export { useStarred } from './Sidebar/useStarred'
|
||||
export { TopBar } from './TopBar/TopBar'
|
||||
export { SearchTrigger } from './TopBar/SearchTrigger'
|
||||
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/Side
|
||||
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
|
||||
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,7 +124,7 @@ export function LayoutSection() {
|
||||
<DemoCard
|
||||
id="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}>
|
||||
<TopBar
|
||||
@@ -132,9 +134,11 @@ export function LayoutSection() {
|
||||
{ label: 'order-ingest' },
|
||||
]}
|
||||
environment="production"
|
||||
|
||||
user={{ name: 'Hendrik' }}
|
||||
/>
|
||||
>
|
||||
<SearchTrigger onClick={() => {}} />
|
||||
<AutoRefreshToggle active={true} onChange={() => {}} />
|
||||
</TopBar>
|
||||
</div>
|
||||
</DemoCard>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user