Compare commits

...

5 Commits

Author SHA1 Message Date
hsiegeln
03ec34bb5c feat(command-palette): open SearchCategory to arbitrary strings
All checks were successful
Build & Publish / publish (push) Successful in 1m33s
Widen SearchCategory from a closed union to string. Known categories
(application, exchange, attribute, route, agent) keep their labels.
Unknown categories render with title-cased labels and appear as
dynamic tabs derived from the data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:21:28 +02:00
hsiegeln
2f1df869db docs: update spec and guide for search position and chevron removal
All checks were successful
Build & Publish / publish (push) Successful in 1m7s
- COMPONENT_GUIDE: note search renders between Header and Sections,
  no chevrons on section headers
- Spec: update rendering diagrams and description to match
  implemented behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:43:00 +02:00
hsiegeln
0cf696cded fix(sidebar): move search below Header, remove section chevrons
All checks were successful
Build & Publish / publish (push) Successful in 1m3s
- Search input now renders between Sidebar.Header and first Section
  instead of above Header (fixes cameleer3-server#120)
- Remove ChevronRight/ChevronDown from section headers — the entire
  row is already clickable, chevrons added visual noise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:40:18 +02:00
hsiegeln
50a1296a9d fix(sidebar): make entire section header row clickable
All checks were successful
Build & Publish / publish (push) Successful in 2m1s
The toggle was only on the chevron button. Now the full row
(chevron + icon + label) triggers onToggle on click or Enter/Space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:59:47 +02:00
hsiegeln
9b8739b5d8 fix(a11y): add keyboard listeners to clickable elements (S1082)
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Add onKeyDown (Enter/Space) to the CommandPalette overlay backdrop div and
result item divs to satisfy SonarQube S1082. RouteFlow and ProcessorTimeline
already had the fixes in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:41:11 +02:00
9 changed files with 125 additions and 56 deletions

View File

@@ -114,8 +114,11 @@ Sidebar compound API:
</Sidebar.Footer> </Sidebar.Footer>
</Sidebar> </Sidebar>
The app controls all content — sections, order, tree data, collapse state. Notes:
Sidebar provides the frame, search input, and icon-rail collapse mode. - 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

View File

@@ -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.

View File

@@ -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':
@@ -194,7 +213,15 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
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()}
@@ -238,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"
@@ -254,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>
)} )}
@@ -275,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)
@@ -298,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}>

View File

@@ -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

View File

@@ -230,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}

View File

@@ -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 {

View File

@@ -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"
aria-expanded={open} tabIndex={0}
aria-label={open ? `Collapse ${label}` : `Expand ${label}`} aria-expanded={open}
> aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }}
</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,35 +194,50 @@ function SidebarRoot({
</button> </button>
)} )}
{/* Search (only when expanded and handler provided) */} {/* Render Header first, then search, then remaining children */}
{onSearchChange && !collapsed && ( {(() => {
<div className={styles.searchWrap}> const childArray = Children.toArray(children)
<div className={styles.searchInner}> const headerIdx = childArray.findIndex(
<span className={styles.searchIcon} aria-hidden="true"> (child) => isValidElement(child) && child.type === SidebarHeader,
<Search size={12} /> )
</span> const header = headerIdx >= 0 ? childArray[headerIdx] : null
<input const rest = headerIdx >= 0
className={styles.searchInput} ? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
type="text" : childArray
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>
)}
{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> </aside>
</SidebarContext.Provider> </SidebarContext.Provider>
) )

View File

@@ -366,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}

View File

@@ -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>