From eabdea3c2bad840be11336052cda4e004b69a5ae Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Mon, 15 Jun 2026 11:15:48 +0200 Subject: [PATCH 1/2] feat: add visualContext prop to top navigation --- pages/top-navigation/simple.page.tsx | 10 +++ pages/top-navigation/utilities.page.tsx | 62 ++++++++++++------- .../__snapshots__/documenter.test.ts.snap | 16 +++++ .../__tests__/top-navigation.test.tsx | 17 +++++ src/top-navigation/interfaces.ts | 9 +++ src/top-navigation/internal.tsx | 53 +++++++++------- 6 files changed, 121 insertions(+), 46 deletions(-) diff --git a/pages/top-navigation/simple.page.tsx b/pages/top-navigation/simple.page.tsx index 61e93837a9..1a37475812 100644 --- a/pages/top-navigation/simple.page.tsx +++ b/pages/top-navigation/simple.page.tsx @@ -63,6 +63,16 @@ export default function TopNavigationPage() { title: 'Tall logo, resized to fit', }} /> +
+ ); } diff --git a/pages/top-navigation/utilities.page.tsx b/pages/top-navigation/utilities.page.tsx index 6fb6ed557f..fb181a05c9 100644 --- a/pages/top-navigation/utilities.page.tsx +++ b/pages/top-navigation/utilities.page.tsx @@ -79,6 +79,34 @@ const simpleActions = [ export default function TopNavigationPage() { const { urlParams, setUrlParams } = useContext(AppContext as PageContext); + + const badgedUtilities = [ + { + type: 'menu-dropdown' as const, + text: 'Settings', + iconName: 'settings' as const, + badge: true, + items: simpleActions, + }, + { + type: 'menu-dropdown' as const, + ariaLabel: 'Notifications', + title: 'Notifications', + iconName: 'notification' as const, + items: notificationActions, + disableUtilityCollapse: true, + badge: true, + }, + { + type: 'menu-dropdown' as const, + text: 'Jane Doe', + description: 'jane.doe@example.com', + iconName: 'envelope' as const, + items: profileActions, + expandableGroups: urlParams.expandableGroups, + }, + ]; + return (

Simple TopNavigation

@@ -146,30 +174,16 @@ export default function TopNavigationPage() {
+
+ {}} />} + utilities={badgedUtilities} />
); diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b0666e38ef..44a4dd8f32 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -32513,6 +32513,22 @@ The following properties are supported across all utility types: "optional": true, "type": "ReadonlyArray", }, + { + "description": "Visual context applied to the navigation bar. +- "top-navigation": Applies the top-navigation visual context. The component maintains a dark appearance regardless of the page's light/dark mode setting. Child components automatically adapt their colors to work on this dark background. +- "none": No visual context applied. The component and its children use the same colors as the rest of the page and respond to the global light/dark mode normally.", + "inlineType": { + "name": "TopNavigationProps.VisualContext", + "type": "union", + "values": [ + "none", + "top-navigation", + ], + }, + "name": "visualContext", + "optional": true, + "type": "string", + }, ], "regions": [ { diff --git a/src/top-navigation/__tests__/top-navigation.test.tsx b/src/top-navigation/__tests__/top-navigation.test.tsx index dd417dc6ef..880f2cf0ff 100644 --- a/src/top-navigation/__tests__/top-navigation.test.tsx +++ b/src/top-navigation/__tests__/top-navigation.test.tsx @@ -405,3 +405,20 @@ describe('URL sanitization', () => { }); }); }); + +describe('visualContext', () => { + test('defaults to top-navigation visual context', () => { + renderTopNavigation({ identity: { href: '#', title: 'Structured' } }); + expect(createWrapper().findAll('[class*="awsui-context-top-navigation"]')).toHaveLength(1); + }); + + test('uses top-navigation visual context explicitly', () => { + renderTopNavigation({ identity: { href: '#', title: 'Structured' }, visualContext: 'top-navigation' }); + expect(createWrapper().findAll('[class*="awsui-context-top-navigation"]')).toHaveLength(1); + }); + + test('uses no visual context', () => { + renderTopNavigation({ identity: { href: '#', title: 'Structured' }, visualContext: 'none' }); + expect(createWrapper().findAll('[class*="awsui-context-top-navigation"]')).toHaveLength(0); + }); +}); diff --git a/src/top-navigation/interfaces.ts b/src/top-navigation/interfaces.ts index 3bad629d32..5f912cf57d 100644 --- a/src/top-navigation/interfaces.ts +++ b/src/top-navigation/interfaces.ts @@ -17,6 +17,13 @@ export interface TopNavigationProps extends BaseComponentProps { */ identity: TopNavigationProps.Identity; + /** + * Visual context applied to the navigation bar. + * - "top-navigation": Applies the top-navigation visual context. The component maintains a dark appearance regardless of the page's light/dark mode setting. Child components automatically adapt their colors to work on this dark background. + * - "none": No visual context applied. The component and its children use the same colors as the rest of the page and respond to the global light/dark mode normally. + */ + visualContext?: TopNavigationProps.VisualContext; + /** * Use with an input or autosuggest control for a global search query. */ @@ -126,4 +133,6 @@ export namespace TopNavigationProps { overflowMenuTriggerText?: string; overflowMenuTitleText?: string; } + + export type VisualContext = 'top-navigation' | 'none'; } diff --git a/src/top-navigation/internal.tsx b/src/top-navigation/internal.tsx index eea5af6761..5084509f02 100644 --- a/src/top-navigation/internal.tsx +++ b/src/top-navigation/internal.tsx @@ -21,12 +21,17 @@ import styles from './styles.css.js'; type InternalTopNavigationProps = SomeRequired & InternalBaseComponentProps; +function wrapWithVisualContext(content: React.ReactNode, visualContext: TopNavigationProps.VisualContext) { + return visualContext === 'none' ? content : {content}; +} + export default function InternalTopNavigation({ __internalRootRef, identity, i18nStrings, utilities, search, + visualContext = 'top-navigation', ...restProps }: InternalTopNavigationProps) { checkSafeUrl('TopNavigation', identity.href); @@ -222,30 +227,34 @@ export default function InternalTopNavigation({ ); }; + const structuredContent = ( + <> + {/* Render virtual content first to ensure React refs for content will be assigned on the actual nodes. */} + {content(true)} + + {content(false)} + + {menuTriggerVisible && overflowMenuOpen && ( +
+ + (!responsiveState.hideUtilities || responsiveState.hideUtilities.indexOf(i) !== -1) && + !utility.disableUtilityCollapse + )} + onClose={toggleOverflowMenu} + /> +
+ )} + + ); + return (
- - {/* Render virtual content first to ensure React refs for content will be assigned on the actual nodes. */} - {content(true)} - - {content(false)} - - {menuTriggerVisible && overflowMenuOpen && ( -
- - (!responsiveState.hideUtilities || responsiveState.hideUtilities.indexOf(i) !== -1) && - !utility.disableUtilityCollapse - )} - onClose={toggleOverflowMenu} - /> -
- )} -
+ {wrapWithVisualContext(structuredContent, visualContext)}
); } From 8a52bc055f6fe57b914d026f562b19d22da718c3 Mon Sep 17 00:00:00 2001 From: Amanuel Sisay Date: Mon, 15 Jun 2026 19:17:22 +0200 Subject: [PATCH 2/2] test: add unit test with mock for all collapsed utilities --- .../__tests__/top-navigation.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/top-navigation/__tests__/top-navigation.test.tsx b/src/top-navigation/__tests__/top-navigation.test.tsx index 880f2cf0ff..9872633cbf 100644 --- a/src/top-navigation/__tests__/top-navigation.test.tsx +++ b/src/top-navigation/__tests__/top-navigation.test.tsx @@ -13,12 +13,28 @@ import TopNavigationWrapper, { } from '../../../lib/components/test-utils/dom/top-navigation'; import TopNavigation, { TopNavigationProps } from '../../../lib/components/top-navigation'; import OverflowMenu from '../../../lib/components/top-navigation/parts/overflow-menu'; +import { useTopNavigation } from '../../../lib/components/top-navigation/use-top-navigation'; jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), warnOnce: jest.fn(), })); +// The hook defaults to the real implementation so existing tests are unaffected. +// Individual tests can override the return value to force a specific responsive state. +jest.mock('../../../lib/components/top-navigation/use-top-navigation', () => { + const actual = jest.requireActual('../../../lib/components/top-navigation/use-top-navigation'); + return { + __esModule: true, + ...actual, + useTopNavigation: jest.fn(actual.useTopNavigation), + }; +}); + +const actualUseTopNavigation = jest.requireActual( + '../../../lib/components/top-navigation/use-top-navigation' +).useTopNavigation; + afterEach(() => { (warnOnce as jest.Mock).mockReset(); }); @@ -422,3 +438,50 @@ describe('visualContext', () => { expect(createWrapper().findAll('[class*="awsui-context-top-navigation"]')).toHaveLength(0); }); }); + +describe('overflow menu', () => { + afterEach(() => { + // Restore the real hook implementation for any subsequent tests. + (useTopNavigation as jest.Mock).mockImplementation(actualUseTopNavigation); + }); + + // The overflow drawer only renders once utilities are collapsed, which depends on layout + // measurements unavailable in JSDOM. We mock the hook to force that state. The behavior itself + // is verified end to end in __integ__/top-navigation.test.ts ("all collapsable utilities collapsed"). + const renderWithCollapsedUtilities = () => { + (useTopNavigation as jest.Mock).mockReturnValue({ + mainRef: { current: null }, + virtualRef: { current: null }, + responsiveState: { hideUtilities: [0], hideUtilityText: true }, + breakpoint: 'default', + isSearchExpanded: false, + onSearchUtilityClick: jest.fn(), + }); + + return renderTopNavigation({ + identity: { href: '#', title: 'Application' }, + utilities: [ + { type: 'button', text: 'Settings', iconName: 'settings' }, + { type: 'button', text: 'User', iconName: 'user-profile' }, + ], + }); + }; + + test('opens and closes the overflow drawer when the menu trigger is clicked', () => { + const topNavigation = renderWithCollapsedUtilities(); + const trigger = topNavigation.findOverflowMenuButton()!; + expect(trigger).not.toBeNull(); + + // Drawer is closed initially. + expect(topNavigation.findOverflowMenu()).toBeNull(); + + // Open the drawer. + act(() => trigger.click()); + expect(topNavigation.findOverflowMenu()).not.toBeNull(); + + // Close the drawer; focus returns to the trigger. + act(() => trigger.click()); + expect(topNavigation.findOverflowMenu()).toBeNull(); + expect(trigger.getElement()).toHaveFocus(); + }); +});