diff --git a/build-tools/utils/custom-css-properties.js b/build-tools/utils/custom-css-properties.js index da8304d986..61f385aba5 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..dcac519e8d --- /dev/null +++ b/pages/side-navigation/collapsed-with-app-layout.page.tsx @@ -0,0 +1,178 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef, useState } from 'react'; + +import { AppLayoutProps, AppLayoutToolbar, Icon } from '~components'; +import Box from '~components/box'; +import BreadcrumbGroup from '~components/breadcrumb-group'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Input from '~components/input'; +import { useMobile } from '~components/internal/hooks/use-mobile'; +import SideNavigation, { SideNavigationProps } from '~components/side-navigation'; +import SpaceBetween from '~components/space-between'; +import { applyTheme } from '~components/theming'; +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 appLayoutRef = useRef(null); + const [navOpen, setNavOpen] = useState(false); + const [activeHref, setActiveHref] = useState('#/dashboard'); + const [collapseBehavior, setCollapseBehavior] = useState<'collapse' | 'hide'>('collapse'); + const [navigationSideBorderHide, setNavigationSideBorderHide] = useState(false); + const [navigationCollapsedWidth, setNavigationCollapsedWidth] = useState(54); + const [hideToolbar, setHideToolbar] = useState(true); + const [hideTools, setHideTools] = useState(true); + const [showHeader, setShowHeader] = useState(true); + const [headerIcon, setHeaderIcon] = useState(true); + const isMobile = useMobile(); + + const header = { text: 'Project 3', href: '#/projects/3' }; + const headerWithIcon = { ...header, logo: { svg: AwsSvg } }; + const resolvedNavigationTriggerHide = isMobile && collapseBehavior === 'collapse' ? false : hideToolbar; + + useEffect(() => { + const { reset } = applyTheme({ + baseThemeId: 'visual-refresh', + theme: { + tokens: navigationSideBorderHide + ? { + colorBorderLayoutSideNavigation: 'transparent', + } + : {}, + }, + }); + + return () => reset(); + }, [navigationSideBorderHide]); + + return ( + setNavOpen(detail.open)} + ariaLabels={labels} + breadcrumbs={ + isMobile ? ( + + ) : undefined + } + navigation={ + { + e.preventDefault(); + setActiveHref(e.detail.href); + }} + /> + } + content={ + +
Collapsed Navigation with AppLayout
+ + + + This page demonstrates the navigationCloseBehavior prop on AppLayoutToolbar. When set to{' '} + "collapse", the navigation shows a collapsed icon rail instead of disappearing + completely when closed. + + Current page: {activeHref} + setCollapseBehavior(detail.checked ? 'collapse' : 'hide')} + > + navigationCloseBehavior = "collapse" + + setNavigationSideBorderHide(detail.checked)} + > + Hide navigation side border (theme token) + + setHideToolbar(detail.checked)}> + Set navigationTriggerHide + + setHideTools(detail.checked)}> + Hide tools + + + setNavigationCollapsedWidth(parseInt(event.detail.value))} + /> + + setShowHeader(detail.checked)}> + Show side nav header + + {showHeader && ( + + setHeaderIcon(detail.checked)}> + Include icon + + + )} + + Current state: navigation is{' '} + {navOpen ? 'open' : collapseBehavior === 'collapse' ? 'collapsed (rail)' : 'closed (hidden)'} + + + +
+ } + /> + ); +} diff --git a/pages/side-navigation/collapsed.page.tsx b/pages/side-navigation/collapsed.page.tsx index eb71900b66..eb01e7eb6f 100644 --- a/pages/side-navigation/collapsed.page.tsx +++ b/pages/side-navigation/collapsed.page.tsx @@ -57,7 +57,7 @@ export default function SideNavigationCollapsedPage() { style={{ inlineSize: `${panelWidth}px`, flexShrink: 0, - transition: reducedMotion ? undefined : 'inline-size 400ms cubic-bezier(0, 0, 0, 1)', + transition: reducedMotion ? undefined : 'inline-size 130ms cubic-bezier(0, 0, 0, 1)', overflow: 'hidden', borderInlineEnd: `1px solid ${colorBorderDividerDefault}`, display: 'flex', diff --git a/src/__integ__/__snapshots__/themes.test.ts.snap b/src/__integ__/__snapshots__/themes.test.ts.snap index b0892e0e9b..7316f9b17f 100644 --- a/src/__integ__/__snapshots__/themes.test.ts.snap +++ b/src/__integ__/__snapshots__/themes.test.ts.snap @@ -76,6 +76,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "compact" 1`] = "border-width-icon-x-small": "1.5px", "border-width-item-card": "0px", "border-width-item-selected": "1px", + "border-width-layout-side-navigation": "1px", "border-width-popover": "1px", "border-width-token": "1px", "color-aws-squid-ink": "#232f3e", @@ -280,6 +281,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "compact" 1`] = "color-border-item-placeholder": "transparent", "color-border-item-selected": "#0073bb", "color-border-layout": "#d5dbdb", + "color-border-layout-side-navigation": "#d5dbdb", "color-border-notification-stack-bar": "#2a2e33", "color-border-panel-header": "#eaeded", "color-border-popover": "#d5dbdb", @@ -1022,6 +1024,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = ` "border-width-icon-x-small": "1.5px", "border-width-item-card": "0px", "border-width-item-selected": "1px", + "border-width-layout-side-navigation": "1px", "border-width-popover": "1px", "border-width-token": "1px", "color-aws-squid-ink": "#232f3e", @@ -1226,6 +1229,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = ` "color-border-item-placeholder": "transparent", "color-border-item-selected": "#00a1c9", "color-border-layout": "#414750", + "color-border-layout-side-navigation": "#414750", "color-border-notification-stack-bar": "#2a2e33", "color-border-panel-header": "#414750", "color-border-popover": "#545b64", @@ -1968,6 +1972,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "light" 1`] = ` "border-width-icon-x-small": "1.5px", "border-width-item-card": "0px", "border-width-item-selected": "1px", + "border-width-layout-side-navigation": "1px", "border-width-popover": "1px", "border-width-token": "1px", "color-aws-squid-ink": "#232f3e", @@ -2172,6 +2177,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "light" 1`] = ` "color-border-item-placeholder": "transparent", "color-border-item-selected": "#0073bb", "color-border-layout": "#d5dbdb", + "color-border-layout-side-navigation": "#d5dbdb", "color-border-notification-stack-bar": "#2a2e33", "color-border-panel-header": "#eaeded", "color-border-popover": "#d5dbdb", @@ -2914,6 +2920,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "reduced-motion" "border-width-icon-x-small": "1.5px", "border-width-item-card": "0px", "border-width-item-selected": "1px", + "border-width-layout-side-navigation": "1px", "border-width-popover": "1px", "border-width-token": "1px", "color-aws-squid-ink": "#232f3e", @@ -3118,6 +3125,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "reduced-motion" "color-border-item-placeholder": "transparent", "color-border-item-selected": "#0073bb", "color-border-layout": "#d5dbdb", + "color-border-layout-side-navigation": "#d5dbdb", "color-border-notification-stack-bar": "#2a2e33", "color-border-panel-header": "#eaeded", "color-border-popover": "#d5dbdb", @@ -3860,6 +3868,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh" "border-width-icon-x-small": "1.5px", "border-width-item-card": "1px", "border-width-item-selected": "2px", + "border-width-layout-side-navigation": "1px", "border-width-popover": "2px", "border-width-token": "2px", "color-aws-squid-ink": "#232f3e", @@ -4064,6 +4073,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh" "color-border-item-placeholder": "#006ce0", "color-border-item-selected": "#006ce0", "color-border-layout": "#c6c6cd", + "color-border-layout-side-navigation": "#c6c6cd", "color-border-notification-stack-bar": "#232b37", "color-border-panel-header": "#c6c6cd", "color-border-popover": "#b4b4bb", @@ -4806,6 +4816,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "border-width-icon-x-small": "1.5px", "border-width-item-card": "1px", "border-width-item-selected": "2px", + "border-width-layout-side-navigation": "1px", "border-width-popover": "2px", "border-width-token": "2px", "color-aws-squid-ink": "#232f3e", @@ -5010,6 +5021,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "color-border-item-placeholder": "#006ce0", "color-border-item-selected": "#006ce0", "color-border-layout": "#c6c6cd", + "color-border-layout-side-navigation": "#c6c6cd", "color-border-notification-stack-bar": "#232b37", "color-border-panel-header": "#c6c6cd", "color-border-popover": "#b4b4bb", @@ -5752,6 +5764,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "border-width-icon-x-small": "1.5px", "border-width-item-card": "1px", "border-width-item-selected": "2px", + "border-width-layout-side-navigation": "1px", "border-width-popover": "2px", "border-width-token": "2px", "color-aws-squid-ink": "#232f3e", @@ -5956,6 +5969,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "color-border-item-placeholder": "#42b4ff", "color-border-item-selected": "#42b4ff", "color-border-layout": "#424650", + "color-border-layout-side-navigation": "#424650", "color-border-notification-stack-bar": "#232b37", "color-border-panel-header": "#424650", "color-border-popover": "#656871", @@ -6698,6 +6712,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "border-width-icon-x-small": "1.5px", "border-width-item-card": "1px", "border-width-item-selected": "2px", + "border-width-layout-side-navigation": "1px", "border-width-popover": "2px", "border-width-token": "2px", "color-aws-squid-ink": "#232f3e", @@ -6902,6 +6917,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- "color-border-item-placeholder": "#42b4ff", "color-border-item-selected": "#42b4ff", "color-border-layout": "#424650", + "color-border-layout-side-navigation": "#424650", "color-border-notification-stack-bar": "#232b37", "color-border-panel-header": "#424650", "color-border-popover": "#656871", 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 882cd94320..ceb668bb26 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/design-tokens.test.ts.snap @@ -189,6 +189,10 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "$description": "The border width of selected items, like table rows.", "$value": "1px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "1px", @@ -1107,6 +1111,13 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "light": "#0073bb", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#414750", + "light": "#d5dbdb", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -3793,6 +3804,10 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "$description": "The border width of selected items, like table rows.", "$value": "1px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "1px", @@ -4711,6 +4726,13 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "light": "#0073bb", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#414750", + "light": "#d5dbdb", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -7397,6 +7419,10 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "$description": "The border width of selected items, like table rows.", "$value": "1px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "1px", @@ -8315,6 +8341,13 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "light": "#0073bb", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#414750", + "light": "#d5dbdb", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -11001,6 +11034,10 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "$description": "The border width of selected items, like table rows.", "$value": "1px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "1px", @@ -11919,6 +11956,13 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "light": "#0073bb", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#414750", + "light": "#d5dbdb", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -14605,6 +14649,10 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "$description": "The border width of selected items, like table rows.", "$value": "1px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "1px", @@ -15523,6 +15571,13 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "light": "#0073bb", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#414750", + "light": "#d5dbdb", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -18209,6 +18264,10 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "$description": "The border width of selected items, like table rows.", "$value": "1px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "1px", @@ -19127,6 +19186,13 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "light": "#00a1c9", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#414750", + "light": "#414750", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -21813,6 +21879,10 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "$description": "The border width of selected items, like table rows.", "$value": "1px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "1px", @@ -22731,6 +22801,13 @@ exports[`Design tokens artifacts Design tokens JSON for classic matches the snap "light": "#0073bb", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#414750", + "light": "#d5dbdb", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -25422,6 +25499,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -26340,6 +26421,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#006ce0", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#c6c6cd", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -29026,6 +29114,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -29944,6 +30036,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#42b4ff", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#424650", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -32630,6 +32729,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -33548,6 +33651,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#006ce0", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#c6c6cd", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -36234,6 +36344,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -37152,6 +37266,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#006ce0", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#c6c6cd", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -39838,6 +39959,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -40756,6 +40881,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#006ce0", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#c6c6cd", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -43442,6 +43574,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -44360,6 +44496,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#006ce0", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#c6c6cd", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -47046,6 +47189,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -47964,6 +48111,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#42b4ff", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#424650", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -50650,6 +50804,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -51568,6 +51726,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#42b4ff", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#424650", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { @@ -54254,6 +54419,10 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "$description": "The border width of selected items, like table rows.", "$value": "2px", }, + "border-width-layout-side-navigation": { + "$description": "The border width of the side navigation divider in the AppLayout and AppLayoutToolbar components.", + "$value": "1px", + }, "border-width-popover": { "$description": "The border width of popovers.", "$value": "2px", @@ -55172,6 +55341,13 @@ exports[`Design tokens artifacts Design tokens JSON for visual-refresh matches t "light": "#006ce0", }, }, + "color-border-layout-side-navigation": { + "$description": "The border color of the side navigation divider in the app layout.", + "$value": { + "dark": "#424650", + "light": "#c6c6cd", + }, + }, "color-border-popover": { "$description": "The border color of the popover.", "$value": { diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index d835dc74ff..006165f030 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -1562,6 +1562,34 @@ If set to \`Number.MAX_VALUE\`, the main content panel will occupy the full avai "optional": true, "type": "number", }, + { + "description": "Determines whether the navigation panel collapses or hides completely when closed. +Defaults to \`hide\` if not specified. + +When set to \`collapse\`, closing the navigation shows a collapsed icon-only navigation rail +at \`navigationCollapsedWidth\` instead of hiding the panel. Use it together with the +\`collapsed\` property of the side navigation component. On mobile viewports the rail +is not available and the closed navigation is completely hidden.", + "inlineType": { + "name": ""hide" | "collapse"", + "type": "union", + "values": [ + "hide", + "collapse", + ], + }, + "name": "navigationCloseBehavior", + "optional": true, + "type": "string", + }, + { + "defaultValue": "54", + "description": "Width of the collapsed navigation rail in pixels. +Only applies when \`navigationCloseBehavior\` is \`collapse\` and \`navigationOpen\` is \`false\`.", + "name": "navigationCollapsedWidth", + "optional": true, + "type": "number", + }, { "description": "If \`true\`, the navigation drawer is not displayed at all.", "name": "navigationHide", @@ -2111,6 +2139,34 @@ If set to \`Number.MAX_VALUE\`, the main content panel will occupy the full avai "optional": true, "type": "number", }, + { + "description": "Determines whether the navigation panel collapses or hides completely when closed. +Defaults to \`hide\` if not specified. + +When set to \`collapse\`, closing the navigation shows a collapsed icon-only navigation rail +at \`navigationCollapsedWidth\` instead of hiding the panel. Use it together with the +\`collapsed\` property of the side navigation component. On mobile viewports the rail +is not available and the closed navigation is completely hidden.", + "inlineType": { + "name": ""hide" | "collapse"", + "type": "union", + "values": [ + "hide", + "collapse", + ], + }, + "name": "navigationCloseBehavior", + "optional": true, + "type": "string", + }, + { + "defaultValue": "54", + "description": "Width of the collapsed navigation rail in pixels. +Only applies when \`navigationCloseBehavior\` is \`collapse\` 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..19d2512a02 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 @@ -192,6 +192,7 @@ Map { "current": null, }, "hasNavigation": true, + "navigationCollapsible": false, "navigationFocusRef": { "current": null, }, @@ -354,6 +355,7 @@ Map { }, }, "bottomDrawerReportedSize": 0, + "navigationCollapsible": false, }, "AppLayoutSplitPanelDrawerBottomImplementation" => { "appLayoutInternals": { @@ -1053,6 +1055,7 @@ Map { }, "hasBreadcrumbsPortal": true, "hasNavigation": true, + "navigationCollapsible": false, "navigationFocusRef": { "current": null, }, @@ -1221,6 +1224,7 @@ Map { }, }, "bottomDrawerReportedSize": 0, + "navigationCollapsible": false, }, "AppLayoutNotificationsImplementation" => { "appLayoutInternals": { @@ -2112,6 +2116,7 @@ Map { "current": null, }, "hasNavigation": true, + "navigationCollapsible": false, "navigationFocusRef": { "current": null, }, @@ -2290,6 +2295,7 @@ Map { }, }, "bottomDrawerReportedSize": 0, + "navigationCollapsible": false, }, "AppLayoutSplitPanelDrawerBottomImplementation" => { "appLayoutInternals": { 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..c374665694 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 @@ -192,6 +192,7 @@ Map { "current": null, }, "hasNavigation": true, + "navigationCollapsible": false, "navigationFocusRef": { "current": null, }, @@ -354,6 +355,7 @@ Map { }, }, "bottomDrawerReportedSize": 0, + "navigationCollapsible": false, }, "AppLayoutSplitPanelDrawerBottomImplementation" => { "appLayoutInternals": { @@ -1051,6 +1053,7 @@ Map { "current": null, }, "hasNavigation": true, + "navigationCollapsible": false, "navigationFocusRef": { "current": null, }, @@ -1213,6 +1216,7 @@ Map { }, }, "bottomDrawerReportedSize": 0, + "navigationCollapsible": false, }, "AppLayoutSplitPanelDrawerBottomImplementation" => { "appLayoutInternals": { 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..da5c5bea25 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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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..b89c04e06b 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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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, + "navigationCollapsible": undefined, "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..828080fe21 --- /dev/null +++ b/src/app-layout/__tests__/navigation-collapsed.test.tsx @@ -0,0 +1,216 @@ +// 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 navigationCloseBehavior="collapse" 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 navigationCloseBehavior="hide" 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 navigationCloseBehavior', () => { + 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'); + }); + }); + + describe('navigationCollapsedWidth vs navigationWidth warning', () => { + let consoleWarnSpy: jest.SpyInstance; + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + afterEach(() => { + consoleWarnSpy?.mockRestore(); + }); + + const warning = + '[AwsUi] [AppLayout] `navigationCollapsedWidth` should be smaller than `navigationWidth`. ' + + 'When collapsed width equals or exceeds the expanded width, the ARIA expanded/collapsed semantics become inverted.'; + + test('does not warn when collapsed width is smaller than navigation width', () => { + renderComponent( + Nav} + /> + ); + expect(console.warn).not.toHaveBeenCalled(); + }); + + test('does not warn when collapse behavior is not enabled, even if collapsed width is larger', () => { + renderComponent(Nav} />); + expect(console.warn).not.toHaveBeenCalled(); + }); + + // Kept last: it primes warnOnce's cache for this message, so it must not run before the negative cases above. + test('warns when collapsed width is not smaller than navigation width', () => { + renderComponent( + Nav} + /> + ); + expect(console.warn).toHaveBeenCalledWith(warning); + }); + }); +}); + +describeEachAppLayout({ themes: ['refresh-toolbar'], sizes: ['mobile'] }, () => { + describe('mobile standard behavior (collapsed is desktop-only)', () => { + test('navigation uses panel-hidden when closed on mobile, even with navigationCloseBehavior="collapse"', () => { + const { wrapper } = renderComponent( + Nav content} /> + ); + const navContainer = wrapper.findNavigation().getElement().closest(`.${skeletonStyles.navigation}`); + expect(navContainer).toHaveClass(skeletonStyles['panel-hidden']); + }); + + test('navigation does not use navigation-collapsed class on mobile', () => { + const { wrapper } = renderComponent( + Nav content} /> + ); + const navContainer = wrapper.findNavigation().getElement().closest(`.${skeletonStyles.navigation}`); + expect(navContainer).not.toHaveClass(skeletonStyles['navigation-collapsed']); + }); + + test('navigation-container does not have is-navigation-collapsed class on mobile', () => { + const { wrapper } = renderComponent( + Nav content} /> + ); + const nav = wrapper.findNavigation().getElement().closest(`.${navStyles['navigation-container']}`); + expect(nav).not.toHaveClass(navStyles['is-navigation-collapsed']); + }); + + test('navigation opens full width on mobile with standard behavior', () => { + const { wrapper } = renderComponent( + Nav content} /> + ); + const navContainer = wrapper.findNavigation().getElement().closest(`.${skeletonStyles.navigation}`); + expect(navContainer).not.toHaveClass(skeletonStyles['panel-hidden']); + }); + }); +}); diff --git a/src/app-layout/__tests__/skeleton.test.tsx b/src/app-layout/__tests__/skeleton.test.tsx index 9f179c2124..a3bb1eb7d9 100644 --- a/src/app-layout/__tests__/skeleton.test.tsx +++ b/src/app-layout/__tests__/skeleton.test.tsx @@ -1,13 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; +import { render } from '@testing-library/react'; import AppLayout from '../../../lib/components/app-layout'; +import { BeforeMainSlotSkeleton } from '../../../lib/components/app-layout/visual-refresh-toolbar/skeleton/skeleton-parts'; import BreadcrumbGroup from '../../../lib/components/breadcrumb-group'; import { getFunnelKeySelector } from '../../../lib/components/internal/analytics/selectors'; import { describeEachAppLayout, renderComponent } from './utils'; import testutilStyles from '../../../lib/components/app-layout/test-classes/styles.selectors.js'; +import navStyles from '../../../lib/components/app-layout/visual-refresh-toolbar/navigation/styles.selectors.js'; import skeletonStyles from '../../../lib/components/app-layout/visual-refresh-toolbar/skeleton/styles.selectors.js'; import toolbarStyles from '../../../lib/components/app-layout/visual-refresh-toolbar/toolbar/styles.selectors.js'; @@ -116,3 +119,38 @@ describeEachAppLayout({ themes: ['refresh-toolbar'] }, () => { }); }); }); + +// The navigation branches of the skeleton depend on `toolbarProps.navigationCollapsible`, which the +// full app layout does not provide while the widget is still loading (the skeleton is shown before +// the state that computes it exists). These render the skeleton part directly to cover those branches. +describe('BeforeMainSlotSkeleton navigation', () => { + const appLayoutProps = { navigation:
nav content
} as any; + + function renderSkeleton(toolbarProps: any) { + const { container } = render( + + ); + return container; + } + + it('shows the collapsed rail when closed with collapse behavior', () => { + const container = renderSkeleton({ hasNavigation: true, navigationOpen: false, navigationCollapsible: true }); + expect(container.querySelector(`.${skeletonStyles['navigation-collapsed']}`)).toBeTruthy(); + expect(container.querySelector(`.${skeletonStyles['panel-hidden']}`)).toBeFalsy(); + expect(container.querySelector(`.${navStyles['is-navigation-collapsed']}`)).toBeTruthy(); + }); + + it('hides the panel when closed without collapse behavior', () => { + const container = renderSkeleton({ hasNavigation: true, navigationOpen: false, navigationCollapsible: false }); + expect(container.querySelector(`.${skeletonStyles['panel-hidden']}`)).toBeTruthy(); + expect(container.querySelector(`.${skeletonStyles['navigation-collapsed']}`)).toBeFalsy(); + expect(container.querySelector(`.${navStyles['is-navigation-collapsed']}`)).toBeFalsy(); + }); + + it('does not apply collapsed rail classes when the navigation is open', () => { + const container = renderSkeleton({ hasNavigation: true, navigationOpen: true, navigationCollapsible: true }); + expect(container.querySelector(`.${skeletonStyles['navigation-collapsed']}`)).toBeFalsy(); + expect(container.querySelector(`.${navStyles['is-navigation-collapsed']}`)).toBeFalsy(); + expect(container.querySelector(`.${navStyles['is-navigation-open']}`)).toBeTruthy(); + }); +}); diff --git a/src/app-layout/constants.scss b/src/app-layout/constants.scss index 5ff3d1d6c1..04a4bcc032 100644 --- a/src/app-layout/constants.scss +++ b/src/app-layout/constants.scss @@ -35,4 +35,8 @@ $toolbar-z-index: 1000; // Shared toolbar drawer component values $toolbar-vertical-panel-icon-offset: 14px; +// Default collapsed navigation rail width +$navigation-collapsed-width-default: 54px; + +$ai-drawer-background: #161d26; $ai-drawer-background: awsui.$color-background-layout-ai-drawer; diff --git a/src/app-layout/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..51ea4bff62 100644 --- a/src/app-layout/interfaces.ts +++ b/src/app-layout/interfaces.ts @@ -74,6 +74,23 @@ export interface BaseLayoutProps extends BaseComponentProps { */ navigationWidth?: number; + /** + * Determines whether the navigation panel collapses or hides completely when closed. + * Defaults to `hide` if not specified. + * + * When set to `collapse`, closing the navigation shows a collapsed icon-only navigation rail + * at `navigationCollapsedWidth` instead of hiding the panel. Use it together with the + * `collapsed` property of the side navigation component. On mobile viewports the rail + * is not available and the closed navigation is completely hidden. + */ + navigationCloseBehavior?: 'collapse' | 'hide'; + + /** + * Width of the collapsed navigation rail in pixels. + * Only applies when `navigationCloseBehavior` is `collapse` and `navigationOpen` is `false`. + */ + navigationCollapsedWidth?: number; + /** * If `true`, the navigation drawer is not displayed at all. */ @@ -361,7 +378,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..020ee167b2 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; + navigationCollapsible: 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, + navigationCollapsible, + navigationCollapsedWidth, placement, minContentWidth, activeDrawerSize, @@ -33,7 +37,7 @@ export function computeHorizontalLayout({ activeGlobalDrawersSizes, activeAiDrawerSize, }: HorizontalLayoutInput) { - const activeNavigationWidth = navigationOpen ? navigationWidth : 0; + const activeNavigationWidth = navigationOpen ? navigationWidth : navigationCollapsible ? 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..9848cf74c5 100644 --- a/src/app-layout/visual-refresh-toolbar/interfaces.ts +++ b/src/app-layout/visual-refresh-toolbar/interfaces.ts @@ -109,6 +109,8 @@ export interface AppLayoutWidgetizedState extends AppLayoutInternals { onActiveBottomDrawerResize: ({ id, size }: { id: string; size: number }) => void; bottomDrawers: ReadonlyArray; featureNotificationsProps?: FeatureNotificationsProps; + navigationCollapsible: boolean; + navigationCollapsedWidth: number; } // New widget interface diff --git a/src/app-layout/visual-refresh-toolbar/navigation/index.tsx b/src/app-layout/visual-refresh-toolbar/navigation/index.tsx index 5241cea55d..759387fc12 100644 --- a/src/app-layout/visual-refresh-toolbar/navigation/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/navigation/index.tsx @@ -16,11 +16,13 @@ import styles from './styles.css.js'; interface AppLayoutNavigationImplementationProps { appLayoutInternals: AppLayoutInternals; bottomDrawerReportedSize?: number; + navigationCollapsible?: boolean; } export function AppLayoutNavigationImplementation({ appLayoutInternals, bottomDrawerReportedSize, + navigationCollapsible, }: AppLayoutNavigationImplementationProps) { const { ariaLabels, @@ -40,6 +42,8 @@ export function AppLayoutNavigationImplementation({ isMobile ? 0 : (bottomDrawerReportedSize ?? 0) ); + const navigationCollapsed = navigationCollapsible && !navigationOpen && !isMobile; + // Close the Navigation drawer on mobile when a user clicks a link inside. const onNavigationClick = (event: React.MouseEvent) => { const hasLink = findUpUntil( @@ -55,6 +59,7 @@ export function AppLayoutNavigationImplementation({
-
+
onNavigationToggle(false)} + ariaLabel={ + navigationCollapsed + ? (ariaLabels?.navigationToggle ?? undefined) + : (ariaLabels?.navigationClose ?? undefined) + } + ariaExpanded={navigationCollapsible && !isMobile ? navigationOpen : undefined} + iconName={navigationCollapsed ? 'angle-right' : isMobile ? 'close' : 'angle-left'} + onClick={() => onNavigationToggle(!navigationOpen)} variant="icon" formAction="none" className={testutilStyles['navigation-close']} ref={navigationFocusControl.refs.close} - analyticsAction="close" + analyticsAction={navigationCollapsed ? 'open' : 'close'} />
{navigation} diff --git a/src/app-layout/visual-refresh-toolbar/navigation/styles.scss b/src/app-layout/visual-refresh-toolbar/navigation/styles.scss index adabab1191..9be149d36a 100644 --- a/src/app-layout/visual-refresh-toolbar/navigation/styles.scss +++ b/src/app-layout/visual-refresh-toolbar/navigation/styles.scss @@ -8,6 +8,14 @@ @use '../../../internal/generated/custom-css-properties/index.scss' as custom-props; @use '../../constants.scss' as constants; +$collapsed-width: var(#{custom-props.$navigationCollapsedWidth}, constants.$navigation-collapsed-width-default); + +.hide-navigation { + position: absolute; + inset-inline-end: awsui.$space-m; + inset-block-start: constants.$toolbar-vertical-panel-icon-offset; +} + // This wrapper clips the actual navigation content during the enter animation. .navigation-container { position: sticky; @@ -20,13 +28,6 @@ display: flex; flex-direction: column; - // All content is hidden by the overflow-x property - &:not(.is-navigation-open) { - inline-size: 0px; - // We need to hide the closed panel to make containing focusable elements not focusable anymore. - display: none; - } - & > .navigation { flex-grow: 1; block-size: 100%; @@ -39,15 +40,45 @@ position: relative; } + // Closed panel: collapsed to zero width and removed from the layout so its + // focusable children are no longer reachable. The .is-navigation-collapsed + // rule below overrides this to render the icon rail instead. + &:not(.is-navigation-open) { + inline-size: 0px; + display: none; + } + + // Closed but showing the collapsed icon rail. This class is only ever set while + // the panel is closed, so it always overrides the rule above (equal specificity, + // later in source order). + &.is-navigation-collapsed { + display: flex; + align-items: center; + inline-size: $collapsed-width; + + & > .navigation { + inline-size: $collapsed-width; + overflow-x: hidden; + } + + // In the rail, replace the top-right close button with a centered expand toggle. + // stylelint-disable-next-line @cloudscape-design/no-implicit-descendant + & > .navigation > .hide-navigation { + display: none; + + &.expand-navigation-toggle { + display: flex; + position: static; + align-items: center; + justify-content: center; + padding-block: awsui.$space-s; + } + } + } + // The Navigation drawer will take up the entire viewport on mobile @include styles.media-breakpoint-down(styles.$breakpoint-x-small) { #{custom-props.$navigationWidth}: 100vw; z-index: constants.$drawer-z-index-mobile; } } - -.hide-navigation { - position: absolute; - inset-inline-end: awsui.$space-m; - inset-block-start: constants.$toolbar-vertical-panel-icon-offset; -} diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/skeleton-parts.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/skeleton-parts.tsx index d697bab89b..5c8e6a8fe1 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/skeleton-parts.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/skeleton-parts.tsx @@ -35,14 +35,18 @@ export const BeforeMainSlotSkeleton = React.forwardRef