Skip to content

Commit ac4670b

Browse files
kubeclaude
andcommitted
Animate subtree collapse/expand with CSS interpolate-size
Use the `interpolate-size: allow-keywords` CSS property to smoothly animate group children height between 0 and auto. Children are now always present in the DOM (marked hidden for keyboard nav) so the CSS transition has stable elements to animate. Extracted a shared `itemRow` helper to deduplicate group header and child row rendering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f70eb49 commit ac4670b

1 file changed

Lines changed: 142 additions & 89 deletions

File tree

libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx

Lines changed: 142 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { css, cva } from "@hashintel/ds-helpers/css";
22
import type { ComponentType, ReactNode } from "react";
3-
import { use, useEffect, useRef, useState } from "react";
3+
import { Fragment, use, useEffect, useRef, useState } from "react";
44
import { LuChevronRight, LuSearch } from "react-icons/lu";
55
import { 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

3139
const 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

Comments
 (0)