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 ( +
+ Service +
+ 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 = ( +
+
{children}
+
+ ); + 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({ >
{showIdentity && ( -
- - {identity.logo && ( + )} @@ -222,30 +245,38 @@ 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} - /> -
- )} -
+ {visualContext === 'top-navigation' ? ( + {structuredContent} + ) : ( + structuredContent + )}
); } diff --git a/src/top-navigation/styles.scss b/src/top-navigation/styles.scss index 6167698a8a..ebd06cb915 100644 --- a/src/top-navigation/styles.scss +++ b/src/top-navigation/styles.scss @@ -42,6 +42,10 @@ inline-size: 9000px; } +.custom-content { + /* used in test-utils */ +} + .hidden { @include styles.awsui-util-hide; visibility: hidden; diff --git a/src/top-navigation/use-top-navigation.ts b/src/top-navigation/use-top-navigation.ts index 9c922fd200..eeaa5cf6a0 100644 --- a/src/top-navigation/use-top-navigation.ts +++ b/src/top-navigation/use-top-navigation.ts @@ -59,7 +59,7 @@ export function useTopNavigation({ identity, search, utilities }: UseTopNavigati // The component works by calculating the possible resize states that it can // be in, and having a state variable to track which state we're currently in. const hasSearch = !!search; - const hasTitleWithLogo = identity && !!identity.logo && !!identity.title; + const hasTitleWithLogo = !!identity && !!identity.logo && !!identity.title; const responsiveStates = useMemo>(() => { return generateResponsiveStateKeys(utilities, hasSearch, hasTitleWithLogo); }, [utilities, hasSearch, hasTitleWithLogo]);