Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e572df8558 | ||
|
|
a3afe3cb1b | ||
|
|
4841a7ad7c | ||
|
|
32a49690fa | ||
|
|
20f7b2f5aa | ||
|
|
5cb51e65be | ||
|
|
4dcd4aaa27 | ||
|
|
58320b9762 | ||
|
|
c48dffaef2 | ||
|
|
3ef4c5686e | ||
|
|
78e28789a5 | ||
|
|
03ec34bb5c | ||
|
|
2f1df869db | ||
|
|
0cf696cded | ||
|
|
50a1296a9d | ||
|
|
9b8739b5d8 | ||
|
|
ba6028c2ea | ||
|
|
93776944b9 | ||
|
|
9240acddb6 |
17
CLAUDE.md
@@ -40,6 +40,10 @@ import { Button, Input } from '../design-system/primitives'
|
|||||||
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
|
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
|
||||||
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
|
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
|
||||||
import { AppShell } from '../design-system/layout/AppShell'
|
import { AppShell } from '../design-system/layout/AppShell'
|
||||||
|
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
|
||||||
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -93,6 +97,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
|||||||
// All components from single entry
|
// All components from single entry
|
||||||
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
|
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Sidebar (compound component — compose your own navigation)
|
||||||
|
import { Sidebar, SidebarTree, useStarred } from '@cameleer/design-system'
|
||||||
|
import type { SidebarTreeNode } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
||||||
|
|
||||||
@@ -110,4 +118,13 @@ import type { ChartSeries, DataPoint } from '@cameleer/design-system'
|
|||||||
|
|
||||||
// Styles (once, at app root)
|
// Styles (once, at app root)
|
||||||
import '@cameleer/design-system/style.css'
|
import '@cameleer/design-system/style.css'
|
||||||
|
|
||||||
|
// Brand assets (static files via ./assets/* export)
|
||||||
|
import logo from '@cameleer/design-system/assets/cameleer3-logo.png' // full resolution
|
||||||
|
import logo32 from '@cameleer/design-system/assets/cameleer3-32.png' // 32×32 favicon
|
||||||
|
import logo180 from '@cameleer/design-system/assets/cameleer3-180.png' // Apple touch icon
|
||||||
|
import logo192 from '@cameleer/design-system/assets/cameleer3-192.png' // Android/PWA icon
|
||||||
|
import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA splash, og:image
|
||||||
|
import logoSvg from '@cameleer/design-system/assets/cameleer3-logo.svg' // high-detail SVG logo
|
||||||
|
import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simplified camel SVG
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -38,10 +38,12 @@
|
|||||||
- Removable label → **Tag**
|
- Removable label → **Tag**
|
||||||
|
|
||||||
### "I need navigation"
|
### "I need navigation"
|
||||||
- App-level sidebar nav → **Sidebar** (via AppShell) — hierarchical trees with starring
|
- App-level sidebar nav → **Sidebar** (compound component — compose sections, trees, footer links)
|
||||||
|
- Sidebar tree section → **SidebarTree** (data-driven tree with expand/collapse, starring, keyboard nav)
|
||||||
|
- Starred items persistence → **useStarred** hook (localStorage-backed)
|
||||||
- Breadcrumb trail → **Breadcrumb**
|
- Breadcrumb trail → **Breadcrumb**
|
||||||
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
|
- Paginated data → **Pagination** (standalone) or **DataTable** (built-in pagination)
|
||||||
- Hierarchical tree navigation → **TreeView** (generic) or **SidebarTree** (sidebar-specific, internal)
|
- Hierarchical tree navigation → **TreeView** (generic)
|
||||||
|
|
||||||
### "I need floating content"
|
### "I need floating content"
|
||||||
- Tooltip on hover → **Tooltip**
|
- Tooltip on hover → **Tooltip**
|
||||||
@@ -99,7 +101,24 @@
|
|||||||
|
|
||||||
### Standard page layout
|
### Standard page layout
|
||||||
```
|
```
|
||||||
AppShell → Sidebar + TopBar + main content + optional DetailPanel
|
AppShell → Sidebar (compound) + TopBar + main content + optional DetailPanel
|
||||||
|
|
||||||
|
Sidebar compound API:
|
||||||
|
<Sidebar collapsed={bool} onCollapseToggle={fn} searchValue={str} onSearchChange={fn}>
|
||||||
|
<Sidebar.Header logo={node} title="str" version="str" />
|
||||||
|
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} active={bool}>
|
||||||
|
<SidebarTree nodes={[...]} selectedPath="..." filterQuery="..." ... />
|
||||||
|
</Sidebar.Section>
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<Sidebar.FooterLink icon={node} label="str" onClick={fn} active={bool} />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
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
|
### Data page pattern
|
||||||
@@ -227,7 +246,7 @@ import {
|
|||||||
| Checkbox | primitive | Boolean input with label |
|
| Checkbox | primitive | Boolean input with label |
|
||||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||||
| Collapsible | primitive | Single expand/collapse section |
|
| 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 |
|
| 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 |
|
| 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 |
|
| DateRangePicker | primitive | Date range selection with presets |
|
||||||
@@ -284,8 +303,10 @@ import {
|
|||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
|
||||||
| Sidebar | Hierarchical navigation with Applications/Agents/Routes trees, starring, search filter, bottom links. Props: `apps: SidebarApp[]` (hierarchical — apps contain routes and agents) |
|
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
|
||||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
|
||||||
|
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
|
||||||
|
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
|
||||||
|
|
||||||
## Import Paths
|
## Import Paths
|
||||||
|
|
||||||
@@ -296,6 +317,10 @@ import { Button, Input, Badge } from './design-system/primitives'
|
|||||||
import { DataTable, Modal, Toast } from './design-system/composites'
|
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||||
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||||
import { AppShell } from './design-system/layout/AppShell'
|
import { AppShell } from './design-system/layout/AppShell'
|
||||||
|
import { Sidebar } from './design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { SidebarTree } from './design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import type { SidebarTreeNode } from './design-system/layout/Sidebar/SidebarTree'
|
||||||
|
import { useStarred } from './design-system/layout/Sidebar/useStarred'
|
||||||
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -309,6 +334,35 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
|
|||||||
|
|
||||||
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||||
|
|
||||||
|
## Brand Assets
|
||||||
|
|
||||||
|
The design system ships logo assets as static files via the `./assets/*` package export. These are not React components — they resolve to file URLs when imported via a bundler. All PNGs have transparent backgrounds.
|
||||||
|
|
||||||
|
| File | Size | Use case |
|
||||||
|
|------|------|----------|
|
||||||
|
| `cameleer3-logo.png` | Original | Full resolution for print/marketing |
|
||||||
|
| `cameleer3-16.png` | 16×16 | Browser tab favicon |
|
||||||
|
| `cameleer3-32.png` | 32×32 | Standard favicon, bookmarks |
|
||||||
|
| `cameleer3-48.png` | 48×48 | Windows taskbar |
|
||||||
|
| `cameleer3-180.png` | 180×180 | Apple touch icon |
|
||||||
|
| `cameleer3-192.png` | 192×192 | Android/PWA icon |
|
||||||
|
| `cameleer3-512.png` | 512×512 | PWA splash, og:image |
|
||||||
|
| `cameleer3-logo.svg` | Vector | High-detail SVG logo (traced from PNG, transparent) |
|
||||||
|
| `camel-logo.svg` | Vector | Simplified camel SVG logo |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import logo from '@cameleer/design-system/assets/cameleer3-512.png'
|
||||||
|
<img src={logo} alt="Cameleer3" />
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Favicons in index.html -->
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/cameleer3-32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/cameleer3-180.png">
|
||||||
|
```
|
||||||
|
|
||||||
## Styling Rules
|
## Styling Rules
|
||||||
|
|
||||||
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
||||||
|
|||||||
3
assets/camel-logo.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/cameleer3-16.png
Normal file
|
After Width: | Height: | Size: 941 B |
BIN
assets/cameleer3-180.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/cameleer3-192.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/cameleer3-32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
assets/cameleer3-48.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
assets/cameleer3-512.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
112
assets/cameleer3-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -73,6 +73,7 @@ The outer shell. Renders the sidebar frame with an optional search input and col
|
|||||||
- Width transition: `transition: width 200ms ease`
|
- Width transition: `transition: width 200ms ease`
|
||||||
- Collapse toggle button (`<<` / `>>` chevron) in top-right corner
|
- Collapse toggle button (`<<` / `>>` chevron) in top-right corner
|
||||||
- Search input hidden when collapsed
|
- Search input hidden when collapsed
|
||||||
|
- Search input auto-positioned between `Sidebar.Header` and first `Sidebar.Section` (not above Header)
|
||||||
|
|
||||||
### `<Sidebar.Header>`
|
### `<Sidebar.Header>`
|
||||||
|
|
||||||
@@ -119,13 +120,13 @@ An accordion section with a collapsible header and content area.
|
|||||||
|
|
||||||
**Expanded rendering:**
|
**Expanded rendering:**
|
||||||
```
|
```
|
||||||
v [icon] APPLICATIONS
|
[icon] APPLICATIONS
|
||||||
(children rendered here)
|
(children rendered here)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Collapsed rendering:**
|
**Collapsed rendering:**
|
||||||
```
|
```
|
||||||
> [icon] APPLICATIONS
|
[icon] APPLICATIONS
|
||||||
```
|
```
|
||||||
|
|
||||||
**In sidebar icon-rail mode:**
|
**In sidebar icon-rail mode:**
|
||||||
@@ -133,7 +134,7 @@ v [icon] APPLICATIONS
|
|||||||
[icon] <- centered, tooltip shows label on hover
|
[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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
"types": "./dist/index.es.d.ts",
|
"types": "./dist/index.es.d.ts",
|
||||||
"import": "./dist/index.es.js"
|
"import": "./dist/index.es.js"
|
||||||
},
|
},
|
||||||
"./style.css": "./dist/style.css"
|
"./style.css": "./dist/style.css",
|
||||||
|
"./assets/*": "./assets/*"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"assets"
|
||||||
],
|
],
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
"*.css"
|
"*.css"
|
||||||
|
|||||||
BIN
src/assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
112
src/assets/cameleer3-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -83,6 +83,20 @@ export function BarChart({
|
|||||||
setTooltip({ x: mx, y: my, label: catLabel, values })
|
setTooltip({ x: mx, y: my, label: catLabel, values })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showBarTooltip(e: React.MouseEvent<SVGRectElement>, cat: string) {
|
||||||
|
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
||||||
|
handleMouseEnter(
|
||||||
|
cat,
|
||||||
|
e.clientX - rect.left,
|
||||||
|
e.clientY - rect.top,
|
||||||
|
series.map((ss, ssi) => ({
|
||||||
|
series: ss.label,
|
||||||
|
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
||||||
|
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
{yLabel && <div className={styles.yLabel}>{yLabel}</div>}
|
||||||
@@ -138,19 +152,7 @@ export function BarChart({
|
|||||||
height={barH}
|
height={barH}
|
||||||
fill={color}
|
fill={color}
|
||||||
className={styles.bar}
|
className={styles.bar}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
||||||
const rect = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - rect.left,
|
|
||||||
e.clientY - rect.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -184,19 +186,7 @@ export function BarChart({
|
|||||||
height={barH}
|
height={barH}
|
||||||
fill={color}
|
fill={color}
|
||||||
className={styles.bar}
|
className={styles.bar}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => showBarTooltip(e, cat)}
|
||||||
const svgEl = e.currentTarget.closest('svg')!.getBoundingClientRect()
|
|
||||||
handleMouseEnter(
|
|
||||||
cat,
|
|
||||||
e.clientX - svgEl.left,
|
|
||||||
e.clientY - svgEl.top,
|
|
||||||
series.map((ss, ssi) => ({
|
|
||||||
series: ss.label,
|
|
||||||
value: ss.data.find((d) => d.x === cat)?.y ?? 0,
|
|
||||||
color: ss.color ?? CHART_COLORS[ssi % CHART_COLORS.length],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ interface CommandPaletteProps {
|
|||||||
onSubmit?: (query: string) => void
|
onSubmit?: (query: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
|
||||||
all: 'All',
|
|
||||||
application: 'Applications',
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
attribute: 'Attributes',
|
attribute: 'Attributes',
|
||||||
@@ -28,8 +27,8 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
|||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
/** Preferred display order for known categories */
|
||||||
'all',
|
const KNOWN_CATEGORY_ORDER: string[] = [
|
||||||
'application',
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
'attribute',
|
'attribute',
|
||||||
@@ -37,6 +36,13 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
|||||||
'agent',
|
'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 {
|
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
||||||
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
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) {
|
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
@@ -128,7 +134,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
|
|
||||||
// Group results by category
|
// Group results by category
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<SearchCategory, SearchResult[]>()
|
const map = new Map<string, SearchResult[]>()
|
||||||
for (const r of filtered) {
|
for (const r of filtered) {
|
||||||
if (!map.has(r.category)) map.set(r.category, [])
|
if (!map.has(r.category)) map.set(r.category, [])
|
||||||
map.get(r.category)!.push(r)
|
map.get(r.category)!.push(r)
|
||||||
@@ -148,6 +154,19 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
return counts
|
return counts
|
||||||
}, [queryFiltered])
|
}, [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) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
@@ -186,10 +205,23 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
|
setScopeFilters((prev) => prev.filter((_, i) => i !== idx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(e: React.MouseEvent, id: string) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setExpandedId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={styles.overlay} onClick={onClose} data-testid="command-palette-overlay">
|
<div
|
||||||
|
className={styles.overlay}
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClose() }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Close command palette"
|
||||||
|
data-testid="command-palette-overlay"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={styles.panel}
|
className={styles.panel}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -233,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
|
|
||||||
{/* Category tabs */}
|
{/* Category tabs */}
|
||||||
<div className={styles.tabs} role="tablist">
|
<div className={styles.tabs} role="tablist">
|
||||||
{ALL_CATEGORIES.map((cat) => (
|
{visibleCategories.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -249,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CATEGORY_LABELS[cat]}
|
{categoryLabel(cat)}
|
||||||
{categoryCounts[cat] != null && (
|
{categoryCounts[cat] != null && (
|
||||||
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
||||||
)}
|
)}
|
||||||
@@ -270,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
Array.from(grouped.entries()).map(([category, items]) => (
|
Array.from(grouped.entries()).map(([category, items]) => (
|
||||||
<div key={category} className={styles.group}>
|
<div key={category} className={styles.group}>
|
||||||
<div className={styles.groupHeader}>
|
<div className={styles.groupHeader}>
|
||||||
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
|
<SectionHeader>{categoryLabel(category)}</SectionHeader>
|
||||||
</div>
|
</div>
|
||||||
{items.map((result) => {
|
{items.map((result) => {
|
||||||
const flatIdx = flatResults.indexOf(result)
|
const flatIdx = flatResults.indexOf(result)
|
||||||
@@ -293,6 +325,12 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
onSelect(result)
|
onSelect(result)
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
onSelect(result)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
||||||
>
|
>
|
||||||
<div className={styles.itemMain}>
|
<div className={styles.itemMain}>
|
||||||
@@ -328,10 +366,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
{result.expandedContent && (
|
{result.expandedContent && (
|
||||||
<button
|
<button
|
||||||
className={styles.expandBtn}
|
className={styles.expandBtn}
|
||||||
onClick={(e) => {
|
onClick={(e) => toggleExpanded(e, result.id)}
|
||||||
e.stopPropagation()
|
|
||||||
setExpandedId((prev) => (prev === result.id ? null : result.id))
|
|
||||||
}}
|
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-label="Toggle detail"
|
aria-label="Toggle detail"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
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 {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const dismiss = useCallback((id: string) => {
|
const dismiss = useCallback((id: string) => {
|
||||||
// Clear auto-dismiss timer if running
|
// Clear auto-dismiss timer if running
|
||||||
const timer = timersRef.current.get(id)
|
const timer = timersRef.current.get(id)
|
||||||
@@ -71,10 +75,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Remove after animation completes
|
// Remove after animation completes
|
||||||
setTimeout(() => {
|
setTimeout(() => removeToast(id), EXIT_ANIMATION_MS)
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
}, [removeToast])
|
||||||
}, EXIT_ANIMATION_MS)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toast = useCallback(
|
const toast = useCallback(
|
||||||
(options: ToastOptions): string => {
|
(options: ToastOptions): string => {
|
||||||
|
|||||||
@@ -31,6 +31,52 @@ function flattenVisibleNodes(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||||
|
const next = visibleNodes[currentIndex + 1]
|
||||||
|
if (next) focusNode(next.node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||||
|
const prev = visibleNodes[currentIndex - 1]
|
||||||
|
if (prev) focusNode(prev.node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowRight(
|
||||||
|
current: FlatNode | undefined,
|
||||||
|
currentIndex: number,
|
||||||
|
expandedSet: Set<string>,
|
||||||
|
visibleNodes: FlatNode[],
|
||||||
|
handleToggle: (id: string) => void,
|
||||||
|
focusNode: (id: string) => void,
|
||||||
|
) {
|
||||||
|
if (!current) return
|
||||||
|
const hasChildren = current.node.children && current.node.children.length > 0
|
||||||
|
if (!hasChildren) return
|
||||||
|
if (!expandedSet.has(current.node.id)) {
|
||||||
|
handleToggle(current.node.id)
|
||||||
|
} else {
|
||||||
|
const next = visibleNodes[currentIndex + 1]
|
||||||
|
if (next) focusNode(next.node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowLeft(
|
||||||
|
current: FlatNode | undefined,
|
||||||
|
expandedSet: Set<string>,
|
||||||
|
handleToggle: (id: string) => void,
|
||||||
|
focusNode: (id: string) => void,
|
||||||
|
) {
|
||||||
|
if (!current) return
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface TreeViewProps {
|
interface TreeViewProps {
|
||||||
nodes: TreeNode[]
|
nodes: TreeNode[]
|
||||||
onSelect?: (id: string) => void
|
onSelect?: (id: string) => void
|
||||||
@@ -105,68 +151,13 @@ export function TreeView({
|
|||||||
const current = visibleNodes[currentIndex]
|
const current = visibleNodes[currentIndex]
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
|
||||||
e.preventDefault()
|
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
|
||||||
const next = visibleNodes[currentIndex + 1]
|
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
|
||||||
if (next) focusNode(next.node.id)
|
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
|
||||||
break
|
case 'Enter': { e.preventDefault(); if (current) onSelect?.(current.node.id); break }
|
||||||
}
|
case 'Home': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[0].node.id); break }
|
||||||
case 'ArrowUp': {
|
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
|
||||||
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)) {
|
|
||||||
// Expand it
|
|
||||||
handleToggle(current.node.id)
|
|
||||||
} else {
|
|
||||||
// Move to first child (it will be the next visible node)
|
|
||||||
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)) {
|
|
||||||
// Collapse
|
|
||||||
handleToggle(current.node.id)
|
|
||||||
} else if (current.parentId !== null) {
|
|
||||||
// Move to parent
|
|
||||||
focusNode(current.parentId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'Enter': {
|
|
||||||
e.preventDefault()
|
|
||||||
if (current) {
|
|
||||||
onSelect?.(current.node.id)
|
|
||||||
}
|
|
||||||
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -239,6 +230,10 @@ function TreeNodeRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li role="none">
|
<li role="none">
|
||||||
|
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
|
||||||
|
onKeyDown={handleKeyDown} which handles Enter (select) and all arrow keys
|
||||||
|
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
|
||||||
|
fire the action twice. */}
|
||||||
<div
|
<div
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
|
|||||||
@@ -215,6 +215,8 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0 4px;
|
padding: 8px 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeSectionChevronBtn {
|
.treeSectionChevronBtn {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { type ReactNode, Children, isValidElement } from 'react'
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
ChevronRight,
|
|
||||||
ChevronDown,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
import { SidebarContext, useSidebarContext } from './SidebarContext'
|
||||||
@@ -115,15 +113,15 @@ function SidebarSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
|
<div className={`${styles.treeSection} ${active ? styles.treeSectionActive : ''} ${className ?? ''}`}>
|
||||||
<div className={styles.treeSectionToggle}>
|
<div
|
||||||
<button
|
className={styles.treeSectionToggle}
|
||||||
className={styles.treeSectionChevronBtn}
|
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }}
|
||||||
>
|
>
|
||||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
||||||
</button>
|
|
||||||
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
||||||
<span className={styles.treeSectionLabel}>{label}</span>
|
<span className={styles.treeSectionLabel}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +194,20 @@ function SidebarRoot({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search (only when expanded and handler provided) */}
|
{/* 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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
{onSearchChange && !collapsed && (
|
{onSearchChange && !collapsed && (
|
||||||
<div className={styles.searchWrap}>
|
<div className={styles.searchWrap}>
|
||||||
<div className={styles.searchInner}>
|
<div className={styles.searchInner}>
|
||||||
@@ -223,8 +234,10 @@ function SidebarRoot({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{rest}
|
||||||
{children}
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</aside>
|
</aside>
|
||||||
</SidebarContext.Provider>
|
</SidebarContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -124,6 +124,52 @@ function filterNodes(
|
|||||||
return { filtered: walk(nodes), matchedParentIds }
|
return { filtered: walk(nodes), matchedParentIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Keyboard nav helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleArrowDown(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||||
|
const next = visibleNodes[currentIndex + 1]
|
||||||
|
if (next) focusNode(next.node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowUp(visibleNodes: FlatNode[], currentIndex: number, focusNode: (id: string) => void) {
|
||||||
|
const prev = visibleNodes[currentIndex - 1]
|
||||||
|
if (prev) focusNode(prev.node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowRight(
|
||||||
|
current: FlatNode | undefined,
|
||||||
|
currentIndex: number,
|
||||||
|
expandedSet: Set<string>,
|
||||||
|
visibleNodes: FlatNode[],
|
||||||
|
handleToggle: (id: string) => void,
|
||||||
|
focusNode: (id: string) => void,
|
||||||
|
) {
|
||||||
|
if (!current) return
|
||||||
|
const hasChildren = current.node.children && current.node.children.length > 0
|
||||||
|
if (!hasChildren) return
|
||||||
|
if (!expandedSet.has(current.node.id)) {
|
||||||
|
handleToggle(current.node.id)
|
||||||
|
} else {
|
||||||
|
const next = visibleNodes[currentIndex + 1]
|
||||||
|
if (next) focusNode(next.node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArrowLeft(
|
||||||
|
current: FlatNode | undefined,
|
||||||
|
expandedSet: Set<string>,
|
||||||
|
handleToggle: (id: string) => void,
|
||||||
|
focusNode: (id: string) => void,
|
||||||
|
) {
|
||||||
|
if (!current) return
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── SidebarTree ──────────────────────────────────────────────────────────────
|
// ── SidebarTree ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SidebarTree({
|
export function SidebarTree({
|
||||||
@@ -222,64 +268,13 @@ export function SidebarTree({
|
|||||||
const current = visibleNodes[currentIndex]
|
const current = visibleNodes[currentIndex]
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': { e.preventDefault(); handleArrowDown(visibleNodes, currentIndex, focusNode); break }
|
||||||
e.preventDefault()
|
case 'ArrowUp': { e.preventDefault(); handleArrowUp(visibleNodes, currentIndex, focusNode); break }
|
||||||
const next = visibleNodes[currentIndex + 1]
|
case 'ArrowRight': { e.preventDefault(); handleArrowRight(current, currentIndex, expandedSet, visibleNodes, handleToggle, focusNode); break }
|
||||||
if (next) focusNode(next.node.id)
|
case 'ArrowLeft': { e.preventDefault(); handleArrowLeft(current, expandedSet, handleToggle, focusNode); break }
|
||||||
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 'ArrowUp': {
|
case 'End': { e.preventDefault(); if (visibleNodes.length > 0) focusNode(visibleNodes[visibleNodes.length - 1].node.id); break }
|
||||||
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -371,6 +366,10 @@ function SidebarTreeRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li role="none">
|
<li role="none">
|
||||||
|
{/* S1082: No onKeyDown here by design — the parent <ul role="tree"> carries
|
||||||
|
onKeyDown={handleKeyDown} which handles Enter (navigate) and all arrow keys
|
||||||
|
per the WAI-ARIA tree widget pattern. Adding a duplicate handler here would
|
||||||
|
fire the action twice. */}
|
||||||
<div
|
<div
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
|
|||||||
@@ -153,14 +153,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.env {
|
.env {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--success-bg);
|
|
||||||
color: var(--success);
|
|
||||||
border: 1px solid var(--success-border);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
import { Search, Moon, Sun, Power } from 'lucide-react'
|
import { Search, 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'
|
||||||
@@ -14,7 +15,7 @@ import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
|||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
environment?: string
|
environment?: ReactNode
|
||||||
user?: { name: string }
|
user?: { name: string }
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
@@ -90,7 +91,7 @@ export function TopBar({
|
|||||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||||
>
|
>
|
||||||
<span className={styles.liveDot} />
|
<span className={styles.liveDot} />
|
||||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.themeToggle}
|
className={styles.themeToggle}
|
||||||
@@ -102,7 +103,7 @@ export function TopBar({
|
|||||||
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
||||||
</button>
|
</button>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<div className={styles.env}>{environment}</div>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ export function InlineEdit({ value, onSave, placeholder, disabled, className }:
|
|||||||
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
<span className={`${styles.display} ${disabled ? styles.disabled : ''} ${className ?? ''}`}>
|
||||||
<span
|
<span
|
||||||
className={isEmpty ? styles.placeholder : styles.value}
|
className={isEmpty ? styles.placeholder : styles.value}
|
||||||
|
role="button"
|
||||||
|
tabIndex={disabled ? undefined : 0}
|
||||||
onClick={startEdit}
|
onClick={startEdit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
startEdit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isEmpty ? placeholder : value}
|
{isEmpty ? placeholder : value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
|||||||
interface GlobalFilterContextValue {
|
interface GlobalFilterContextValue {
|
||||||
timeRange: TimeRange
|
timeRange: TimeRange
|
||||||
setTimeRange: (range: TimeRange) => void
|
setTimeRange: (range: TimeRange) => void
|
||||||
|
refreshTimeRange: () => void
|
||||||
statusFilters: Set<ExchangeStatus>
|
statusFilters: Set<ExchangeStatus>
|
||||||
toggleStatus: (status: ExchangeStatus) => void
|
toggleStatus: (status: ExchangeStatus) => void
|
||||||
clearStatusFilters: () => void
|
clearStatusFilters: () => void
|
||||||
@@ -76,6 +77,14 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [autoRefresh, timeRange.preset])
|
}, [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(
|
const isInTimeRange = useCallback(
|
||||||
(timestamp: Date) => {
|
(timestamp: Date) => {
|
||||||
if (timeRange.preset) {
|
if (timeRange.preset) {
|
||||||
@@ -90,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalFilterContext.Provider
|
<GlobalFilterContext.Provider
|
||||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</GlobalFilterContext.Provider>
|
</GlobalFilterContext.Provider>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import styles from './Inventory.module.css'
|
|||||||
import { PrimitivesSection } from './sections/PrimitivesSection'
|
import { PrimitivesSection } from './sections/PrimitivesSection'
|
||||||
import { CompositesSection } from './sections/CompositesSection'
|
import { CompositesSection } from './sections/CompositesSection'
|
||||||
import { LayoutSection } from './sections/LayoutSection'
|
import { LayoutSection } from './sections/LayoutSection'
|
||||||
|
import { BrandAssetsSection } from './sections/BrandAssetsSection'
|
||||||
|
|
||||||
const NAV_SECTIONS = [
|
const NAV_SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -93,6 +94,13 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'TopBar', href: '#topbar' },
|
{ label: 'TopBar', href: '#topbar' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Brand Assets',
|
||||||
|
href: '#brand-assets',
|
||||||
|
components: [
|
||||||
|
{ label: 'Cameleer3 Logo', href: '#brand-assets' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Inventory() {
|
export function Inventory() {
|
||||||
@@ -121,6 +129,7 @@ export function Inventory() {
|
|||||||
<PrimitivesSection />
|
<PrimitivesSection />
|
||||||
<CompositesSection />
|
<CompositesSection />
|
||||||
<LayoutSection />
|
<LayoutSection />
|
||||||
|
<BrandAssetsSection />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
86
src/pages/Inventory/sections/BrandAssetsSection.module.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
.section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.componentCard {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.componentTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.componentDesc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demoArea {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoPreview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-body);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoPreview img {
|
||||||
|
display: block;
|
||||||
|
image-rendering: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoExport {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-body);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
77
src/pages/Inventory/sections/BrandAssetsSection.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import styles from './BrandAssetsSection.module.css'
|
||||||
|
import camelLogoSvg from '../../../assets/camel-logo.svg'
|
||||||
|
import cameleer3Logo from '../../../assets/cameleer3-logo.png'
|
||||||
|
import cameleer3LogoSvg from '../../../assets/cameleer3-logo.svg'
|
||||||
|
|
||||||
|
const LOGO_SIZES = [16, 32, 48, 180, 192, 512] as const
|
||||||
|
|
||||||
|
export function BrandAssetsSection() {
|
||||||
|
return (
|
||||||
|
<section id="brand-assets" className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Brand Assets</h2>
|
||||||
|
|
||||||
|
<div className={styles.componentCard}>
|
||||||
|
<h3 className={styles.componentTitle}>Cameleer3 Logo (PNG)</h3>
|
||||||
|
<p className={styles.componentDesc}>
|
||||||
|
Full-resolution logo and pre-generated size variants for favicons, PWA icons, and social images.
|
||||||
|
Shipped as static assets via <code>@cameleer/design-system/assets/*</code> export.
|
||||||
|
</p>
|
||||||
|
<div className={styles.demoArea}>
|
||||||
|
<div className={styles.logoGrid}>
|
||||||
|
{LOGO_SIZES.map((size) => (
|
||||||
|
<div key={size} className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img
|
||||||
|
src={cameleer3Logo}
|
||||||
|
alt={`Logo ${size}×${size}`}
|
||||||
|
width={Math.min(size, 96)}
|
||||||
|
height={Math.min(size, 96)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>{size}×{size}</span>
|
||||||
|
<code className={styles.logoExport}>assets/cameleer3-{size}.png</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img
|
||||||
|
src={cameleer3Logo}
|
||||||
|
alt="Full resolution logo"
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>Original</span>
|
||||||
|
<code className={styles.logoExport}>assets/cameleer3-logo.png</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.componentCard}>
|
||||||
|
<h3 className={styles.componentTitle}>SVG Logos</h3>
|
||||||
|
<p className={styles.componentDesc}>
|
||||||
|
Vector logos for scalable usage. Transparent backgrounds, infinitely scalable.
|
||||||
|
</p>
|
||||||
|
<div className={styles.demoArea}>
|
||||||
|
<div className={styles.logoGrid}>
|
||||||
|
<div className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img src={cameleer3LogoSvg} alt="Cameleer3 SVG logo" width={96} height={96} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>Cameleer3 SVG</span>
|
||||||
|
<code className={styles.logoExport}>assets/cameleer3-logo.svg</code>
|
||||||
|
</div>
|
||||||
|
<div className={styles.logoItem}>
|
||||||
|
<div className={styles.logoPreview}>
|
||||||
|
<img src={camelLogoSvg} alt="Camel SVG logo" width={96} height={96} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.logoLabel}>Camel SVG</span>
|
||||||
|
<code className={styles.logoExport}>assets/camel-logo.svg</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||