@@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from "react";
22
33import { useAppStore } from "../state/useAppStore" ;
44import type { ManifestEntry } from "../types/probe" ;
5- import { groupNeuropixels , variantLabel } from "../utils/neuropixelsGrouping" ;
5+ import { groupEntries } from "../grouping/groupEntries" ;
6+ import { getGroupingConfig } from "../grouping" ;
7+ import type { GroupNode } from "../grouping/types" ;
68
79const MANUFACTURER_DISPLAY_NAMES : Record < string , string > = {
810 cambridgeneurotech : "Cambridge NeuroTech" ,
@@ -61,43 +63,34 @@ export function Sidebar() {
6163 }
6264 } , [ filteredEntries , selectedProbeId , selectProbe ] ) ;
6365
64- // Neuropixels (imec) gets a hierarchy-grouped list (platform -> family) ;
66+ // A manufacturer with a grouping config (only IMEC today) gets a hierarchy ;
6567 // every other manufacturer keeps the simple flat list.
66- const isNeuropixels = selectedManufacturer === "imec" ;
68+ const groupingConfig = selectedManufacturer
69+ ? getGroupingConfig ( selectedManufacturer )
70+ : undefined ;
6771 const groups = useMemo (
68- ( ) => ( isNeuropixels ? groupNeuropixels ( filteredEntries ) : [ ] ) ,
69- [ isNeuropixels , filteredEntries ] ,
72+ ( ) => ( groupingConfig ? groupEntries ( filteredEntries , groupingConfig ) : null ) ,
73+ [ groupingConfig , filteredEntries ] ,
7074 ) ;
7175
7276 // While searching, force every group open so matches aren't hidden inside a
7377 // collapsed group (filteredEntries already contains only the hits). When the
7478 // search clears, the user's manual expand/collapse state takes over again.
7579 const isSearching = searchQuery . trim ( ) . length > 0 ;
7680
77- // Both levels start collapsed (empty expanded sets). A platform shows its
78- // families only when expanded; a family shows its probes only when expanded.
79- const [ expandedPlatforms , setExpandedPlatforms ] = useState < Set < string > > (
80- ( ) => new Set ( ) ,
81- ) ;
82- const [ expandedFamilies , setExpandedFamilies ] = useState < Set < string > > (
83- ( ) => new Set ( ) ,
84- ) ;
85- const togglePlatform = ( platform : string ) =>
86- setExpandedPlatforms ( ( prev ) => {
87- const next = new Set ( prev ) ;
88- if ( next . has ( platform ) ) next . delete ( platform ) ;
89- else next . add ( platform ) ;
90- return next ;
91- } ) ;
92- const toggleFamily = ( key : string ) =>
93- setExpandedFamilies ( ( prev ) => {
81+ // Every collapsible node starts collapsed. Expansion is keyed by the node's
82+ // full path (joined ancestor labels), so the same key is stable across the
83+ // arbitrary-depth hierarchy.
84+ const [ expanded , setExpanded ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
85+ const toggle = ( key : string ) =>
86+ setExpanded ( ( prev ) => {
9487 const next = new Set ( prev ) ;
9588 if ( next . has ( key ) ) next . delete ( key ) ;
9689 else next . add ( key ) ;
9790 return next ;
9891 } ) ;
9992
100- const renderItem = ( entry : ManifestEntry , showVariant : boolean ) => (
93+ const renderItem = ( entry : ManifestEntry ) => (
10194 < button
10295 key = { entry . id }
10396 type = "button"
@@ -108,18 +101,58 @@ export function Sidebar() {
108101 }
109102 onClick = { ( ) => selectProbe ( entry . id ) }
110103 >
111- < span className = "sidebar-item-name" >
112- { entry . displayName }
113- { showVariant && variantLabel ( entry ) ? (
114- < span className = "sidebar-item-variant" > { variantLabel ( entry ) } </ span >
115- ) : null }
116- </ span >
104+ < span className = "sidebar-item-name" > { entry . displayName } </ span >
117105 < span className = "sidebar-item-meta" >
118106 { entry . contactCount } contacts · { entry . shankCount } shanks
119107 </ span >
120108 </ button >
121109 ) ;
122110
111+ // Renders one node of the grouped tree at any depth. A collapsible node is a
112+ // toggle header; a non-collapsible one is a static divider. Open nodes recurse
113+ // into their children, or list their entries when they are leaves.
114+ const renderNode = ( node : GroupNode , depth : number , parentPath : string [ ] ) => {
115+ const path = [ ...parentPath , node . label ] ;
116+ const key = path . join ( "||" ) ;
117+ const open = ! node . collapsible || isSearching || expanded . has ( key ) ;
118+ const wrapClass =
119+ depth === 0
120+ ? "sidebar-group"
121+ : depth === 1
122+ ? "sidebar-subgroup"
123+ : "sidebar-subdivision" ;
124+ return (
125+ < div className = { wrapClass } key = { key } >
126+ { node . collapsible ? (
127+ < button
128+ type = "button"
129+ className = {
130+ depth === 0 ? "sidebar-group-header" : "sidebar-subgroup-header"
131+ }
132+ aria-expanded = { open }
133+ onClick = { ( ) => toggle ( key ) }
134+ >
135+ < span className = "sidebar-group-caret" > { open ? "▾" : "▸" } </ span >
136+ < span
137+ className = {
138+ depth === 0 ? "sidebar-group-title" : "sidebar-subgroup-title"
139+ }
140+ >
141+ { node . label }
142+ </ span >
143+ < span className = "sidebar-group-count" > { node . count } </ span >
144+ </ button >
145+ ) : (
146+ < p className = "sidebar-subgroup-divider" > { node . label } </ p >
147+ ) }
148+ { open &&
149+ ( node . children
150+ ? node . children . map ( ( child ) => renderNode ( child , depth + 1 , path ) )
151+ : node . entries ?. map ( ( entry ) => renderItem ( entry ) ) ) }
152+ </ div >
153+ ) ;
154+ } ;
155+
123156 return (
124157 < div className = "sidebar" >
125158 < header className = "sidebar-header" >
@@ -173,71 +206,12 @@ export function Sidebar() {
173206 ) }
174207
175208 { manifestStatus === "success" &&
176- ! isNeuropixels &&
177- filteredEntries . map ( ( entry ) => renderItem ( entry , false ) ) }
209+ ! groups &&
210+ filteredEntries . map ( ( entry ) => renderItem ( entry ) ) }
178211
179212 { manifestStatus === "success" &&
180- isNeuropixels &&
181- groups . map ( ( group ) => {
182- const platformOpen = isSearching || expandedPlatforms . has ( group . platform ) ;
183- return (
184- < div className = "sidebar-group" key = { group . platform } >
185- < button
186- type = "button"
187- className = "sidebar-group-header"
188- aria-expanded = { platformOpen }
189- onClick = { ( ) => togglePlatform ( group . platform ) }
190- >
191- < span className = "sidebar-group-caret" >
192- { platformOpen ? "▾" : "▸" }
193- </ span >
194- < span className = "sidebar-group-title" > { group . platform } </ span >
195- < span className = "sidebar-group-count" > { group . count } </ span >
196- </ button >
197- { platformOpen &&
198- group . families . map ( ( fam ) => {
199- const familyKey = `${ group . platform } ||${ fam . family } ` ;
200- const familyOpen = isSearching || expandedFamilies . has ( familyKey ) ;
201- return (
202- < div className = "sidebar-subgroup" key = { fam . family } >
203- < button
204- type = "button"
205- className = "sidebar-subgroup-header"
206- aria-expanded = { familyOpen }
207- onClick = { ( ) => toggleFamily ( familyKey ) }
208- >
209- < span className = "sidebar-group-caret" >
210- { familyOpen ? "▾" : "▸" }
211- </ span >
212- < span className = "sidebar-subgroup-title" >
213- { fam . family }
214- </ span >
215- < span className = "sidebar-group-count" >
216- { fam . entries . length }
217- </ span >
218- </ button >
219- { familyOpen &&
220- fam . subgroups . map ( ( sub ) => (
221- < div
222- className = "sidebar-subdivision"
223- key = { sub . label || "_flat" }
224- >
225- { sub . label && (
226- < p className = "sidebar-subgroup-divider" >
227- { sub . label }
228- </ p >
229- ) }
230- { sub . entries . map ( ( entry ) =>
231- renderItem ( entry , true ) ,
232- ) }
233- </ div >
234- ) ) }
235- </ div >
236- ) ;
237- } ) }
238- </ div >
239- ) ;
240- } ) }
213+ groups &&
214+ groups . map ( ( node ) => renderNode ( node , 0 , [ ] ) ) }
241215 </ div >
242216 </ div >
243217 ) ;
0 commit comments