diff --git a/src/__integ__/__snapshots__/themes.test.ts.snap b/src/__integ__/__snapshots__/themes.test.ts.snap
index f19634ea03..65f473ffed 100644
--- a/src/__integ__/__snapshots__/themes.test.ts.snap
+++ b/src/__integ__/__snapshots__/themes.test.ts.snap
@@ -861,6 +861,8 @@ exports[`CSS Custom Properties match previous snapshot for mode "compact" 1`] =
"size-icon-large": "48px",
"size-icon-medium": "16px",
"size-icon-normal": "16px",
+ "size-side-navigation-collapsed-width": "52px",
+ "size-side-navigation-item-collapsed": "28px",
"size-side-navigation-item-height": "24px",
"size-table-selection-horizontal": "54px",
"size-vertical-input": "28px",
@@ -1165,7 +1167,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = `
"color-background-segment-wrapper": "#2a2e33",
"color-background-side-navigation-item-active": "transparent",
"color-background-side-navigation-item-active-collapsed": "#00a1c9",
- "color-background-side-navigation-item-icon-hover": "#414750",
+ "color-background-side-navigation-item-icon-hover": "#12293b",
"color-background-skeleton": "#2a2e33",
"color-background-skeleton-wave": "#414750",
"color-background-slider-handle-active": "#44b9d6",
@@ -1844,6 +1846,8 @@ exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = `
"size-icon-large": "48px",
"size-icon-medium": "16px",
"size-icon-normal": "16px",
+ "size-side-navigation-collapsed-width": "52px",
+ "size-side-navigation-item-collapsed": "30px",
"size-side-navigation-item-height": "28px",
"size-table-selection-horizontal": "54px",
"size-vertical-input": "32px",
@@ -2827,6 +2831,8 @@ exports[`CSS Custom Properties match previous snapshot for mode "light" 1`] = `
"size-icon-large": "48px",
"size-icon-medium": "16px",
"size-icon-normal": "16px",
+ "size-side-navigation-collapsed-width": "52px",
+ "size-side-navigation-item-collapsed": "30px",
"size-side-navigation-item-height": "28px",
"size-table-selection-horizontal": "54px",
"size-vertical-input": "32px",
@@ -3810,6 +3816,8 @@ exports[`CSS Custom Properties match previous snapshot for mode "reduced-motion"
"size-icon-large": "48px",
"size-icon-medium": "16px",
"size-icon-normal": "16px",
+ "size-side-navigation-collapsed-width": "52px",
+ "size-side-navigation-item-collapsed": "30px",
"size-side-navigation-item-height": "28px",
"size-table-selection-horizontal": "54px",
"size-vertical-input": "32px",
@@ -4793,6 +4801,8 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh"
"size-icon-large": "48px",
"size-icon-medium": "20px",
"size-icon-normal": "16px",
+ "size-side-navigation-collapsed-width": "52px",
+ "size-side-navigation-item-collapsed": "30px",
"size-side-navigation-item-height": "28px",
"size-table-selection-horizontal": "40px",
"size-vertical-input": "32px",
@@ -5776,6 +5786,8 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-
"size-icon-large": "48px",
"size-icon-medium": "20px",
"size-icon-normal": "16px",
+ "size-side-navigation-collapsed-width": "52px",
+ "size-side-navigation-item-collapsed": "28px",
"size-side-navigation-item-height": "24px",
"size-table-selection-horizontal": "40px",
"size-vertical-input": "28px",
@@ -6080,7 +6092,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-
"color-background-segment-wrapper": "#0f141a",
"color-background-side-navigation-item-active": "transparent",
"color-background-side-navigation-item-active-collapsed": "#42b4ff",
- "color-background-side-navigation-item-icon-hover": "#333843",
+ "color-background-side-navigation-item-icon-hover": "#001129",
"color-background-skeleton": "#232b37",
"color-background-skeleton-wave": "#333843",
"color-background-slider-handle-active": "#75cfff",
@@ -6759,6 +6771,8 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-
"size-icon-large": "48px",
"size-icon-medium": "20px",
"size-icon-normal": "16px",
+ "size-side-navigation-collapsed-width": "52px",
+ "size-side-navigation-item-collapsed": "30px",
"size-side-navigation-item-height": "28px",
"size-table-selection-horizontal": "40px",
"size-vertical-input": "32px",
@@ -7063,7 +7077,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-
"color-background-segment-wrapper": "#161d26",
"color-background-side-navigation-item-active": "transparent",
"color-background-side-navigation-item-active-collapsed": "#42b4ff",
- "color-background-side-navigation-item-icon-hover": "#333843",
+ "color-background-side-navigation-item-icon-hover": "#001129",
"color-background-skeleton": "#232b37",
"color-background-skeleton-wave": "#333843",
"color-background-slider-handle-active": "#75cfff",
@@ -7742,6 +7756,8 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-
"size-icon-large": "48px",
"size-icon-medium": "20px",
"size-icon-normal": "16px",
+ "size-side-navigation-collapsed-width": "52px",
+ "size-side-navigation-item-collapsed": "30px",
"size-side-navigation-item-height": "28px",
"size-table-selection-horizontal": "40px",
"size-vertical-input": "32px",
diff --git a/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap
index 2de90ad51c..7b0c44c509 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap
@@ -3357,6 +3357,20 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap
"light": "0px 4px 8px rgba(0, 28, 36, 0.45)",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -7000,6 +7014,20 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap
"light": "0px 4px 8px rgba(0, 28, 36, 0.45)",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -10643,6 +10671,20 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap
"light": "0px 4px 8px rgba(0, 28, 36, 0.45)",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -14286,6 +14328,20 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap
"light": "0px 4px 8px rgba(0, 28, 36, 0.45)",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -17929,6 +17985,20 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap
"light": "0px 4px 8px rgba(0, 28, 36, 0.45)",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -21572,6 +21642,20 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap
"light": "0px 4px 8px rgba(0, 28, 36, 0.45)",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -25215,6 +25299,20 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap
"light": "0px 4px 8px rgba(0, 28, 36, 0.45)",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -28863,6 +28961,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #e9ebed, 0px 6px 36px #0007161a",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -32506,6 +32618,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #e9ebed, 0px 6px 36px #0007161a",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -36149,6 +36275,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #e9ebed, 0px 6px 36px #0007161a",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -39792,6 +39932,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #e9ebed, 0px 6px 36px #0007161a",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -43435,6 +43589,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #e9ebed, 0px 6px 36px #0007161a",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -47078,6 +47246,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #e9ebed, 0px 6px 36px #0007161a",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -50721,6 +50903,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #192534, 0px 6px 36px #00040c",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -54364,6 +54560,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #e9ebed, 0px 6px 36px #0007161a",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
@@ -58007,6 +58217,20 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t
"light": "0px 1px 1px 1px #e9ebed, 0px 6px 36px #0007161a",
},
},
+ "size-side-navigation-collapsed-width": {
+ "$description": "The default width of the collapsed side navigation rail.",
+ "$value": {
+ "comfortable": "52px",
+ "compact": "52px",
+ },
+ },
+ "size-side-navigation-item-collapsed": {
+ "$description": "The square size of collapsed side navigation items.",
+ "$value": {
+ "comfortable": "30px",
+ "compact": "28px",
+ },
+ },
"size-side-navigation-item-height": {
"$description": "The minimum height of side navigation items.",
"$value": {
diff --git a/src/expandable-section/styles.scss b/src/expandable-section/styles.scss
index fa33da2a13..2501ff3494 100644
--- a/src/expandable-section/styles.scss
+++ b/src/expandable-section/styles.scss
@@ -289,62 +289,78 @@ $icon-total-space-medium: calc(#{$icon-width-medium} + #{$icon-margin-left} + #{
}
.content {
- display: grid;
- grid-template-rows: 0fr;
+ display: none;
- // HIDE (collapsed state): quick collapse, no delay
- @include styles.with-motion {
- transition: grid-template-rows $reveal-duration-hide $reveal-easing;
+ &-expanded {
+ display: block;
}
- &-expanded {
- grid-template-rows: 1fr;
+ &-compact {
+ padding-inline-start: $icon-total-space-normal;
+ }
- // SHOW (expanded state): paced height opening, no delay — height leads.
+ // One-theme only: replace the display:none/block toggle above with an animated
+ // grid-template-rows reveal. Scoped to one-theme (opt-in) so Visual Refresh and
+ // classic keep the pre-existing instantaneous display toggle — preserving the DOM
+ // visibility timing that existing consumer test suites (unit and e2e/webdriver
+ // isDisplayed()/isExisting() checks written against the old contract) rely on.
+ @include theming.one-theme-only {
+ display: grid;
+ grid-template-rows: 0fr;
+
+ // HIDE (collapsed state): quick collapse, no delay
@include styles.with-motion {
- transition: grid-template-rows $reveal-duration-show $reveal-easing;
+ transition: grid-template-rows $reveal-duration-hide $reveal-easing;
}
- }
- &-compact {
- padding-inline-start: $icon-total-space-normal;
+ &-expanded {
+ grid-template-rows: 1fr;
+
+ // SHOW (expanded state): paced height opening, no delay — height leads.
+ @include styles.with-motion {
+ transition: grid-template-rows $reveal-duration-show $reveal-easing;
+ }
+ }
}
}
-// Inner wrapper that collapses to 0 height via the grid-row track.
+// One-theme only: inner wrapper that collapses to 0 height via the grid-row track.
// NO padding-block here — padding lives on .content-inner-body so the grid
-// track can fully collapse to zero at 0fr.
+// track can fully collapse to zero at 0fr. Unused (no-op) outside one-theme, since
+// .content itself is display:none/block there and never establishes a grid track.
.content-inner {
- // Use overflow-y: clip (not hidden) so the grid-row track can still collapse
- // content vertically, while overflow-x: visible lets negative-margin
- // backgrounds (e.g. side-navigation active/hover states) bleed past the
- // inline edges without being cut off. `clip` does not coerce the other axis
- // to `auto` like `hidden` does, so this combination is safe from scrollbars.
- overflow-x: visible;
- overflow-y: clip;
- min-block-size: 0;
- opacity: 0;
-
- // HIDE (collapsed state): quick opacity collapse, no delay.
- @include styles.with-motion {
- transition: opacity $reveal-duration-hide $reveal-easing;
- }
+ @include theming.one-theme-only {
+ // Use overflow-y: clip (not hidden) so the grid-row track can still collapse
+ // content vertically, while overflow-x: visible lets negative-margin
+ // backgrounds (e.g. side-navigation active/hover states) bleed past the
+ // inline edges without being cut off. `clip` does not coerce the other axis
+ // to `auto` like `hidden` does, so this combination is safe from scrollbars.
+ overflow-x: visible;
+ overflow-y: clip;
+ min-block-size: 0;
+ opacity: 0;
+
+ // HIDE (collapsed state): quick opacity collapse, no delay.
+ @include styles.with-motion {
+ transition: opacity $reveal-duration-hide $reveal-easing;
+ }
- &-expanded {
- opacity: 1;
+ &-expanded {
+ opacity: 1;
- // SHOW (expanded state): opacity fades in AFTER height has partially
- // opened, creating a perceptible fade in the revealed space.
- @include styles.with-motion {
- transition: opacity $reveal-duration-show $reveal-easing $reveal-show-delay;
+ // SHOW (expanded state): opacity fades in AFTER height has partially
+ // opened, creating a perceptible fade in the revealed space.
+ @include styles.with-motion {
+ transition: opacity $reveal-duration-show $reveal-easing $reveal-show-delay;
+ }
}
- }
- // Applied after the expand transition settles. Switches overflow to visible
- // so active-state backgrounds and focus rings that extend beyond the content
- // box (via negative margins or outline-offset) are not clipped.
- &-settled {
- overflow: visible;
+ // Applied after the expand transition settles. Switches overflow to visible
+ // so active-state backgrounds and focus rings that extend beyond the content
+ // box (via negative margins or outline-offset) are not clipped.
+ &-settled {
+ overflow: visible;
+ }
}
}
diff --git a/src/side-navigation/__tests__/collapsed-side-navigation.test.tsx b/src/side-navigation/__tests__/collapsed-side-navigation.test.tsx
index 351a7ad052..f7b0ccb4a1 100644
--- a/src/side-navigation/__tests__/collapsed-side-navigation.test.tsx
+++ b/src/side-navigation/__tests__/collapsed-side-navigation.test.tsx
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
-import { act, fireEvent, render } from '@testing-library/react';
+import { fireEvent, render } from '@testing-library/react';
import Icon from '../../../lib/components/icon';
import SideNavigation, { SideNavigationProps } from '../../../lib/components/side-navigation';
@@ -990,86 +990,6 @@ describe('SideNavigation collapsed mode', () => {
});
});
- describe('content-settled class for wrap-jank prevention', () => {
- beforeEach(() => {
- jest.useFakeTimers();
- });
- afterEach(() => {
- jest.useRealTimers();
- });
-
- it('applies settled class on link-text-wrapper-content when expanded and transition completes', () => {
- const { container } = render();
- // Initially settled=true on mount when not collapsed (no transition needed).
- const contentEl = container.querySelector(`.${CSS.escape(styles['link-text-wrapper-content'])}`);
- expect(contentEl).not.toBeNull();
- expect(contentEl!.classList.contains(styles['link-text-wrapper-content--settled'])).toBe(true);
- });
-
- it('does not apply settled class when collapsed', () => {
- const { container } = render();
- const contentEl = container.querySelector(`.${CSS.escape(styles['link-text-wrapper-content'])}`);
- expect(contentEl).not.toBeNull();
- expect(contentEl!.classList.contains(styles['link-text-wrapper-content--settled'])).toBe(false);
- });
-
- it('removes settled class immediately on collapse and applies it after expand transition', () => {
- const { container, rerender } = render(
-
- );
- const getContentEl = () => container.querySelector(`.${CSS.escape(styles['link-text-wrapper-content'])}`);
-
- // Start: settled (expanded at rest).
- expect(getContentEl()!.classList.contains(styles['link-text-wrapper-content--settled'])).toBe(true);
-
- // Collapse: settled removed immediately.
- rerender();
- expect(getContentEl()!.classList.contains(styles['link-text-wrapper-content--settled'])).toBe(false);
-
- // Expand: settled is false during transition.
- rerender();
- expect(getContentEl()!.classList.contains(styles['link-text-wrapper-content--settled'])).toBe(false);
-
- // At 320ms (within transition tail), settled is STILL false — no premature wrap.
- act(() => {
- jest.advanceTimersByTime(320);
- });
- expect(getContentEl()!.classList.contains(styles['link-text-wrapper-content--settled'])).toBe(false);
-
- // After the fallback timer (500ms total), settled becomes true.
- // In a real browser, transitionend fires at ~300ms; in jsdom without CSS
- // transitions, the 500ms fallback guarantees settlement.
- act(() => {
- jest.advanceTimersByTime(180);
- });
- expect(getContentEl()!.classList.contains(styles['link-text-wrapper-content--settled'])).toBe(true);
- });
-
- it('applies settled class on label-text when expanded and settled', () => {
- const { container } = render(
-
- );
- const labelEl = container.querySelector(`.${CSS.escape(styles['label-text'])}`);
- expect(labelEl).not.toBeNull();
- expect(labelEl!.classList.contains(styles['label-text--settled'])).toBe(true);
- });
-
- it('does not apply label-text--settled when collapsed', () => {
- const { container } = render(
-
- );
- const labelEl = container.querySelector(`.${CSS.escape(styles['label-text'])}`);
- expect(labelEl).not.toBeNull();
- expect(labelEl!.classList.contains(styles['label-text--settled'])).toBe(false);
- });
- });
-
describe('inter-group spacing identifier class', () => {
it('applies list-item--group class to section, section-group, expandable-link-group, and link-group items when collapsed', () => {
const { container } = render(
diff --git a/src/side-navigation/implementation.tsx b/src/side-navigation/implementation.tsx
index 4d327a2377..451ed10d84 100644
--- a/src/side-navigation/implementation.tsx
+++ b/src/side-navigation/implementation.tsx
@@ -3,8 +3,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import clsx from 'clsx';
-import { useReducedMotion } from '@cloudscape-design/component-toolkit/internal';
-
import { useAppLayoutToolbarDesignEnabled } from '../app-layout/utils/feature-flags';
import { getBaseProps } from '../internal/base-component';
import { fireCancelableEvent, fireNonCancelableEvent } from '../internal/events';
@@ -41,81 +39,7 @@ export function SideNavigationImplementation({
const withIcons = useMemo(() => hasNavigationIcons(items), [items]);
const [activeTooltip, setActiveTooltip] = useState(null);
- // --- Content-settled flag ---
- // Mirrors the expandable-section pattern: settled=true ONLY when fully expanded
- // AND the expand transition has completed. Prevents text wrap-jank during expand.
- // On expand (collapsed true→false): settled is FALSE for the entire transition,
- // then becomes TRUE after ~300ms (the $link-enter-spatial-duration).
- // On collapse (false→true): settled is FALSE immediately.
const listContainerRef = useRef(null);
- const isReducedMotion = useReducedMotion(listContainerRef);
- // Read reduced-motion via ref so the effect only re-fires on `collapsed` changes,
- // preventing the timer from being cancelled by unrelated MutationObserver re-evaluations.
- const isReducedMotionRef = useRef(isReducedMotion);
- isReducedMotionRef.current = isReducedMotion;
- const [contentSettled, setContentSettled] = useState(() => !collapsed);
- const prevCollapsedRef = useRef(collapsed);
-
- useEffect(() => {
- const prevCollapsed = prevCollapsedRef.current;
- prevCollapsedRef.current = collapsed;
-
- if (collapsed) {
- // Collapsing: immediately unsettle (text stays nowrap).
- setContentSettled(false);
- return;
- }
-
- // Not an actual transition — either initial mount with collapsed=false or a
- // no-op re-render. Keep the current settled state (true on mount, unchanged otherwise).
- if (prevCollapsed === collapsed) {
- return;
- }
-
- // EXPAND START (collapsed was true, now false): unsettle immediately so text
- // stays nowrap during the width transition, preventing wrap-jank.
- setContentSettled(false);
-
- if (isReducedMotionRef.current) {
- // No CSS transition runs under reduced-motion — settle on next frame.
- const frameId = requestAnimationFrame(() => setContentSettled(true));
- return () => cancelAnimationFrame(frameId);
- }
-
- // Listen for the grid-template-columns transition to end on any descendant
- // .link-text-wrapper before allowing text wrap. This guarantees the column has
- // reached its final width (1fr) regardless of easing tail, rather than relying
- // on a fixed timer that may fire before the last paint frame.
- const container = listContainerRef.current;
- let settled = false;
- const settle = () => {
- if (!settled) {
- settled = true;
- setContentSettled(true);
- }
- };
-
- const handleTransitionEnd = (e: TransitionEvent) => {
- if (e.propertyName === 'grid-template-columns') {
- settle();
- }
- };
-
- if (container) {
- container.addEventListener('transitionend', handleTransitionEnd);
- }
-
- // Fallback: if transitionend doesn't fire (e.g., element removed, no transition
- // applied, or display:none), settle after a generous 500ms ceiling.
- const fallbackTimer = setTimeout(settle, 500);
-
- return () => {
- if (container) {
- container.removeEventListener('transitionend', handleTransitionEnd);
- }
- clearTimeout(fallbackTimer);
- };
- }, [collapsed]);
if (isDevelopment) {
// This code should be wiped in production anyway.
@@ -177,7 +101,6 @@ export function SideNavigationImplementation({
fireChange={onChangeHandler}
activeHref={activeHref}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
diff --git a/src/side-navigation/parts.tsx b/src/side-navigation/parts.tsx
index 51d8943e88..83a2ed49b8 100644
--- a/src/side-navigation/parts.tsx
+++ b/src/side-navigation/parts.tsx
@@ -36,7 +36,6 @@ interface BaseItemComponentProps {
) => void;
position?: string;
collapsed?: boolean;
- contentSettled?: boolean;
activeTooltip?: string | null;
setActiveTooltip?: (position: string | null) => void;
}
@@ -127,7 +126,6 @@ export function NavigationItemsList({
fireFollow,
position = '',
collapsed,
- contentSettled,
withIcons,
ariaLabel,
activeTooltip,
@@ -193,7 +191,6 @@ export function NavigationItemsList({
fireFollow={fireFollow}
position={itemPosition}
collapsed={collapsed}
- contentSettled={contentSettled}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
/>
@@ -222,7 +219,6 @@ export function NavigationItemsList({
fireFollow={fireFollow}
position={itemPosition}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
@@ -251,9 +247,7 @@ export function NavigationItemsList({
fireFollow={fireFollow}
position={itemPosition}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
- variant={variant}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
/>
@@ -281,7 +275,6 @@ export function NavigationItemsList({
fireFollow={fireFollow}
position={itemPosition}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
@@ -311,7 +304,6 @@ export function NavigationItemsList({
variant={variant}
position={itemPosition}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
@@ -467,16 +459,7 @@ function useCollapsedTooltip({
return { triggerRef, triggerProps, tooltip };
}
-function Link({
- definition,
- activeHref,
- fireFollow,
- position,
- collapsed,
- contentSettled,
- activeTooltip,
- setActiveTooltip,
-}: LinkProps) {
+function Link({ definition, activeHref, fireFollow, position, collapsed, activeTooltip, setActiveTooltip }: LinkProps) {
checkSafeUrl('SideNavigation', definition.href);
const isActive = definition.href === activeHref;
const i18n = useInternalI18n('link');
@@ -552,12 +535,7 @@ function Link({
className={clsx(styles['link-text-wrapper'], collapsed && styles['link-text-wrapper--collapsed'])}
aria-hidden={collapsed ? true : undefined}
>
-
+ {definition.text}
{definition.external && (
@@ -593,7 +571,6 @@ function Section({
variant,
position,
collapsed,
- contentSettled,
withIcons,
activeTooltip,
setActiveTooltip,
@@ -614,14 +591,9 @@ function Section({
setExpanded(definition.defaultExpanded ?? true);
}, [definition]);
- // Apply inert to the section header wrapper when collapsed so the hidden
- // expand toggle () is removed from the
- // tab order and assistive technology. The header is the first child of the
- // InternalExpandableSection root div.
- // Sections are always transparent — they promote their children in the collapsed
- // rail regardless of nesting depth. Only the section header is made inert so
- // child links remain focusable. Parent link-group/ELG containers independently
- // hide their children when collapsed, so no extra hiding is needed here.
+ // Sections are transparent in the collapsed rail — only the header is made inert
+ // so child links remain focusable. The header hosts the expand toggle that must
+ // leave the tab order when collapsed.
useEffect(() => {
const el = sectionRef.current as HTMLElement | null;
if (el) {
@@ -651,12 +623,7 @@ function Section({
)}
headerText={
{definition.text}
@@ -672,7 +639,6 @@ function Section({
activeHref={activeHref}
position={position}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
@@ -683,7 +649,6 @@ function Section({
interface SectionGroupProps extends BaseItemComponentProps {
definition: SideNavigationProps.SectionGroup;
- variant: 'section' | 'section-group' | 'link-group' | 'expandable-link-group' | 'root';
withIcons?: boolean;
}
@@ -694,7 +659,6 @@ function SectionGroup({
fireChange,
position,
collapsed,
- contentSettled,
withIcons,
activeTooltip,
setActiveTooltip,
@@ -702,9 +666,7 @@ function SectionGroup({
const isOneTheme = useOneTheme();
const sectionGroupRef = useRef(null);
- // Section-groups are always transparent — they promote their children in the
- // collapsed rail regardless of nesting depth. Parent link-group/ELG containers
- // independently hide their children when collapsed, so no extra hiding is needed.
+ // Section-groups are transparent — children are always promoted in the collapsed rail.
useEffect(() => {
if (sectionGroupRef.current) {
sectionGroupRef.current.inert = false;
@@ -712,18 +674,14 @@ function SectionGroup({
}, [collapsed]);
return (
-
+
{definition.title}
@@ -737,7 +695,6 @@ function SectionGroup({
activeHref={activeHref}
position={position}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
ariaLabel={collapsed ? definition.title : undefined}
activeTooltip={activeTooltip}
@@ -759,7 +716,6 @@ function LinkGroup({
fireChange,
position,
collapsed,
- contentSettled,
withIcons,
activeTooltip,
setActiveTooltip,
@@ -767,8 +723,8 @@ function LinkGroup({
checkSafeUrl('SideNavigation', definition.href);
const childrenRef = useRef(null);
- // Mirror the ELG mechanism: set inert on the children container when collapsed
- // so collapsed children are non-focusable and hidden from assistive technology.
+ // Set inert on the children container when collapsed so they leave
+ // the tab order and assistive technology.
useEffect(() => {
if (childrenRef.current) {
childrenRef.current.inert = !!collapsed;
@@ -790,7 +746,6 @@ function LinkGroup({
activeHref={activeHref}
position={position}
collapsed={collapsed}
- contentSettled={contentSettled}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
/>
@@ -810,7 +765,6 @@ function LinkGroup({
activeHref={activeHref}
position={position}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
ariaLabel={collapsed ? definition.text : undefined}
activeTooltip={activeTooltip}
@@ -836,7 +790,6 @@ function ExpandableLinkGroup({
variant,
position,
collapsed,
- contentSettled,
withIcons,
activeTooltip,
setActiveTooltip,
@@ -908,7 +861,6 @@ function ExpandableLinkGroup({
activeHref={activeHref}
position={position}
collapsed={collapsed}
- contentSettled={contentSettled}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
/>
@@ -922,7 +874,6 @@ function ExpandableLinkGroup({
activeHref={activeHref}
position={position}
collapsed={collapsed}
- contentSettled={contentSettled}
withIcons={withIcons}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
diff --git a/src/side-navigation/styles.scss b/src/side-navigation/styles.scss
index 581d2b9e84..9155a954ba 100644
--- a/src/side-navigation/styles.scss
+++ b/src/side-navigation/styles.scss
@@ -30,7 +30,7 @@ $link-enter-spatial-duration: 300ms;
// Dedicated faster duration for the text-wrapper grid-template-columns reveal (less staccato).
$link-enter-text-duration: 135ms;
$link-enter-opacity-duration: 150ms;
-$link-enter-opacity-delay: calc(#{$link-enter-text-duration} / 2); // 50ms — opacity finishes with text width
+$link-enter-opacity-delay: calc(#{$link-enter-text-duration} / 2); // opacity starts mid-text-reveal
// EXIT (collapse): opacity leads (fast text fade), spatial follows (width collapses after).
$link-exit-opacity-duration: 110ms;
@@ -436,17 +436,20 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
}
.section-group-title {
- // Grid-based collapse: mirrors .link-text-wrapper pattern.
- // Animating grid-template-columns 1fr→0fr collapses inline-size so the title
- // no longer reserves horizontal space in the collapsed rail.
+ // Grid-based collapse: grid-template-columns 1fr→0fr with a 0s step-snap
+ // (matching .link-text-wrapper) — width holds at 1fr during opacity fade,
+ // then snaps to 0fr instantly. No intermediate widths → no text reflow/squish.
display: grid;
grid-template-columns: 1fr;
align-items: center;
+ overflow: hidden;
+ min-inline-size: 0;
margin-block: 0;
@include styles.with-motion {
- // ENTER: spatial leads (width + height expand), no delay.
+ // ENTER: grid-columns snap to 1fr instantly (0s) — full width before text appears.
+ // Height expands with spatial timing separately.
transition:
- grid-template-columns $link-enter-text-duration $link-enter-easing,
+ grid-template-columns 0s,
block-size $link-enter-spatial-duration $link-enter-easing,
padding-block $link-enter-spatial-duration $link-enter-easing;
}
@@ -457,11 +460,13 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
padding-block: 0;
margin-block: 0;
@include styles.with-motion {
- // EXIT: spatial follows opacity — collapse width and height together.
+ // EXIT: opacity leads (on .label-text child), grid-columns snap to 0fr AFTER
+ // opacity completes (0s duration, delay = exit opacity duration) — holds at 1fr
+ // during the fade. Height collapses immediately with exit spatial timing (no delay).
transition:
- grid-template-columns $link-exit-spatial-duration $link-exit-easing,
- block-size $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay,
- padding-block $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay;
+ grid-template-columns 0s $link-exit-opacity-duration,
+ block-size $link-exit-spatial-duration $link-exit-easing,
+ padding-block $link-exit-spatial-duration $link-exit-easing;
}
}
}
@@ -498,8 +503,6 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
// instead of hard-swapping (fixes jarring background on state transitions).
transition:
block-size $link-enter-spatial-duration $link-enter-easing,
- padding-block $link-enter-spatial-duration $link-enter-easing,
- padding-inline $link-enter-spatial-duration $link-enter-easing,
background-color awsui.$motion-duration-responsive ease-in-out;
}
@@ -515,6 +518,11 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
&:hover {
color: awsui.$color-text-side-navigation-item-active-collapsed;
}
+ // Icon-less links collapse to zero size — suppress active background so it
+ // doesn't visibly shrink as a colored square/circle during the spatial exit.
+ &:not(:has(.item-icon)) {
+ background-color: transparent;
+ }
}
// stylelint-disable-next-line no-duplicate-selectors
& {
@@ -522,9 +530,29 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
transition:
color awsui.$motion-duration-responsive ease-in-out,
background-color $link-enter-spatial-duration ease-in-out,
- block-size $link-enter-spatial-duration $link-enter-easing,
- padding-block $link-enter-spatial-duration $link-enter-easing,
- padding-inline $link-enter-spatial-duration $link-enter-easing;
+ block-size $link-enter-spatial-duration $link-enter-easing;
+ }
+ }
+
+ // Icon-less links: step-function the bg so it snaps AFTER the spatial expand
+ // finishes — prevents a growing colored square on enter (mirrors the exit fix
+ // that snaps transparent immediately on collapse).
+ &:not(:has(.item-icon)) {
+ @include styles.with-motion {
+ transition:
+ color awsui.$motion-duration-responsive ease-in-out,
+ background-color 0ms linear $link-enter-spatial-duration,
+ block-size $link-enter-spatial-duration $link-enter-easing;
+ }
+
+ // Collapsed state: snap bg to transparent immediately (no delay) — exit fix.
+ &.link--collapsed {
+ @include styles.with-motion {
+ transition:
+ background-color 0ms linear,
+ block-size $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay,
+ min-block-size $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay;
+ }
}
}
}
@@ -539,8 +567,8 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
// Icon-bearing items: anchor the icon at the horizontal center of the collapsed
// square ($size-side-navigation-item-collapsed) independent of the text-wrapper width.
// Using padding-inline-start to fix the icon position decouples it from the
- // text-wrapper's grid-template-columns 1fr→0fr collapse animation, so:
- // (1) the text fades/collapses smoothly (no instant clamp), AND
+ // text-wrapper's grid-template-columns step-function collapse, so:
+ // (1) the text fades out via opacity (compositor-cheap), AND
// (2) the icon center stays stable (no drift from justify-content re-centering).
&:has(.item-icon) {
justify-content: flex-start;
@@ -551,8 +579,8 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
min-block-size: awsui.$size-side-navigation-item-collapsed;
max-block-size: awsui.$size-side-navigation-item-collapsed;
// Square forms naturally at rest: min-inline-size enforces minimum, text-wrapper at
- // grid-template-columns: 0fr contributes zero width. Parent ELG clips overflow during
- // transition. No max-inline-size needed — it would kill the text fade animation.
+ // grid-template-columns: 0fr contributes zero width. fit-content prevents transient
+ // overflow without a fixed max that would clip the text fade animation.
max-inline-size: fit-content;
box-sizing: border-box;
}
@@ -570,9 +598,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
// EXIT: spatial follows opacity — delay block collapse.
transition:
block-size $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay,
- min-block-size $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay,
- padding-block $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay,
- padding-inline $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay;
+ min-block-size $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay;
}
}
}
@@ -614,78 +640,69 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
// Misc
// ==========================================================================
-// Default: single-line text in all states (collapsed + transitioning).
-// The --settled modifier switches to white-space: normal once the expand
-// transition completes — preventing wrap-jank at intermediate column widths.
+// Text content: wraps at rest (white-space: normal). The parent grid container
+// (.link-text-wrapper) handles the 0fr clip via overflow: hidden + min-inline-size: 0,
+// so the step-snap holds full width during the opacity fade → no intermediate widths → no reflow.
.link-text-wrapper-content {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-
- &--settled {
- white-space: normal;
- overflow: visible;
- text-overflow: clip;
- }
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ min-inline-size: 0;
}
.link-text-wrapper {
- // Grid-based collapse: the wrapper itself is a single-column grid.
- // Animating grid-template-columns 1fr→0fr smoothly collapses content width
- // (unlike inline-size: auto→0 which is not animatable).
+ // Phase 1: Opacity-fade + step-function grid-snap for width collapse.
+ // grid-template-columns 1fr↔0fr is interpolable, so `0s + delay` correctly
+ // HOLDS at 1fr until the delay fires (unlike inline-size: auto→0 which is
+ // not transitionable and ignores delay — that was the Phase 1 inline-size bug).
+ // Width never passes through intermediate values → no text wrap jank.
+ // The step-snap makes the settled hack unnecessary: text is at full width
+ // (1fr) during the entire opacity fade, then snaps to 0fr instantly.
display: grid;
grid-template-columns: 1fr;
+ overflow: hidden;
min-inline-size: 0;
opacity: 1;
// ENTER (expanded state governs transition INTO expanded):
- // Spatial leads (width opens 200ms), opacity follows (150ms, delayed 50ms).
- // Delayed opacity keeps text invisible while width opens → no left-to-right wipe.
+ // Grid snaps to 1fr instantly (0s, no delay) — full width before text appears.
+ // Opacity fades in (150ms, delayed ~68ms) — text appears after width is established.
@include styles.with-motion {
transition:
- grid-template-columns $link-enter-text-duration $link-enter-easing,
+ grid-template-columns 0s,
opacity $link-enter-opacity-duration $link-enter-easing $link-enter-opacity-delay;
}
&--collapsed {
- opacity: 0;
grid-template-columns: 0fr;
+ opacity: 0;
// EXIT (collapsed state governs transition INTO collapsed):
- // Opacity leads (50ms, no delay) — text fades out fast.
- // Spatial follows (200ms, 25ms delay) — width collapses after opacity starts.
+ // Opacity leads (110ms, no delay) — text fades out visibly.
+ // Grid snaps to 0fr AFTER opacity completes (0s duration, delay = exit opacity duration)
+ // — holds at 1fr during the fade so text remains full-width + visible while fading.
@include styles.with-motion {
transition:
- grid-template-columns $link-exit-spatial-duration $link-exit-easing,
+ grid-template-columns 0s $link-exit-opacity-duration,
opacity $link-exit-opacity-duration $link-exit-easing;
}
- // Grid child: must allow shrinking below content width for 0fr to collapse fully.
- // white-space: nowrap prevents text from wrapping to multiple lines as the column narrows during collapse.
- > .link-text-wrapper-content {
- overflow: hidden;
- min-inline-size: 0;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
}
}
// Uniform text-fade for non-link labels (section headers, section-group titles).
// Uses the same opacity timing as .link-text-wrapper for consistency.
+// white-space: normal at rest so labels wrap like pre-collapse behavior.
+// The --collapsed state restores nowrap + overflow: hidden for the 0fr grid-row clip.
.label-text {
opacity: 1;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ min-inline-size: 0;
@include styles.with-motion {
// ENTER: opacity follows spatial — delayed fade-in after height expands.
transition: opacity $link-enter-opacity-duration $link-enter-easing $link-enter-opacity-delay;
}
- &--settled {
- white-space: normal;
- overflow: visible;
- text-overflow: clip;
- }
&--collapsed {
opacity: 0;
overflow: hidden;
From e9b172f187db5196d65be11973f49bf455c85ad3 Mon Sep 17 00:00:00 2001
From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com>
Date: Tue, 4 Aug 2026 19:03:38 -0700
Subject: [PATCH 3/8] chore: Make side-nav animations one theme only
---
src/internal/styles/motion/mixins.scss | 17 ++++++++++
src/side-navigation/styles.scss | 44 +++++++++++++-------------
2 files changed, 39 insertions(+), 22 deletions(-)
diff --git a/src/internal/styles/motion/mixins.scss b/src/internal/styles/motion/mixins.scss
index 187fd82c4c..3bcdbde8d8 100644
--- a/src/internal/styles/motion/mixins.scss
+++ b/src/internal/styles/motion/mixins.scss
@@ -21,3 +21,20 @@
}
}
/* stylelint-enable @cloudscape-design/no-motion-outside-of-mixin, selector-combinator-disallowed-list, selector-pseudo-class-no-unknown, selector-class-pattern */
+
+// Variant of with-motion for animations that are only offered in one-theme (opt-in).
+// Outside one-theme, no transition/animation is declared at all, so the surrounding
+// property changes apply instantly — same final CSS values, no animated reveal. Use
+// this (instead of with-motion) for animation-only additions so that Visual Refresh
+// and classic keep their pre-existing instantaneous state changes.
+// Named to end in "with-motion" so it satisfies the no-motion-outside-of-mixin stylelint
+// rule, which only recognizes @include params ending in that exact suffix.
+/* stylelint-disable @cloudscape-design/no-motion-outside-of-mixin, selector-combinator-disallowed-list, selector-pseudo-class-no-unknown, selector-class-pattern */
+@mixin one-theme-with-motion($selector: '') {
+ :global(#{$selector}.awsui-one-theme) & {
+ @include with-motion {
+ @content;
+ }
+ }
+}
+/* stylelint-enable @cloudscape-design/no-motion-outside-of-mixin, selector-combinator-disallowed-list, selector-pseudo-class-no-unknown, selector-class-pattern */
diff --git a/src/side-navigation/styles.scss b/src/side-navigation/styles.scss
index 9155a954ba..ea05e061c0 100644
--- a/src/side-navigation/styles.scss
+++ b/src/side-navigation/styles.scss
@@ -197,13 +197,13 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
.list-variant-link-group {
padding-inline-start: $item-indent;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition: padding-inline-start $link-enter-spatial-duration $link-enter-easing;
}
&--collapsed {
padding-inline-start: 0;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition: padding-inline-start $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay;
}
}
@@ -215,14 +215,14 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
display: grid;
grid-template-rows: 1fr;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition: grid-template-rows $link-enter-spatial-duration $link-enter-easing;
}
&--collapsed {
grid-template-rows: 0fr;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition: grid-template-rows $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay;
}
}
@@ -235,14 +235,14 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
opacity: 0;
// EXIT: opacity leads (fades children text before grid-rows collapses height).
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition: opacity $link-exit-opacity-duration $link-exit-easing;
}
&--expanded {
opacity: 1;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition: opacity $link-enter-opacity-duration $link-enter-easing $link-enter-opacity-delay;
}
}
@@ -343,7 +343,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
overflow-x: visible;
overflow-y: visible;
inline-size: awsui.$size-side-navigation-item-collapsed;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition: inline-size 0s linear $link-reveal-clip-delay;
}
}
@@ -355,7 +355,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
block-size: 0;
padding-block: 0;
margin-block: 0;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition:
overflow 0s linear $link-reveal-clip-delay,
inline-size 0s linear $link-reveal-clip-delay,
@@ -387,7 +387,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
display: grid;
grid-template-rows: 1fr;
opacity: 1;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition:
grid-template-rows $link-enter-spatial-duration $link-enter-easing,
padding-block $link-enter-spatial-duration $link-enter-easing,
@@ -405,7 +405,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
overflow: hidden;
padding-block: 0;
opacity: 0;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
// EXIT: spatial follows opacity — delay collapse.
transition:
grid-template-rows $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay,
@@ -445,7 +445,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
overflow: hidden;
min-inline-size: 0;
margin-block: 0;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
// ENTER: grid-columns snap to 1fr instantly (0s) — full width before text appears.
// Height expands with spatial timing separately.
transition:
@@ -459,7 +459,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
block-size: 0;
padding-block: 0;
margin-block: 0;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
// EXIT: opacity leads (on .label-text child), grid-columns snap to 0fr AFTER
// opacity completes (0s duration, delay = exit opacity duration) — holds at 1fr
// during the fade. Height collapses immediately with exit spatial timing (no delay).
@@ -497,7 +497,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
font-weight: styles.$font-weight-normal;
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
// ENTER: spatial leads — block-size/padding expand with no delay.
// background-color included so hover/active color changes interpolate smoothly
// instead of hard-swapping (fixes jarring background on state transitions).
@@ -526,7 +526,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
}
// stylelint-disable-next-line no-duplicate-selectors
& {
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition:
color awsui.$motion-duration-responsive ease-in-out,
background-color $link-enter-spatial-duration ease-in-out,
@@ -538,7 +538,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
// finishes — prevents a growing colored square on enter (mirrors the exit fix
// that snaps transparent immediately on collapse).
&:not(:has(.item-icon)) {
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition:
color awsui.$motion-duration-responsive ease-in-out,
background-color 0ms linear $link-enter-spatial-duration,
@@ -547,7 +547,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
// Collapsed state: snap bg to transparent immediately (no delay) — exit fix.
&.link--collapsed {
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition:
background-color 0ms linear,
block-size $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay,
@@ -594,7 +594,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
min-block-size: 0;
padding-block: 0;
margin-block: 0;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
// EXIT: spatial follows opacity — delay block collapse.
transition:
block-size $link-exit-spatial-duration $link-exit-easing $link-exit-spatial-delay,
@@ -667,7 +667,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
// ENTER (expanded state governs transition INTO expanded):
// Grid snaps to 1fr instantly (0s, no delay) — full width before text appears.
// Opacity fades in (150ms, delayed ~68ms) — text appears after width is established.
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition:
grid-template-columns 0s,
opacity $link-enter-opacity-duration $link-enter-easing $link-enter-opacity-delay;
@@ -681,7 +681,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
// Opacity leads (110ms, no delay) — text fades out visibly.
// Grid snaps to 0fr AFTER opacity completes (0s duration, delay = exit opacity duration)
// — holds at 1fr during the fade so text remains full-width + visible while fading.
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition:
grid-template-columns 0s $link-exit-opacity-duration,
opacity $link-exit-opacity-duration $link-exit-easing;
@@ -699,7 +699,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
overflow: visible;
text-overflow: clip;
min-inline-size: 0;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
// ENTER: opacity follows spatial — delayed fade-in after height expands.
transition: opacity $link-enter-opacity-duration $link-enter-easing $link-enter-opacity-delay;
}
@@ -707,7 +707,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
opacity: 0;
overflow: hidden;
white-space: nowrap;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
// EXIT: opacity leads — fast fade-out, no delay.
transition: opacity $link-exit-opacity-duration $link-exit-easing;
}
@@ -722,7 +722,7 @@ $link-reveal-clip-delay: calc(#{$link-exit-spatial-duration} + #{$link-exit-spat
.item-icon {
display: inline-flex;
flex-shrink: 0;
- @include styles.with-motion {
+ @include styles.one-theme-with-motion {
transition:
color awsui.$motion-duration-responsive awsui.$motion-easing-responsive,
margin-inline-end $link-exit-spatial-duration $link-exit-easing;
From 5a75cf06de1308e0a5e5502237c6a2a64e3a2217 Mon Sep 17 00:00:00 2001
From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:04:20 -0700
Subject: [PATCH 4/8] chore: More cleanup
---
src/expandable-section/styles.scss | 22 +-
src/internal/styles/motion/mixins.scss | 11 +-
.../collapsed-side-navigation.test.tsx | 967 ++++--------------
src/side-navigation/styles.scss | 97 +-
style-dictionary/one-theme/index.ts | 2 +-
style-dictionary/one-theme/motion.ts | 23 +
6 files changed, 285 insertions(+), 837 deletions(-)
create mode 100644 style-dictionary/one-theme/motion.ts
diff --git a/src/expandable-section/styles.scss b/src/expandable-section/styles.scss
index 2501ff3494..a4c8da2276 100644
--- a/src/expandable-section/styles.scss
+++ b/src/expandable-section/styles.scss
@@ -171,6 +171,7 @@ $icon-total-space-medium: calc(#{$icon-width-medium} + #{$icon-margin-left} + #{
padding-block: awsui.$space-container-header-top awsui.$space-container-header-bottom;
padding-inline-end: container.$header-padding-horizontal;
+ // HIDE (collapsed state): padding settles back to symmetric top/bottom.
@include styles.with-motion {
transition: padding-block-end $reveal-duration-hide $reveal-easing;
}
@@ -299,11 +300,10 @@ $icon-total-space-medium: calc(#{$icon-width-medium} + #{$icon-margin-left} + #{
padding-inline-start: $icon-total-space-normal;
}
- // One-theme only: replace the display:none/block toggle above with an animated
- // grid-template-rows reveal. Scoped to one-theme (opt-in) so Visual Refresh and
- // classic keep the pre-existing instantaneous display toggle — preserving the DOM
- // visibility timing that existing consumer test suites (unit and e2e/webdriver
- // isDisplayed()/isExisting() checks written against the old contract) rely on.
+ // One-theme only: animated grid-template-rows reveal instead of the display
+ // toggle above. Visual Refresh and classic keep instantaneous display toggling,
+ // since consumers of those themes rely on synchronous isDisplayed()/isExisting()
+ // DOM visibility checks.
@include theming.one-theme-only {
display: grid;
grid-template-rows: 0fr;
@@ -519,14 +519,10 @@ $icon-total-space-medium: calc(#{$icon-width-medium} + #{$icon-margin-left} + #{
}
}
-// Semantic wrapper around HeadingTag + actions in non-container variants.
-// min-block-size: 0 allows this element (a grid item) to collapse to zero
-// height when an ancestor uses grid-template-rows: 0fr (side-navigation
-// section header collapse). Without it, min-block-size: auto (the default)
-// holds the row open at content height.
-// IMPORTANT: This rule must remain GLOBAL (not scoped to .header-icon-end)
-// because the side-nav section collapse applies grid-template-rows: 0fr on
-// the header wrapper regardless of icon position (withIcons may be false).
+// min-block-size: 0 lets this grid item collapse to zero height when an ancestor
+// uses grid-template-rows: 0fr (side-navigation section header collapse).
+// Must remain global (not scoped to .header-icon-end) because the side-nav
+// collapse targets the header wrapper regardless of icon position.
.header-content {
min-block-size: 0;
}
diff --git a/src/internal/styles/motion/mixins.scss b/src/internal/styles/motion/mixins.scss
index 3bcdbde8d8..3d81b59635 100644
--- a/src/internal/styles/motion/mixins.scss
+++ b/src/internal/styles/motion/mixins.scss
@@ -22,13 +22,10 @@
}
/* stylelint-enable @cloudscape-design/no-motion-outside-of-mixin, selector-combinator-disallowed-list, selector-pseudo-class-no-unknown, selector-class-pattern */
-// Variant of with-motion for animations that are only offered in one-theme (opt-in).
-// Outside one-theme, no transition/animation is declared at all, so the surrounding
-// property changes apply instantly — same final CSS values, no animated reveal. Use
-// this (instead of with-motion) for animation-only additions so that Visual Refresh
-// and classic keep their pre-existing instantaneous state changes.
-// Named to end in "with-motion" so it satisfies the no-motion-outside-of-mixin stylelint
-// rule, which only recognizes @include params ending in that exact suffix.
+// Like with-motion, but scoped to one-theme only — outside that context no
+// transition/animation is declared, so property changes apply instantly.
+// Use for animation-only additions where VR/classic must remain instantaneous.
+// Named "…with-motion" to satisfy the no-motion-outside-of-mixin stylelint rule.
/* stylelint-disable @cloudscape-design/no-motion-outside-of-mixin, selector-combinator-disallowed-list, selector-pseudo-class-no-unknown, selector-class-pattern */
@mixin one-theme-with-motion($selector: '') {
:global(#{$selector}.awsui-one-theme) & {
diff --git a/src/side-navigation/__tests__/collapsed-side-navigation.test.tsx b/src/side-navigation/__tests__/collapsed-side-navigation.test.tsx
index f7b0ccb4a1..ba17caaaae 100644
--- a/src/side-navigation/__tests__/collapsed-side-navigation.test.tsx
+++ b/src/side-navigation/__tests__/collapsed-side-navigation.test.tsx
@@ -7,7 +7,6 @@ import Icon from '../../../lib/components/icon';
import SideNavigation, { SideNavigationProps } from '../../../lib/components/side-navigation';
import createWrapper from '../../../lib/components/test-utils/dom';
-import expandableSectionStyles from '../../../lib/components/expandable-section/styles.css.js';
import styles from '../../../lib/components/side-navigation/styles.css.js';
import testStyles from '../../../lib/components/side-navigation/test-classes/styles.css.js';
@@ -162,7 +161,6 @@ describe('SideNavigation collapsed mode', () => {
],
});
expect(wrapper.findAll('hr')).toHaveLength(0);
- // Section children with icons are mounted.
expect(wrapper.findLinkByHref('#/compute')).not.toBeNull();
expect(wrapper.findLinkByHref('#/storage')).not.toBeNull();
});
@@ -175,88 +173,39 @@ describe('SideNavigation collapsed mode', () => {
expect(wrapper.findAll('hr')).toHaveLength(0);
});
- it('forces section expanded when collapsed so children are not height-collapsed', () => {
- const { container } = render(
-
- );
- // The section's expandable trigger should not have aria-expanded="false"
- // (which would indicate children are collapsed to zero height via grid).
- const triggers = container.querySelectorAll('[aria-expanded]');
- triggers.forEach(trigger => {
- expect(trigger.getAttribute('aria-expanded')).not.toBe('false');
+ it('keeps the section expanded when collapsed so children are not height-collapsed', () => {
+ const wrapper = renderSideNavigation({
+ collapsed: true,
+ items: [
+ {
+ type: 'section',
+ text: 'Resources',
+ items: [iconLink('Compute', '#/compute'), iconLink('Storage', '#/storage')],
+ },
+ ],
});
+ expect(wrapper.findItemByIndex(1)?.findSection()?.findExpandedContent()).toBeTruthy();
});
- it('section-group title collapses to zero height when collapsed', () => {
- const { container } = render(
-
- );
- // The section-group-title element should have the collapsed class.
- const titleEl = container.querySelector('[class*="section-group-title"]');
- expect(titleEl).not.toBeNull();
- expect(titleEl!.className).toMatch(/section-group-title--collapsed/);
- });
-
- it('section-group title has both base and collapsed class for specificity override', () => {
- const { container } = render(
-
- );
- // The title element must have both the base class and the --collapsed modifier
- // (compound selector .section-group-title.section-group-title--collapsed overrides
- // InternalBox's .box.h3-variant padding).
- const titleEl = container.querySelector(`.${CSS.escape(styles['section-group-title'])}`);
- expect(titleEl).not.toBeNull();
- expect(titleEl!.classList.contains(styles['section-group-title--collapsed'])).toBe(true);
- });
-
- it('section-group title does not have collapsed class when not collapsed', () => {
- const { container } = render(
-
- );
- const titleEl = container.querySelector(`.${CSS.escape(styles['section-group-title'])}`);
- expect(titleEl).not.toBeNull();
- expect(titleEl!.classList.contains(styles['section-group-title--collapsed'])).toBe(false);
- });
+ it.each([true, false])(
+ 'applies the collapsed class to the section-group title only when collapsed=%s',
+ collapsed => {
+ const wrapper = renderSideNavigation({
+ collapsed,
+ items: [{ type: 'section-group', title: 'My Group', items: [iconLink('Item A', '#/a')] }],
+ });
+ const titleEl = wrapper.findItemByIndex(1)?.findSectionGroupTitle()?.getElement();
+ expect(titleEl).not.toBeNull();
+ expect(titleEl!.classList.contains(styles['section-group-title--collapsed'])).toBe(collapsed);
+ }
+ );
});
describe('expandable link groups', () => {
- it('keeps children mounted with faded text in collapsed mode', () => {
+ it.each([
+ { label: 'icon-bearing', items: [iconLink('Alarms', '#/alarms'), iconLink('Logs', '#/logs')] },
+ { label: 'icon-less', items: [plainLink('VPC', '#/vpc'), plainLink('Route 53', '#/r53')] },
+ ])('keeps $label children mounted so the collapse animation has content to transition', ({ items }) => {
const wrapper = renderSideNavigation({
collapsed: true,
items: [
@@ -265,120 +214,49 @@ describe('SideNavigation collapsed mode', () => {
text: 'Monitoring',
href: '#/monitoring',
icon: ,
- items: [iconLink('Alarms', '#/alarms'), iconLink('Logs', '#/logs')],
+ items,
},
],
});
- // The ELG header link is still rendered.
expect(wrapper.findLinkByHref('#/monitoring')).not.toBeNull();
- // Children with icons are mounted (not unmounted).
- expect(wrapper.findLinkByHref('#/alarms')).not.toBeNull();
- expect(wrapper.findLinkByHref('#/logs')).not.toBeNull();
+ items.forEach(item => expect(wrapper.findLinkByHref(item.href)).not.toBeNull());
});
- it('forces ELG children collapsed (aria-expanded=false) when nav is collapsed', () => {
- const { container } = render(
- ,
- items: [iconLink('Alarms', '#/alarms')],
- },
- ]}
- />
- );
- // When collapsed, the ELG expand icon is hidden from AT (no interactive button).
- // The children content is collapsed via the inert attribute on the content-inner div.
- const elgRoot = container.querySelector(`.${CSS.escape(styles['expandable-link-group--collapsed'])}`);
- expect(elgRoot).not.toBeNull();
- // No interactive expand button should be present.
- expect(elgRoot!.querySelector('button[aria-expanded]')).toBeNull();
- // The content-inner should be inerted (children non-focusable).
- // Find the content wrapper by its role="group" attribute (rendered by InternalExpandableSection).
- const groupDiv = elgRoot!.querySelector('[role="group"]');
- expect(groupDiv).not.toBeNull();
- const contentInner = groupDiv!.firstElementChild as HTMLElement | null;
- expect(contentInner).not.toBeNull();
- expect(contentInner!.inert).toBe(true);
- });
-
- it('keeps ELG without icon mounted with header and children in collapsed mode', () => {
+ it('hides the expand button and inerts the content when collapsed', () => {
const wrapper = renderSideNavigation({
collapsed: true,
items: [
{
type: 'expandable-link-group',
- text: 'No Icon ELG',
- href: '#/elg',
- items: [iconLink('Child', '#/child')],
+ text: 'Monitoring',
+ href: '#/monitoring',
+ icon: ,
+ items: [iconLink('Alarms', '#/alarms')],
},
],
});
- // Both the ELG header and children are mounted.
- expect(wrapper.findLinkByHref('#/child')).not.toBeNull();
- expect(wrapper.findLinkByHref('#/elg')).not.toBeNull();
- });
-
- it('keeps icon-less ELG children mounted and inert in collapsed mode for animation', () => {
- const { container } = render(
- ,
- items: [plainLink('VPC', '#/vpc'), plainLink('Route 53', '#/r53')],
- },
- ]}
- />
- );
- const wrapper = createWrapper(container).findSideNavigation()!;
- // Icon-less children inside an ELG must stay mounted (not filtered)
- // so the grid-template-rows collapse animation has content to transition.
- expect(wrapper.findLinkByHref('#/vpc')).not.toBeNull();
- expect(wrapper.findLinkByHref('#/r53')).not.toBeNull();
- // The ELG content is inerted when collapsed.
- const elgRoot = container.querySelector(`.${CSS.escape(styles['expandable-link-group--collapsed'])}`);
- expect(elgRoot).not.toBeNull();
- const groupDiv = elgRoot!.querySelector('[role="group"]');
- expect(groupDiv).not.toBeNull();
- const contentInner = groupDiv!.firstElementChild as HTMLElement | null;
- expect(contentInner).not.toBeNull();
- expect(contentInner!.inert).toBe(true);
+ const elg = wrapper.findItemByIndex(1)?.findExpandableLinkGroup();
+ expect(elg?.findExpandButton()).toBeNull();
+ const contentInner = elg?.findContent().getElement().firstElementChild as HTMLElement;
+ expect(contentInner.inert).toBe(true);
});
});
describe('link groups', () => {
- it('keeps children mounted but inert in collapsed mode', () => {
+ it.each([
+ { label: 'icon-bearing', items: [iconLink('Child 1', '#/c1'), iconLink('Child 2', '#/c2')] },
+ { label: 'icon-less', items: [plainLink('Lambda', '#/lambda'), plainLink('S3', '#/s3')] },
+ ])('keeps $label children mounted but inert when collapsed', ({ items }) => {
const { container } = render(
,
- items: [iconLink('Child 1', '#/c1'), iconLink('Child 2', '#/c2')],
- },
- ]}
+ items={[{ type: 'link-group', text: 'Group', href: '#/group', icon: , items }]}
/>
);
const wrapper = createWrapper(container).findSideNavigation()!;
- // The header link is still rendered.
expect(wrapper.findLinkByHref('#/group')).not.toBeNull();
- // Children are mounted in the DOM.
- expect(wrapper.findLinkByHref('#/c1')).not.toBeNull();
- expect(wrapper.findLinkByHref('#/c2')).not.toBeNull();
- // Children container is inert (non-focusable, hidden from AT).
+ items.forEach(item => expect(wrapper.findLinkByHref(item.href)).not.toBeNull());
const childrenInner = container.querySelector(`.${CSS.escape(styles['link-group-children-inner'])}`);
- expect(childrenInner).not.toBeNull();
expect((childrenInner as HTMLElement).inert).toBe(true);
});
@@ -392,17 +270,16 @@ describe('SideNavigation collapsed mode', () => {
text: 'Group',
href: '#/group',
icon: ,
- items: [iconLink('Child 1', '#/c1'), iconLink('Child 2', '#/c2')],
+ items: [iconLink('Child 1', '#/c1')],
},
]}
/>
);
const childrenInner = container.querySelector(`.${CSS.escape(styles['link-group-children-inner'])}`);
- expect(childrenInner).not.toBeNull();
expect((childrenInner as HTMLElement).inert).toBe(false);
});
- it('applies collapsed class to children container for grid animation', () => {
+ it('applies the collapsed class to the children container for the grid animation', () => {
const { container } = render(
{
/>
);
const childrenContainer = container.querySelector(`.${CSS.escape(styles['link-group-children'])}`);
- expect(childrenContainer).not.toBeNull();
- expect(childrenContainer!.classList.contains(styles['link-group-children--collapsed'])).toBe(true);
- });
-
- it('keeps icon-less children mounted and inert in collapsed mode for animation', () => {
- const { container } = render(
- ,
- items: [plainLink('Lambda', '#/lambda'), plainLink('S3', '#/s3')],
- },
- ]}
- />
- );
- const wrapper = createWrapper(container).findSideNavigation()!;
- // Icon-less children inside a link-group must stay mounted (not filtered)
- // so the grid-template-rows collapse animation has content to transition.
- expect(wrapper.findLinkByHref('#/lambda')).not.toBeNull();
- expect(wrapper.findLinkByHref('#/s3')).not.toBeNull();
- // The children container must be inert (non-focusable, hidden from AT).
- const childrenInner = container.querySelector(`.${CSS.escape(styles['link-group-children-inner'])}`);
- expect(childrenInner).not.toBeNull();
- expect((childrenInner as HTMLElement).inert).toBe(true);
- // The children wrapper has the collapsed class for the 0fr animation target.
- const childrenContainer = container.querySelector(`.${CSS.escape(styles['link-group-children'])}`);
expect(childrenContainer!.classList.contains(styles['link-group-children--collapsed'])).toBe(true);
});
});
@@ -555,57 +402,22 @@ describe('SideNavigation collapsed mode', () => {
});
describe('collapsed CSS classes on root list', () => {
- it('applies list-variant-root--collapsed class to the root
when collapsed is true', () => {
+ it.each([true, false])('applies the collapsed class to the root list only when collapsed=%s', collapsed => {
const { container } = render(
- ,
- items: [iconLink('Build', '#/build')],
- },
- ]}
- />
+
);
- // Query the root
— the first
inside the list-container that has list-variant-root class
const rootUl = container.querySelector(`ul.${CSS.escape(styles['list-variant-root'])}`);
- expect(rootUl).not.toBeNull();
- expect(rootUl!.classList.contains(styles['list-variant-root--collapsed'])).toBe(true);
+ expect(rootUl!.classList.contains(styles['list-variant-root--collapsed'])).toBe(collapsed);
});
- it('does NOT apply list-variant-root--collapsed class when collapsed is false', () => {
- const { container } = render();
- const rootUl = container.querySelector(`ul.${CSS.escape(styles['list-variant-root'])}`);
- expect(rootUl).not.toBeNull();
- expect(rootUl!.classList.contains(styles['list-variant-root--collapsed'])).toBe(false);
- });
-
- it('applies list-variant-section--collapsed class to section