@@ -524,6 +540,7 @@ Map {
},
"hasBreadcrumbsPortal": true,
"hasNavigation": true,
+ "navigationCollapsed": false,
"navigationFocusRef": undefined,
"navigationOpen": true,
"onNavigationToggle": undefined,
@@ -555,6 +572,7 @@ Map {
"navigation":
,
+ "navigationCollapsedWidth": 54,
"navigationOpen": true,
"navigationWidth": 280,
"notifications":
@@ -625,6 +643,7 @@ Map {
],
"maxContentWidth": undefined,
"minContentWidth": 280,
+ "navigationCollapsedWidth": 54,
"navigationOpen": true,
"navigationWidth": 280,
"onDrawerChange": [Function],
@@ -655,6 +674,7 @@ Map {
"toolsToggle": undefined,
},
"hasNavigation": true,
+ "navigationCollapsed": false,
"navigationFocusRef": undefined,
"navigationOpen": true,
"onNavigationToggle": undefined,
@@ -695,6 +715,7 @@ Map {
],
"maxContentWidth": undefined,
"minContentWidth": 280,
+ "navigationCollapsedWidth": 54,
"navigationOpen": true,
"navigationWidth": 280,
"onDrawerChange": [Function],
@@ -725,6 +746,7 @@ Map {
"toolsToggle": undefined,
},
"hasNavigation": true,
+ "navigationCollapsed": false,
"navigationFocusRef": undefined,
"navigationOpen": true,
"onNavigationToggle": undefined,
@@ -765,6 +787,7 @@ Map {
],
"maxContentWidth": undefined,
"minContentWidth": 280,
+ "navigationCollapsedWidth": 54,
"navigationOpen": true,
"navigationWidth": 280,
"onDrawerChange": [Function],
@@ -795,6 +818,7 @@ Map {
"toolsToggle": undefined,
},
"hasNavigation": true,
+ "navigationCollapsed": false,
"navigationFocusRef": undefined,
"navigationOpen": true,
"onNavigationToggle": undefined,
@@ -835,6 +859,7 @@ Map {
],
"maxContentWidth": undefined,
"minContentWidth": 280,
+ "navigationCollapsedWidth": 54,
"navigationOpen": true,
"navigationWidth": 280,
"onDrawerChange": [Function],
@@ -865,6 +890,7 @@ Map {
"toolsToggle": undefined,
},
"hasNavigation": true,
+ "navigationCollapsed": false,
"navigationFocusRef": undefined,
"navigationOpen": true,
"onNavigationToggle": undefined,
@@ -905,6 +931,7 @@ Map {
],
"maxContentWidth": undefined,
"minContentWidth": 280,
+ "navigationCollapsedWidth": 54,
"navigationOpen": true,
"navigationWidth": 280,
"onDrawerChange": [Function],
diff --git a/src/app-layout/__tests__/navigation-collapsed.test.tsx b/src/app-layout/__tests__/navigation-collapsed.test.tsx
new file mode 100644
index 0000000000..bfac5eb5c8
--- /dev/null
+++ b/src/app-layout/__tests__/navigation-collapsed.test.tsx
@@ -0,0 +1,182 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+
+import AppLayout from '../../../lib/components/app-layout';
+import customCssProps from '../../../lib/components/internal/generated/custom-css-properties';
+import { describeEachAppLayout, renderComponent } from './utils';
+
+import navStyles from '../../../lib/components/app-layout/visual-refresh-toolbar/navigation/styles.css.js';
+import skeletonStyles from '../../../lib/components/app-layout/visual-refresh-toolbar/skeleton/styles.css.js';
+import iconStyles from '../../../lib/components/icon/styles.css.js';
+
+jest.mock('@cloudscape-design/component-toolkit', () => ({
+ ...jest.requireActual('@cloudscape-design/component-toolkit'),
+ useContainerQuery: () => [1300, () => {}],
+}));
+
+describeEachAppLayout({ themes: ['refresh-toolbar'], sizes: ['desktop'] }, () => {
+ describe('collapsed rail visibility', () => {
+ test('navigation is visible when navigationCollapsed=true and navigationOpen=false', () => {
+ const { wrapper } = renderComponent(
+
Nav content>} />
+ );
+ const navContainer = wrapper.findNavigation().getElement().closest(`.${skeletonStyles.navigation}`);
+ expect(navContainer).not.toHaveClass(skeletonStyles['panel-hidden']);
+ expect(navContainer).toHaveClass(skeletonStyles['navigation-collapsed']);
+ });
+
+ test('navigation is hidden when navigationCollapsed=false and navigationOpen=false', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ const navContainer = wrapper.findNavigation().getElement().closest(`.${skeletonStyles.navigation}`);
+ expect(navContainer).toHaveClass(skeletonStyles['panel-hidden']);
+ expect(navContainer).not.toHaveClass(skeletonStyles['navigation-collapsed']);
+ });
+ });
+
+ describe('toggle behavior', () => {
+ test('close button shows angle-right icon in collapsed state', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ const closeButton = wrapper.findNavigationClose().getElement();
+ expect(closeButton.querySelector(`.${iconStyles['name-angle-right']}`)).not.toBeNull();
+ });
+
+ test('close button shows angle-left icon in open state on desktop', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ const closeButton = wrapper.findNavigationClose().getElement();
+ expect(closeButton.querySelector(`.${iconStyles['name-angle-left']}`)).not.toBeNull();
+ });
+ });
+
+ describe('aria-expanded', () => {
+ test('close button has aria-expanded=false when navigation is closed', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findNavigationClose().getElement()).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ test('close button has aria-expanded=true when navigation is open', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findNavigationClose().getElement()).toHaveAttribute('aria-expanded', 'true');
+ });
+ });
+
+ describe('aria-hidden', () => {
+ test('nav is not aria-hidden when collapsed (visible and interactive)', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findNavigation().getElement().closest('nav')).toHaveAttribute('aria-hidden', 'false');
+ });
+
+ test('nav is aria-hidden when closed without collapsed', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findNavigation().getElement().closest('nav')).toHaveAttribute('aria-hidden', 'true');
+ });
+ });
+
+ describe('navigationHide guard', () => {
+ test('navigation is fully hidden when navigationHide=true regardless of navigationCollapsed', () => {
+ const { wrapper } = renderComponent(
+ Nav>} />
+ );
+ expect(wrapper.findNavigation()).toBeFalsy();
+ expect(wrapper.findNavigationToggle()).toBeFalsy();
+ });
+ });
+
+ describe('layout computation', () => {
+ test('sets navigationCollapsedWidth CSS property', () => {
+ const { wrapper } = renderComponent(
+ Nav>} />
+ );
+ const rootStyle = wrapper.getElement().style;
+ expect(rootStyle.getPropertyValue(customCssProps.navigationCollapsedWidth)).toBe('54px');
+ });
+
+ test('uses custom navigationCollapsedWidth when provided', () => {
+ const { wrapper } = renderComponent(
+ Nav>}
+ />
+ );
+ const rootStyle = wrapper.getElement().style;
+ expect(rootStyle.getPropertyValue(customCssProps.navigationCollapsedWidth)).toBe('80px');
+ });
+ });
+
+ describe('default values', () => {
+ test('navigationCollapsedWidth defaults to 54', () => {
+ const { wrapper } = renderComponent(
+ Nav>} />
+ );
+ const rootStyle = wrapper.getElement().style;
+ expect(rootStyle.getPropertyValue(customCssProps.navigationCollapsedWidth)).toBe('54px');
+ });
+ });
+});
+
+describeEachAppLayout({ themes: ['refresh-toolbar'], sizes: ['mobile'] }, () => {
+ describe('mobile overlay', () => {
+ test('renders backdrop when navigationCollapsed=true and navigationOpen=true', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findByClassName(skeletonStyles['mobile-backdrop'])).not.toBeNull();
+ });
+
+ test('does not render backdrop when navigationOpen=false', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findByClassName(skeletonStyles['mobile-backdrop'])).toBeNull();
+ });
+
+ test('does not render backdrop without navigationCollapsed', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findByClassName(skeletonStyles['mobile-backdrop'])).toBeNull();
+ });
+ });
+
+ describe('toggle behavior on mobile', () => {
+ test('close button has aria-expanded=true when navigation open on mobile', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findNavigationClose().getElement()).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ test('close button has aria-expanded=false when collapsed on mobile', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ expect(wrapper.findNavigationClose().getElement()).toHaveAttribute('aria-expanded', 'false');
+ });
+ });
+
+ describe('navigation collapsed state', () => {
+ test('navigation panel has is-navigation-collapsed class on mobile', () => {
+ const { wrapper } = renderComponent(
+ Nav content>} />
+ );
+ const nav = wrapper.findNavigation().getElement().closest(`.${navStyles['navigation-container']}`);
+ expect(nav).toHaveClass(navStyles['is-navigation-collapsed']);
+ });
+ });
+});
diff --git a/src/app-layout/constants.scss b/src/app-layout/constants.scss
index 51ed2a67f2..9dfa46984d 100644
--- a/src/app-layout/constants.scss
+++ b/src/app-layout/constants.scss
@@ -35,4 +35,7 @@ $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;
diff --git a/src/app-layout/index.tsx b/src/app-layout/index.tsx
index 5c492b8c85..166fcd24b6 100644
--- a/src/app-layout/index.tsx
+++ b/src/app-layout/index.tsx
@@ -28,6 +28,7 @@ const AppLayout = React.forwardRef(
headerSelector = '#b #h',
footerSelector = '#b #f',
navigationWidth = 280,
+ navigationCollapsedWidth = 54,
toolsWidth = 290,
maxContentWidth,
minContentWidth,
@@ -96,6 +97,7 @@ const AppLayout = React.forwardRef(
const props = {
contentType,
navigationWidth,
+ navigationCollapsedWidth,
toolsWidth,
navigationOpen,
onNavigationChange,
diff --git a/src/app-layout/interfaces.ts b/src/app-layout/interfaces.ts
index 753933908d..bc56482fae 100644
--- a/src/app-layout/interfaces.ts
+++ b/src/app-layout/interfaces.ts
@@ -74,6 +74,19 @@ export interface BaseLayoutProps extends BaseComponentProps {
*/
navigationWidth?: number;
+ /**
+ * If `true`, the navigation panel shows a collapsed rail at `navigationCollapsedWidth`
+ * when `navigationOpen` is `false`, instead of being completely hidden.
+ */
+ navigationCollapsed?: boolean;
+
+ /**
+ * Width of the collapsed navigation rail in pixels.
+ * Only applies when `navigationCollapsed` is `true` and `navigationOpen` is `false`.
+ * @default 54
+ */
+ navigationCollapsedWidth?: number;
+
/**
* If `true`, the navigation drawer is not displayed at all.
*/
@@ -361,7 +374,13 @@ export namespace AppLayoutProps {
export type AppLayoutPropsWithDefaults = SomeRequired<
Omit,
- 'contentType' | 'navigationWidth' | 'toolsWidth' | 'minContentWidth' | 'navigationOpen' | 'onNavigationChange'
+ | 'contentType'
+ | 'navigationWidth'
+ | 'navigationCollapsedWidth'
+ | 'toolsWidth'
+ | 'minContentWidth'
+ | 'navigationOpen'
+ | 'onNavigationChange'
> & {
placement: {
insetBlockStart: number;
diff --git a/src/app-layout/visual-refresh-toolbar/compute-layout.ts b/src/app-layout/visual-refresh-toolbar/compute-layout.ts
index 3bc00b19ff..8274de349b 100644
--- a/src/app-layout/visual-refresh-toolbar/compute-layout.ts
+++ b/src/app-layout/visual-refresh-toolbar/compute-layout.ts
@@ -7,6 +7,8 @@ import { shouldSplitPanelBeForcedToBottom } from '../split-panel/split-panel-for
interface HorizontalLayoutInput {
navigationOpen: boolean;
navigationWidth: number;
+ navigationCollapsed: boolean;
+ navigationCollapsedWidth: number;
placement: AppLayoutPropsWithDefaults['placement'];
minContentWidth: number;
activeDrawerSize: number;
@@ -23,6 +25,8 @@ export const CONTENT_PADDING = 2 * 24; // space-xl
export function computeHorizontalLayout({
navigationOpen,
navigationWidth,
+ navigationCollapsed,
+ navigationCollapsedWidth,
placement,
minContentWidth,
activeDrawerSize,
@@ -33,7 +37,7 @@ export function computeHorizontalLayout({
activeGlobalDrawersSizes,
activeAiDrawerSize,
}: HorizontalLayoutInput) {
- const activeNavigationWidth = navigationOpen ? navigationWidth : 0;
+ const activeNavigationWidth = navigationOpen ? navigationWidth : navigationCollapsed ? navigationCollapsedWidth : 0;
let resizableSpaceAvailable =
placement.inlineSize - minContentWidth - CONTENT_PADDING - activeNavigationWidth - activeAiDrawerSize;
diff --git a/src/app-layout/visual-refresh-toolbar/interfaces.ts b/src/app-layout/visual-refresh-toolbar/interfaces.ts
index fae99a33ad..d69db96261 100644
--- a/src/app-layout/visual-refresh-toolbar/interfaces.ts
+++ b/src/app-layout/visual-refresh-toolbar/interfaces.ts
@@ -39,6 +39,8 @@ export interface AppLayoutInternals {
headerVariant: AppLayoutPropsWithDefaults['headerVariant'];
placement: AppLayoutPropsWithDefaults['placement'];
navigationOpen: AppLayoutPropsWithDefaults['navigationOpen'];
+ navigationCollapsed: boolean;
+ navigationCollapsedWidth: number;
navigationFocusControl: FocusControlState;
navigation: React.ReactNode;
splitPanelPosition: AppLayoutProps.SplitPanelPreferences['position'];
diff --git a/src/app-layout/visual-refresh-toolbar/navigation/index.tsx b/src/app-layout/visual-refresh-toolbar/navigation/index.tsx
index 5241cea55d..ccb868536b 100644
--- a/src/app-layout/visual-refresh-toolbar/navigation/index.tsx
+++ b/src/app-layout/visual-refresh-toolbar/navigation/index.tsx
@@ -27,6 +27,7 @@ export function AppLayoutNavigationImplementation({
onNavigationToggle,
isMobile,
navigationOpen,
+ navigationCollapsed,
navigation,
navigationFocusControl,
placement,
@@ -40,6 +41,12 @@ export function AppLayoutNavigationImplementation({
isMobile ? 0 : (bottomDrawerReportedSize ?? 0)
);
+ const isCollapsedState = navigationCollapsed && !navigationOpen;
+ // Always show the expand toggle in the collapsed rail
+ const showToggleInCollapsedState = isCollapsedState;
+ // On mobile with collapsed nav, use fixed overlay with backdrop when open
+ const useMobileOverlay = isMobile && navigationCollapsed;
+
// Close the Navigation drawer on mobile when a user clicks a link inside.
const onNavigationClick = (event: React.MouseEvent) => {
const hasLink = findUpUntil(
@@ -55,11 +62,17 @@ export function AppLayoutNavigationImplementation({