diff --git a/pages/top-navigation/custom-content.page.tsx b/pages/top-navigation/custom-content.page.tsx
new file mode 100644
index 0000000000..cf53f15970
--- /dev/null
+++ b/pages/top-navigation/custom-content.page.tsx
@@ -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 }) {
+ return (
+
+
+
+ onSearchChange(detail.value)}
+ ariaLabel="Search"
+ />
+
+
+ );
+}
+
+export default function CustomContentPage() {
+ const [searchValue, setSearchValue] = useState('');
+
+ return (
+
+ Custom content (default visual context)
+
+
+
+
+
+
+ Custom content (visualContext="none")
+
+
+
+
+
+
+ Structured mode (unchanged)
+
+
+ );
+}
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index 5f33c3ee4f..2095010475 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -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",
},
{
@@ -32497,8 +32497,30 @@ The following properties are supported across all utility types:
"optional": true,
"type": "ReadonlyArray",
},
+ {
+ "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,
@@ -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": [
{
@@ -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": [],
diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
index 90d771415e..330dfbc9fd 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
@@ -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",
diff --git a/src/test-utils/dom/top-navigation/index.ts b/src/test-utils/dom/top-navigation/index.ts
index 0b9e955047..9ca2ab29cb 100644
--- a/src/test-utils/dom/top-navigation/index.ts
+++ b/src/test-utils/dom/top-navigation/index.ts
@@ -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 {
+ return this.find(`.${styles.identity} a`);
}
findLogo(): ElementWrapper | null {
diff --git a/src/top-navigation/__integ__/top-navigation.test.ts b/src/top-navigation/__integ__/top-navigation.test.ts
index c59aeb5087..b9aba2a335 100644
--- a/src/top-navigation/__integ__/top-navigation.test.ts
+++ b/src/top-navigation/__integ__/top-navigation.test.ts
@@ -233,3 +233,21 @@ describe('Top navigation', () => {
})
);
});
+
+describe('Top navigation - children (custom content)', () => {
+ const setupCustomContentTest = (testFn: (page: TopNavigationPage) => Promise) => {
+ 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(
+ 'renders custom content',
+ setupCustomContentTest(async page => {
+ await expect(page.getText(wrapper.findContent().toSelector())).resolves.toContain('My Service');
+ })
+ );
+});
diff --git a/src/top-navigation/__tests__/top-navigation-custom-content.test.tsx b/src/top-navigation/__tests__/top-navigation-custom-content.test.tsx
new file mode 100644
index 0000000000..9d151affd2
--- /dev/null
+++ b/src/top-navigation/__tests__/top-navigation-custom-content.test.tsx
@@ -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 = {
+ searchIconAriaLabel: 'Search',
+ searchDismissIconAriaLabel: 'Close search',
+ overflowMenuTriggerText: 'More',
+ overflowMenuTitleText: 'All',
+ overflowMenuBackIconAriaLabel: 'Back',
+ overflowMenuDismissIconAriaLabel: 'Close',
+};
+
+const renderTopNavigation = (props: TopNavigationProps, children?: React.ReactNode) => {
+ const { container } = render(
+
+ {children}
+
+ );
+ return createWrapper(container).findTopNavigation()!;
+};
+
+describe('children', () => {
+ test('renders custom content when children are provided', () => {
+ const wrapper = renderTopNavigation({}, Custom Nav
);
+ expect(wrapper.findContent()).not.toBeNull();
+ expect(wrapper.findContent()!.getElement()).toHaveTextContent('Custom Nav');
+ });
+
+ test('does not render identity when children are provided', () => {
+ const wrapper = renderTopNavigation({ identity: { href: '#', title: 'Should Not Render' } }, Custom
);
+ 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' }] },
+ Custom
+ );
+ expect(wrapper.findUtilities()).toHaveLength(0);
+ });
+
+ test('does not render search when children are provided', () => {
+ const wrapper = renderTopNavigation(
+ { identity: { href: '#', title: 'Title' }, search: },
+ Custom
+ );
+ 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', () => {
+ test('defaults to top-navigation (dark visual context)', () => {
+ const { container } = render( );
+ expect(container.querySelector('[class*="awsui-context-top-navigation"]')).not.toBeNull();
+ });
+
+ test('applies visual context when visualContext is "top-navigation"', () => {
+ const { container } = render(
+
+ Custom
+
+ );
+ expect(container.querySelector('[class*="awsui-context-top-navigation"]')).not.toBeNull();
+ });
+
+ test('does not apply visual context when visualContext is "none"', () => {
+ const { container } = render(
+
+ Custom
+
+ );
+ expect(container.querySelector('[class*="awsui-context-top-navigation"]')).toBeNull();
+ });
+
+ test('visualContext="none" works with structured mode', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('[class*="awsui-context-top-navigation"]')).toBeNull();
+ });
+});
diff --git a/src/top-navigation/__tests__/top-navigation.test.tsx b/src/top-navigation/__tests__/top-navigation.test.tsx
index dd417dc6ef..f1a72fb554 100644
--- a/src/top-navigation/__tests__/top-navigation.test.tsx
+++ b/src/top-navigation/__tests__/top-navigation.test.tsx
@@ -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({});
});
@@ -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 });
@@ -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);
});
diff --git a/src/top-navigation/interfaces.ts b/src/top-navigation/interfaces.ts
index 3bad629d32..0e3b67954f 100644
--- a/src/top-navigation/interfaces.ts
+++ b/src/top-navigation/interfaces.ts
@@ -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;
+
+ /**
+ * 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.
+ * - "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.
@@ -126,4 +140,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..b8431f5ed2 100644
--- a/src/top-navigation/internal.tsx
+++ b/src/top-navigation/internal.tsx
@@ -27,12 +27,17 @@ export default function InternalTopNavigation({
i18nStrings,
utilities,
search,
+ children,
+ visualContext = 'top-navigation',
...restProps
}: InternalTopNavigationProps) {
- checkSafeUrl('TopNavigation', identity.href);
+ if (identity) {
+ checkSafeUrl('TopNavigation', identity.href);
+ }
const baseProps = getBaseProps(restProps);
+
const { mainRef, virtualRef, breakpoint, responsiveState, isSearchExpanded, onSearchUtilityClick } = useTopNavigation(
- { identity, search, utilities }
+ { identity: identity ?? { href: '' }, search, utilities }
);
const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
const overflowMenuTriggerRef = useRef(null);
@@ -41,16 +46,6 @@ export default function InternalTopNavigation({
const isLargeViewport = breakpoint === 's';
const i18n = useInternalI18n('top-navigation');
- const onIdentityClick = (event: React.MouseEvent) => {
- if (isPlainLeftClick(event)) {
- fireCancelableEvent(identity.onFollow, {}, event);
- }
- };
-
- const toggleOverflowMenu = () => {
- setOverflowMenuOpen(overflowMenuOpen => !overflowMenuOpen);
- };
-
const menuTriggerVisible = !isSearchExpanded && responsiveState.hideUtilities;
useEffect(() => {
@@ -63,12 +58,40 @@ export default function InternalTopNavigation({
}
}, [overflowMenuOpen]);
+ // Custom content mode: render a simple container with no structured layout
+ if (children !== undefined) {
+ const header = (
+
+ );
+ return (
+
+ {visualContext === 'top-navigation' ? (
+ {header}
+ ) : (
+ header
+ )}
+
+ );
+ }
+
+ const onIdentityClick = (event: React.MouseEvent) => {
+ if (isPlainLeftClick(event)) {
+ fireCancelableEvent(identity?.onFollow, {}, event);
+ }
+ };
+
+ const toggleOverflowMenu = () => {
+ setOverflowMenuOpen(overflowMenuOpen => !overflowMenuOpen);
+ };
+
// Render the top nav twice; once as the top nav that users can see, and another
// "virtual" top nav used just for calculations. The virtual top nav doesn't react to
// layout changes and renders two sets of utilities: one with labels and one without.
const content = (isVirtual: boolean) => {
const Wrapper = isVirtual ? 'div' : 'header';
- const showIdentity = isVirtual || !isSearchExpanded;
+ const showIdentity = !!identity && (isVirtual || !isSearchExpanded);
const showTitle = isVirtual || !responsiveState.hideTitle;
const showSearchSlot = search && (isVirtual || !responsiveState.hideSearch || isSearchExpanded);
const showSearchUtility = isVirtual || (search && responsiveState.hideSearch);
@@ -88,9 +111,9 @@ export default function InternalTopNavigation({
>