Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58320b9762 | ||
|
|
c48dffaef2 | ||
|
|
3ef4c5686e | ||
|
|
78e28789a5 | ||
|
|
03ec34bb5c | ||
|
|
2f1df869db | ||
|
|
0cf696cded |
@@ -114,8 +114,11 @@ Sidebar compound API:
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
|
||||
The app controls all content — sections, order, tree data, collapse state.
|
||||
Sidebar provides the frame, search input, and icon-rail collapse mode.
|
||||
Notes:
|
||||
- Search input auto-renders between Header and first Section (not above Header)
|
||||
- Section headers have no chevron — the entire row is clickable to toggle
|
||||
- The app controls all content — sections, order, tree data, collapse state
|
||||
- Sidebar provides the frame, search input, and icon-rail collapse mode
|
||||
```
|
||||
|
||||
### Data page pattern
|
||||
@@ -243,7 +246,7 @@ import {
|
||||
| Checkbox | primitive | Boolean input with label |
|
||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||
| Collapsible | primitive | Single expand/collapse section |
|
||||
| CommandPalette | composite | Full-screen search and command interface |
|
||||
| CommandPalette | composite | Full-screen search and command interface. `SearchCategory` is an open `string` type — known categories (application, exchange, attribute, route, agent) have built-in labels; custom categories render with title-cased labels and appear as dynamic tabs. |
|
||||
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||
| DateRangePicker | primitive | Date range selection with presets |
|
||||
|
||||
@@ -73,6 +73,7 @@ The outer shell. Renders the sidebar frame with an optional search input and col
|
||||
- Width transition: `transition: width 200ms ease`
|
||||
- Collapse toggle button (`<<` / `>>` chevron) in top-right corner
|
||||
- Search input hidden when collapsed
|
||||
- Search input auto-positioned between `Sidebar.Header` and first `Sidebar.Section` (not above Header)
|
||||
|
||||
### `<Sidebar.Header>`
|
||||
|
||||
@@ -119,13 +120,13 @@ An accordion section with a collapsible header and content area.
|
||||
|
||||
**Expanded rendering:**
|
||||
```
|
||||
v [icon] APPLICATIONS
|
||||
[icon] APPLICATIONS
|
||||
(children rendered here)
|
||||
```
|
||||
|
||||
**Collapsed rendering:**
|
||||
```
|
||||
> [icon] APPLICATIONS
|
||||
[icon] APPLICATIONS
|
||||
```
|
||||
|
||||
**In sidebar icon-rail mode:**
|
||||
@@ -133,7 +134,7 @@ v [icon] APPLICATIONS
|
||||
[icon] <- centered, tooltip shows label on hover
|
||||
```
|
||||
|
||||
Header has: chevron (left), icon, label. Chevron rotates on collapse/expand. Active section gets the amber left-border accent (existing pattern). Clicking the header calls `onToggle`.
|
||||
Header has: icon and label (no chevron — the entire row is clickable). Active section gets the amber left-border accent (existing pattern). Clicking anywhere on the header row calls `onToggle`.
|
||||
|
||||
**Implementation detail:** `Sidebar.Section` and `Sidebar.Header` need to know the parent's `collapsed` state to switch between expanded and icon-rail rendering. The `<Sidebar>` component provides `collapsed` and `onCollapseToggle` via React context (`SidebarContext`). Sub-components read from context — no prop drilling needed.
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ interface CommandPaletteProps {
|
||||
onSubmit?: (query: string) => void
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||
all: 'All',
|
||||
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
|
||||
application: 'Applications',
|
||||
exchange: 'Exchanges',
|
||||
attribute: 'Attributes',
|
||||
@@ -28,8 +27,8 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||
agent: 'Agents',
|
||||
}
|
||||
|
||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||
'all',
|
||||
/** Preferred display order for known categories */
|
||||
const KNOWN_CATEGORY_ORDER: string[] = [
|
||||
'application',
|
||||
'exchange',
|
||||
'attribute',
|
||||
@@ -37,6 +36,13 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||
'agent',
|
||||
]
|
||||
|
||||
function categoryLabel(cat: string): string {
|
||||
if (cat === 'all') return 'All'
|
||||
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
|
||||
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
|
||||
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
||||
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
||||
|
||||
@@ -69,7 +75,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
||||
|
||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
@@ -128,7 +134,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
|
||||
// Group results by category
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<SearchCategory, SearchResult[]>()
|
||||
const map = new Map<string, SearchResult[]>()
|
||||
for (const r of filtered) {
|
||||
if (!map.has(r.category)) map.set(r.category, [])
|
||||
map.get(r.category)!.push(r)
|
||||
@@ -148,6 +154,19 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
return counts
|
||||
}, [queryFiltered])
|
||||
|
||||
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
|
||||
const visibleCategories = useMemo(() => {
|
||||
const dataCategories = new Set(data.map((r) => r.category))
|
||||
const tabs: string[] = ['all']
|
||||
for (const cat of KNOWN_CATEGORY_ORDER) {
|
||||
if (dataCategories.has(cat)) tabs.push(cat)
|
||||
}
|
||||
for (const cat of dataCategories) {
|
||||
if (!tabs.includes(cat)) tabs.push(cat)
|
||||
}
|
||||
return tabs
|
||||
}, [data])
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
@@ -246,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className={styles.tabs} role="tablist">
|
||||
{ALL_CATEGORIES.map((cat) => (
|
||||
{visibleCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
role="tab"
|
||||
@@ -262,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
setFocusedIdx(0)
|
||||
}}
|
||||
>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
{categoryLabel(cat)}
|
||||
{categoryCounts[cat] != null && (
|
||||
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
||||
)}
|
||||
@@ -283,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
Array.from(grouped.entries()).map(([category, items]) => (
|
||||
<div key={category} className={styles.group}>
|
||||
<div className={styles.groupHeader}>
|
||||
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
|
||||
<SectionHeader>{categoryLabel(category)}</SectionHeader>
|
||||
</div>
|
||||
{items.map((result) => {
|
||||
const flatIdx = flatResults.indexOf(result)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent'
|
||||
/** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
|
||||
export type SearchCategory = string
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactNode, Children, isValidElement } from 'react'
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import styles from './Sidebar.module.css'
|
||||
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
||||
@@ -124,9 +122,6 @@ function SidebarSection({
|
||||
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }}
|
||||
>
|
||||
<span className={styles.treeSectionChevronBtn} aria-hidden="true">
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
||||
<span className={styles.treeSectionLabel}>{label}</span>
|
||||
</div>
|
||||
@@ -199,35 +194,50 @@ function SidebarRoot({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search (only when expanded and handler provided) */}
|
||||
{onSearchChange && !collapsed && (
|
||||
<div className={styles.searchWrap}>
|
||||
<div className={styles.searchInner}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<Search size={12} />
|
||||
</span>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchClear}
|
||||
onClick={() => onSearchChange('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Render Header first, then search, then remaining children */}
|
||||
{(() => {
|
||||
const childArray = Children.toArray(children)
|
||||
const headerIdx = childArray.findIndex(
|
||||
(child) => isValidElement(child) && child.type === SidebarHeader,
|
||||
)
|
||||
const header = headerIdx >= 0 ? childArray[headerIdx] : null
|
||||
const rest = headerIdx >= 0
|
||||
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
|
||||
: childArray
|
||||
|
||||
{children}
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{onSearchChange && !collapsed && (
|
||||
<div className={styles.searchWrap}>
|
||||
<div className={styles.searchInner}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">
|
||||
<Search size={12} />
|
||||
</span>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.searchClear}
|
||||
onClick={() => onSearchChange('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{rest}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</aside>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
|
||||
@@ -90,7 +90,7 @@ export function TopBar({
|
||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||
>
|
||||
<span className={styles.liveDot} />
|
||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
||||
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
||||
interface GlobalFilterContextValue {
|
||||
timeRange: TimeRange
|
||||
setTimeRange: (range: TimeRange) => void
|
||||
refreshTimeRange: () => void
|
||||
statusFilters: Set<ExchangeStatus>
|
||||
toggleStatus: (status: ExchangeStatus) => void
|
||||
clearStatusFilters: () => void
|
||||
@@ -76,6 +77,14 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
return () => clearInterval(id)
|
||||
}, [autoRefresh, timeRange.preset])
|
||||
|
||||
// Recompute time range from preset on demand (for manual refresh in PAUSED mode)
|
||||
const refreshTimeRange = useCallback(() => {
|
||||
if (timeRange.preset) {
|
||||
const { start, end } = computePresetRange(timeRange.preset)
|
||||
setTimeRangeState({ start, end, preset: timeRange.preset })
|
||||
}
|
||||
}, [timeRange.preset])
|
||||
|
||||
const isInTimeRange = useCallback(
|
||||
(timestamp: Date) => {
|
||||
if (timeRange.preset) {
|
||||
@@ -90,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<GlobalFilterContext.Provider
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||
>
|
||||
{children}
|
||||
</GlobalFilterContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user