Skip to content
68 changes: 68 additions & 0 deletions pages/top-navigation/custom-content.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import Input from '~components/input';
import TopNavigation from '~components/top-navigation';

import { SimplePage } from '../app/templates';
import { I18N_STRINGS } from './common';
import logo from './logos/simple-logo.svg';

function CustomNav({ searchValue, onSearchChange }: { searchValue: string; onSearchChange: (value: string) => void }) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: this custom nav is not that custom. I would extend this example or even create a few more examples to feature more Cloudscape components that can be used inside the top-nav, such as button group, button dropdown, links, and more.

return (
<div style={{ display: 'flex', alignItems: 'center', paddingInline: 24, height: 48, gap: 16 }}>
<img src={logo} alt="Service" style={{ height: 24 }} />
<div style={{ flex: 1, maxWidth: 320 }}>
<Input
type="search"
placeholder="Search..."
value={searchValue}
onChange={({ detail }) => onSearchChange(detail.value)}
ariaLabel="Search"
/>
</div>
</div>
);
}

export default function CustomContentPage() {
const [searchValue, setSearchValue] = useState('');

return (
<SimplePage title="TopNavigation children" screenshotArea={{}}>
<h2>Custom content (default visual context)</h2>
<TopNavigation>
<CustomNav searchValue={searchValue} onSearchChange={setSearchValue} />
</TopNavigation>

<br />

<h2>Custom content (visualContext=&quot;none&quot;)</h2>
<TopNavigation visualContext="none">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would also add one or two examples with visualContext="none" to some existing top-nav pages that use structured content

<CustomNav searchValue={searchValue} onSearchChange={setSearchValue} />
</TopNavigation>

<br />

<h2>Structured mode (unchanged)</h2>
<TopNavigation
Comment thread
amanabiy marked this conversation as resolved.
identity={{ href: '#', title: 'Structured Mode', logo: { src: logo, alt: 'Logo' } }}
i18nStrings={I18N_STRINGS}
utilities={[
{ type: 'button', iconName: 'notification', ariaLabel: 'Notifications', badge: true },
{
type: 'menu-dropdown',
text: 'Jane Doe',
description: 'jane.doe@example.com',
iconName: 'user-profile',
items: [
{ id: 'profile', text: 'Profile' },
{ id: 'signout', text: 'Sign out' },
],
},
]}
/>
</SimplePage>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32453,7 +32453,7 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
"type": "object",
},
"name": "identity",
"optional": false,
"optional": true,
"type": "TopNavigationProps.Identity",
},
{
Expand Down Expand Up @@ -32497,8 +32497,30 @@ The following properties are supported across all utility types:
"optional": true,
"type": "ReadonlyArray<TopNavigationProps.Utility>",
},
{
"description": "Controls the color scheme of the navigation bar and its contents.
- "top-navigation": Applies the top-navigation visual context. The component and its contents use dark, branded colors in both light and dark mode.
- "none": No visual context. The component and its contents use the same colors as the rest of the page.",
"inlineType": {
"name": "TopNavigationProps.VisualContext",
"type": "union",
"values": [
"none",
"top-navigation",
],
},
"name": "visualContext",
"optional": true,
"type": "string",
},
],
"regions": [
{
"description": "Specifies custom navigation content.
When provided, replaces all structured content (identity, search, utilities are ignored).",
"isDefault": true,
"name": "children",
},
{
"description": "Use with an input or autosuggest control for a global search query.",
"isDefault": false,
Expand Down Expand Up @@ -44822,11 +44844,24 @@ Searches within this tooltip's scope to avoid conflicts with popovers.",
},
{
"methods": [
{
"name": "findContent",
"parameters": [],
"returnType": {
"isNullable": true,
"name": "ElementWrapper",
"typeArguments": [
{
"name": "HTMLElement",
},
],
},
},
{
"name": "findIdentityLink",
"parameters": [],
"returnType": {
"isNullable": false,
"isNullable": true,
"name": "ElementWrapper",
"typeArguments": [
{
Expand Down Expand Up @@ -53769,6 +53804,14 @@ Searches within this tooltip's scope to avoid conflicts with popovers.",
},
{
"methods": [
{
"name": "findContent",
"parameters": [],
"returnType": {
"isNullable": false,
"name": "ElementWrapper",
},
},
{
"name": "findIdentityLink",
"parameters": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,7 @@ exports[`test-utils selectors 1`] = `
"awsui_root_1u26h",
],
"top-navigation": [
"awsui_custom-content_k5dlb",
"awsui_hidden_k5dlb",
"awsui_identity_k5dlb",
"awsui_logo_k5dlb",
Expand Down
8 changes: 6 additions & 2 deletions src/test-utils/dom/top-navigation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ import styles from '../../../top-navigation/styles.selectors.js';
export default class TopNavigationWrapper extends ComponentWrapper {
static rootSelector = `${styles['top-navigation']}:not(.${styles.hidden})`;

findIdentityLink(): ElementWrapper {
return this.find(`.${styles.identity} a`)!;
findContent(): ElementWrapper | null {
return this.find(`.${styles['custom-content']}`);
}

findIdentityLink(): ElementWrapper | null {
Comment thread
amanabiy marked this conversation as resolved.
return this.find(`.${styles.identity} a`);
}

findLogo(): ElementWrapper | null {
Expand Down
18 changes: 18 additions & 0 deletions src/top-navigation/__integ__/top-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,21 @@
})
);
});

describe('Top navigation - children (custom content)', () => {
const setupCustomContentTest = (testFn: (page: TopNavigationPage) => Promise<void>) => {
return useBrowser(async browser => {
await browser.url('#/light/top-navigation/custom-content');
const page = new TopNavigationPage(browser);
await page.waitForVisible(wrapper.toSelector());
await testFn(page);
});
};

test(
Comment thread
amanabiy marked this conversation as resolved.
'renders custom content',
setupCustomContentTest(async page => {
await expect(page.getText(wrapper.findContent().toSelector())).resolves.toContain('My Service');

Check failure on line 250 in src/top-navigation/__integ__/top-navigation.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 16, shard 4/4)

Top navigation - children (custom content) › renders custom content

expect(received).resolves.toContain(expected) // indexOf Expected substring: "My Service" Received string: "" at Object.toContain (node_modules/expect/build/index.js:174:22) at src/top-navigation/__integ__/top-navigation.test.ts:250:79 at src/top-navigation/__integ__/top-navigation.test.ts:243:13 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)

Check warning on line 250 in src/top-navigation/__integ__/top-navigation.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 16, shard 4/4)

RETRY 3: Top navigation - children (custom content) › renders custom content

expect(received).resolves.toContain(expected) // indexOf Expected substring: "My Service" Received string: "" at Object.toContain (node_modules/expect/build/index.js:174:22) at src/top-navigation/__integ__/top-navigation.test.ts:250:79 at src/top-navigation/__integ__/top-navigation.test.ts:243:13 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)

Check warning on line 250 in src/top-navigation/__integ__/top-navigation.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 16, shard 4/4)

RETRY 2: Top navigation - children (custom content) › renders custom content

expect(received).resolves.toContain(expected) // indexOf Expected substring: "My Service" Received string: "" at Object.toContain (node_modules/expect/build/index.js:174:22) at src/top-navigation/__integ__/top-navigation.test.ts:250:79 at src/top-navigation/__integ__/top-navigation.test.ts:243:13 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)

Check warning on line 250 in src/top-navigation/__integ__/top-navigation.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 16, shard 4/4)

RETRY 1: Top navigation - children (custom content) › renders custom content

expect(received).resolves.toContain(expected) // indexOf Expected substring: "My Service" Received string: "" at Object.toContain (node_modules/expect/build/index.js:174:22) at src/top-navigation/__integ__/top-navigation.test.ts:250:79 at src/top-navigation/__integ__/top-navigation.test.ts:243:13 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)

Check failure on line 250 in src/top-navigation/__integ__/top-navigation.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 4/4)

Top navigation - children (custom content) › renders custom content

expect(received).resolves.toContain(expected) // indexOf Expected substring: "My Service" Received string: "" at Object.toContain (node_modules/expect/build/index.js:174:22) at src/top-navigation/__integ__/top-navigation.test.ts:250:79 at src/top-navigation/__integ__/top-navigation.test.ts:243:13 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)

Check warning on line 250 in src/top-navigation/__integ__/top-navigation.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 4/4)

RETRY 3: Top navigation - children (custom content) › renders custom content

expect(received).resolves.toContain(expected) // indexOf Expected substring: "My Service" Received string: "" at Object.toContain (node_modules/expect/build/index.js:174:22) at src/top-navigation/__integ__/top-navigation.test.ts:250:79 at src/top-navigation/__integ__/top-navigation.test.ts:243:13 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)

Check warning on line 250 in src/top-navigation/__integ__/top-navigation.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 4/4)

RETRY 2: Top navigation - children (custom content) › renders custom content

expect(received).resolves.toContain(expected) // indexOf Expected substring: "My Service" Received string: "" at Object.toContain (node_modules/expect/build/index.js:174:22) at src/top-navigation/__integ__/top-navigation.test.ts:250:79 at src/top-navigation/__integ__/top-navigation.test.ts:243:13 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)

Check warning on line 250 in src/top-navigation/__integ__/top-navigation.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 4/4)

RETRY 1: Top navigation - children (custom content) › renders custom content

expect(received).resolves.toContain(expected) // indexOf Expected substring: "My Service" Received string: "" at Object.toContain (node_modules/expect/build/index.js:174:22) at src/top-navigation/__integ__/top-navigation.test.ts:250:79 at src/top-navigation/__integ__/top-navigation.test.ts:243:13 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)
})
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// 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 createWrapper from '../../../lib/components/test-utils/dom';
import TopNavigation, { TopNavigationProps } from '../../../lib/components/top-navigation';

const I18N_STRINGS: TopNavigationProps.I18nStrings = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why are these strings needed in the custom content tests?

searchIconAriaLabel: 'Search',
searchDismissIconAriaLabel: 'Close search',
overflowMenuTriggerText: 'More',
overflowMenuTitleText: 'All',
overflowMenuBackIconAriaLabel: 'Back',
overflowMenuDismissIconAriaLabel: 'Close',
};

const renderTopNavigation = (props: TopNavigationProps, children?: React.ReactNode) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: children is already a part of TopNavigationProps - why do we define it separately?

const { container } = render(
<TopNavigation i18nStrings={I18N_STRINGS} {...props}>
{children}
</TopNavigation>
);
return createWrapper(container).findTopNavigation()!;
};

describe('children', () => {
test('renders custom content when children are provided', () => {
const wrapper = renderTopNavigation({}, <div data-testid="custom">Custom Nav</div>);
expect(wrapper.findContent()).not.toBeNull();
expect(wrapper.findContent()!.getElement()).toHaveTextContent('Custom Nav');
});

test('does not render identity when children are provided', () => {

@pan-kot pan-kot Jun 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: should we add these assertions to the 'renders custom content when children are provided' test instead:

expect(wrapper.findTitle()).toBeNull();
expect(wrapper.findIdentityLink()).toBeNull();
expect(wrapper.findSearch()).toBeNull();
expect(wrapper.findUtilities()).toHaveLength(0);

?

As custom content replaces everything else, I think it makes sense to check for everything in one place rather than creating separate tests for that.

const wrapper = renderTopNavigation({ identity: { href: '#', title: 'Should Not Render' } }, <div>Custom</div>);
expect(wrapper.findTitle()).toBeNull();
expect(wrapper.findIdentityLink()).toBeNull();
});

test('does not render utilities when children are provided', () => {
const wrapper = renderTopNavigation(
{ identity: { href: '#', title: 'Title' }, utilities: [{ type: 'button', text: 'Help' }] },
<div>Custom</div>
);
expect(wrapper.findUtilities()).toHaveLength(0);
});

test('does not render search when children are provided', () => {
const wrapper = renderTopNavigation(
{ identity: { href: '#', title: 'Title' }, search: <input placeholder="Search" /> },
<div>Custom</div>
);
expect(wrapper.findSearch()).toBeNull();
});

test('renders structured mode when children are not provided', () => {
const wrapper = renderTopNavigation({ identity: { href: '#', title: 'Structured' } });
expect(wrapper.findContent()).toBeNull();
expect(wrapper.findTitle()!.getElement()).toHaveTextContent('Structured');
});
});

describe('visualContext', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do visual context related tests live inside top-navigation-custom-content.test?

test('defaults to top-navigation (dark visual context)', () => {
const { container } = render(<TopNavigation identity={{ href: '#', title: 'Title' }} i18nStrings={I18N_STRINGS} />);
expect(container.querySelector('[class*="awsui-context-top-navigation"]')).not.toBeNull();
});

test('applies visual context when visualContext is "top-navigation"', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test also checks custom content, but that is not explicitly called out. I recommend making test names better uniform to ensure we capture all 4 states explicitly. Alternatively, we can group tests together:

test('defaults to top-navigation visual context', () => {
  render(
    <>
      <TopNavigation identity={{ href: '#', title: 'Title' }} />
      <TopNavigation>custom</TopNavigation>
    </>
  );
  expect(createWrapper().findAll('[class*="awsui-context-top-navigation"]')).toHaveLength(2);
});

test('uses top-navigation visual context explicitly', () => {
  // ...
});

test('uses no visual context', () => {
  // ...
});

const { container } = render(
<TopNavigation visualContext="top-navigation">
<div>Custom</div>
</TopNavigation>
);
expect(container.querySelector('[class*="awsui-context-top-navigation"]')).not.toBeNull();
});

test('does not apply visual context when visualContext is "none"', () => {
const { container } = render(
<TopNavigation visualContext="none">
<div>Custom</div>
</TopNavigation>
);
expect(container.querySelector('[class*="awsui-context-top-navigation"]')).toBeNull();
});

test('visualContext="none" works with structured mode', () => {
const { container } = render(
<TopNavigation visualContext="none" identity={{ href: '#', title: 'Light Nav' }} i18nStrings={I18N_STRINGS} />
);
expect(container.querySelector('[class*="awsui-context-top-navigation"]')).toBeNull();
});
});
6 changes: 3 additions & 3 deletions src/top-navigation/__tests__/top-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('TopNavigation Component', () => {
onFollow: event => onFollowSpy(event.detail),
},
});
const identityLink = topNavigation.findIdentityLink().getElement();
const identityLink = topNavigation.findIdentityLink()!.getElement();
identityLink.click();
expect(onFollowSpy).toHaveBeenCalledWith({});
});
Expand All @@ -81,7 +81,7 @@ describe('TopNavigation Component', () => {
onFollow: event => onFollowSpy(event.detail),
},
});
const identityLink = topNavigation.findIdentityLink();
const identityLink = topNavigation.findIdentityLink()!;
identityLink.click({ ctrlKey: true });
identityLink.click({ altKey: true });
identityLink.click({ shiftKey: true });
Expand Down Expand Up @@ -253,7 +253,7 @@ describe('URL sanitization', () => {
describe('for the identity', () => {
test('does not throw an error when a safe javascript: URL is passed', () => {
const element = renderTopNavigation({ identity: { href: 'javascript:void(0)' } });
expect((element.findIdentityLink().getElement() as HTMLAnchorElement).href).toBe('javascript:void(0)');
expect((element.findIdentityLink()!.getElement() as HTMLAnchorElement).href).toBe('javascript:void(0)');

expect(warnOnce).toHaveBeenCalledTimes(0);
});
Expand Down
18 changes: 17 additions & 1 deletion src/top-navigation/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,21 @@ export interface TopNavigationProps extends BaseComponentProps {
* * `href` (string) - Specifies the `href` that the header links to.
* * `onFollow` (() => void) - Specifies the event handler called when the identity is clicked without any modifier keys.
*/
identity: TopNavigationProps.Identity;
identity?: TopNavigationProps.Identity;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What happens if a top nav is rendered without custom content and also without identity? Will the component maintain its layout and responsiveness or appear broken?


/**
* Specifies custom navigation content.
* When provided, replaces all structured content (identity, search, utilities are ignored).
*/
children?: React.ReactNode;

/**
* Controls the color scheme of the navigation bar and its contents.
* - "top-navigation": Applies the top-navigation visual context. The component and its contents use dark, branded colors in both light and dark mode.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what is "branded colors"?

* - "none": No visual context. The component and its contents use the same colors as the rest of the page.
* @default "top-navigation"
*/
visualContext?: TopNavigationProps.VisualContext;

/**
* Use with an input or autosuggest control for a global search query.
Expand Down Expand Up @@ -126,4 +140,6 @@ export namespace TopNavigationProps {
overflowMenuTriggerText?: string;
overflowMenuTitleText?: string;
}

export type VisualContext = 'top-navigation' | 'none';
}
Loading
Loading