11import { css , cva } from "@hashintel/ds-helpers/css" ;
22import type { ComponentType , ReactNode } from "react" ;
3- import { use , useEffect , useRef , useState } from "react" ;
3+ import { Fragment , use , useEffect , useRef , useState } from "react" ;
44import { LuChevronRight , LuSearch } from "react-icons/lu" ;
55import { TbDots } from "react-icons/tb" ;
66
@@ -26,6 +26,14 @@ const listContainerStyle = css({
2626 mx : "-1" ,
2727 /** Suppress browser default focus ring — focus is shown per-row via isFocused variant */
2828 outline : "none" ,
29+ /** Enable animating height to/from `auto` for collapsible group children */
30+ interpolateSize : "[allow-keywords]" ,
31+ } ) ;
32+
33+ /** Wrapper around a group's children that animates height on collapse/expand */
34+ const groupChildrenStyle = css ( {
35+ overflow : "hidden" ,
36+ transition : "[height 150ms ease-out]" ,
2937} ) ;
3038
3139const listItemRowStyle = cva ( {
@@ -266,27 +274,38 @@ const FilterableListContent = <T extends FilterableListItem>({
266274 const containerRef = useRef < HTMLDivElement > ( null ) ;
267275 const rowRefs = useRef < ( HTMLDivElement | null ) [ ] > ( [ ] ) ;
268276
269- // Flatten tree: items with children become group header + child rows
277+ // Flatten tree: items with children become group header + child rows.
278+ // Children are always included (even when collapsed) so the DOM stays
279+ // stable for height animation. The `hidden` flag marks collapsed children
280+ // so keyboard navigation can skip them.
270281 const flatRows : {
271282 item : T ;
272283 depth : number ;
273284 isGroup : boolean ;
285+ hidden : boolean ;
274286 emptyGroupMessage ?: string ;
275287 } [ ] = [ ] ;
276288 for ( const item of items ) {
277289 const children = item . children as T [ ] | undefined ;
278290 const isGroup = children !== undefined ;
279- flatRows . push ( { item, depth : 0 , isGroup } ) ;
280- if ( isGroup && ! collapsedGroups . has ( item . id ) ) {
291+ flatRows . push ( { item, depth : 0 , isGroup, hidden : false } ) ;
292+ if ( isGroup ) {
293+ const isCollapsed = collapsedGroups . has ( item . id ) ;
281294 if ( children ! . length > 0 ) {
282295 for ( const child of children ! ) {
283- flatRows . push ( { item : child , depth : 1 , isGroup : false } ) ;
296+ flatRows . push ( {
297+ item : child ,
298+ depth : 1 ,
299+ isGroup : false ,
300+ hidden : isCollapsed ,
301+ } ) ;
284302 }
285303 } else if ( item . emptyGroupMessage ) {
286304 flatRows . push ( {
287305 item,
288306 depth : 1 ,
289307 isGroup : false ,
308+ hidden : isCollapsed ,
290309 emptyGroupMessage : item . emptyGroupMessage ,
291310 } ) ;
292311 }
@@ -334,7 +353,7 @@ const FilterableListContent = <T extends FilterableListItem>({
334353 const newSelection : SelectionMap = new Map ( ) ;
335354 for ( let i = start ; i <= end ; i ++ ) {
336355 const row = flatRows [ i ] ;
337- if ( row && ! row . isGroup && ! row . emptyGroupMessage ) {
356+ if ( row && ! row . isGroup && ! row . hidden && ! row . emptyGroupMessage ) {
338357 const selItem = getSelectionItem ( row . item ) ;
339358 newSelection . set ( selItem . id , selItem ) ;
340359 }
@@ -358,16 +377,17 @@ const FilterableListContent = <T extends FilterableListItem>({
358377 focusedIndex === null
359378 ? 0
360379 : Math . min ( focusedIndex + 1 , flatRows . length - 1 ) ;
361- // Skip empty placeholder rows
380+ // Skip hidden and empty placeholder rows
362381 while (
363382 nextIndex < flatRows . length - 1 &&
364- flatRows [ nextIndex ] ?. emptyGroupMessage
383+ ( flatRows [ nextIndex ] ?. hidden ||
384+ flatRows [ nextIndex ] ?. emptyGroupMessage )
365385 ) {
366386 nextIndex ++ ;
367387 }
368388 setFocusedIndex ( nextIndex ) ;
369389 const row = flatRows [ nextIndex ] ;
370- if ( row && ! row . isGroup && ! row . emptyGroupMessage ) {
390+ if ( row && ! row . isGroup && ! row . hidden && ! row . emptyGroupMessage ) {
371391 if ( event . shiftKey ) {
372392 selectRange ( anchorIndex ?? nextIndex , nextIndex ) ;
373393 } else {
@@ -383,13 +403,17 @@ const FilterableListContent = <T extends FilterableListItem>({
383403 focusedIndex === null
384404 ? flatRows . length - 1
385405 : Math . max ( focusedIndex - 1 , 0 ) ;
386- // Skip empty placeholder rows
387- while ( nextIndex > 0 && flatRows [ nextIndex ] ?. emptyGroupMessage ) {
406+ // Skip hidden and empty placeholder rows
407+ while (
408+ nextIndex > 0 &&
409+ ( flatRows [ nextIndex ] ?. hidden ||
410+ flatRows [ nextIndex ] ?. emptyGroupMessage )
411+ ) {
388412 nextIndex -- ;
389413 }
390414 setFocusedIndex ( nextIndex ) ;
391415 const row = flatRows [ nextIndex ] ;
392- if ( row && ! row . isGroup && ! row . emptyGroupMessage ) {
416+ if ( row && ! row . isGroup && ! row . hidden && ! row . emptyGroupMessage ) {
393417 if ( event . shiftKey ) {
394418 selectRange ( anchorIndex ?? nextIndex , nextIndex ) ;
395419 } else {
@@ -404,7 +428,7 @@ const FilterableListContent = <T extends FilterableListItem>({
404428 event . preventDefault ( ) ;
405429 if ( focusedIndex !== null ) {
406430 const row = flatRows [ focusedIndex ] ;
407- if ( row && ! row . emptyGroupMessage ) {
431+ if ( row && ! row . hidden && ! row . emptyGroupMessage ) {
408432 if ( row . isGroup ) {
409433 toggleGroup ( row . item . id ) ;
410434 } else {
@@ -491,96 +515,125 @@ const FilterableListContent = <T extends FilterableListItem>({
491515 }
492516 } }
493517 >
494- { flatRows . map ( ( { item, depth, isGroup, emptyGroupMessage } , index ) => {
495- if ( emptyGroupMessage ) {
518+ { items . map ( ( topItem ) => {
519+ const children = topItem . children as T [ ] | undefined ;
520+ const isGroup = children !== undefined ;
521+ const isCollapsed = isGroup && collapsedGroups . has ( topItem . id ) ;
522+
523+ const itemRow = ( item : T , depth : number ) => {
524+ const index = flatRows . findIndex (
525+ ( r ) => r . item === item && r . depth === depth ,
526+ ) ;
527+ const isItemGroup = item === topItem && isGroup ;
528+ const selected = ! isItemGroup && checkIsSelected ( item . id ) ;
529+ const focused = focusedIndex === index ;
530+
496531 return (
497532 < div
498- key = { `empty-${ item . id } ` }
533+ key = { `${ depth } -${ item . id } ` }
534+ ref = { ( el ) => {
535+ rowRefs . current [ index ] = el ;
536+ } }
537+ onClick = { ( event ) =>
538+ handleRowClick ( event , index , {
539+ item,
540+ isGroup : isItemGroup ,
541+ } )
542+ }
543+ onKeyDown = { ( event ) => {
544+ if ( event . key === "Enter" || event . key === " " ) {
545+ event . preventDefault ( ) ;
546+ if ( isItemGroup ) {
547+ toggleGroup ( item . id ) ;
548+ } else {
549+ selectItem ( getSelectionItem ( item ) ) ;
550+ setFocusedIndex ( index ) ;
551+ setAnchorIndex ( index ) ;
552+ }
553+ }
554+ } }
555+ role = "option"
556+ aria-selected = { selected }
499557 className = { listItemRowStyle ( {
500- selectable : false ,
501- isSelected : false ,
502- isFocused : false ,
558+ selectable : true ,
559+ isSelected : selected ,
560+ isFocused : focused ,
503561 } ) }
504- style = { { paddingLeft : depth * NESTING_INDENT + 4 } }
562+ style = {
563+ depth > 0
564+ ? { paddingLeft : depth * NESTING_INDENT + 4 }
565+ : undefined
566+ }
505567 >
506568 < div className = { listItemContentStyle } >
507- < div
508- className = { listItemNameStyle }
509- style = { { color : "var(--colors-neutral-s65)" } }
510- >
511- { emptyGroupMessage }
569+ { isItemGroup && (
570+ < span className = { chevronStyle ( { expanded : ! isCollapsed } ) } >
571+ < LuChevronRight size = { CHEVRON_SIZE } />
572+ </ span >
573+ ) }
574+ { item . icon && (
575+ < span
576+ className = { listItemIconStyle }
577+ style = { {
578+ color : item . iconColor ?? LIST_ITEM_ICON_COLOR ,
579+ } }
580+ >
581+ < item . icon size = { LIST_ITEM_ICON_SIZE } />
582+ </ span >
583+ ) }
584+ < div className = { listItemNameStyle } >
585+ { renderItem ( item , selected ) }
512586 </ div >
513587 </ div >
588+ { isItemGroup && item . renderGroupAction && (
589+ < span
590+ role = "presentation"
591+ data-row-action
592+ onClick = { ( event ) => event . stopPropagation ( ) }
593+ onKeyDown = { ( event ) => event . stopPropagation ( ) }
594+ >
595+ < item . renderGroupAction />
596+ </ span >
597+ ) }
598+ { ! isItemGroup && RenderRowMenu && < RenderRowMenu item = { item } /> }
514599 </ div >
515600 ) ;
516- }
601+ } ;
517602
518- const isSelected = ! isGroup && checkIsSelected ( item . id ) ;
519- const isFocused = focusedIndex === index ;
520- const isCollapsed = isGroup && collapsedGroups . has ( item . id ) ;
603+ if ( ! isGroup ) {
604+ return itemRow ( topItem , 0 ) ;
605+ }
521606
522607 return (
523- < div
524- key = { `${ depth } -${ item . id } ` }
525- ref = { ( el ) => {
526- rowRefs . current [ index ] = el ;
527- } }
528- onClick = { ( event ) => handleRowClick ( event , index , { item, isGroup } ) }
529- onKeyDown = { ( event ) => {
530- if ( event . key === "Enter" || event . key === " " ) {
531- event . preventDefault ( ) ;
532- if ( isGroup ) {
533- toggleGroup ( item . id ) ;
534- } else {
535- selectItem ( getSelectionItem ( item ) ) ;
536- setFocusedIndex ( index ) ;
537- setAnchorIndex ( index ) ;
538- }
539- }
540- } }
541- role = "option"
542- aria-selected = { isSelected }
543- className = { listItemRowStyle ( {
544- selectable : true ,
545- isSelected,
546- isFocused,
547- } ) }
548- style = {
549- depth > 0
550- ? { paddingLeft : depth * NESTING_INDENT + 4 }
551- : undefined
552- }
553- >
554- < div className = { listItemContentStyle } >
555- { isGroup && (
556- < span className = { chevronStyle ( { expanded : ! isCollapsed } ) } >
557- < LuChevronRight size = { CHEVRON_SIZE } />
558- </ span >
559- ) }
560- { item . icon && (
561- < span
562- className = { listItemIconStyle }
563- style = { { color : item . iconColor ?? LIST_ITEM_ICON_COLOR } }
564- >
565- < item . icon size = { LIST_ITEM_ICON_SIZE } />
566- </ span >
567- ) }
568- < div className = { listItemNameStyle } >
569- { renderItem ( item , isSelected ) }
570- </ div >
608+ < Fragment key = { topItem . id } >
609+ { itemRow ( topItem , 0 ) }
610+ < div
611+ className = { groupChildrenStyle }
612+ style = { { height : isCollapsed ? 0 : "auto" } }
613+ >
614+ { children ! . length > 0
615+ ? children ! . map ( ( child ) => itemRow ( child , 1 ) )
616+ : topItem . emptyGroupMessage && (
617+ < div
618+ className = { listItemRowStyle ( {
619+ selectable : false ,
620+ isSelected : false ,
621+ isFocused : false ,
622+ } ) }
623+ style = { { paddingLeft : NESTING_INDENT + 4 } }
624+ >
625+ < div className = { listItemContentStyle } >
626+ < div
627+ className = { listItemNameStyle }
628+ style = { { color : "var(--colors-neutral-s65)" } }
629+ >
630+ { topItem . emptyGroupMessage }
631+ </ div >
632+ </ div >
633+ </ div >
634+ ) }
571635 </ div >
572- { isGroup && item . renderGroupAction && (
573- < span
574- role = "presentation"
575- data-row-action
576- onClick = { ( event ) => event . stopPropagation ( ) }
577- onKeyDown = { ( event ) => event . stopPropagation ( ) }
578- >
579- < item . renderGroupAction />
580- </ span >
581- ) }
582- { ! isGroup && RenderRowMenu && < RenderRowMenu item = { item } /> }
583- </ div >
636+ </ Fragment >
584637 ) ;
585638 } ) }
586639 { items . length === 0 && (
0 commit comments