Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pages/top-navigation/simple.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ export default function TopNavigationPage() {
title: 'Tall logo, resized to fit',
}}
/>
<br />
<TopNavigation
visualContext="none"
i18nStrings={I18N_STRINGS}
identity={{
href: '#',
title: 'Light mode with logo',
logo: { src: logo, alt: 'Logo' },
}}
/>
</article>
);
}
62 changes: 38 additions & 24 deletions pages/top-navigation/utilities.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<article>
<h1>Simple TopNavigation</h1>
Expand Down Expand Up @@ -146,30 +174,16 @@ export default function TopNavigationPage() {
<br />
<TopNavigation
i18nStrings={I18N_STRINGS}
identity={{
href: '#',
title: 'Title with an href',
}}
utilities={[
{ type: 'menu-dropdown', text: 'Settings', iconName: 'settings', badge: true, items: simpleActions },
{
type: 'menu-dropdown',
ariaLabel: 'Notifications',
title: 'Notifications',
iconName: 'notification',
items: notificationActions,
disableUtilityCollapse: true,
badge: true,
},
{
type: 'menu-dropdown',
text: 'Jane Doe',
description: 'jane.doe@example.com',
iconName: 'envelope',
items: profileActions,
expandableGroups: urlParams.expandableGroups,
},
]}
identity={{ href: '#', title: 'Title with an href' }}
utilities={badgedUtilities}
/>
<br />
<TopNavigation
i18nStrings={I18N_STRINGS}
identity={{ href: '#', title: 'Title with an href' }}
visualContext="none"
search={<Input ariaLabel="Input field" value="" onChange={() => {}} />}
utilities={badgedUtilities}
/>
</article>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32513,6 +32513,22 @@ The following properties are supported across all utility types:
"optional": true,
"type": "ReadonlyArray<TopNavigationProps.Utility>",
},
{
"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": [
{
Expand Down
80 changes: 80 additions & 0 deletions src/top-navigation/__tests__/top-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -405,3 +421,67 @@ 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);
});
});

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New addition: I have added this test with a mock to help with the test coverage.

// 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();
});
});
9 changes: 9 additions & 0 deletions src/top-navigation/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -126,4 +133,6 @@ export namespace TopNavigationProps {
overflowMenuTriggerText?: string;
overflowMenuTitleText?: string;
}

export type VisualContext = 'top-navigation' | 'none';
}
53 changes: 31 additions & 22 deletions src/top-navigation/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@ import styles from './styles.css.js';

type InternalTopNavigationProps = SomeRequired<TopNavigationProps, 'utilities'> & InternalBaseComponentProps;

function wrapWithVisualContext(content: React.ReactNode, visualContext: TopNavigationProps.VisualContext) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to be reused for the custom-content.

return visualContext === 'none' ? content : <VisualContext contextName={visualContext}>{content}</VisualContext>;
}

export default function InternalTopNavigation({
__internalRootRef,
identity,
i18nStrings,
utilities,
search,
visualContext = 'top-navigation',
...restProps
}: InternalTopNavigationProps) {
checkSafeUrl('TopNavigation', identity.href);
Expand Down Expand Up @@ -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 && (
<div className={styles['overflow-menu-drawer']}>
<OverflowMenu
headerText={i18nStrings?.overflowMenuTitleText}
dismissIconAriaLabel={i18nStrings?.overflowMenuDismissIconAriaLabel}
backIconAriaLabel={i18nStrings?.overflowMenuBackIconAriaLabel}
items={utilities.filter(
(utility, i) =>
(!responsiveState.hideUtilities || responsiveState.hideUtilities.indexOf(i) !== -1) &&
!utility.disableUtilityCollapse
)}
onClose={toggleOverflowMenu}
/>
</div>
)}
</>
);

return (
<div {...baseProps} ref={__internalRootRef}>
<VisualContext contextName="top-navigation">
{/* Render virtual content first to ensure React refs for content will be assigned on the actual nodes. */}
{content(true)}

{content(false)}

{menuTriggerVisible && overflowMenuOpen && (
<div className={styles['overflow-menu-drawer']}>
<OverflowMenu
headerText={i18nStrings?.overflowMenuTitleText}
dismissIconAriaLabel={i18nStrings?.overflowMenuDismissIconAriaLabel}
backIconAriaLabel={i18nStrings?.overflowMenuBackIconAriaLabel}
items={utilities.filter(
(utility, i) =>
(!responsiveState.hideUtilities || responsiveState.hideUtilities.indexOf(i) !== -1) &&
!utility.disableUtilityCollapse
)}
onClose={toggleOverflowMenu}
/>
</div>
)}
</VisualContext>
{wrapWithVisualContext(structuredContent, visualContext)}
</div>
);
}
Loading