diff --git a/pages/app-layout/with-drawers-empty.page.tsx b/pages/app-layout/with-drawers-empty.page.tsx new file mode 100644 index 0000000000..fe12d0bf8e --- /dev/null +++ b/pages/app-layout/with-drawers-empty.page.tsx @@ -0,0 +1,58 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { AppLayout, ContentLayout, Header, SplitPanel } from '~components'; +import appLayoutLabels from './utils/labels'; +import { Breadcrumbs, Containers } from './utils/content-blocks'; +import ScreenshotArea from '../utils/screenshot-area'; + +export default function WithDrawers() { + const drawers = { + drawers: { + items: [], + }, + }; + + return ( + + } + content={ + + Page without drawers + + } + > + + + } + splitPanel={ + + This is the Split Panel! + + } + {...drawers} + /> + + ); +} diff --git a/src/app-layout/__tests__/drawers.test.tsx b/src/app-layout/__tests__/drawers.test.tsx index 6fc63f7798..a20c491d17 100644 --- a/src/app-layout/__tests__/drawers.test.tsx +++ b/src/app-layout/__tests__/drawers.test.tsx @@ -1,12 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { describeEachAppLayout, renderComponent, singleDrawer, manyDrawers, singleDrawerOpen } from './utils'; +import { describeEachAppLayout, renderComponent, singleDrawer, manyDrawers } from './utils'; import createWrapper from '../../../lib/components/test-utils/dom'; import { render } from '@testing-library/react'; import AppLayout from '../../../lib/components/app-layout'; -import { TOOLS_DRAWER_ID } from '../../../lib/components/app-layout/utils/use-drawers'; jest.mock('../../../lib/components/internal/hooks/use-mobile', () => ({ useMobile: jest.fn().mockReturnValue(true), @@ -17,7 +16,7 @@ jest.mock('@cloudscape-design/component-toolkit', () => ({ useContainerQuery: () => [100, () => {}], })); -describeEachAppLayout(size => { +describeEachAppLayout(() => { test(`should not render drawer when it is not defined`, () => { const { wrapper, rerender } = renderComponent(); expect(wrapper.findDrawersTriggers()).toHaveLength(1); @@ -35,82 +34,14 @@ describeEachAppLayout(size => { const { wrapper } = renderComponent(); expect(wrapper.findDrawersTriggers()).toHaveLength(0); - expect(wrapper.findToolsToggle()).toBeTruthy(); + expect(wrapper.findToolsToggle()).toBeFalsy(); }); - test('should apply drawers treatment to the tools if at least one other drawer is provided', () => { - const { wrapper } = renderComponent(); - expect(wrapper.findDrawersTriggers()).toHaveLength(2); - expect(wrapper.findToolsToggle()).toBeTruthy(); - }); - - test('renders drawers with the tools', () => { + test('ignores tools when drawers API is used', () => { const { wrapper } = renderComponent(); - expect(wrapper.findDrawersTriggers()).toHaveLength(2); - }); - - // this behavior is no longer supported for compatibility with runtime API - test.skip('should respect toolsOpen property when merging into drawers', () => { - const { wrapper } = renderComponent(); - - expect(wrapper.findDrawerTriggerById(TOOLS_DRAWER_ID)!.getElement()).toHaveAttribute('aria-expanded', 'true'); - expect(wrapper.findDrawerTriggerById('security')!.getElement()).toHaveAttribute('aria-expanded', 'false'); - expect(wrapper.findActiveDrawer()!.getElement()).toHaveTextContent('Tools content'); - }); - - test('should fire tools change event when closing tools panel while drawers are present', () => { - const onToolsChange = jest.fn(); - const { wrapper } = renderComponent( - onToolsChange(event.detail)} {...singleDrawer} /> - ); - - wrapper.findToolsToggle().click(); - expect(onToolsChange).toHaveBeenCalledWith({ open: true }); - - onToolsChange.mockClear(); - wrapper.findToolsClose().click(); - expect(onToolsChange).toHaveBeenCalledWith({ open: false }); - }); - - // drawers render full screen on mobile sizes, switching open drawers does not work there - if (size === 'desktop') { - test('should fire tools close event when switching from tools to another drawer', () => { - const onToolsChange = jest.fn(); - const { wrapper } = renderComponent( - onToolsChange(event.detail)} - {...singleDrawer} - /> - ); - - wrapper.findDrawerTriggerById('security')!.click(); - expect(onToolsChange).toHaveBeenCalledWith({ open: false }); - }); - - test('should fire tools open event when switching from another drawer to tools', () => { - const onToolsChange = jest.fn(); - const { wrapper } = renderComponent( - onToolsChange(event.detail)} - {...singleDrawerOpen} - /> - ); - wrapper.findToolsToggle().click(); - expect(onToolsChange).toHaveBeenCalledWith({ open: true }); - }); - } - - test('activeDrawerId has priority over toolsOpen', () => { - const { wrapper } = renderComponent(); - - expect(wrapper.findDrawerTriggerById(TOOLS_DRAWER_ID)!.getElement()).toHaveAttribute('aria-expanded', 'false'); - expect(wrapper.findDrawerTriggerById('security')!.getElement()).toHaveAttribute('aria-expanded', 'true'); - expect(wrapper.findActiveDrawer()!.getElement()).toHaveTextContent('Security'); + expect(wrapper.findToolsToggle()).toBeFalsy(); + expect(wrapper.findDrawersTriggers()).toHaveLength(1); }); test('should open active drawer on click of overflow item', () => { diff --git a/src/app-layout/__tests__/runtime-drawers.test.tsx b/src/app-layout/__tests__/runtime-drawers.test.tsx index 8fa8eccc31..9d3059cc68 100644 --- a/src/app-layout/__tests__/runtime-drawers.test.tsx +++ b/src/app-layout/__tests__/runtime-drawers.test.tsx @@ -180,6 +180,30 @@ describeEachAppLayout(size => { expect(onToolsChange).toHaveBeenCalledWith({ open: false }); expect(wrapper.findActiveDrawer()!.getElement()).toHaveTextContent('runtime drawer content'); + onToolsChange.mockReset(); + wrapper.findToolsToggle().click(); + expect(onToolsChange).toHaveBeenCalledWith({ open: true }); + }); + + test('should fire tools close event when switching from tools to another drawer', async () => { + awsuiPlugins.appLayout.registerDrawer(drawerDefaults); + const onToolsChange = jest.fn(); + const { wrapper } = await renderComponent( + onToolsChange(event.detail)} /> + ); + + wrapper.findDrawerTriggerById(drawerDefaults.id)!.click(); + expect(onToolsChange).toHaveBeenCalledWith({ open: false }); + }); + + test('should fire tools open event when switching from another drawer to tools', async () => { + awsuiPlugins.appLayout.registerDrawer({ ...drawerDefaults, defaultActive: true }); + const onToolsChange = jest.fn(); + const { wrapper } = await renderComponent( + onToolsChange(event.detail)} /> + ); + expect(wrapper.findActiveDrawer()!.getElement()).toBeInTheDocument(); + wrapper.findToolsToggle().click(); expect(onToolsChange).toHaveBeenCalledWith({ open: true }); }); @@ -356,7 +380,7 @@ describeEachAppLayout(size => { ]); }); - test('allows mixing static and runtime drawers', async () => { + test('ignores tools when drawers are present', async () => { awsuiPlugins.appLayout.registerDrawer({ ...drawerDefaults, id: 'aaa', ariaLabels: { triggerButton: 'aaa' } }); awsuiPlugins.appLayout.registerDrawer({ ...drawerDefaults, @@ -379,7 +403,6 @@ describeEachAppLayout(size => { ); expect(wrapper.findDrawersTriggers().map(trigger => trigger.getElement().getAttribute('aria-label'))).toEqual([ - 'tools toggle', 'bbb', 'ddd', 'aaa', @@ -387,4 +410,19 @@ describeEachAppLayout(size => { ]); }); }); + + test('should fire tools change event when closing tools panel while drawers are present', async () => { + const onToolsChange = jest.fn(); + awsuiPlugins.appLayout.registerDrawer(drawerDefaults); + const { wrapper } = await renderComponent( + onToolsChange(event.detail)} /> + ); + + wrapper.findToolsToggle().click(); + expect(onToolsChange).toHaveBeenCalledWith({ open: true }); + + onToolsChange.mockClear(); + wrapper.findToolsClose().click(); + expect(onToolsChange).toHaveBeenCalledWith({ open: false }); + }); }); diff --git a/src/app-layout/drawer/index.tsx b/src/app-layout/drawer/index.tsx index e715e50e3f..36448f0470 100644 --- a/src/app-layout/drawer/index.tsx +++ b/src/app-layout/drawer/index.tsx @@ -110,7 +110,7 @@ export const Drawer = React.forwardRef( style={{ width: drawerContentWidth, top: topOffset, bottom: bottomOffset }} className={clsx(styles['drawer-content'], styles['drawer-content-clickable'], contentClassName)} > - {!isMobile && regularOpenButton} + {!isMobile && !drawers && regularOpenButton} {resizeHandle} 0; + } = useDrawers(props as InternalDrawerProps, { + ariaLabels, + tools, + toolsOpen, + toolsHide, + toolsWidth, + onToolsChange, + }); + const hasDrawers = !!drawers; const { refs: navigationRefs, setFocus: focusNavButtons } = useFocusControl(navigationOpen); const { @@ -248,7 +255,11 @@ const OldAppLayout = React.forwardRef( const effectiveNavigationWidth = navigationHide ? 0 : navigationOpen ? navigationWidth : closedDrawerWidth; const getEffectiveToolsWidth = () => { - if (toolsHide && (!splitPanelDisplayed || splitPanelPreferences?.position !== 'side') && drawers.length === 0) { + if ( + toolsHide && + (!splitPanelDisplayed || splitPanelPreferences?.position !== 'side') && + (!drawers || drawers.length === 0) + ) { return 0; } @@ -403,7 +414,7 @@ const OldAppLayout = React.forwardRef( return toolsWidth + closedDrawerWidth; } - return closedDrawerWidth; + return drawers.length > 0 ? closedDrawerWidth : 0; } if (!toolsHide && toolsOpen) { @@ -507,7 +518,7 @@ const OldAppLayout = React.forwardRef( onChange: changeDetail => { onActiveDrawerChange(changeDetail.activeDrawerId); if (changeDetail.activeDrawerId !== activeDrawerId) { - onToolsToggle(changeDetail.activeDrawerId === TOOLS_DRAWER_ID); + focusToolsButtons(); focusDrawersButtons(); setDrawerLastInteraction({ type: 'open' }); } @@ -638,67 +649,68 @@ const OldAppLayout = React.forwardRef( )} - {((hasDrawers && activeDrawerId) || (!hasDrawers && !toolsHide)) && - (hasDrawers ? ( - { - onToolsToggle(false); - setDrawerLastInteraction({ type: 'close' }); - onActiveDrawerChange(changeDetail.activeDrawerId); - }, - }} - size={!isResizeInvalid ? activeDrawerSize : toolsWidth} - onResize={changeDetail => onActiveDrawerResize(changeDetail)} - refs={drawerRefs} - getMaxWidth={getDrawerMaxWidth} - > - {activeDrawer?.content} - - ) : ( - - {tools} - - ))} - {hasDrawers && ( + {hasDrawers + ? activeDrawerId && ( + { + /*noop in this mode*/ + }} + isOpen={true} + toggleRefs={toolsRefs} + type="tools" + onLoseFocus={loseDrawersFocus} + activeDrawer={activeDrawer} + drawers={{ + items: drawers, + activeDrawerId: activeDrawerId, + onChange: changeDetail => { + focusToolsButtons(); + setDrawerLastInteraction({ type: 'close' }); + onActiveDrawerChange(changeDetail.activeDrawerId); + }, + }} + size={!isResizeInvalid ? activeDrawerSize : toolsWidth} + onResize={changeDetail => onActiveDrawerResize(changeDetail)} + refs={drawerRefs} + getMaxWidth={getDrawerMaxWidth} + > + {activeDrawer?.content} + + ) + : !toolsHide && ( + + {tools} + + )} + {hasDrawers && drawers.length > 0 && ( { if (activeDrawerId !== changeDetail.activeDrawerId) { - onToolsToggle(changeDetail.activeDrawerId === TOOLS_DRAWER_ID); + focusToolsButtons(); focusDrawersButtons(); setDrawerLastInteraction({ type: 'open' }); } diff --git a/src/app-layout/mobile-toolbar/index.tsx b/src/app-layout/mobile-toolbar/index.tsx index 1b7574d49e..9bf5151cb2 100644 --- a/src/app-layout/mobile-toolbar/index.tsx +++ b/src/app-layout/mobile-toolbar/index.tsx @@ -137,11 +137,10 @@ export function MobileToolbar({ onClick={() => drawers.onChange({ activeDrawerId: item.id })} > void; + +function getToolsDrawerItem(props: ToolsProps): DrawerItem | null { if (props.toolsHide) { return null; } @@ -45,9 +48,10 @@ function getToolsDrawerItem(props: ToolsProps) { function useRuntimeDrawers( disableRuntimeDrawers: boolean | undefined, activeDrawerId: string | undefined, - onActiveDrawerChange: (id: string | undefined) => void + onActiveDrawerChange: DrawerChangeHandler ) { const [runtimeDrawers, setRuntimeDrawers] = useState({ before: [], after: [] }); + const onActiveDrawerChangeStable = useStableCallback(onActiveDrawerChange); const drawerWasOpenRef = useRef(false); drawerWasOpenRef.current = drawerWasOpenRef.current || !!activeDrawerId; @@ -61,7 +65,7 @@ function useRuntimeDrawers( if (!drawerWasOpenRef.current) { const defaultActiveDrawer = sortByPriority(drawers).find(drawer => drawer.defaultActive); if (defaultActiveDrawer) { - onActiveDrawerChange(defaultActiveDrawer.id); + onActiveDrawerChangeStable(defaultActiveDrawer.id); } } }); @@ -69,11 +73,24 @@ function useRuntimeDrawers( unsubscribe(); setRuntimeDrawers({ before: [], after: [] }); }; - }, [disableRuntimeDrawers, onActiveDrawerChange]); + }, [disableRuntimeDrawers, onActiveDrawerChangeStable]); return runtimeDrawers; } +function applyToolsDrawer(toolsProps: ToolsProps, runtimeDrawers: DrawersLayout) { + const drawers = [...runtimeDrawers.before, ...runtimeDrawers.after]; + if (drawers.length === 0) { + return null; + } + const toolsItem = getToolsDrawerItem(toolsProps); + if (toolsItem) { + drawers.unshift(toolsItem); + } + + return drawers; +} + export function useDrawers( { drawers: ownDrawers, @@ -81,8 +98,6 @@ export function useDrawers( }: InternalDrawerProps & { __disableRuntimeDrawers?: boolean }, toolsProps: ToolsProps ) { - const toolsDrawer = getToolsDrawerItem(toolsProps); - const [activeDrawerId, setActiveDrawerId] = useControllable( ownDrawers?.activeDrawerId, ownDrawers?.onChange, @@ -95,28 +110,31 @@ export function useDrawers( ); const [drawerSizes, setDrawerSizes] = useState>({}); - const onActiveDrawerChange = useStableCallback((newDrawerId: string | undefined) => { + function onActiveDrawerResize({ id, size }: { id: string; size: number }) { + setDrawerSizes(oldSizes => ({ ...oldSizes, [id]: size })); + fireNonCancelableEvent(ownDrawers?.onResize, { id, size }); + } + + function onActiveDrawerChange(newDrawerId: string | undefined) { setActiveDrawerId(newDrawerId); - fireNonCancelableEvent(ownDrawers?.onChange, newDrawerId); - }); + if (hasOwnDrawers) { + fireNonCancelableEvent(ownDrawers?.onChange, newDrawerId); + } else if (!toolsProps.toolsHide) { + fireNonCancelableEvent(toolsProps.onToolsChange, { open: newDrawerId === TOOLS_DRAWER_ID }); + } + } + const hasOwnDrawers = !!ownDrawers?.items; const runtimeDrawers = useRuntimeDrawers(disableRuntimeDrawers, activeDrawerId, onActiveDrawerChange); - const combinedDrawers = [...runtimeDrawers.before, ...(ownDrawers?.items ?? []), ...runtimeDrawers.after]; - if (toolsDrawer && combinedDrawers.length > 0) { - combinedDrawers.unshift(toolsDrawer); - } + const combinedDrawers = hasOwnDrawers + ? [...runtimeDrawers.before, ...ownDrawers.items, ...runtimeDrawers.after] + : applyToolsDrawer(toolsProps, runtimeDrawers); // support toolsOpen in runtime-drawers-only mode - let activeDrawerIdResolved = - toolsProps.toolsOpen && (ownDrawers?.items ?? []).length === 0 ? TOOLS_DRAWER_ID : activeDrawerId; - const activeDrawer = combinedDrawers.find(drawer => drawer.id === activeDrawerIdResolved); + let activeDrawerIdResolved = toolsProps.toolsOpen && !hasOwnDrawers ? TOOLS_DRAWER_ID : activeDrawerId; + const activeDrawer = combinedDrawers?.find(drawer => drawer.id === activeDrawerIdResolved); // ensure that id is only defined when the drawer exists activeDrawerIdResolved = activeDrawer?.id; - function onActiveDrawerResize({ id, size }: { id: string; size: number }) { - setDrawerSizes(oldSizes => ({ ...oldSizes, [id]: size })); - fireNonCancelableEvent(ownDrawers?.onResize, { id, size }); - } - return { ariaLabel: ownDrawers?.ariaLabel, overflowAriaLabel: ownDrawers?.overflowAriaLabel, diff --git a/src/app-layout/visual-refresh/context.tsx b/src/app-layout/visual-refresh/context.tsx index ac4efb65e9..1a3299cae6 100644 --- a/src/app-layout/visual-refresh/context.tsx +++ b/src/app-layout/visual-refresh/context.tsx @@ -36,7 +36,7 @@ import { useUniqueId } from '../../internal/hooks/use-unique-id'; interface AppLayoutInternals extends AppLayoutProps { activeDrawerId: string | undefined; - drawers: Array; + drawers: Array | null; drawersAriaLabel: string | undefined; drawersOverflowAriaLabel: string | undefined; drawersRefs: DrawerFocusControlRefs; @@ -398,6 +398,7 @@ export const AppLayoutInternalsProvider = React.forwardRef( toolsOpen: isToolsOpen, tools: props.tools, toolsWidth, + onToolsChange: props.onToolsChange, }); const [drawersMaxWidth, setDrawersMaxWidth] = useState(toolsWidth); @@ -428,8 +429,10 @@ export const AppLayoutInternalsProvider = React.forwardRef( setDrawerLastInteraction({ type: activeDrawerId ? 'close' : 'open' }); }; - const drawersTriggerCount = - drawers.length + (splitPanelDisplayed && splitPanelPosition === 'side' ? 1 : 0) + (!toolsHide ? 1 : 0); + let drawersTriggerCount = drawers ? drawers.length : !toolsHide ? 1 : 0; + if (splitPanelDisplayed && splitPanelPosition === 'side') { + drawersTriggerCount++; + } const hasOpenDrawer = activeDrawerId !== undefined || (!toolsHide && isToolsOpen) || diff --git a/src/app-layout/visual-refresh/drawers.tsx b/src/app-layout/visual-refresh/drawers.tsx index 5f6478aa2e..3ffb84bfe3 100644 --- a/src/app-layout/visual-refresh/drawers.tsx +++ b/src/app-layout/visual-refresh/drawers.tsx @@ -26,6 +26,7 @@ export default function Drawers() { const { disableBodyScroll, drawers, + drawersTriggerCount, hasDrawerViewportOverlay, hasOpenDrawer, isNavigationOpen, @@ -35,7 +36,7 @@ export default function Drawers() { const isUnfocusable = hasDrawerViewportOverlay && isNavigationOpen && !navigationHide; - if (drawers.length === 0) { + if (!drawers || drawersTriggerCount === 0) { return null; } @@ -66,7 +67,6 @@ function ActiveDrawer() { isMobile, isNavigationOpen, navigationHide, - toolsRefs, loseDrawersFocus, resizeHandle, drawerSize, @@ -74,7 +74,7 @@ function ActiveDrawer() { drawerRef, } = useAppLayoutInternals(); - const activeDrawer = drawers.find(item => item.id === activeDrawerId) ?? null; + const activeDrawer = drawers?.find(item => item.id === activeDrawerId) ?? null; const computedAriaLabels = { closeButton: activeDrawerId ? activeDrawer?.ariaLabels?.closeButton : ariaLabels?.toolsClose, @@ -123,7 +123,7 @@ function ActiveDrawer() { handleDrawersClick(activeDrawerId ?? undefined); handleToolsClick(false); }} - ref={isToolsDrawer ? toolsRefs.close : drawersRefs.close} + ref={drawersRefs.close} variant="icon" /> @@ -149,10 +149,8 @@ function DesktopTriggers() { drawersTriggerCount, handleDrawersClick, handleSplitPanelClick, - handleToolsClick, hasOpenDrawer, isSplitPanelOpen, - isToolsOpen, splitPanel, splitPanelControlId, splitPanelDisplayed, @@ -164,7 +162,7 @@ function DesktopTriggers() { } = useAppLayoutInternals(); const hasMultipleTriggers = drawersTriggerCount > 1; - const hasSplitPanel = splitPanel && splitPanelDisplayed && splitPanelPosition === 'side' ? true : false; + const hasSplitPanel = splitPanel && splitPanelDisplayed && splitPanelPosition === 'side'; const previousActiveDrawerId = useRef(activeDrawerId); const [containerHeight, triggersContainerRef] = useContainerQuery(rect => rect.contentBoxHeight); @@ -196,18 +194,9 @@ function DesktopTriggers() { return 0; }; - const { visibleItems, overflowItems } = splitItems(drawers, getIndexOfOverflowItem(), activeDrawerId); + const { visibleItems, overflowItems } = splitItems(drawers ?? undefined, getIndexOfOverflowItem(), activeDrawerId); const overflowMenuHasBadge = !!overflowItems.find(item => item.badge); - function handleItemClick(itemId: string | undefined) { - if (itemId === TOOLS_DRAWER_ID) { - handleToolsClick(!isToolsOpen, true); - } else { - handleToolsClick(false, true); - } - handleDrawersClick(itemId); - } - return (