From c10ea38286d8f5acae550491c3bb152879bfbf33 Mon Sep 17 00:00:00 2001 From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:44:10 -0700 Subject: [PATCH 1/3] feat: Add props to AppLayoutToolbar for collapsedNavigation and non-toolbar mobile state # Conflicts: # src/app-layout/visual-refresh-toolbar/widget-areas/before-main-slot.tsx --- build-tools/utils/custom-css-properties.js | 1 + .../collapsed-with-app-layout.page.tsx | 124 ++++++++++++ .../__snapshots__/documenter.test.ts.snap | 30 +++ src/app-layout-toolbar/index.tsx | 2 + .../widget-contract-old.test.tsx.snap | 35 ++++ ...get-contract-split-panel-old.test.tsx.snap | 22 +++ .../widget-contract-split-panel.test.tsx.snap | 18 ++ .../widget-contract.test.tsx.snap | 27 +++ .../__tests__/navigation-collapsed.test.tsx | 182 ++++++++++++++++++ src/app-layout/constants.scss | 3 + src/app-layout/index.tsx | 2 + src/app-layout/interfaces.ts | 21 +- .../visual-refresh-toolbar/compute-layout.ts | 6 +- .../visual-refresh-toolbar/interfaces.ts | 2 + .../navigation/index.tsx | 42 +++- .../navigation/styles.scss | 78 ++++++-- .../skeleton/skeleton-parts.tsx | 8 +- .../skeleton/styles.scss | 42 +++- .../state/interfaces.ts | 1 + .../state/props-merger.ts | 2 + .../state/use-app-layout.tsx | 15 +- .../state/use-skeleton-slots-attributes.ts | 15 +- .../visual-refresh-toolbar/toolbar/index.tsx | 1 + .../toolbar/styles.scss | 4 +- .../widget-areas/before-main-slot.tsx | 29 ++- .../collapsed-side-navigation.test.tsx | 6 +- src/side-navigation/implementation.tsx | 7 +- src/side-navigation/parts.tsx | 70 +++---- src/side-navigation/styles.scss | 4 + 29 files changed, 718 insertions(+), 81 deletions(-) create mode 100644 pages/side-navigation/collapsed-with-app-layout.page.tsx create mode 100644 src/app-layout/__tests__/navigation-collapsed.test.tsx diff --git a/build-tools/utils/custom-css-properties.js b/build-tools/utils/custom-css-properties.js index 73511df043..6096d0fae0 100644 --- a/build-tools/utils/custom-css-properties.js +++ b/build-tools/utils/custom-css-properties.js @@ -32,6 +32,7 @@ const customCssPropertiesList = [ 'offsetTop', 'overlapHeight', 'navigationWidth', + 'navigationCollapsedWidth', 'splitPanelReportedHeaderSize', 'splitPanelReportedSize', 'splitPanelHeight', diff --git a/pages/side-navigation/collapsed-with-app-layout.page.tsx b/pages/side-navigation/collapsed-with-app-layout.page.tsx new file mode 100644 index 0000000000..0485c4f33e --- /dev/null +++ b/pages/side-navigation/collapsed-with-app-layout.page.tsx @@ -0,0 +1,124 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { AppLayoutToolbar, Icon } from '~components'; +import Box from '~components/box'; +import Button from '~components/button'; +import Header from '~components/header'; +import SideNavigation, { SideNavigationProps } from '~components/side-navigation'; +import SpaceBetween from '~components/space-between'; +import Toggle from '~components/toggle'; + +import labels from '../app-layout/utils/labels'; + +const items: SideNavigationProps.Item[] = [ + { type: 'link', text: 'Dashboard', href: '#/dashboard', icon: }, + { type: 'link', text: 'Events', href: '#/events', icon: }, + { + type: 'expandable-link-group', + text: 'Projects', + href: '#/projects', + icon: , + items: [ + { type: 'link', text: 'Project 1', href: '#/projects/1' }, + { type: 'link', text: 'Project 2', href: '#/projects/2' }, + { type: 'link', text: 'Project 3', href: '#/projects/3' }, + ], + }, + { type: 'link', text: 'Team', href: '#/team', icon: }, + { type: 'link', text: 'Settings', href: '#/settings', icon: }, + { type: 'divider' }, + { type: 'link', text: 'Notifications', href: '#/notifications', icon: }, + { type: 'link', text: 'Documentation', href: '#/docs', icon: , external: true }, +]; + +const AwsSvg = ( + + + + + +); + +export default function CollapsedWithAppLayoutPage() { + const [navOpen, setNavOpen] = useState(false); + const [activeHref, setActiveHref] = useState('#/dashboard'); + const [collapsedEnabled, setCollapsedEnabled] = useState(true); + const [hideToolbar, setHideToolbar] = useState(true); + const [hideTools, setHideTools] = useState(true); + const [showHeader, setShowHeader] = useState(true); + const [headerIcon, setHeaderIcon] = useState(true); + + const header = { text: 'Project 3', href: '#/projects/3' }; + const headerWithIcon = { ...header, logo: { svg: AwsSvg } }; + + return ( + setNavOpen(detail.open)} + ariaLabels={labels} + navigation={ + { + e.preventDefault(); + setActiveHref(e.detail.href); + }} + /> + } + content={ + +
Collapsed Navigation with AppLayout
+ + + + This page demonstrates the navigationCollapsed prop on AppLayoutToolbar. When enabled, the + navigation shows a collapsed icon rail instead of disappearing completely when closed. + + setCollapsedEnabled(detail.checked)}> + Enable navigationCollapsed + + setHideToolbar(detail.checked)}> + Set navigationTriggerHide + + setHideTools(detail.checked)}> + Hide tools + + setShowHeader(detail.checked)}> + Show side nav header + + {showHeader && ( + + setHeaderIcon(detail.checked)}> + Include icon + + + )} + + + Current state: navigation is{' '} + {navOpen ? 'open' : collapsedEnabled ? 'collapsed (rail)' : 'closed (hidden)'} + + + +
+ } + /> + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 3a778ff8b2..88669f8f80 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -1473,6 +1473,21 @@ If set to \`Number.MAX_VALUE\`, the main content panel will occupy the full avai "optional": true, "type": "number", }, + { + "description": "If \`true\`, the navigation panel shows a collapsed rail at \`navigationCollapsedWidth\` +when \`navigationOpen\` is \`false\`, instead of being completely hidden.", + "name": "navigationCollapsed", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "54", + "description": "Width of the collapsed navigation rail in pixels. +Only applies when \`navigationCollapsed\` is \`true\` and \`navigationOpen\` is \`false\`.", + "name": "navigationCollapsedWidth", + "optional": true, + "type": "number", + }, { "description": "If \`true\`, the navigation drawer is not displayed at all.", "name": "navigationHide", @@ -2022,6 +2037,21 @@ If set to \`Number.MAX_VALUE\`, the main content panel will occupy the full avai "optional": true, "type": "number", }, + { + "description": "If \`true\`, the navigation panel shows a collapsed rail at \`navigationCollapsedWidth\` +when \`navigationOpen\` is \`false\`, instead of being completely hidden.", + "name": "navigationCollapsed", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "54", + "description": "Width of the collapsed navigation rail in pixels. +Only applies when \`navigationCollapsed\` is \`true\` and \`navigationOpen\` is \`false\`.", + "name": "navigationCollapsedWidth", + "optional": true, + "type": "number", + }, { "description": "If \`true\`, the navigation drawer is not displayed at all.", "name": "navigationHide", diff --git a/src/app-layout-toolbar/index.tsx b/src/app-layout-toolbar/index.tsx index 6697b6f5fc..a40660a8ae 100644 --- a/src/app-layout-toolbar/index.tsx +++ b/src/app-layout-toolbar/index.tsx @@ -29,6 +29,7 @@ const AppLayoutToolbar = React.forwardRef( headerSelector = '#b #h', footerSelector = '#b #f', navigationWidth = 280, + navigationCollapsedWidth = 54, toolsWidth = 290, maxContentWidth, minContentWidth, @@ -101,6 +102,7 @@ const AppLayoutToolbar = React.forwardRef( const props = { contentType, navigationWidth, + navigationCollapsedWidth, toolsWidth, navigationOpen, onNavigationChange, diff --git a/src/app-layout/__tests__/__snapshots__/widget-contract-old.test.tsx.snap b/src/app-layout/__tests__/__snapshots__/widget-contract-old.test.tsx.snap index d23fa65725..c44fc9b362 100644 --- a/src/app-layout/__tests__/__snapshots__/widget-contract-old.test.tsx.snap +++ b/src/app-layout/__tests__/__snapshots__/widget-contract-old.test.tsx.snap @@ -90,6 +90,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -192,6 +194,7 @@ Map { "current": null, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": { "current": null, }, @@ -288,6 +291,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -443,6 +448,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -628,6 +635,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -783,6 +792,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -948,6 +959,8 @@ Map { "navigation":
navigation
, + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1053,6 +1066,7 @@ Map { }, "hasBreadcrumbsPortal": true, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": { "current": null, }, @@ -1155,6 +1169,8 @@ Map { "navigation":
navigation
, + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1316,6 +1332,8 @@ Map { "navigation":
navigation
, + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1479,6 +1497,8 @@ Map { "navigation":
navigation
, + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1672,6 +1692,8 @@ Map { "navigation":
navigation
, + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1833,6 +1855,8 @@ Map { "navigation":
navigation
, + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -2008,6 +2032,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -2112,6 +2138,7 @@ Map { "current": null, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": { "current": null, }, @@ -2224,6 +2251,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -2395,6 +2424,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -2596,6 +2627,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -2767,6 +2800,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { diff --git a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel-old.test.tsx.snap b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel-old.test.tsx.snap index 4883c5fc36..01157680aa 100644 --- a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel-old.test.tsx.snap +++ b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel-old.test.tsx.snap @@ -90,6 +90,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -192,6 +194,7 @@ Map { "current": null, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": { "current": null, }, @@ -288,6 +291,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -443,6 +448,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -635,6 +642,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -790,6 +799,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -949,6 +960,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1051,6 +1064,7 @@ Map { "current": null, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": { "current": null, }, @@ -1147,6 +1161,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1302,6 +1318,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1493,6 +1511,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { @@ -1648,6 +1668,8 @@ Map { "minDrawerSize": 290, "minGlobalDrawersSizes": {}, "navigation": , + "navigationCollapsed": false, + "navigationCollapsedWidth": 54, "navigationFocusControl": { "loseFocus": [Function], "refs": { diff --git a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap index f880546fa8..a21ca0d373 100644 --- a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap +++ b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap @@ -19,6 +19,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -56,6 +57,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -78,6 +80,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -115,6 +118,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -137,6 +141,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -174,6 +179,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -196,6 +202,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -233,6 +240,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -255,6 +263,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -306,6 +315,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -342,6 +352,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -364,6 +375,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -400,6 +412,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -422,6 +435,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -458,6 +472,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -480,6 +495,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -516,6 +532,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -538,6 +555,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], diff --git a/src/app-layout/__tests__/__snapshots__/widget-contract.test.tsx.snap b/src/app-layout/__tests__/__snapshots__/widget-contract.test.tsx.snap index 701222b3bc..85438b4129 100644 --- a/src/app-layout/__tests__/__snapshots__/widget-contract.test.tsx.snap +++ b/src/app-layout/__tests__/__snapshots__/widget-contract.test.tsx.snap @@ -19,6 +19,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -48,6 +49,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -70,6 +72,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -99,6 +102,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -121,6 +125,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -150,6 +155,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -172,6 +178,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -201,6 +208,7 @@ Map { "toolsToggle": undefined, }, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -223,6 +231,7 @@ Map { "contentType": "default", "maxContentWidth": undefined, "minContentWidth": 280, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "onNavigationChange": [Function], @@ -275,6 +284,7 @@ Map { "navigation":
navigation
, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "notifications":
@@ -314,6 +324,7 @@ Map { }, "hasBreadcrumbsPortal": true, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -345,6 +356,7 @@ Map { "navigation":
navigation
, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "notifications":
@@ -384,6 +396,7 @@ Map { }, "hasBreadcrumbsPortal": true, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -415,6 +428,7 @@ Map { "navigation":
navigation
, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "notifications":
@@ -454,6 +468,7 @@ Map { }, "hasBreadcrumbsPortal": true, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -485,6 +500,7 @@ Map { "navigation":
navigation
, + "navigationCollapsedWidth": 54, "navigationOpen": true, "navigationWidth": 280, "notifications":
@@ -524,6 +540,7 @@ Map { }, "hasBreadcrumbsPortal": true, "hasNavigation": true, + "navigationCollapsed": false, "navigationFocusRef": undefined, "navigationOpen": true, "onNavigationToggle": undefined, @@ -555,6 +572,7 @@ Map { "navigation":
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({