Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: AppLayout horizontal scroll #2839

Merged
merged 16 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 48 additions & 9 deletions src/app-layout/__integ__/runtime-drawers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,14 @@ describe.each(['classic', 'refresh', 'refresh-toolbar'] as const)('%s', theme =>
});

describe('Visual refresh toolbar only', () => {
function setupTest(testFn: (page: BasePageObject) => Promise<void>) {
class PageObject extends BasePageObject {
hasHorizontalScroll() {
return this.browser.execute(() => document.body.scrollWidth - document.body.clientWidth > 0);
}
}
function setupTest(testFn: (page: PageObject) => Promise<void>) {
return useBrowser(async browser => {
const page = new BasePageObject(browser);
const page = new PageObject(browser);

await browser.url(
`#/light/app-layout/runtime-drawers?${new URLSearchParams({
Expand Down Expand Up @@ -154,7 +159,8 @@ describe('Visual refresh toolbar only', () => {
})
);

test('first opened drawer should be closed when active drawers can not be shrunk to accommodate it (1400px)', () => {
test(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized that this test suite didn't run earlier due to an extra wrap with an arrow function

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

'first opened drawer should be closed when active drawers can not be shrunk to accommodate it (1400px)',
setupTest(async page => {
await page.setWindowSize({ ...viewports.desktop, width: 1400 });
await page.click(wrapper.findDrawerTriggerById('circle-global').toSelector());
Expand All @@ -164,12 +170,13 @@ describe('Visual refresh toolbar only', () => {
await expect(page.isClickable(findDrawerById(wrapper, 'circle-global')!.toSelector())).resolves.toBe(false);
await expect(page.isClickable(findDrawerById(wrapper, 'security')!.toSelector())).resolves.toBe(true);
await expect(page.isClickable(findDrawerById(wrapper, 'circle2-global')!.toSelector())).resolves.toBe(true);
});
});
})
);

test('first opened drawer should be closed when active drawers can not be shrunk to accommodate it (1200px)', () => {
test(
'first opened drawer should be closed when active drawers can not be shrunk to accommodate it (1330px)',
setupTest(async page => {
await page.setWindowSize({ ...viewports.desktop, width: 1200 });
await page.setWindowSize({ ...viewports.desktop, width: 1330 });
await page.click(wrapper.findDrawerTriggerById('circle').toSelector());
await page.click(wrapper.findDrawerTriggerById('security').toSelector());
await page.click(wrapper.findDrawerTriggerById('circle-global').toSelector());
Expand All @@ -179,7 +186,39 @@ describe('Visual refresh toolbar only', () => {
await expect(page.isClickable(findDrawerById(wrapper, 'security')!.toSelector())).resolves.toBe(false);
await expect(page.isClickable(findDrawerById(wrapper, 'circle-global')!.toSelector())).resolves.toBe(true);
await expect(page.isClickable(findDrawerById(wrapper, 'circle2-global')!.toSelector())).resolves.toBe(true);
});
});
})
);
});

test(
'should prevent the horizontal page scroll from appearing during resize',
setupTest(async page => {
await page.setWindowSize({ ...viewports.desktop, width: 1600 });
await page.click(wrapper.findDrawerTriggerById('circle').toSelector());
await page.click(wrapper.findDrawerTriggerById('circle2-global').toSelector());
await page.click(wrapper.findDrawerTriggerById('circle3-global').toSelector());

await expect(page.isClickable(wrapper.findNavigation().toSelector())).resolves.toBe(true);
await expect(page.isClickable(findDrawerById(wrapper, 'circle')!.toSelector())).resolves.toBe(true);
await expect(page.isClickable(findDrawerById(wrapper, 'circle2-global')!.toSelector())).resolves.toBe(true);
await expect(page.isClickable(findDrawerById(wrapper, 'circle3-global')!.toSelector())).resolves.toBe(true);
await expect(page.hasHorizontalScroll()).resolves.toBe(false);

await page.setWindowSize({ ...viewports.desktop, width: 1185 });
// navigation panel closes first
await expect(page.isClickable(wrapper.findNavigation().toSelector())).resolves.toBe(false);
await expect(page.isClickable(findDrawerById(wrapper, 'circle')!.toSelector())).resolves.toBe(true);
await expect(page.isClickable(findDrawerById(wrapper, 'circle2-global')!.toSelector())).resolves.toBe(true);
await expect(page.isClickable(findDrawerById(wrapper, 'circle3-global')!.toSelector())).resolves.toBe(true);
await expect(page.hasHorizontalScroll()).resolves.toBe(false);

await page.setWindowSize({ ...viewports.desktop, width: 900 });
// then the first opened drawer closes
await expect(page.isClickable(wrapper.findNavigation().toSelector())).resolves.toBe(false);
await expect(page.isClickable(findDrawerById(wrapper, 'circle')!.toSelector())).resolves.toBe(false);
await expect(page.isClickable(findDrawerById(wrapper, 'circle2-global')!.toSelector())).resolves.toBe(true);
await expect(page.isClickable(findDrawerById(wrapper, 'circle3-global')!.toSelector())).resolves.toBe(true);
await expect(page.hasHorizontalScroll()).resolves.toBe(false);
})
);
});
94 changes: 93 additions & 1 deletion src/app-layout/__tests__/runtime-drawers-layout.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
/* eslint simple-import-sort/imports: 0 */
import React from 'react';
import React, { RefObject } from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { describeEachAppLayout, getGlobalDrawersTestUtils, testDrawer } from './utils';
import AppLayout from '../../../lib/components/app-layout';
Expand All @@ -10,6 +10,7 @@ import { computeHorizontalLayout } from '../../../lib/components/app-layout/visu
import { DrawerConfig } from '../../../lib/components/internal/plugins/controllers/drawers';
import createWrapper from '../../../lib/components/test-utils/dom';
import { KeyCode } from '../../internal/keycode';
import { useAppLayoutPlacement } from '../../../lib/components/app-layout/utils/use-app-layout-placement';

beforeEach(() => {
awsuiPluginsInternal.appLayout.clearRegisteredDrawers();
Expand All @@ -36,6 +37,22 @@ jest.mock('../../../lib/components/app-layout/visual-refresh-toolbar/compute-lay
};
});

jest.mock('../../../lib/components/app-layout/utils/use-app-layout-placement', () => {
return {
...jest.requireActual('../../../lib/components/app-layout/utils/use-app-layout-placement'),
useAppLayoutPlacement: jest.fn().mockReturnValue([
{ current: null } as RefObject<HTMLElement>,
{
insetInlineStart: 0,
insetInlineEnd: 0,
inlineSize: Infinity,
insetBlockStart: 0,
insetBlockEnd: 0,
},
]),
};
});

async function renderComponent(jsx: React.ReactElement) {
const { container, rerender } = render(jsx);
const wrapper = createWrapper(container).findAppLayout()!;
Expand Down Expand Up @@ -195,5 +212,80 @@ describe('toolbar mode only features', () => {

expect(onDrawerItemResize).toHaveBeenCalledWith({ size: expect.any(Number), id: 'global-drawer-1' });
});

test('should prevent the horizontal page scroll from appearing during resize (navigation open)', async () => {
const dummyRef = { current: null } as RefObject<HTMLElement>;
jest.mocked(useAppLayoutPlacement).mockReturnValue([
dummyRef,
{
insetInlineStart: 0,
insetInlineEnd: 0,
inlineSize: 900,
insetBlockStart: 0,
insetBlockEnd: 0,
},
]);
awsuiPlugins.appLayout.registerDrawer({
...drawerDefaults,
id: 'global-drawer-1',
type: 'global',
mountContent: container => (container.textContent = 'global drawer content 1'),
});
awsuiPlugins.appLayout.registerDrawer({
...drawerDefaults,
id: 'global-drawer-2',
type: 'global',
mountContent: container => (container.textContent = 'global drawer content 2'),
});

const { wrapper, globalDrawersWrapper } = await renderComponent(<AppLayout drawers={[testDrawer]} />);

wrapper.findDrawerTriggerById('security')!.click();
wrapper.findDrawerTriggerById('global-drawer-1')!.click();
wrapper.findDrawerTriggerById('global-drawer-2')!.click();
expect(wrapper.findNavigation().getElement()).toHaveAttribute('aria-hidden', 'true');
expect(wrapper.findActiveDrawer()).toBeTruthy();
expect(globalDrawersWrapper.findDrawerById('global-drawer-1')!.isActive()).toBe(true);
expect(globalDrawersWrapper.findDrawerById('global-drawer-2')!.isActive()).toBe(true);
});

test('should prevent the horizontal page scroll from appearing during resize (navigation closed)', async () => {
const dummyRef = { current: null } as RefObject<HTMLElement>;
jest.mocked(useAppLayoutPlacement).mockReturnValue([
dummyRef,
{
insetInlineStart: 0,
insetInlineEnd: 0,
inlineSize: 700,
insetBlockStart: 0,
insetBlockEnd: 0,
},
]);
awsuiPlugins.appLayout.registerDrawer({
...drawerDefaults,
id: 'global-drawer-1',
type: 'global',
mountContent: container => (container.textContent = 'global drawer content 1'),
});
awsuiPlugins.appLayout.registerDrawer({
...drawerDefaults,
id: 'global-drawer-2',
type: 'global',
mountContent: container => (container.textContent = 'global drawer content 2'),
});

const { wrapper, globalDrawersWrapper } = await renderComponent(
<AppLayout drawers={[testDrawer]} navigationOpen={false} />
);

await delay();

wrapper.findDrawerTriggerById('global-drawer-1')!.click();
wrapper.findDrawerTriggerById('security')!.click();
wrapper.findDrawerTriggerById('global-drawer-2')!.click();
expect(globalDrawersWrapper.findDrawerById('global-drawer-1')).toBeFalsy();
expect(wrapper.findActiveDrawer()).toBeTruthy();
expect(globalDrawersWrapper.findDrawerById('global-drawer-2')!.isActive()).toBe(true);
});
});
});
87 changes: 67 additions & 20 deletions src/app-layout/visual-refresh-toolbar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useImperativeHandle, useState } from 'react';
import React, { useCallback, useEffect, useImperativeHandle, useState } from 'react';

import ScreenreaderOnly from '../../internal/components/screenreader-only';
import { SplitPanelSideToggleProps } from '../../internal/context/split-panel-context';
Expand Down Expand Up @@ -74,11 +74,6 @@
const [toolbarHeight, setToolbarHeight] = useState(0);
const [notificationsHeight, setNotificationsHeight] = useState(0);

const onNavigationToggle = (open: boolean) => {
navigationFocusControl.setFocus();
fireNonCancelableEvent(onNavigationChange, { open });
};

const [toolsOpen = false, setToolsOpen] = useControllable(controlledToolsOpen, onToolsChange, false, {
componentName: 'AppLayout',
controlledProp: 'toolsOpen',
Expand Down Expand Up @@ -116,27 +111,15 @@
// and compare a given number with the new drawer id min size

// the total size of all global drawers resized to their min size
let totalActiveDrawersMinSize = activeGlobalDrawersIds
.map(
activeDrawerId => combinedDrawers.find(drawer => drawer.id === activeDrawerId)?.defaultSize ?? MIN_DRAWER_SIZE
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized that this calculation works incorrectly, as it returns min size only if defaultSize is not defined, but it should compare them and return min of them. Fixed, see below.

)
.reduce((acc, curr) => acc + curr, 0);
if (activeDrawer) {
totalActiveDrawersMinSize += Math.min(activeDrawer?.defaultSize ?? MIN_DRAWER_SIZE, MIN_DRAWER_SIZE);
}
const totalActiveDrawersMinSize = getActiveDrawersTotalMinSize();

const availableSpaceForNewDrawer = resizableSpaceAvailable - totalActiveDrawersMinSize;
if (availableSpaceForNewDrawer >= newDrawerSize) {
return;
}

// now we made sure we cannot accommodate the new drawer with existing ones
const drawerToClose = drawersOpenQueue[drawersOpenQueue.length - 1];
if (activeDrawer && activeDrawer?.id === drawerToClose) {
onActiveDrawerChange(null);
} else if (activeGlobalDrawersIds.includes(drawerToClose)) {
onActiveGlobalDrawersChange(drawerToClose);
}
closeFirstDrawer();
};

const {
Expand Down Expand Up @@ -164,6 +147,23 @@
onToolsToggle,
});

const getActiveDrawersTotalMinSize = useCallback(() => {
const combinedDrawers = [...(drawers || []), ...globalDrawers];
let totalActiveDrawersMinSize = activeGlobalDrawersIds
.map(activeDrawerId =>
Math.min(
combinedDrawers.find(drawer => drawer.id === activeDrawerId)?.defaultSize ?? MIN_DRAWER_SIZE,
MIN_DRAWER_SIZE
)
)
.reduce((acc, curr) => acc + curr, 0);
if (activeDrawer) {
totalActiveDrawersMinSize += Math.min(activeDrawer?.defaultSize ?? MIN_DRAWER_SIZE, MIN_DRAWER_SIZE);
}

return totalActiveDrawersMinSize;
}, [activeDrawer, activeGlobalDrawersIds, drawers, globalDrawers]);

const onActiveDrawerChangeHandler = (drawerId: string | null) => {
onActiveDrawerChange(drawerId);
drawersFocusControl.setFocus();
Expand Down Expand Up @@ -227,6 +227,14 @@
const navigationFocusControl = useFocusControl(navigationOpen);
const splitPanelFocusControl = useSplitPanelFocusControl([splitPanelPreferences, splitPanelOpen]);

const onNavigationToggle = useCallback(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this handler below because it relies on navigationFocusControl, which is declared earlier

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use our useStableCallback util.

There is no promise that onNavigationChange is a stable callback, so your code does not really do what you expect.

To an extent, the same applies to other useCallback additions here. Make sure their dependencies are really stable (drawers array, for example is not)

(open: boolean) => {
navigationFocusControl.setFocus();
fireNonCancelableEvent(onNavigationChange, { open });
},
[navigationFocusControl, onNavigationChange]
);

useImperativeHandle(forwardRef, () => ({
closeNavigationIfNecessary: () => isMobile && onNavigationToggle(false),
openTools: () => onToolsToggle(true),
Expand Down Expand Up @@ -361,6 +369,15 @@
refs: splitPanelFocusControl.refs,
};

const closeFirstDrawer = useCallback(() => {
const drawerToClose = drawersOpenQueue[drawersOpenQueue.length - 1];
if (activeDrawer && activeDrawer?.id === drawerToClose) {
onActiveDrawerChange(null);
} else if (activeGlobalDrawersIds.includes(drawerToClose)) {
onActiveGlobalDrawersChange(drawerToClose);
}
}, [activeDrawer, activeGlobalDrawersIds, drawersOpenQueue, onActiveDrawerChange, onActiveGlobalDrawersChange]);

useEffect(() => {
// Close navigation drawer on mobile so that the main content is visible
if (isMobile) {
Expand All @@ -369,6 +386,36 @@
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile]);

useEffect(() => {
if (isMobile) {
return;
}

const totalActiveDrawersMinSize = getActiveDrawersTotalMinSize();
const activeNavigationWidth = navigationOpen ? navigationWidth : 0;
// collapsed content width is $space-layout-content-horizontal * 2 = 48px
const minContentVisibleWidth = 48;
Copy link
Contributor Author

@georgylobko georgylobko Oct 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no way to calculate this directly from the DOM tree since $space-layout-content-horizontal is applied in the grid-template to . area, specifically in this line: https://github.com/cloudscape-design/components/blob/main/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss#L56

If you have a better idea, feel free to share it in this thread.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have this constant:

const contentPadding = 2 * 24; // space-xl

I suggest to reuse it somehow


const scrollWidth = activeNavigationWidth + minContentVisibleWidth + totalActiveDrawersMinSize;
const hasHorizontalScroll = scrollWidth > placement.inlineSize;
if (hasHorizontalScroll) {
if (navigationOpen) {
onNavigationToggle(false);
return;
}

closeFirstDrawer();

Check warning on line 407 in src/app-layout/visual-refresh-toolbar/index.tsx

View check run for this annotation

Codecov / codecov/patch

src/app-layout/visual-refresh-toolbar/index.tsx#L407

Added line #L407 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't we want to have an iteration here, "close until hasHorizontalScroll is false"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we don't, because closing a drawer triggers a change in the dependency array, which leads to recalculations until the horizontal scrollbar is no longer present

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not instantly obvious, but okay

}
}, [
closeFirstDrawer,
getActiveDrawersTotalMinSize,
isMobile,
navigationOpen,
navigationWidth,
onNavigationToggle,
placement.inlineSize,
]);

return (
<>
{/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */}
Expand Down
Loading