diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 36b3aedea0..c23250be8a 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -50,6 +50,7 @@ const pluralizationMap = { Modal: 'Modals', Multiselect: 'Multiselects', Pagination: 'Paginations', + AppLayoutToolbar: 'AppLayoutToolbars', PieChart: 'PieCharts', Popover: 'Popovers', ProgressBar: 'ProgressBars', diff --git a/pages/app-layout-toolbar/default.page.tsx b/pages/app-layout-toolbar/default.page.tsx new file mode 100644 index 0000000000..a21fc41a69 --- /dev/null +++ b/pages/app-layout-toolbar/default.page.tsx @@ -0,0 +1,213 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useRef, useState } from 'react'; + +import { + AppLayoutToolbar, + Button, + ContentLayout, + Header, + HelpPanel, + Link, + SpaceBetween, + SplitPanel, + Toggle, +} from '~components'; +import { AppLayoutToolbarProps } from '~components/app-layout-toolbar'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { Breadcrumbs, Containers, CustomDrawerContent, Navigation } from '../app-layout/utils/content-blocks'; +import { drawerLabels } from '../app-layout/utils/drawers'; +import appLayoutLabels from '../app-layout/utils/labels'; + +type DemoContext = React.Context< + AppContextType<{ + navigationTriggerHide: boolean | undefined; + drawerTriggerHide: boolean | undefined; + splitPanelTriggerHide: boolean | undefined; + breadcrumbsHide: boolean | undefined; + splitPanelPosition: AppLayoutToolbarProps.SplitPanelPreferences['position']; + }> +>; + +export default function WithDrawers() { + const [activeDrawerId, setActiveDrawerId] = useState(null); + const [helpPathSlug, setHelpPathSlug] = useState('default'); + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + const navigationTriggerHide = urlParams.navigationTriggerHide ?? false; + const drawerTriggerHide = urlParams.drawerTriggerHide ?? false; + const splitPanelTriggerHide = urlParams.splitPanelTriggerHide ?? false; + const breadcrumbsHide = urlParams.breadcrumbsHide ?? false; + const [isToolsOpen, setIsToolsOpen] = useState(false); + const [isNavigationOpen, setIsNavigationOpen] = useState(true); + const [splitPanelOpen, setSplitPanelOpen] = useState(false); + const pageLayoutRef = useRef(null); + + const drawersProps: Pick | null = { + activeDrawerId: activeDrawerId, + drawers: [ + { + ariaLabels: { + closeButton: 'ProHelp close button', + drawerName: 'ProHelp drawer content', + triggerButton: 'ProHelp trigger button', + resizeHandle: 'ProHelp resize handle', + }, + content: , + id: 'pro-help', + trigger: drawerTriggerHide + ? undefined + : { + iconName: 'contact', + }, + }, + ], + onDrawerChange: event => { + setActiveDrawerId(event.detail.activeDrawerId); + }, + }; + + return ( + } + ref={pageLayoutRef} + content={ + +
{ + setHelpPathSlug('header'); + setIsToolsOpen(true); + pageLayoutRef.current?.focusToolsClose(); + }} + > + Info + + } + > + Page layout +
+ + + setUrlParams({ navigationTriggerHide: detail.checked })} + > + Hide navigation trigger + + setUrlParams({ drawerTriggerHide: detail.checked })} + > + Hide drawer trigger + + setUrlParams({ splitPanelTriggerHide: detail.checked })} + > + Hide split panel trigger + + setUrlParams({ breadcrumbsHide: detail.checked })} + > + Hide breadcrumbs + + + + + + + + + + } + > +
{ + setHelpPathSlug('content'); + setIsToolsOpen(true); + }} + > + Info + + } + > + Content +
+ +
+ } + splitPanel={ + + This is the Split Panel! + + } + splitPanelOpen={splitPanelOpen} + splitPanelPreferences={{ + position: urlParams.splitPanelPosition, + }} + onSplitPanelToggle={event => setSplitPanelOpen(event.detail.open)} + onSplitPanelPreferencesChange={event => { + const { position } = event.detail; + setUrlParams({ splitPanelPosition: position === 'side' ? position : undefined }); + }} + onToolsChange={event => { + setIsToolsOpen(event.detail.open); + }} + tools={} + toolsOpen={isToolsOpen} + navigationOpen={isNavigationOpen} + navigation={} + onNavigationChange={event => setIsNavigationOpen(event.detail.open)} + navigationTriggerHide={navigationTriggerHide} + {...drawersProps} + /> + ); +} + +function Info({ helpPathSlug }: { helpPathSlug: string }) { + return Info}>Here is some info for you: {helpPathSlug}; +} diff --git a/pages/app-layout-toolbar/multi-layout-with-hidden-instances.page.tsx b/pages/app-layout-toolbar/multi-layout-with-hidden-instances.page.tsx new file mode 100644 index 0000000000..f96150537e --- /dev/null +++ b/pages/app-layout-toolbar/multi-layout-with-hidden-instances.page.tsx @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import AppLayoutToolbar from '~components/app-layout-toolbar'; +import BreadcrumbGroup from '~components/breadcrumb-group'; +import Header from '~components/header'; +import ScreenreaderOnly from '~components/internal/components/screenreader-only'; +import Link from '~components/link'; +import SideNavigation, { SideNavigationProps } from '~components/side-navigation'; +import SpaceBetween from '~components/space-between'; + +import { Tools } from '../app-layout/utils/content-blocks'; +import labels from '../app-layout/utils/labels'; +import { IframeWrapper } from '../utils/iframe-wrapper'; +import ScreenshotArea from '../utils/screenshot-area'; + +function createView(name: string) { + return function View() { + return ( + event.preventDefault()} + items={[ + { text: 'Home', href: '#' }, + { text: name, href: `#${name}` }, + ]} + /> + ) + } + navigationHide={true} + content={ + +
+ Multiple page layouts +
+ + + External link + + +
Page content: {name}
+
+ } + tools={Tools content: {name}} + /> + ); + }; +} + +const ROUTES: Array<{ navLink: SideNavigationProps.Link; View: React.ComponentType }> = [ + { navLink: { type: 'link', text: 'Page 1', href: 'page1' }, View: createView('page1') }, + { navLink: { type: 'link', text: 'Page 2', href: 'page2' }, View: createView('page2') }, + { navLink: { type: 'link', text: 'Page 3', href: 'page3' }, View: createView('page3') }, +]; + +export default function () { + const [activeHref, setActiveHref] = useState('page1'); + const openPagesHistory = useRef>(new Set([activeHref])); + + return ( + + { + if (!event.detail.external) { + event.preventDefault(); + openPagesHistory.current.add(event.detail.href); + setActiveHref(event.detail.href); + } + }} + items={ROUTES.map(route => route.navLink)} + /> + } + toolsHide={true} + disableContentPaddings={true} + content={ + <> + +

Multiple app layouts with iframe

+
+ {ROUTES.filter( + item => item.navLink.href === activeHref || openPagesHistory.current.has(item.navLink.href) + ).map(item => ( +
+ +
+ ))} + + } + /> +
+ ); +} diff --git a/pages/app-layout-toolbar/without-toolbar.page.tsx b/pages/app-layout-toolbar/without-toolbar.page.tsx new file mode 100644 index 0000000000..48dfbfc880 --- /dev/null +++ b/pages/app-layout-toolbar/without-toolbar.page.tsx @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import { AppLayoutToolbar, Button, ContentLayout, Header, HelpPanel, Link, SpaceBetween } from '~components'; +import { AppLayoutToolbarProps } from '~components/app-layout-toolbar'; + +import { Containers, CustomDrawerContent, Navigation } from '../app-layout/utils/content-blocks'; +import { drawerLabels } from '../app-layout/utils/drawers'; +import appLayoutLabels from '../app-layout/utils/labels'; +import ScreenshotArea from '../utils/screenshot-area'; + +export default function WithDrawers() { + const [activeDrawerId, setActiveDrawerId] = useState(null); + const [helpPathSlug, setHelpPathSlug] = useState('default'); + const [isToolsOpen, setIsToolsOpen] = useState(false); + const [isNavigationOpen, setIsNavigationOpen] = useState(true); + const pageLayoutRef = useRef(null); + + const drawersProps: Pick | null = { + activeDrawerId: activeDrawerId, + drawers: [ + { + ariaLabels: { + closeButton: 'ProHelp close button', + drawerName: 'ProHelp drawer content', + triggerButton: 'ProHelp trigger button', + resizeHandle: 'ProHelp resize handle', + }, + content: , + id: 'pro-help', + }, + ], + onDrawerChange: event => { + setActiveDrawerId(event.detail.activeDrawerId); + }, + }; + + return ( + + +
{ + setHelpPathSlug('header'); + setIsToolsOpen(true); + pageLayoutRef.current?.focusToolsClose(); + }} + > + Info + + } + > + Page layout without the toolbar +
+ + + + + + + + + } + > +
{ + setHelpPathSlug('content'); + setIsToolsOpen(true); + }} + > + Info + + } + > + Content +
+ + + } + onToolsChange={event => { + setIsToolsOpen(event.detail.open); + }} + tools={} + toolsOpen={isToolsOpen} + navigationTriggerHide={true} + navigationOpen={isNavigationOpen} + navigation={} + onNavigationChange={event => setIsNavigationOpen(event.detail.open)} + {...drawersProps} + /> +
+ ); +} + +function Info({ helpPathSlug }: { helpPathSlug: string }) { + return Info}>Here is some info for you: {helpPathSlug}; +} diff --git a/src/__a11y__/run-a11y-tests.ts b/src/__a11y__/run-a11y-tests.ts index 4e2a41a9f0..8b141da55f 100644 --- a/src/__a11y__/run-a11y-tests.ts +++ b/src/__a11y__/run-a11y-tests.ts @@ -22,6 +22,8 @@ function urlFormatter(inputUrl: string, theme: Theme, mode: Mode) { return `#/${mode}/${inputUrl}?visualRefresh=${theme === 'visual-refresh' ? 'true' : 'false'}`; } +const vrOnlyComponents = ['app-layout-toolbar']; + export default function runA11yTests(theme: Theme, mode: Mode, skip: string[] = []) { describe(`A11y checks for ${mode} ${theme}`, () => { findAllPages().forEach(inputUrl => { @@ -31,7 +33,11 @@ export default function runA11yTests(theme: Theme, mode: Mode, skip: string[] = // this page intentionally has issues to test the helper 'undefined-texts', ]; - const testFunction = skipPages.includes(inputUrl) ? test.skip : test; + const testFunction = + skipPages.includes(inputUrl) || + (theme !== 'visual-refresh' && vrOnlyComponents.some(vrOnlyComponent => inputUrl.startsWith(vrOnlyComponent))) + ? test.skip + : test; const url = urlFormatter(inputUrl, theme, mode); testFunction( url, diff --git a/src/__tests__/functional-tests/base-props-support.test.tsx b/src/__tests__/functional-tests/base-props-support.test.tsx index 1aab4b094e..25379b8e3b 100644 --- a/src/__tests__/functional-tests/base-props-support.test.tsx +++ b/src/__tests__/functional-tests/base-props-support.test.tsx @@ -3,9 +3,22 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/internal/testing'; + import { getRequiredPropsForComponent } from '../required-props-for-components'; import { getAllComponents, requireComponent, supportsDOMProperties } from '../utils'; +const globalWithFlags = globalThis as any; + +beforeEach(() => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; +}); + +afterEach(() => { + delete globalWithFlags[Symbol.for('awsui-visual-refresh-flag')]; + clearVisualRefreshState(); +}); + describe('Base props support', () => { const componentRoot = document.createElement('div'); document.body.appendChild(componentRoot); diff --git a/src/__tests__/functional-tests/outer-form-submit.test.tsx b/src/__tests__/functional-tests/outer-form-submit.test.tsx index 5f7a78bbcf..9d4fa2f6a5 100644 --- a/src/__tests__/functional-tests/outer-form-submit.test.tsx +++ b/src/__tests__/functional-tests/outer-form-submit.test.tsx @@ -3,9 +3,22 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/internal/testing'; + import { getRequiredPropsForComponent } from '../required-props-for-components'; import { getAllComponents, requireComponent } from '../utils'; +const globalWithFlags = globalThis as any; + +beforeEach(() => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; +}); + +afterEach(() => { + delete globalWithFlags[Symbol.for('awsui-visual-refresh-flag')]; + clearVisualRefreshState(); +}); + const skippedComponents = ['button']; describe('Check outer form submission', () => { diff --git a/src/__tests__/functional-tests/ssr.test.ts b/src/__tests__/functional-tests/ssr.test.ts index 4f514479c8..d594eb6771 100644 --- a/src/__tests__/functional-tests/ssr.test.ts +++ b/src/__tests__/functional-tests/ssr.test.ts @@ -7,15 +7,30 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/internal/testing'; + import { getRequiredPropsForComponent } from '../required-props-for-components'; import { getAllComponents, requireComponent } from '../utils'; +const globalWithFlags = globalThis as any; + +beforeEach(() => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; +}); + +afterEach(() => { + delete globalWithFlags[Symbol.for('awsui-visual-refresh-flag')]; + clearVisualRefreshState(); +}); + +const vrOnlyComponents = ['app-layout-toolbar']; + test('ensure is it not DOM', () => { expect(typeof window).toBe('undefined'); expect(typeof CSS).toBe('undefined'); }); -for (const componentName of getAllComponents()) { +for (const componentName of getAllComponents().filter(component => vrOnlyComponents.indexOf(component) === -1)) { test(`renders ${componentName} without crashing`, () => { const { default: Component } = requireComponent(componentName); const props = getRequiredPropsForComponent(componentName); @@ -28,3 +43,15 @@ for (const componentName of getAllComponents()) { } }); } + +describe.each(vrOnlyComponents)('VR only component %s', componentName => { + beforeEach(() => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => false; + }); + + test('should throw an error in classic', () => { + const { default: Component } = requireComponent(componentName); + const props = getRequiredPropsForComponent(componentName); + expect(() => renderToStaticMarkup(React.createElement(Component, props, 'test content'))).toThrowError(); + }); +}); diff --git a/src/__tests__/functional-tests/test-utils.test.tsx b/src/__tests__/functional-tests/test-utils.test.tsx index d606731719..7001d2b22a 100644 --- a/src/__tests__/functional-tests/test-utils.test.tsx +++ b/src/__tests__/functional-tests/test-utils.test.tsx @@ -9,6 +9,8 @@ import { render } from 'react-dom'; import { render as renderTestingLibrary } from '@testing-library/react'; import { pascalCase } from 'change-case'; +import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/internal/testing'; + import { Modal } from '../../../lib/components'; import Button from '../../../lib/components/button'; import createWrapperDom, { ElementWrapper as DomElementWrapper } from '../../../lib/components/test-utils/dom'; @@ -16,7 +18,18 @@ import createWrapperSelectors from '../../../lib/components/test-utils/selectors import { getRequiredPropsForComponent } from '../required-props-for-components'; import { getAllComponents, requireComponent } from '../utils'; -const componentWithMultipleRootElements = ['top-navigation', 'app-layout']; +const globalWithFlags = globalThis as any; + +beforeEach(() => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; +}); + +afterEach(() => { + delete globalWithFlags[Symbol.for('awsui-visual-refresh-flag')]; + clearVisualRefreshState(); +}); + +const componentWithMultipleRootElements = ['top-navigation', 'app-layout', 'app-layout-toolbar']; const componentsWithExceptions = ['annotation-context', ...componentWithMultipleRootElements]; const components = getAllComponents().filter(component => !componentsWithExceptions.includes(component)); diff --git a/src/__tests__/functional-tests/use-base-component.test.tsx b/src/__tests__/functional-tests/use-base-component.test.tsx index 7e9553cb91..7e40235e89 100644 --- a/src/__tests__/functional-tests/use-base-component.test.tsx +++ b/src/__tests__/functional-tests/use-base-component.test.tsx @@ -5,11 +5,23 @@ import { render } from '@testing-library/react'; import { pascalCase } from 'change-case'; import { COMPONENT_METADATA_KEY } from '@cloudscape-design/component-toolkit/internal'; +import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/internal/testing'; import { PACKAGE_VERSION } from '../../../lib/components/internal/environment'; import { getRequiredPropsForComponent } from '../required-props-for-components'; import { getAllComponents, requireComponent, supportsDOMProperties } from '../utils'; +const globalWithFlags = globalThis as any; + +beforeEach(() => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; +}); + +afterEach(() => { + delete globalWithFlags[Symbol.for('awsui-visual-refresh-flag')]; + clearVisualRefreshState(); +}); + describe('useBaseComponent hook is used in all allowlisted public components', () => { const componentRoot = document.createElement('div'); document.body.appendChild(componentRoot); diff --git a/src/__tests__/functional-tests/use-telemetry-usage.test.tsx b/src/__tests__/functional-tests/use-telemetry-usage.test.tsx index 825c4cb679..c82ec71c5e 100644 --- a/src/__tests__/functional-tests/use-telemetry-usage.test.tsx +++ b/src/__tests__/functional-tests/use-telemetry-usage.test.tsx @@ -3,9 +3,22 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/internal/testing'; + import { getRequiredPropsForComponent } from '../required-props-for-components'; import { getAllComponents, requireComponent } from '../utils'; +const globalWithFlags = globalThis as any; + +beforeEach(() => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; +}); + +afterEach(() => { + delete globalWithFlags[Symbol.for('awsui-visual-refresh-flag')]; + clearVisualRefreshState(); +}); + declare global { interface Window { AWSC?: any; diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index e6bcd8c3e2..858fdcfebc 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -965,6 +965,530 @@ Note: If provided, this property should be set to \`null\` or \`undefined\` if a } `; +exports[`Documenter definition for app-layout-toolbar matches the snapshot: app-layout-toolbar 1`] = ` +{ + "events": [ + { + "cancelable": false, + "description": "Fired when the active drawer is toggled.", + "detailInlineType": { + "name": "AppLayoutProps.DrawerChangeDetail", + "properties": [ + { + "name": "activeDrawerId", + "optional": false, + "type": "null | string", + }, + ], + "type": "object", + }, + "detailType": "AppLayoutProps.DrawerChangeDetail", + "name": "onDrawerChange", + }, + { + "cancelable": false, + "description": "Fired when the navigation drawer is toggled.", + "detailInlineType": { + "name": "AppLayoutProps.ChangeDetail", + "properties": [ + { + "name": "open", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "AppLayoutProps.ChangeDetail", + "name": "onNavigationChange", + }, + { + "cancelable": false, + "description": "Fired when the split panel preferences change.", + "detailInlineType": { + "name": "AppLayoutProps.SplitPanelPreferences", + "properties": [ + { + "name": "position", + "optional": false, + "type": ""bottom" | "side"", + }, + ], + "type": "object", + }, + "detailType": "AppLayoutProps.SplitPanelPreferences", + "name": "onSplitPanelPreferencesChange", + }, + { + "cancelable": false, + "description": "Fired when the split panel is resized.", + "detailInlineType": { + "name": "AppLayoutProps.SplitPanelResizeDetail", + "properties": [ + { + "name": "size", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "detailType": "AppLayoutProps.SplitPanelResizeDetail", + "name": "onSplitPanelResize", + }, + { + "cancelable": false, + "description": "Fired when the split panel is toggled.", + "detailInlineType": { + "name": "AppLayoutProps.ChangeDetail", + "properties": [ + { + "name": "open", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "AppLayoutProps.ChangeDetail", + "name": "onSplitPanelToggle", + }, + { + "cancelable": false, + "description": "Fired when the tools drawer is toggled.", + "detailInlineType": { + "name": "AppLayoutProps.ChangeDetail", + "properties": [ + { + "name": "open", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "AppLayoutProps.ChangeDetail", + "name": "onToolsChange", + }, + ], + "functions": [ + { + "description": "Manually closes the navigation drawer if it is necessary for the current +viewport size.", + "name": "closeNavigationIfNecessary", + "parameters": [], + "returnType": "void", + }, + { + "description": "Focuses the active drawer. Use this to focus the active drawer after opening it programmatically.", + "name": "focusActiveDrawer", + "parameters": [], + "returnType": "void", + }, + { + "description": "Focuses the navigation. Use this to focus the navigation after opening it programmatically.", + "name": "focusNavigation", + "parameters": [], + "returnType": "void", + }, + { + "description": "Focuses the split panel if it is open.", + "name": "focusSplitPanel", + "parameters": [], + "returnType": "void", + }, + { + "description": "Focuses the tools panel if it is open. Use this to focus the tools panel +after changing the content, for example when clicking on an 'info' link while +the panel is already open.", + "name": "focusToolsClose", + "parameters": [], + "returnType": "void", + }, + { + "description": "Opens the tools panel if it is not already open. Note that it is preferable +to control the state by listening to \`toolsChange\` and providing \`toolsOpen\`.", + "name": "openTools", + "parameters": [], + "returnType": "void", + }, + ], + "name": "AppLayoutToolbar", + "properties": [ + { + "description": "The active drawer id. If you want to clear the active drawer, use \`null\`.", + "name": "activeDrawerId", + "optional": true, + "type": "null | string", + }, + { + "analyticsTag": "", + "description": "Specifies additional analytics-related metadata. +* \`instanceIdentifier\` - A unique string that identifies this component instance in your application. +* \`flowType\` - Identifies the type of flow represented by the component. +**Note:** This API is currently experimental.", + "inlineType": { + "name": "AppLayoutProps.AnalyticsMetadata", + "properties": [ + { + "name": "flowType", + "optional": true, + "type": "FlowType", + }, + { + "name": "instanceIdentifier", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "analyticsMetadata", + "optional": true, + "type": "AppLayoutProps.AnalyticsMetadata", + }, + { + "description": "Aria labels for the drawer operating buttons. Use this property to ensure accessibility. +* \`navigation\` (string) - Label for the landmark that wraps the navigation drawer. +* \`navigationClose\` (string) - Label for the button that closes the navigation drawer. +* \`navigationToggle\` (string) - Label for the button that opens the navigation drawer. +* \`notification\` (string) - Label for the region that contains notification messages. +* \`tools\` (string) - Label for the landmark that wraps the tools drawer. +* \`toolsClose\` (string) - Label for the button that closes the tools drawer. +* \`toolsToggle\` (string) - Label for the button that opens the tools drawer. +* \`drawers\` (string) - Label for the landmark that wraps the active drawer. +* \`drawersOverflow\` (string) - Label for the ellipsis button with any overflow drawers. +* \`drawersOverflowWithBadge\` (string) - Label for the ellipsis button with any overflow drawers, with a badge. + +Example: +\`\`\` +{ + navigation: "Navigation drawer", + navigationClose: "Close navigation drawer", + navigationToggle: "Open navigation drawer", + notifications: "Notifications", + tools: "Help panel", + toolsClose: "Close help panel", + toolsToggle: "Open help panel", + drawers: "Drawers", + drawersOverflow: "Overflow drawers", + drawersOverflowWithBadge: "Overflow drawers (Unread notifications)" +} +\`\`\`", + "i18nTag": true, + "inlineType": { + "name": "AppLayoutProps.Labels", + "properties": [ + { + "name": "drawers", + "optional": true, + "type": "string", + }, + { + "name": "drawersOverflow", + "optional": true, + "type": "string", + }, + { + "name": "drawersOverflowWithBadge", + "optional": true, + "type": "string", + }, + { + "name": "navigation", + "optional": true, + "type": "string", + }, + { + "name": "navigationClose", + "optional": true, + "type": "string", + }, + { + "name": "navigationToggle", + "optional": true, + "type": "string", + }, + { + "name": "notifications", + "optional": true, + "type": "string", + }, + { + "name": "tools", + "optional": true, + "type": "string", + }, + { + "name": "toolsClose", + "optional": true, + "type": "string", + }, + { + "name": "toolsToggle", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "ariaLabels", + "optional": true, + "type": "AppLayoutProps.Labels", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "defaultValue": "'default'", + "description": "Determines the default behavior of the component based on some predefined page layouts. +Individual properties will always take precedence over the default coming from the content type.", + "inlineType": { + "name": "AppLayoutProps.ContentType", + "type": "union", + "values": [ + "default", + "form", + "table", + "cards", + "wizard", + "dashboard", + ], + }, + "name": "contentType", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "Replaced by the \`disableOverlap\` property of the [content layout](/components/content-layout/) component.", + "description": "Disables overlap between \`contentHeader\` and \`content\` slots.", + "name": "disableContentHeaderOverlap", + "optional": true, + "type": "boolean", + "visualRefreshTag": "", + }, + { + "description": "If \`true\`, disables outer paddings for the content slot.", + "name": "disableContentPaddings", + "optional": true, + "type": "boolean", + }, + { + "description": "Drawers property. If you set both \`drawers\` and \`tools\`, \`drawers\` will take precedence. +Each Drawer is an item in the drawers wrapper with the following properties: +* id (string) - the id of the drawer. +* content (React.ReactNode) - the content in the drawer. +* trigger (DrawerTrigger) - (Optional) the button that opens and closes the active drawer. +If not set, a corresponding trigger button is not displayed, while the drawer might be displayed, but opened using a custom trigger. +* ariaLabels (DrawerAriaLabels) - the labels for the interactive elements of the drawer. +* badge (boolean) - (Optional) Adds a badge to the corner of the icon to indicate a state change. For example: Unread notifications. +* resizable (boolean) - (Optional) if the drawer is resizable or not. +* defaultSize (number) - (Optional) starting size of the drawer. if not set, defaults to 290. +* onResize (({ size: number }) => void) - (Optional) Fired when the active drawer is resized. + +#### DrawerTrigger +- \`iconName\` (IconProps.Name) - (Optional) Specifies the icon to be displayed. +- \`iconSvg\` (React.ReactNode) - (Optional) Specifies the SVG of a custom icon. For more information, see [SVG icon guidelines](/components/icon/?tabId=api#slots) + +#### DrawerAriaLabels +- \`drawerName\` (string) - Label for the drawer itself, and for the drawer trigger button tooltip text. +- \`closeButton\` (string) - (Optional) Label for the close button. +- \`triggerButton\` (string) - (Optional) Label for the trigger button. +- \`resizeHandle\` (string) - (Optional) Label for the resize handle. +", + "name": "drawers", + "optional": true, + "type": "Array", + }, + { + "defaultValue": "'#b #f'", + "description": "CSS selector for the application footer.", + "name": "footerSelector", + "optional": true, + "type": "string", + }, + { + "defaultValue": "'#b #h'", + "description": "CSS selector for the application header.", + "name": "headerSelector", + "optional": true, + "type": "string", + }, + { + "description": "Determines the visual treatment for the breadcrumbs and notifications slots. Specifically: +* \`default\` - Does not apply any visual treatment. +* \`high-contrast\` - Applies high-contrast to both slots. Use in conjunction with \`headerVariant="high-contrast"\` in ContentLayout.", + "inlineType": { + "name": "", + "type": "union", + "values": [ + "default", + "high-contrast", + ], + }, + "name": "headerVariant", + "optional": true, + "type": "string", + "visualRefreshTag": "", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Maximum main content panel width in pixels. +If set to \`Number.MAX_VALUE\`, the main content panel will occupy the full available width. +", + "name": "maxContentWidth", + "optional": true, + "type": "number", + }, + { + "description": "Minimum main content panel width in pixels.", + "name": "minContentWidth", + "optional": true, + "type": "number", + }, + { + "description": "If \`true\`, the navigation drawer is not displayed at all.", + "name": "navigationHide", + "optional": true, + "type": "boolean", + }, + { + "description": "State of the navigation drawer.", + "name": "navigationOpen", + "optional": true, + "type": "boolean", + }, + { + "description": "If \`true\`, the navigation trigger is not displayed at all, +while navigation drawer might be displayed, but opened using a custom trigger.", + "name": "navigationTriggerHide", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "280", + "description": "Navigation drawer width in pixels.", + "name": "navigationWidth", + "optional": true, + "type": "number", + }, + { + "description": "State of the split panel.", + "name": "splitPanelOpen", + "optional": true, + "type": "boolean", + }, + { + "description": "Controls the split panel preferences. +By default, the preference is \`{ position: 'bottom' }\` +", + "inlineType": { + "name": "AppLayoutProps.SplitPanelPreferences", + "properties": [ + { + "name": "position", + "optional": false, + "type": ""bottom" | "side"", + }, + ], + "type": "object", + }, + "name": "splitPanelPreferences", + "optional": true, + "type": "AppLayoutProps.SplitPanelPreferences", + }, + { + "description": "The size of the split panel in pixels.", + "name": "splitPanelSize", + "optional": true, + "type": "number", + }, + { + "description": "If true, the notification slot is rendered above the scrollable +content area so it is always visible.", + "name": "stickyNotifications", + "optional": true, + "type": "boolean", + }, + { + "description": "If \`true\`, the tools drawer is not displayed at all.", + "name": "toolsHide", + "optional": true, + "type": "boolean", + }, + { + "description": "State of the tools drawer.", + "name": "toolsOpen", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "290", + "description": "Tools drawer width in pixels.", + "name": "toolsWidth", + "optional": true, + "type": "number", + }, + ], + "regions": [ + { + "description": "Use this slot to add the [breadcrumb group component](/components/breadcrumb-group/) to the app layout.", + "isDefault": false, + "name": "breadcrumbs", + }, + { + "description": "Main content.", + "isDefault": false, + "name": "content", + }, + { + "deprecatedTag": "Replaced by the \`header\` slot of the [content layout](/components/content-layout/) component.", + "description": "Top area of the page content.", + "isDefault": false, + "name": "contentHeader", + "visualRefreshTag": "", + }, + { + "description": "Navigation drawer.", + "isDefault": false, + "name": "navigation", + }, + { + "description": "Displayed on top of the main content in the scrollable area. +Conceived to contain notifications (flash messages). +", + "isDefault": false, + "name": "notifications", + }, + { + "description": "Use this slot to add the [split panel component](/components/split-panel/) to the app layout. +Note: If provided, this property should be set to \`null\` or \`undefined\` if a split panel should not be rendered. +", + "isDefault": false, + "name": "splitPanel", + }, + { + "description": "Tools drawer.", + "isDefault": false, + "name": "tools", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`Documenter definition for area-chart matches the snapshot: area-chart 1`] = ` { "events": [ diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 1439c63c91..cdfb9b922f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -13,6 +13,7 @@ import AlertWrapper from './alert'; import AnchorNavigationWrapper from './anchor-navigation'; import AnnotationWrapper from './annotation'; import AppLayoutWrapper from './app-layout'; +import AppLayoutToolbarWrapper from './app-layout-toolbar'; import AreaChartWrapper from './area-chart'; import AttributeEditorWrapper from './attribute-editor'; import AutosuggestWrapper from './autosuggest'; @@ -94,6 +95,7 @@ export { AlertWrapper }; export { AnchorNavigationWrapper }; export { AnnotationWrapper }; export { AppLayoutWrapper }; +export { AppLayoutToolbarWrapper }; export { AreaChartWrapper }; export { AttributeEditorWrapper }; export { AutosuggestWrapper }; @@ -249,6 +251,25 @@ findAppLayout(selector?: string): AppLayoutWrapper | null; * @returns {Array} */ findAllAppLayouts(selector?: string): Array; +/** + * Returns the wrapper of the first AppLayoutToolbar that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first AppLayoutToolbar. + * If no matching AppLayoutToolbar is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {AppLayoutToolbarWrapper | null} + */ +findAppLayoutToolbar(selector?: string): AppLayoutToolbarWrapper | null; + +/** + * Returns an array of AppLayoutToolbar wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the AppLayoutToolbars inside the current wrapper. + * If no matching AppLayoutToolbar is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllAppLayoutToolbars(selector?: string): Array; /** * Returns the wrapper of the first AreaChart that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first AreaChart. @@ -1718,6 +1739,16 @@ ElementWrapper.prototype.findAppLayout = function(selector) { ElementWrapper.prototype.findAllAppLayouts = function(selector) { return this.findAllComponents(AppLayoutWrapper, selector); }; +ElementWrapper.prototype.findAppLayoutToolbar = function(selector) { + const rootSelector = \`.\${AppLayoutToolbarWrapper.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, AppLayoutToolbarWrapper); +}; + +ElementWrapper.prototype.findAllAppLayoutToolbars = function(selector) { + return this.findAllComponents(AppLayoutToolbarWrapper, selector); +}; ElementWrapper.prototype.findAreaChart = function(selector) { const rootSelector = \`.\${AreaChartWrapper.rootSelector}\`; // casting to 'any' is needed to avoid this issue with generics @@ -2492,6 +2523,7 @@ import AlertWrapper from './alert'; import AnchorNavigationWrapper from './anchor-navigation'; import AnnotationWrapper from './annotation'; import AppLayoutWrapper from './app-layout'; +import AppLayoutToolbarWrapper from './app-layout-toolbar'; import AreaChartWrapper from './area-chart'; import AttributeEditorWrapper from './attribute-editor'; import AutosuggestWrapper from './autosuggest'; @@ -2573,6 +2605,7 @@ export { AlertWrapper }; export { AnchorNavigationWrapper }; export { AnnotationWrapper }; export { AppLayoutWrapper }; +export { AppLayoutToolbarWrapper }; export { AreaChartWrapper }; export { AttributeEditorWrapper }; export { AutosuggestWrapper }; @@ -2720,6 +2753,23 @@ findAppLayout(selector?: string): AppLayoutWrapper; * @returns {MultiElementWrapper} */ findAllAppLayouts(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the AppLayoutToolbars with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches AppLayoutToolbars. + * + * @param {string} [selector] CSS Selector + * @returns {AppLayoutToolbarWrapper} + */ +findAppLayoutToolbar(selector?: string): AppLayoutToolbarWrapper; + +/** + * Returns a multi-element wrapper that matches AppLayoutToolbars with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches AppLayoutToolbars. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllAppLayoutToolbars(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the AreaCharts with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches AreaCharts. @@ -4039,6 +4089,16 @@ ElementWrapper.prototype.findAppLayout = function(selector) { ElementWrapper.prototype.findAllAppLayouts = function(selector) { return this.findAllComponents(AppLayoutWrapper, selector); }; +ElementWrapper.prototype.findAppLayoutToolbar = function(selector) { + const rootSelector = \`.\${AppLayoutToolbarWrapper.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, AppLayoutToolbarWrapper); +}; + +ElementWrapper.prototype.findAllAppLayoutToolbars = function(selector) { + return this.findAllComponents(AppLayoutToolbarWrapper, selector); +}; ElementWrapper.prototype.findAreaChart = function(selector) { const rootSelector = \`.\${AreaChartWrapper.rootSelector}\`; // casting to 'any' is needed to avoid this issue with generics diff --git a/src/app-layout-toolbar/__integ__/multi-app-layout-toolbar-navigation.test.ts b/src/app-layout-toolbar/__integ__/multi-app-layout-toolbar-navigation.test.ts new file mode 100644 index 0000000000..f363ea7806 --- /dev/null +++ b/src/app-layout-toolbar/__integ__/multi-app-layout-toolbar-navigation.test.ts @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../lib/components/test-utils/selectors'; + +class PageObject extends BasePageObject { + clickHref(href: string) { + return this.click(`[href="${href}"]`); + } +} + +describe('Multi page layout navigation', () => { + const mainLayout = createWrapper().find('[data-testid="main-layout"]').findAppLayout(); + const secondaryLayout = createWrapper().find('[data-testid="secondary-layout"]').findAppLayout(); + const setupTest = (testFn: (page: PageObject) => Promise) => + useBrowser(async browser => { + const page = new PageObject(browser); + await browser.url('#/light/app-layout-toolbar/multi-layout-with-hidden-instances'); + await testFn(page); + }); + + test( + 'should keep toolbars and breadcrumbs state independently in both layouts', + setupTest(async page => { + expect(await page.isExisting(mainLayout.findBreadcrumbs().toSelector())).toBeFalsy(); + await page.runInsideIframe('#page1', true, async () => { + await expect(page.getText(secondaryLayout.findBreadcrumbs().toSelector())).resolves.toContain('page1'); + }); + + await page.clickHref('page2'); + expect(await page.isExisting(mainLayout.findBreadcrumbs().toSelector())).toBeFalsy(); + await page.runInsideIframe('#page1', true, async () => { + await expect(page.getText(secondaryLayout.findBreadcrumbs().toSelector())).resolves.toBeFalsy(); + }); + + await page.clickHref('page3'); + expect(await page.isExisting(mainLayout.findBreadcrumbs().toSelector())).toBeFalsy(); + await page.runInsideIframe('#page3', true, async () => { + await expect(page.getText(secondaryLayout.findBreadcrumbs().toSelector())).resolves.toContain('page3'); + }); + + await page.clickHref('page1'); + expect(await page.isExisting(mainLayout.findBreadcrumbs().toSelector())).toBeFalsy(); + await page.runInsideIframe('#page1', true, async () => { + await expect(page.getText(secondaryLayout.findBreadcrumbs().toSelector())).resolves.toContain('page1'); + }); + }) + ); +}); diff --git a/src/app-layout-toolbar/__tests__/app-layout-toolbar.test.tsx b/src/app-layout-toolbar/__tests__/app-layout-toolbar.test.tsx new file mode 100644 index 0000000000..47bec8ed72 --- /dev/null +++ b/src/app-layout-toolbar/__tests__/app-layout-toolbar.test.tsx @@ -0,0 +1,175 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +/* eslint simple-import-sort/imports: 0 */ +import React, { useRef, useState } from 'react'; +import { render } from '@testing-library/react'; +import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/internal/testing'; +import AppLayoutToolbar, { AppLayoutToolbarProps } from '../../../lib/components/app-layout-toolbar'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import BreadcrumbGroup from '../../../lib/components/breadcrumb-group'; + +export function renderComponent(jsx: React.ReactElement) { + const { container, rerender } = render(jsx); + const wrapper = createWrapper(container).findAppLayoutToolbar()!; + + return { wrapper, rerender, container }; +} + +describe('AppLayoutToolbar component', () => { + const globalWithFlags = globalThis as any; + + beforeEach(() => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; + }); + + afterEach(() => { + delete globalWithFlags[Symbol.for('awsui-visual-refresh-flag')]; + clearVisualRefreshState(); + }); + + test('throws an error when use in classic theme', () => { + globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => false; + + expect(() => render(} />)).toThrowError( + 'AppLayoutToolbar component is not supported in the Classic theme. Please switch to the Refresh theme. For more details, refer to the documentation.' + ); + }); + + test('triggerless navigation', () => { + const AppLayoutToolbarWrapper = () => { + const [isNavigationOpen, setIsNavigationOpen] = useState(false); + const appLayoutToolbarRef = useRef(null); + + return ( + setIsNavigationOpen(event.detail.open)} + navigation={<>Mock Navigation} + content={ +
+ Content + +
+ } + /> + ); + }; + const { wrapper } = renderComponent(); + + expect(wrapper.findNavigationToggle()).toBeFalsy(); + expect(wrapper.findOpenNavigationPanel()).toBeFalsy(); + + wrapper.find(`[data-testid="toggle-navigation"]`)!.click(); + + expect(wrapper.findOpenNavigationPanel()).toBeTruthy(); + expect(wrapper.findNavigationClose()!.getElement()).toHaveFocus(); + }); + + test('triggerless drawers', () => { + const drawerId = 'pro-help'; + const AppLayoutToolbarWrapper = () => { + const [activeDrawerId, setActiveDrawerId] = useState(null); + const appLayoutToolbarRef = useRef(null); + + return ( + Drawer content, + id: drawerId, + }, + ]} + onDrawerChange={event => { + setActiveDrawerId(event.detail.activeDrawerId); + }} + content={ +
+ Content + +
+ } + /> + ); + }; + const { wrapper } = renderComponent(); + + expect(wrapper.findDrawerTriggerById(drawerId)).toBeFalsy(); + expect(wrapper.findActiveDrawer()).toBeFalsy(); + + wrapper.find('[data-testid="open-drawer"]')!.click(); + + expect(wrapper.findActiveDrawer()).toBeTruthy(); + }); + + test('should not render the toolbar when there is no nav & drawers triggers & no breadcrumbs', () => { + const { wrapper } = renderComponent( + Mock Navigation} + breadcrumbs={undefined} + drawers={[ + { + ariaLabels: { + closeButton: 'ProHelp close button', + drawerName: 'ProHelp drawer content', + triggerButton: 'ProHelp trigger button', + resizeHandle: 'ProHelp resize handle', + }, + content:
Drawer content
, + id: 'pro-help', + }, + ]} + content={
Content
} + /> + ); + + expect(wrapper.findToolbar()).toBeFalsy(); + }); + + test('should not deduplicate toolbar props in nested components', () => { + const { container } = render( + Mock Navigation} + toolsHide={true} + breadcrumbs={} + content={ + } + content={
Content
} + /> + } + /> + ); + + const layouts = createWrapper(container).findAllAppLayoutToolbars(); + const [outer, inner] = layouts; + + expect(layouts).toHaveLength(2); + expect(outer.findToolbar()).toBeTruthy(); + expect(inner.findToolbar()).toBeTruthy(); + expect(outer.findBreadcrumbs()!.getElement()).toHaveTextContent('HomeOuter'); + expect(inner.findBreadcrumbs()!.getElement()).toHaveTextContent('HomeInner'); + }); +}); diff --git a/src/app-layout-toolbar/index.tsx b/src/app-layout-toolbar/index.tsx new file mode 100644 index 0000000000..3a043e4d3d --- /dev/null +++ b/src/app-layout-toolbar/index.tsx @@ -0,0 +1,140 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { applyDefaults } from '../app-layout/defaults'; +import { AppLayoutProps } from '../app-layout/interfaces'; +import { useAppLayoutPlacement } from '../app-layout/utils/use-app-layout-placement'; +import AppLayoutToolbarInternal from '../app-layout/visual-refresh-toolbar'; +import { AppLayoutToolbarPublicContext } from '../app-layout/visual-refresh-toolbar/contexts'; +import { useInternalI18n } from '../i18n/context'; +import { getBaseProps } from '../internal/base-component'; +import { NonCancelableCustomEvent } from '../internal/events'; +import useBaseComponent from '../internal/hooks/use-base-component'; +import { useControllable } from '../internal/hooks/use-controllable'; +import { useMergeRefs } from '../internal/hooks/use-merge-refs'; +import { useMobile } from '../internal/hooks/use-mobile'; +import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { isDevelopment } from '../internal/is-development'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { AppLayoutToolbarProps } from './interfaces'; + +export { AppLayoutToolbarProps }; + +const AppLayoutToolbar = React.forwardRef( + ( + { + contentType = 'default', + headerSelector = '#b #h', + footerSelector = '#b #f', + navigationWidth = 280, + toolsWidth = 290, + maxContentWidth, + minContentWidth, + navigationOpen: controlledNavigationOpen, + onNavigationChange: controlledOnNavigationChange, + analyticsMetadata, + ...rest + }: AppLayoutToolbarProps, + ref: React.Ref + ) => { + const isRefresh = useVisualRefresh(); + if (!isRefresh) { + throw new Error( + `AppLayoutToolbar component is not supported in the Classic theme. Please switch to the Refresh theme. For more details, refer to the documentation.` + ); + } + if (isDevelopment) { + if (rest.toolsOpen && rest.toolsHide) { + warnOnce( + 'AppLayoutToolbar', + `You have enabled both the \`toolsOpen\` prop and the \`toolsHide\` prop. This is not supported. Set \`toolsOpen\` to \`false\` when you set \`toolsHide\` to \`true\`.` + ); + } + } + const { __internalRootRef } = useBaseComponent( + 'AppLayoutToolbar', + { + props: { + contentType, + disableContentPaddings: rest.disableContentPaddings, + navigationWidth, + navigationHide: rest.navigationHide, + toolsHide: rest.toolsHide, + toolsWidth, + maxContentWidth, + minContentWidth, + stickyNotifications: rest.stickyNotifications, + disableContentHeaderOverlap: rest.disableContentHeaderOverlap, + navigationTriggerHide: rest.navigationTriggerHide, + }, + metadata: { + drawersCount: rest.drawers?.length ?? null, + hasContentHeader: !!rest.contentHeader, + }, + }, + analyticsMetadata + ); + const isMobile = useMobile(); + + const i18n = useInternalI18n('app-layout'); + const ariaLabels = { + navigation: i18n('ariaLabels.navigation', rest.ariaLabels?.navigation), + navigationClose: i18n('ariaLabels.navigationClose', rest.ariaLabels?.navigationClose), + navigationToggle: i18n('ariaLabels.navigationToggle', rest.ariaLabels?.navigationToggle), + notifications: i18n('ariaLabels.notifications', rest.ariaLabels?.notifications), + tools: i18n('ariaLabels.tools', rest.ariaLabels?.tools), + toolsClose: i18n('ariaLabels.toolsClose', rest.ariaLabels?.toolsClose), + toolsToggle: i18n('ariaLabels.toolsToggle', rest.ariaLabels?.toolsToggle), + drawers: i18n('ariaLabels.drawers', rest.ariaLabels?.drawers), + drawersOverflow: i18n('ariaLabels.drawersOverflow', rest.ariaLabels?.drawersOverflow), + drawersOverflowWithBadge: i18n('ariaLabels.drawersOverflowWithBadge', rest.ariaLabels?.drawersOverflowWithBadge), + }; + const { navigationOpen: defaultNavigationOpen, ...restDefaults } = applyDefaults( + contentType, + { maxContentWidth, minContentWidth }, + isRefresh + ); + + const [navigationOpen = false, setNavigationOpen] = useControllable( + controlledNavigationOpen, + controlledOnNavigationChange, + isMobile ? false : defaultNavigationOpen, + { componentName: 'AppLayoutToolbar', controlledProp: 'navigationOpen', changeHandler: 'onNavigationChange' } + ); + const onNavigationChange = (event: NonCancelableCustomEvent) => { + setNavigationOpen(event.detail.open); + controlledOnNavigationChange?.(event); + }; + + const [rootRef, placement] = useAppLayoutPlacement(headerSelector, footerSelector); + + // This re-builds the props including the default values + const props = { + contentType, + navigationWidth, + toolsWidth, + navigationOpen, + onNavigationChange, + ...restDefaults, + ...rest, + ariaLabels, + placement, + }; + + const baseProps = getBaseProps(rest); + + return ( + +
+ +
+
+ ); + } +); + +applyDisplayName(AppLayoutToolbar, 'AppLayoutToolbar'); +export default AppLayoutToolbar; diff --git a/src/app-layout-toolbar/interfaces.ts b/src/app-layout-toolbar/interfaces.ts new file mode 100644 index 0000000000..bda7984c95 --- /dev/null +++ b/src/app-layout-toolbar/interfaces.ts @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { AppLayoutProps, BaseLayoutProps } from '../app-layout/interfaces'; + +export interface AppLayoutToolbarProps extends BaseLayoutProps { + /** + * If `true`, the navigation trigger is not displayed at all, + * while navigation drawer might be displayed, but opened using a custom trigger. + */ + navigationTriggerHide?: boolean; + + /** + * Drawers property. If you set both `drawers` and `tools`, `drawers` will take precedence. + + * Each Drawer is an item in the drawers wrapper with the following properties: + * * id (string) - the id of the drawer. + * * content (React.ReactNode) - the content in the drawer. + * * trigger (DrawerTrigger) - (Optional) the button that opens and closes the active drawer. + * If not set, a corresponding trigger button is not displayed, while the drawer might be displayed, but opened using a custom trigger. + * * ariaLabels (DrawerAriaLabels) - the labels for the interactive elements of the drawer. + * * badge (boolean) - (Optional) Adds a badge to the corner of the icon to indicate a state change. For example: Unread notifications. + * * resizable (boolean) - (Optional) if the drawer is resizable or not. + * * defaultSize (number) - (Optional) starting size of the drawer. if not set, defaults to 290. + * * onResize (({ size: number }) => void) - (Optional) Fired when the active drawer is resized. + * + * #### DrawerTrigger + * - `iconName` (IconProps.Name) - (Optional) Specifies the icon to be displayed. + * - `iconSvg` (React.ReactNode) - (Optional) Specifies the SVG of a custom icon. For more information, see [SVG icon guidelines](/components/icon/?tabId=api#slots) + * + * #### DrawerAriaLabels + * - `drawerName` (string) - Label for the drawer itself, and for the drawer trigger button tooltip text. + * - `closeButton` (string) - (Optional) Label for the close button. + * - `triggerButton` (string) - (Optional) Label for the trigger button. + * - `resizeHandle` (string) - (Optional) Label for the resize handle. + */ + drawers?: Array; +} + +export namespace AppLayoutToolbarProps { + export type AnalyticsMetadata = AppLayoutProps.AnalyticsMetadata; + export type ContentType = AppLayoutProps.ContentType; + export interface Ref extends AppLayoutProps.Ref { + /** + * Focuses the navigation. Use this to focus the navigation after opening it programmatically. + */ + focusNavigation(): void; + } + export type Drawer = AppLayoutProps.Drawer; + export type DrawerAriaLabels = AppLayoutProps.DrawerAriaLabels; + export type Labels = AppLayoutProps.Labels; + export type ChangeDetail = AppLayoutProps.ChangeDetail; + export type SplitPanelResizeDetail = AppLayoutProps.SplitPanelResizeDetail; + export type SplitPanelPreferences = AppLayoutProps.SplitPanelPreferences; + export type SplitPanelPosition = AppLayoutProps.SplitPanelPosition; + export type DrawerChangeDetail = AppLayoutProps.DrawerChangeDetail; +} diff --git a/src/app-layout/interfaces.ts b/src/app-layout/interfaces.ts index 43c456a5a3..58c4b446ae 100644 --- a/src/app-layout/interfaces.ts +++ b/src/app-layout/interfaces.ts @@ -8,7 +8,7 @@ import { BaseComponentProps } from '../internal/base-component'; import { NonCancelableEventHandler } from '../internal/events'; import { SomeRequired } from '../internal/types'; -export interface AppLayoutProps extends BaseComponentProps { +export interface BaseLayoutProps extends BaseComponentProps { /** * Specifies additional analytics-related metadata. * * `instanceIdentifier` - A unique string that identifies this component instance in your application. @@ -64,12 +64,6 @@ export interface AppLayoutProps extends BaseComponentProps { */ disableContentPaddings?: boolean; - /** - * Activates a backwards-compatibility mode for applications with non-fixed headers and footers. - * @deprecated This layout is being phased out and may miss some features. - */ - disableBodyScroll?: boolean; - /** * State of the navigation drawer. */ @@ -256,6 +250,14 @@ export interface AppLayoutProps extends BaseComponentProps { onSplitPanelPreferencesChange?: NonCancelableEventHandler; } +export interface AppLayoutProps extends BaseLayoutProps { + /** + * Activates a backwards-compatibility mode for applications with non-fixed headers and footers. + * @deprecated This layout is being phased out and may miss some features. + */ + disableBodyScroll?: boolean; +} + export namespace AppLayoutProps { export interface AnalyticsMetadata { instanceIdentifier?: string; diff --git a/src/app-layout/internal.tsx b/src/app-layout/internal.tsx index 033d14ad76..ce493b15d4 100644 --- a/src/app-layout/internal.tsx +++ b/src/app-layout/internal.tsx @@ -5,13 +5,13 @@ import React from 'react'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import ClassicAppLayout from './classic'; import { AppLayoutProps, AppLayoutPropsWithDefaults } from './interfaces'; -import { useAppLayoutToolbarEnabled } from './utils/feature-flags'; +import { useAppLayoutFlagEnabled } from './utils/feature-flags'; import RefreshedAppLayout from './visual-refresh'; import ToolbarAppLayout from './visual-refresh-toolbar'; export const AppLayoutInternal = React.forwardRef((props, ref) => { const isRefresh = useVisualRefresh(); - const isToolbar = useAppLayoutToolbarEnabled(); + const isToolbar = useAppLayoutFlagEnabled(); if (isRefresh) { if (isToolbar) { return ; diff --git a/src/app-layout/utils/feature-flags.ts b/src/app-layout/utils/feature-flags.ts index ac4ed1857f..71525f2298 100644 --- a/src/app-layout/utils/feature-flags.ts +++ b/src/app-layout/utils/feature-flags.ts @@ -1,10 +1,25 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { useContext } from 'react'; + import { getGlobalFlag } from '@cloudscape-design/component-toolkit/internal'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; +import { AppLayoutToolbarPublicContext } from '../visual-refresh-toolbar/contexts'; -export const useAppLayoutToolbarEnabled = () => { +// useAppLayoutFlagEnabled is set to true only in consoles. It controls if AppLayout theme is toolbar +export const useAppLayoutFlagEnabled = () => { const isRefresh = useVisualRefresh(); return isRefresh && (getGlobalFlag('appLayoutWidget') || getGlobalFlag('appLayoutToolbar')); }; + +// AppLayoutToolbar component will have 2 modes: +// - for those who use AppLayout component with toolbar. they expect to have all existing features, including deduplication +// - for non-console usage. in this case we don't need "hidden" features to be enabled, now it's only deduplication +// the hooks I want to name will exist only internally to control this behavior +export const useAppLayoutToolbarDesignEnabled = () => { + const isToolbarPrivate = useAppLayoutFlagEnabled(); + const isToolbarPublic = useContext(AppLayoutToolbarPublicContext) ?? false; + + return isToolbarPublic || isToolbarPrivate; +}; diff --git a/src/app-layout/visual-refresh-toolbar/contexts.ts b/src/app-layout/visual-refresh-toolbar/contexts.ts index 87415050ed..78418f47a5 100644 --- a/src/app-layout/visual-refresh-toolbar/contexts.ts +++ b/src/app-layout/visual-refresh-toolbar/contexts.ts @@ -15,3 +15,8 @@ export const AppLayoutVisibilityContext = awsuiPluginsInternal.sharedReactContex React, 'AppLayoutVisibilityContext' ); + +export const AppLayoutToolbarPublicContext = awsuiPluginsInternal.sharedReactContexts.createContext( + React, + 'AppLayoutToolbarPublicContext' +); diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index 709e376fef..0bf8253873 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -240,6 +240,7 @@ const AppLayoutVisualRefreshToolbar = React.forwardRef drawersFocusControl.setFocus(true), focusActiveDrawer: () => drawersFocusControl.setFocus(true), focusSplitPanel: () => splitPanelFocusControl.refs.slider.current?.focus(), + focusNavigation: () => navigationFocusControl.setFocus(true), })); const resolvedStickyNotifications = !!stickyNotifications && !isMobile; diff --git a/src/app-layout/visual-refresh-toolbar/multi-layout.ts b/src/app-layout/visual-refresh-toolbar/multi-layout.ts index fda04b85a3..4977a00024 100644 --- a/src/app-layout/visual-refresh-toolbar/multi-layout.ts +++ b/src/app-layout/visual-refresh-toolbar/multi-layout.ts @@ -7,6 +7,7 @@ import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { awsuiPluginsInternal } from '../../internal/plugins/api'; import { RegistrationState } from '../../internal/plugins/controllers/app-layout-widget'; import { AppLayoutProps } from '../interfaces'; +import { useAppLayoutFlagEnabled } from '../utils/feature-flags'; import { OnChangeParams } from '../utils/use-drawers'; import { Focusable, FocusControlMultipleStates } from '../utils/use-focus-control'; import { SplitPanelToggleProps, ToolbarProps } from './toolbar'; @@ -94,9 +95,10 @@ export function mergeProps( export function useMultiAppLayout(props: SharedProps, isEnabled: boolean) { const [registration, setRegistration] = useState | null>(null); const { forceDeduplicationType } = props; + const isToolbar = useAppLayoutFlagEnabled(); useLayoutEffect(() => { - if (!isEnabled || forceDeduplicationType === 'suspended') { + if (!isEnabled || forceDeduplicationType === 'suspended' || !isToolbar) { return; } if (forceDeduplicationType === 'off') { @@ -106,7 +108,7 @@ export function useMultiAppLayout(props: SharedProps, isEnabled: boolean) { return awsuiPluginsInternal.appLayoutWidget.register(forceDeduplicationType, props => setRegistration(props as RegistrationState) ); - }, [forceDeduplicationType, isEnabled]); + }, [forceDeduplicationType, isEnabled, isToolbar]); useLayoutEffect(() => { if (registration?.type === 'secondary') { @@ -114,6 +116,15 @@ export function useMultiAppLayout(props: SharedProps, isEnabled: boolean) { } }); + if (!isToolbar) { + return { + registered: 'primary', + // mergeProps is needed here because the toolbar's behavior depends on reconciliation logic + // in this function. For example, navigation trigger visibility + toolbarProps: mergeProps(props, []), + }; + } + return { registered: !!registration?.type, toolbarProps: registration?.type === 'primary' ? mergeProps(props, registration.discoveredProps) : null, diff --git a/src/drawer/implementation.tsx b/src/drawer/implementation.tsx index 36a39801e2..a5d549c082 100644 --- a/src/drawer/implementation.tsx +++ b/src/drawer/implementation.tsx @@ -3,7 +3,7 @@ import React from 'react'; import clsx from 'clsx'; -import { useAppLayoutToolbarEnabled } from '../app-layout/utils/feature-flags'; +import { useAppLayoutToolbarDesignEnabled } from '../app-layout/utils/feature-flags'; import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; @@ -25,7 +25,7 @@ export function DrawerImplementation({ ...restProps }: DrawerInternalProps) { const baseProps = getBaseProps(restProps); - const isToolbar = useAppLayoutToolbarEnabled(); + const isToolbar = useAppLayoutToolbarDesignEnabled(); const i18n = useInternalI18n('drawer'); const containerProps = { ...baseProps, diff --git a/src/help-panel/implementation.tsx b/src/help-panel/implementation.tsx index c1cf3d66d6..d940809bb7 100644 --- a/src/help-panel/implementation.tsx +++ b/src/help-panel/implementation.tsx @@ -3,7 +3,7 @@ import React from 'react'; import clsx from 'clsx'; -import { useAppLayoutToolbarEnabled } from '../app-layout/utils/feature-flags'; +import { useAppLayoutToolbarDesignEnabled } from '../app-layout/utils/feature-flags'; import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; @@ -27,7 +27,7 @@ export function HelpPanelImplementation({ ...restProps }: HelpPanelInternalProps) { const baseProps = getBaseProps(restProps); - const isToolbar = useAppLayoutToolbarEnabled(); + const isToolbar = useAppLayoutToolbarDesignEnabled(); const i18n = useInternalI18n('help-panel'); const containerProps = { ...baseProps, diff --git a/src/internal/plugins/helpers/use-global-breadcrumbs.ts b/src/internal/plugins/helpers/use-global-breadcrumbs.ts index dff80a6c64..f0d97b181c 100644 --- a/src/internal/plugins/helpers/use-global-breadcrumbs.ts +++ b/src/internal/plugins/helpers/use-global-breadcrumbs.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { useAppLayoutToolbarEnabled } from '../../../app-layout/utils/feature-flags'; +import { useAppLayoutFlagEnabled } from '../../../app-layout/utils/feature-flags'; import { AppLayoutVisibilityContext, BreadcrumbsSlotContext, @@ -45,7 +45,7 @@ function useSetGlobalBreadcrumbsImplementation({ export function useSetGlobalBreadcrumbs(props: BreadcrumbGroupProps) { // avoid additional side effects when this feature is not active - if (!useAppLayoutToolbarEnabled()) { + if (!useAppLayoutFlagEnabled()) { return false; } // getGlobalFlag() value does not change without full page reload diff --git a/src/side-navigation/implementation.tsx b/src/side-navigation/implementation.tsx index 3101cd1e56..a70aab0b7b 100644 --- a/src/side-navigation/implementation.tsx +++ b/src/side-navigation/implementation.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import clsx from 'clsx'; -import { useAppLayoutToolbarEnabled } from '../app-layout/utils/feature-flags'; +import { useAppLayoutToolbarDesignEnabled } from '../app-layout/utils/feature-flags'; import { getBaseProps } from '../internal/base-component'; import { fireCancelableEvent, fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; @@ -28,7 +28,7 @@ export function SideNavigationImplementation({ ...props }: SideNavigationInternalProps) { const baseProps = getBaseProps(props); - const isToolbar = useAppLayoutToolbarEnabled(); + const isToolbar = useAppLayoutToolbarDesignEnabled(); const parentMap = useMemo(() => generateExpandableItemsMapping(items), [items]); if (isDevelopment) { diff --git a/src/split-panel/bottom.tsx b/src/split-panel/bottom.tsx index 8610f7c423..9bebad818c 100644 --- a/src/split-panel/bottom.tsx +++ b/src/split-panel/bottom.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; -import { useAppLayoutToolbarEnabled } from '../app-layout/utils/feature-flags'; +import { useAppLayoutToolbarDesignEnabled } from '../app-layout/utils/feature-flags'; import { useSplitPanelContext } from '../internal/context/split-panel-context'; import * as tokens from '../internal/generated/styles/tokens'; import { useMobile } from '../internal/hooks/use-mobile'; @@ -33,7 +33,7 @@ export function SplitPanelContentBottom({ onToggle, }: SplitPanelContentBottomProps) { const isRefresh = useVisualRefresh(); - const isToolbar = useAppLayoutToolbarEnabled(); + const isToolbar = useAppLayoutToolbarDesignEnabled(); const { bottomOffset, leftOffset, diff --git a/src/split-panel/implementation.tsx b/src/split-panel/implementation.tsx index 682b7fddba..24e26950d2 100644 --- a/src/split-panel/implementation.tsx +++ b/src/split-panel/implementation.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import { useAppLayoutToolbarEnabled } from '../app-layout/utils/feature-flags'; +import { useAppLayoutToolbarDesignEnabled } from '../app-layout/utils/feature-flags'; import { SizeControlProps } from '../app-layout/utils/interfaces'; import { useKeyboardEvents } from '../app-layout/utils/use-keyboard-events'; import { usePointerEvents } from '../app-layout/utils/use-pointer-events'; @@ -39,7 +39,7 @@ export function SplitPanelImplementation({ ...restProps }: SplitPanelImplementationProps) { const isRefresh = useVisualRefresh(); - const isToolbar = useAppLayoutToolbarEnabled(); + const isToolbar = useAppLayoutToolbarDesignEnabled(); const { position, diff --git a/src/split-panel/side.tsx b/src/split-panel/side.tsx index 29d0e70779..2f8c58b73e 100644 --- a/src/split-panel/side.tsx +++ b/src/split-panel/side.tsx @@ -3,7 +3,7 @@ import React from 'react'; import clsx from 'clsx'; -import { useAppLayoutToolbarEnabled } from '../app-layout/utils/feature-flags'; +import { useAppLayoutToolbarDesignEnabled } from '../app-layout/utils/feature-flags'; import { ButtonProps } from '../button/interfaces'; import InternalButton from '../button/internal'; import { useSplitPanelContext } from '../internal/context/split-panel-context'; @@ -35,7 +35,7 @@ export function SplitPanelContentSide({ }: SplitPanelContentSideProps) { const { topOffset, bottomOffset, animationDisabled } = useSplitPanelContext(); const isRefresh = useVisualRefresh(); - const isToolbar = useAppLayoutToolbarEnabled(); + const isToolbar = useAppLayoutToolbarDesignEnabled(); return (