From 8df1f5eadf87edb8eb745bf8ced07ec4756ba592 Mon Sep 17 00:00:00 2001 From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:11:07 -0700 Subject: [PATCH 1/8] feat: Animate side navigation collapse and expandable section reveal Replace the display:none content toggle in ExpandableSection with a grid-template-rows reveal, and animate the SideNavigation collapse/expand: text fades while icons stay centered, spatial and opacity transitions are staggered with tunable local timing vars, groups get collapsed-state spacing, and hidden content is inert for accessibility. WIP checkpoint on dev-v3-jkuelz-side-nav-animations-refactor so the existing icon-layout PR stays clean. --- pages/side-navigation/collapsed.page.tsx | 771 +++++++++++- src/app-layout/constants.scss | 3 - .../navigation/styles.scss | 2 +- .../skeleton/styles.scss | 2 +- .../__tests__/expandable-section.test.tsx | 21 + .../expandable-section-container.tsx | 4 +- .../expandable-section-header.tsx | 7 +- src/expandable-section/internal.tsx | 100 +- src/expandable-section/motion.scss | 30 - src/expandable-section/styles.scss | 117 +- .../collapsed-side-navigation.test.tsx | 1068 ++++++++++++++++- src/side-navigation/implementation.tsx | 92 +- src/side-navigation/parts.tsx | 335 ++++-- src/side-navigation/styles.scss | 441 ++++++- src/side-navigation/util.tsx | 59 + style-dictionary/one-theme/sizes.ts | 1 + style-dictionary/utils/token-names.ts | 4 +- style-dictionary/visual-refresh/colors.ts | 2 +- .../visual-refresh/metadata/sizes.ts | 10 + style-dictionary/visual-refresh/sizes.ts | 2 + 20 files changed, 2788 insertions(+), 283 deletions(-) delete mode 100644 src/expandable-section/motion.scss diff --git a/pages/side-navigation/collapsed.page.tsx b/pages/side-navigation/collapsed.page.tsx index 7d35fe90d0..5456e336e7 100644 --- a/pages/side-navigation/collapsed.page.tsx +++ b/pages/side-navigation/collapsed.page.tsx @@ -7,7 +7,18 @@ import { useReducedMotion } from '@cloudscape-design/component-toolkit/internal' import { Box, Button, Icon, SpaceBetween } from '~components'; import SideNavigation, { SideNavigationProps } from '~components/side-navigation'; -import { colorBorderDividerDefault } from '~design-tokens'; +import { + colorBackgroundCellShaded, + colorBackgroundStatusInfo, + colorBackgroundStatusSuccess, + colorBackgroundStatusWarning, + colorBorderDividerDefault, + colorTextBodyDefault, + colorTextStatusInfo, + colorTextStatusSuccess, + colorTextStatusWarning, + fontFamilyBase, +} from '~design-tokens'; const items: SideNavigationProps.Item[] = [ { type: 'link', text: 'Calendar', href: '#/calendar', icon: }, @@ -25,11 +36,12 @@ const items: SideNavigationProps.Item[] = [ { type: 'link-group', text: 'Link group', + icon: , href: '#/', items: [ - { type: 'link', text: 'Announcements', href: '#/announcements3', icon: }, - { type: 'link', text: 'Team', href: '#/team3', icon: }, - { type: 'link', text: 'Networking', href: '#/networking3', icon: }, + { type: 'link', text: 'Announcements', href: '#/announcements3' }, + { type: 'link', text: 'Team', href: '#/team3' }, + { type: 'link', text: 'Networking', href: '#/networking3' }, ], }, { @@ -51,10 +63,716 @@ const items: SideNavigationProps.Item[] = [ { type: 'link', text: 'Announcements', href: '#/announcements2', icon: }, { type: 'link', text: 'Team', href: '#/team2', icon: }, { type: 'link', text: 'Networking', href: '#/networking2', icon: }, + { + type: 'section', + text: 'Section', + items: [ + { type: 'link', text: 'Announcements', href: '#/announcements', icon: }, + { type: 'link', text: 'Team', href: '#/team', icon: }, + { type: 'link', text: 'Networking', href: '#/networking', icon: }, + { + type: 'expandable-link-group', + href: '', + text: 'Section', + icon: , + items: [ + { type: 'link', text: 'Announcements', href: '#/announcements', icon: }, + { type: 'link', text: 'Team', href: '#/team', icon: }, + { type: 'link', text: 'Networking', href: '#/networking', icon: }, + { + type: 'expandable-link-group', + href: '', + text: 'Section', + icon: , + items: [ + { type: 'link', text: 'Announcements', href: '#/announcements', icon: }, + { type: 'link', text: 'Team', href: '#/team', icon: }, + { type: 'link', text: 'Networking', href: '#/networking', icon: }, + ], + }, + ], + }, + ], + }, ], }, ]; +const demoItemsWithIcons: SideNavigationProps.Item[] = [ + { + type: 'link', + text: 'Dashboard', + href: '#/page1', + icon: ( + + + + } + /> + ), + }, + { + type: 'link', + text: 'Storage', + href: '#/page2', + icon: ( + + + + } + /> + ), + }, + { + type: 'link', + text: 'Metrics', + href: '#/page3', + icon: ( + + + + } + /> + ), + }, + { type: 'divider' }, + { + type: 'link', + text: 'Notifications', + href: '#/notifications', + icon: , + }, + { + type: 'link', + text: 'Support', + href: 'https://example.com', + external: true, + icon: , + }, +]; + +const demoServiceItemsWithIcons: SideNavigationProps.Item[] = [ + { + type: 'link', + text: 'Dashboard', + href: '#/page1', + icon: ( + + + + } + /> + ), + }, + { + type: 'link', + text: 'Deployments', + href: '#/page2', + icon: ( + + + + } + /> + ), + }, + { + type: 'section', + text: 'Reports and Analytics', + items: [ + { + type: 'link', + text: 'Usage', + href: '#/page4', + icon: ( + + + + } + /> + ), + }, + { + type: 'link', + text: 'Alarms', + href: '#/page5', + icon: , + }, + { + type: 'link', + text: 'Logs', + href: '#/page6', + icon: , + }, + ], + }, + { + type: 'section', + text: 'Security', + items: [ + { + type: 'link', + text: 'API Keys', + href: '#/page7', + icon: , + }, + { + type: 'link', + text: 'Encryption', + href: '#/page8', + icon: , + }, + { + type: 'link', + text: 'Authentication', + href: '#/page9', + icon: , + }, + ], + }, +]; + +const demoLinkGroupsWithIcons: SideNavigationProps.Item[] = [ + { + type: 'link', + text: 'Home', + href: '#/page1', + icon: ( + + + + + } + /> + ), + }, + { + type: 'link', + text: 'Deployments', + href: '#/page2', + icon: ( + + + + } + /> + ), + }, + { + type: 'expandable-link-group', + text: 'Projects', + href: '#/group1', + icon: , + defaultExpanded: true, + items: [ + { + type: 'link', + text: 'Project 1', + href: '#/page4', + }, + { + type: 'link', + text: 'Project 2', + href: '#/page5', + }, + { + type: 'link', + text: 'Project 3', + href: '#/page6', + }, + ], + }, + { + type: 'expandable-link-group', + text: 'Groups', + href: '#/group2', + icon: , + defaultExpanded: true, + items: [ + { + type: 'link', + text: 'My team', + href: '#/page7', + }, + { + type: 'link', + text: 'My org', + href: '#/page8', + }, + { + type: 'link', + text: 'My company', + href: '#/page9', + }, + ], + }, + { type: 'divider' }, + { + type: 'link', + text: 'Help and support', + href: 'https://example.com', + icon: , + }, + { + type: 'link', + text: 'Feedback', + href: '#/notifications', + icon: , + }, +]; + +/** + * IATreeVisualizer renders an SVG-based node-link tree diagram showing the + * information architecture of a SideNavigation item set. + * + * - Plain links, and items inside a `section`/`section-group`, are flattened + * to a single depth: they all branch directly off the main trunk. A + * `section`/`section-group` has no href (it's just a label), so it does not + * get a node — instead its title is drawn as a text label beside the trunk, + * with extra vertical spacing inserted before its first child to set the + * group apart. + * - `link-group` / `expandable-link-group` items ARE real nodes (they have + * their own href), so they keep their nested children on a genuine + * sub-trunk/sub-branch one depth deeper. + * - Divider items are silently skipped. + * + * Nodes are colored and lettered by depth (root = "A", depth 1 = "B", depth 2 + * = "C", ...), not by sibling group, so every node at the same indentation + * level shares a color and a letter. + * + * Color tokens used (all from the public design-tokens surface): + * - Root node (depth 0): colorBackgroundCellShaded (neutral grey fill) + colorBorderInputDefault (border) + * - Depth 1: colorBackgroundStatusInfo (light blue fill) + colorBorderStatusInfo (blue stroke) + * - Depth 2: colorBackgroundStatusSuccess (light green fill) + colorBorderStatusSuccess (green stroke) + * - Depth 3+: colorBackgroundStatusWarning (light amber fill) + colorBorderStatusWarning (amber stroke), repeating + */ +const CONNECTOR_STROKE_WIDTH = 2; +const NODE_STROKE_WIDTH = 1; + +const DEPTH_COLORS = [ + { fill: colorBackgroundCellShaded, stroke: colorTextBodyDefault }, // depth 0 (root) + { fill: colorBackgroundStatusInfo, stroke: colorTextStatusInfo }, // depth 1 + { fill: colorBackgroundStatusSuccess, stroke: colorTextStatusSuccess }, // depth 2 + { fill: colorBackgroundStatusWarning, stroke: colorTextStatusWarning }, // depth 3+ +]; + +function depthColor(depth: number) { + return DEPTH_COLORS[Math.min(depth, DEPTH_COLORS.length - 1)]; +} + +function depthLetter(depth: number) { + return String.fromCharCode('A'.charCodeAt(0) + depth); +} + +// Items whose header is a label only (no href) — their children flatten to +// the parent's depth instead of nesting on a sub-branch. +function isLabelOnlyGroup( + item: SideNavigationProps.Item +): item is SideNavigationProps.Section | SideNavigationProps.SectionGroup { + return item.type === 'section' || item.type === 'section-group'; +} + +// Items that are real nodes (have an href) whose children genuinely nest one +// depth deeper on their own sub-branch. +function getNestedChildren(item: SideNavigationProps.Item): ReadonlyArray | null { + switch (item.type) { + case 'link-group': + case 'expandable-link-group': + return item.items; + default: + return null; + } +} + +function groupTitle(item: SideNavigationProps.Section | SideNavigationProps.SectionGroup): string { + return item.type === 'section' ? item.text : item.title; +} + +// A flat row in the tree: either a real node (with optional nested children) +// or a label-only group boundary that introduces extra spacing before its +// (already-flattened) children. `dividerBefore` marks a row that was +// immediately preceded by a real `divider` item in the source data — this is +// the ONLY thing that produces a dashed trunk segment (dashes represent an +// actual divider, not a generic "list is long" cue). +interface TreeRow { + isNode: boolean; + label: string | null; + extraGapBefore: boolean; + dividerBefore: boolean; + nestedChildren: ReadonlyArray | null; +} + +// Flattens a list of items to depth-1 tree rows. Label-only groups (section / +// section-group) contribute no node of their own — their children are +// recursively flattened into the same row list, with the group's title +// attached to the first child row and an extra gap inserted before it. +// Divider items contribute no node either, but mark the following row as +// `dividerBefore` so it renders with a dashed (not solid) trunk segment above it. +function buildRows(items: ReadonlyArray): TreeRow[] { + const rows: TreeRow[] = []; + let pendingDivider = false; + items.forEach(item => { + if (item.type === 'divider') { + pendingDivider = true; + return; + } + if (isLabelOnlyGroup(item)) { + const childRows = buildRows(item.items); + if (childRows.length > 0) { + childRows[0] = { + ...childRows[0], + label: groupTitle(item), + extraGapBefore: true, + dividerBefore: pendingDivider, + }; + pendingDivider = false; + } + rows.push(...childRows); + return; + } + rows.push({ + isNode: true, + label: null, + extraGapBefore: false, + dividerBefore: pendingDivider, + nestedChildren: getNestedChildren(item), + }); + pendingDivider = false; + }); + return rows; +} + +function IATreeVisualizer({ items }: { items: SideNavigationProps.Item[] }) { + const rows = buildRows(items); + + // Layout constants (condensed) + const nodeSize = 24; + const nodeRadius = 6; + const rowSpacing = 36; + const groupGap = 24; // extra vertical space inserted before a label-only group's first child + const edgeMargin = NODE_STROKE_WIDTH; // breathing room on every edge so stroked node borders never get clipped by the viewBox + const trunkX = edgeMargin + nodeSize / 2; + const branchOffset = 36; + const nestedBranchOffset = 36; + const rootGap = 28; // vertical gap from the root node down to the first branch point (larger than a regular row gap) + const fontSize = 12; + + interface NodePosition { + x: number; + y: number; + depth: number; + parentY: number; + } + + const positions: NodePosition[] = []; + const rowPositions: { + y: number; + label: string | null; + dividerBefore: boolean; + nestedChildren: ReadonlyArray | null; + }[] = []; + + const rootY = edgeMargin; + let currentY = rootY + nodeSize + rootGap; + let maxDepth = 1; + + rows.forEach(row => { + // A divider and a label-only group boundary both introduce the same + // extra vertical breathing room before the row that follows them. + if (row.extraGapBefore || row.dividerBefore) { + currentY += groupGap; + } + const y = currentY; + rowPositions.push({ y, label: row.label, dividerBefore: row.dividerBefore, nestedChildren: row.nestedChildren }); + positions.push({ x: branchOffset, y, depth: 1, parentY: y }); + currentY += rowSpacing; + + if (row.nestedChildren && row.nestedChildren.length > 0) { + maxDepth = Math.max(maxDepth, 2); + row.nestedChildren + .filter(child => child.type !== 'divider') + .forEach(() => { + positions.push({ x: branchOffset + nestedBranchOffset, y: currentY, depth: 2, parentY: y }); + currentY += rowSpacing; + }); + } + }); + + // The trunk ends at the vertical center of the last top-level row — not at + // the running layout cursor, which may have advanced past it (e.g. through + // trailing nested-child rows or a group gap) and no longer reflects that point. + const lastRowY = rowPositions.length > 0 ? rowPositions[rowPositions.length - 1].y : rootY + nodeSize; + const trunkEndY = lastRowY + nodeSize / 2; + // contentBottomY is the center of the last positioned node (top-level or + // nested child, whichever comes last) — the drawing must extend past its + // full bottom edge (+ nodeSize / 2), not just its center. + const contentBottomY = currentY - rowSpacing / 2; + const svgHeight = Math.max(trunkEndY, contentBottomY) + nodeSize / 2 + edgeMargin; + + // Compute the drawing width from actual content: branchOffset/nestedBranchOffset + // are already absolute x-coordinates (they include the left edgeMargin via + // trunkX), so the rightmost content edge is the deepest branch column's node + // plus a matching right margin. + const rightmostBranchX = maxDepth > 1 ? branchOffset + nestedBranchOffset : branchOffset; + const svgWidth = rightmostBranchX + nodeSize + edgeMargin; + + // Curved connector path from trunk to node (S-curve bezier) + function connectorPath(fromX: number, fromY: number, toX: number, toY: number, stroke: string) { + const midX = fromX + (toX - fromX) * 0.4; + const d = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; + return ( + + ); + } + + function node(x: number, y: number, depth: number, key: string) { + const colors = depthColor(depth); + return ( + + + + {depthLetter(depth)} + + + ); + } + + // Trunk segments: split into solid runs, with a dashed run wherever a row + // is marked `dividerBefore` — dashes represent an actual `divider` item + // from the source data, spanning the full gap between that row and the one + // before it (not a generic "list is long" cue). + const trunkSegments: { y1: number; y2: number; dashed: boolean }[] = []; + let segmentStartY = rootY + nodeSize; + rowPositions.forEach((row, idx) => { + if (row.dividerBefore && idx > 0) { + const gapStartY = rowPositions[idx - 1].y + nodeSize / 2; + const gapEndY = row.y + nodeSize / 2; + trunkSegments.push({ y1: segmentStartY, y2: gapStartY, dashed: false }); + trunkSegments.push({ y1: gapStartY, y2: gapEndY, dashed: true }); + segmentStartY = gapEndY; + } + }); + trunkSegments.push({ y1: segmentStartY, y2: trunkEndY, dashed: false }); + + return ( + + ); +} + +function stripIcons(items: ReadonlyArray): SideNavigationProps.Item[] { + return items.map((item): SideNavigationProps.Item => { + switch (item.type) { + case 'divider': + return item; + case 'link': + return { ...item, icon: undefined }; + case 'section': + return { ...item, items: stripIcons(item.items) }; + case 'link-group': + return { ...item, icon: undefined, items: stripIcons(item.items) }; + case 'expandable-link-group': + return { ...item, icon: undefined, items: stripIcons(item.items) }; + case 'section-group': + return { + ...item, + items: stripIcons(item.items) as Array< + | SideNavigationProps.Section + | SideNavigationProps.Link + | SideNavigationProps.LinkGroup + | SideNavigationProps.ExpandableLinkGroup + >, + }; + } + }); +} + +function renderSideNavConfigs( + itemsWithIcons: SideNavigationProps.Item[], + activeHref: string, + setActiveHref: (href: string) => void, + header: SideNavigationProps['header'] +) { + const onFollow: SideNavigationProps['onFollow'] = event => { + if (!event.detail.external) { + event.preventDefault(); + setActiveHref(event.detail.href); + } + }; + + return ( + <> + + Standard navigation + + +
+ + Collapsible navigation + + +
+ +
+
+
+
+ + Example IA +
+ +
+
+ + ); +} + const COLLAPSED_WIDTH = 52; const EXPANDED_WIDTH = 220; @@ -99,7 +817,8 @@ export default function SideNavigationCollapsedPage() { {/* Toggle button at top */}
Product name}
+ // + // + // + // + // ), + // }, + // }} activeHref={activeHref} items={items} collapsed={collapsed} @@ -137,6 +879,23 @@ export default function SideNavigationCollapsedPage() { Toggle the navigation panel using the button. Items without icons are hidden in collapsed mode. Sections show their icon-bearing children as a flat list, and the section title becomes a divider in its place. + + {renderSideNavConfigs(demoItemsWithIcons, activeHref, setActiveHref, { href: '#/', text: 'Simple' })} + + + + {renderSideNavConfigs(demoServiceItemsWithIcons, activeHref, setActiveHref, { + href: '#/', + text: 'Service name', + })} + + + + {renderSideNavConfigs(demoLinkGroupsWithIcons, activeHref, setActiveHref, { + href: '#/', + text: 'Service name', + })} + diff --git a/src/app-layout/constants.scss b/src/app-layout/constants.scss index 04a4bcc032..2dff5ba050 100644 --- a/src/app-layout/constants.scss +++ b/src/app-layout/constants.scss @@ -35,8 +35,5 @@ $toolbar-z-index: 1000; // Shared toolbar drawer component values $toolbar-vertical-panel-icon-offset: 14px; -// Default collapsed navigation rail width -$navigation-collapsed-width-default: 54px; - $ai-drawer-background: #161d26; $ai-drawer-background: awsui.$color-background-layout-ai-drawer; diff --git a/src/app-layout/visual-refresh-toolbar/navigation/styles.scss b/src/app-layout/visual-refresh-toolbar/navigation/styles.scss index 5830eb28e7..36d9a15f62 100644 --- a/src/app-layout/visual-refresh-toolbar/navigation/styles.scss +++ b/src/app-layout/visual-refresh-toolbar/navigation/styles.scss @@ -8,7 +8,7 @@ @use '../../../internal/generated/custom-css-properties/index.scss' as custom-props; @use '../../constants.scss' as constants; -$collapsed-width: var(#{custom-props.$navigationCollapsedWidth}, constants.$navigation-collapsed-width-default); +$collapsed-width: var(#{custom-props.$navigationCollapsedWidth}, awsui.$size-side-navigation-collapsed-width); .hide-navigation { position: absolute; diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss b/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss index 65263ecd5f..4b4d4ac56b 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss +++ b/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss @@ -155,7 +155,7 @@ &.navigation-collapsed { @include desktop-only { - inline-size: var(#{custom-props.$navigationCollapsedWidth}, constants.$navigation-collapsed-width-default); + inline-size: var(#{custom-props.$navigationCollapsedWidth}, awsui.$size-side-navigation-collapsed-width); } } } diff --git a/src/expandable-section/__tests__/expandable-section.test.tsx b/src/expandable-section/__tests__/expandable-section.test.tsx index 751611d859..3b85f8b1d3 100644 --- a/src/expandable-section/__tests__/expandable-section.test.tsx +++ b/src/expandable-section/__tests__/expandable-section.test.tsx @@ -618,6 +618,27 @@ describe('__expandIconPosition', () => { }); expect(wrapper.findContent().getElement()).not.toHaveClass(styles['disable-content-paddings']); }); + + test('applies disable-content-paddings class for container variant', () => { + const wrapper = renderExpandableSection({ + variant: 'container', + disableContentPaddings: true, + defaultExpanded: true, + headerText: 'Header', + children: 'Content', + }); + expect(wrapper.findContent().getElement()).toHaveClass(styles['disable-content-paddings']); + }); + + test('does not apply disable-content-paddings class for container variant by default', () => { + const wrapper = renderExpandableSection({ + variant: 'container', + defaultExpanded: true, + headerText: 'Header', + children: 'Content', + }); + expect(wrapper.findContent().getElement()).not.toHaveClass(styles['disable-content-paddings']); + }); }); describe('outside icon button (end position with renderIconOutsideHeader)', () => { diff --git a/src/expandable-section/expandable-section-container.tsx b/src/expandable-section/expandable-section-container.tsx index f1d045aa40..c0237133cf 100644 --- a/src/expandable-section/expandable-section-container.tsx +++ b/src/expandable-section/expandable-section-container.tsx @@ -22,7 +22,6 @@ interface ExpandableSectionContainerProps extends InternalBaseComponentProps { children?: React.ReactNode; variant: InternalVariant; expanded: boolean | undefined; - disableContentPaddings: boolean | undefined; __injectAnalyticsComponentMetadata?: boolean; } @@ -32,7 +31,6 @@ export const ExpandableSectionContainer = ({ header, variant, expanded, - disableContentPaddings, __internalRootRef, __injectAnalyticsComponentMetadata, ...rest @@ -59,7 +57,7 @@ export const ExpandableSectionContainer = ({ className={className} header={header} variant={variant === 'stacked' ? 'stacked' : 'default'} - disableContentPaddings={disableContentPaddings || !expanded} + disableContentPaddings={true} disableHeaderPaddings={true} __hiddenContent={!expanded} __internalRootRef={__internalRootRef} diff --git a/src/expandable-section/expandable-section-header.tsx b/src/expandable-section/expandable-section-header.tsx index c14266309c..09245de032 100644 --- a/src/expandable-section/expandable-section-header.tsx +++ b/src/expandable-section/expandable-section-header.tsx @@ -104,6 +104,7 @@ interface ExpandableDefaultHeaderProps { interface ExpandableNavigationHeaderProps extends Omit { ariaLabelledBy?: string; + hideExpandIcon?: boolean; } interface ExpandableHeaderTextWrapperProps extends ExpandableDefaultHeaderProps { @@ -123,6 +124,7 @@ interface ExpandableSectionHeaderProps extends Omit { @@ -186,8 +188,9 @@ const ExpandableNavigationHeader = ({ children, icon, expandIconPosition, + hideExpandIcon, }: ExpandableNavigationHeaderProps) => { - const expandButton = ( + const expandButton = hideExpandIcon ? null : ( { const alwaysShowDivider = variantRequiresActionsDivider(variant) && headerActions; const icon = ( @@ -446,6 +450,7 @@ export const ExpandableSectionHeader = ({ {headerText ?? header} diff --git a/src/expandable-section/internal.tsx b/src/expandable-section/internal.tsx index 92f36921b4..3cd19d9c39 100644 --- a/src/expandable-section/internal.tsx +++ b/src/expandable-section/internal.tsx @@ -1,7 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { KeyboardEvent, useCallback, useRef } from 'react'; -import { CSSTransition } from 'react-transition-group'; +import React, { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; @@ -34,10 +33,15 @@ export type InternalExpandableSectionProps = Omit