diff --git a/pages/top-navigation/custom-content.page.tsx b/pages/top-navigation/custom-content.page.tsx new file mode 100644 index 0000000000..c9786bf125 --- /dev/null +++ b/pages/top-navigation/custom-content.page.tsx @@ -0,0 +1,139 @@ +// 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 Link from '~components/link'; +import TopNavigation from '~components/top-navigation'; + +import { SimplePage } from '../app/templates'; +import { I18N_STRINGS } from './common'; +import logo from './logos/simple-logo.svg'; + +export default function CustomContentPage() { + const [searchValue, setSearchValue] = useState(''); + + return ( + +

customContent + identity + utilities

+ + Dashboard + Resources + Docs + + } + 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' }, + ], + }, + ]} + /> + +

customContent + identity (no utilities)

+ + Overview + Settings + + } + /> + +

customContent + utilities (no identity)

+ + Custom Brand + Home + About + + } + 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' }, + ], + }, + ]} + /> + +

customContent only (no identity, no search, no utilities)

+ + Brand + My App +
+ Features + Pricing + Contact +
+ + } + /> + +

customContent + identity + utilities + visualContext="none"

+ + Pricing + Blog + + } + utilities={[{ type: 'button', text: 'Contact', iconName: 'envelope' }]} + /> + +

No customContent (structured mode, unchanged)

+ setSearchValue(detail.value)} + ariaLabel="Search" + /> + } + 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' }, + ], + }, + ]} + /> +
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a327e6590a..636f894eb3 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -32432,7 +32432,7 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "type": "object", }, "name": "identity", - "optional": false, + "optional": true, "type": "TopNavigationProps.Identity", }, { @@ -32476,8 +32476,31 @@ 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. +This is placed next to the search and should be used only when you want to provide a custom content +Can be combined with the identity and utilities if needed.", + "isDefault": false, + "name": "customContent", + }, { "description": "Use with an input or autosuggest control for a global search query.", "isDefault": false, @@ -44801,6 +44824,19 @@ 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": [], @@ -53748,6 +53784,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..2709841623 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 - customContent', () => { + 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 alongside identity', + setupCustomContentTest(async page => { + await expect(page.getText(wrapper.findContent().toSelector())).resolves.toContain('Dashboard'); + }) + ); +}); 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..f0d38e9047 --- /dev/null +++ b/src/top-navigation/__tests__/top-navigation-custom-content.test.tsx @@ -0,0 +1,83 @@ +// 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) => { + const { container } = render(); + return createWrapper(container).findTopNavigation()!; +}; + +describe('customContent', () => { + test('renders custom content alongside identity', () => { + const wrapper = renderTopNavigation({ + identity: { href: '#', title: 'My Service' }, + customContent:
Custom Links
, + }); + expect(wrapper.findContent()).not.toBeNull(); + expect(wrapper.findContent()!.getElement()).toHaveTextContent('Custom Links'); + expect(wrapper.findTitle()!.getElement()).toHaveTextContent('My Service'); + }); + + test('renders custom content alongside utilities', () => { + const wrapper = renderTopNavigation({ + identity: { href: '#', title: 'Title' }, + utilities: [{ type: 'button', text: 'Help' }], + customContent:
Custom
, + }); + expect(wrapper.findContent()).not.toBeNull(); + expect(wrapper.findUtilities()).toHaveLength(1); + }); + + test('hides search when customContent is provided', () => { + const wrapper = renderTopNavigation({ + identity: { href: '#', title: 'Title' }, + search: , + customContent:
Custom
, + }); + expect(wrapper.findContent()).not.toBeNull(); + expect(wrapper.findSearch()).toBeNull(); + }); + + test('does not render custom content wrapper when customContent is not provided', () => { + const wrapper = renderTopNavigation({ + identity: { href: '#', title: 'Structured' }, + }); + expect(wrapper.findContent()).toBeNull(); + }); + + test('renders custom content without identity', () => { + const wrapper = renderTopNavigation({ + customContent:
Only custom
, + utilities: [{ type: 'button', text: 'Help' }], + }); + expect(wrapper.findContent()!.getElement()).toHaveTextContent('Only custom'); + expect(wrapper.findUtilities()).toHaveLength(1); + }); +}); + +describe('visualContext', () => { + test('defaults to top-navigation (dark visual context)', () => { + const { container } = render(); + expect(container.querySelector('[class*="awsui-context-top-navigation"]')).not.toBeNull(); + }); + + test('does not apply visual context when visualContext is "none"', () => { + const { container } = render( + + ); + expect(container.querySelector('[class*="awsui-context-top-navigation"]')).toBeNull(); + }); +}); diff --git a/src/top-navigation/__tests__/top-navigation-responsiveness.test.tsx b/src/top-navigation/__tests__/top-navigation-responsiveness.test.tsx index ac441baec6..98d52152fe 100644 --- a/src/top-navigation/__tests__/top-navigation-responsiveness.test.tsx +++ b/src/top-navigation/__tests__/top-navigation-responsiveness.test.tsx @@ -30,6 +30,7 @@ function testResponsiveness( fullIdentityWidth: 0, titleWidth: 0, searchSlotWidth: 0, + customContentWidth: 0, searchUtilityWidth: 0, menuTriggerUtilityWidth: 0, ...sizeConfig, diff --git a/src/top-navigation/__tests__/top-navigation.test.tsx b/src/top-navigation/__tests__/top-navigation.test.tsx index dd417dc6ef..8a326425c6 100644 --- a/src/top-navigation/__tests__/top-navigation.test.tsx +++ b/src/top-navigation/__tests__/top-navigation.test.tsx @@ -53,7 +53,7 @@ describe('TopNavigation Component', () => { test('has a link', () => { const topNavigation = renderTopNavigation({ identity: { href: '#', title: 'Application Title' } }); - expect(topNavigation.findIdentityLink().getElement()).toHaveAttribute('href', '#'); + expect(topNavigation.findIdentityLink()?.getElement()).toHaveAttribute('href', '#'); }); test('fires follow event when the title is clicked', () => { @@ -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..cd4d03fe6a 100644 --- a/src/top-navigation/interfaces.ts +++ b/src/top-navigation/interfaces.ts @@ -15,7 +15,22 @@ 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. + * This is placed next to the search and should be used only when you want to provide a custom content + * Can be combined with the identity and utilities if needed. + */ + customContent?: 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 +141,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..39ef09b141 100644 --- a/src/top-navigation/internal.tsx +++ b/src/top-navigation/internal.tsx @@ -27,12 +27,17 @@ export default function InternalTopNavigation({ i18nStrings, utilities, search, + customContent, + 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,17 +46,7 @@ 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; + const menuTriggerVisible = !isSearchExpanded && responsiveState.hideUtilities && utilities.length > 0; useEffect(() => { setOverflowMenuOpen(false); @@ -63,17 +58,28 @@ export default function InternalTopNavigation({ } }, [overflowMenuOpen]); + 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); + const showSearchSlot = !customContent && search && (isVirtual || !responsiveState.hideSearch || isSearchExpanded); + const showSearchUtility = !customContent && (isVirtual || (search && responsiveState.hideSearch)); const showUtilities = isVirtual || !isSearchExpanded; - const showMenuTrigger = isVirtual || menuTriggerVisible; + const hasCollapsibleUtilities = utilities.some(u => !u.disableUtilityCollapse); + const showMenuTrigger = hasCollapsibleUtilities && (isVirtual || menuTriggerVisible); return (
{showIdentity && ( - )} + {customContent &&
{customContent}
} +
{showSearchUtility && (
+ {/* 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..fa22b49abc 100644 --- a/src/top-navigation/use-top-navigation.ts +++ b/src/top-navigation/use-top-navigation.ts @@ -30,6 +30,7 @@ export interface TopNavigationSizeConfiguration { fullIdentityWidth: number; titleWidth: number; searchSlotWidth: number; + customContentWidth: number; searchUtilityWidth: number; utilityWithLabelWidths: number[]; utilityWithoutLabelWidths: number[]; @@ -59,7 +60,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]); @@ -93,11 +94,13 @@ export function useTopNavigation({ identity, search, utilities }: UseTopNavigati availableWidth, // Get widths from the hidden top navigation - fullIdentityWidth: virtualRef.current.querySelector(`.${styles.identity}`)!.getBoundingClientRect().width, + fullIdentityWidth: virtualRef.current.querySelector(`.${styles.identity}`)?.getBoundingClientRect().width ?? 0, titleWidth: virtualRef.current.querySelector(`.${styles.title}`)?.getBoundingClientRect().width ?? 0, searchSlotWidth: virtualRef.current.querySelector(`.${styles.search}`)?.getBoundingClientRect().width ?? 0, - searchUtilityWidth: virtualRef.current.querySelector('[data-utility-special="search"]')!.getBoundingClientRect() - .width, + searchUtilityWidth: + virtualRef.current.querySelector('[data-utility-special="search"]')?.getBoundingClientRect().width ?? 0, + customContentWidth: + virtualRef.current.querySelector(`.${styles['custom-content']}`)?.getBoundingClientRect().width ?? 0, utilitiesLeftPadding: parseFloat( getComputedStyle(virtualRef.current.querySelector(`.${styles.utilities}`)!).paddingLeft || '0px' ), @@ -107,9 +110,8 @@ export function useTopNavigation({ identity, search, utilities }: UseTopNavigati utilityWithoutLabelWidths: Array.prototype.slice .call(virtualRef.current.querySelectorAll(`[data-utility-hide="true"]`)) .map((element: Element) => element.getBoundingClientRect().width), - menuTriggerUtilityWidth: virtualRef.current - .querySelector('[data-utility-special="menu-trigger"]')! - .getBoundingClientRect().width, + menuTriggerUtilityWidth: + virtualRef.current.querySelector('[data-utility-special="menu-trigger"]')?.getBoundingClientRect().width ?? 0, }; setResponsiveState(determineBestResponsiveState(responsiveStates, sizeConfiguration)); }, [responsiveStates, hasSearch]); @@ -226,6 +228,7 @@ export function determineBestResponsiveState( fullIdentityWidth, titleWidth, searchSlotWidth, + customContentWidth, searchUtilityWidth, utilityWithLabelWidths, utilityWithoutLabelWidths, @@ -239,7 +242,8 @@ export function determineBestResponsiveState( .reduce((sum, width) => sum + width, 0); const menuTriggerWidth = state.hideUtilities ? menuTriggerUtilityWidth : 0; const identityWidth = state.hideTitle ? fullIdentityWidth - titleWidth : fullIdentityWidth; - const expectedInnerWidth = identityWidth + searchWidth + utilitiesLeftPadding + utilitiesWidth + menuTriggerWidth; + const expectedInnerWidth = + identityWidth + searchWidth + customContentWidth + utilitiesLeftPadding + utilitiesWidth + menuTriggerWidth; if (expectedInnerWidth <= availableWidth - RESPONSIVENESS_BUFFER) { return state; }