Skip to content

Commit

Permalink
fix: Render header background height in sync with content (#1387)
Browse files Browse the repository at this point in the history
  • Loading branch information
jperals authored Aug 3, 2023
1 parent 232149b commit f6fe295
Show file tree
Hide file tree
Showing 10 changed files with 261 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import AppLayout from '~components/app-layout';
import labels from './utils/labels';
import Table from '~components/table';
import { generateItems, Instance } from '../table/generate-data';
import { columnsConfig } from '../table/shared-configs';
import ExpandableSection from '~components/expandable-section';
import Header from '~components/header';

const items = generateItems(20);

export default function () {
return (
<AppLayout
ariaLabels={labels}
contentType="table"
navigationHide={true}
content={
<Table<Instance>
header={
<>
<Header variant="awsui-h1-sticky">Header that changes size when scrolling</Header>
<ExpandableSection headerText="Click to expand header area">
<div style={{ height: '300px' }}>Content</div>
</ExpandableSection>
</>
}
stickyHeader={true}
variant="full-page"
columnDefinitions={columnsConfig}
items={items}
/>
}
/>
);
}
113 changes: 113 additions & 0 deletions src/app-layout/__tests__/background-overlap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { render, screen } from '@testing-library/react';
import AppLayout, { AppLayoutProps } from '../../../lib/components/app-layout';
import { useDynamicOverlap } from '../../../lib/components/internal/hooks/use-dynamic-overlap';
import { useAppLayoutInternals } from '../../../lib/components/app-layout/visual-refresh/context';

jest.mock('../../../lib/components/internal/hooks/use-visual-mode', () => ({
...jest.requireActual('../../../lib/components/internal/hooks/use-visual-mode'),
useVisualRefresh: jest.fn().mockReturnValue(true),
}));

let positiveHeight = true;

jest.mock('../../../lib/components/internal/hooks/container-queries/utils', () => ({
...jest.requireActual('../../../lib/components/internal/hooks/container-queries/utils'),
convertResizeObserverEntry: () => ({ contentBoxHeight: positiveHeight ? 800 : 0 }),
}));

describe('Background overlap', () => {
function ComponentWithDynamicOverlap() {
const ref = useDynamicOverlap();
const { hasBackgroundOverlap, isBackgroundOverlapDisabled } = useAppLayoutInternals();
return (
<>
<div ref={ref} />
<div data-testid="has-background-overlap">{hasBackgroundOverlap.toString()}</div>
<div data-testid="is-background-overlap-disabled">{isBackgroundOverlapDisabled.toString()}</div>
</>
);
}

function ComponentWithoutDynamicOverlap() {
const { hasBackgroundOverlap, isBackgroundOverlapDisabled } = useAppLayoutInternals();
return (
<>
<div data-testid="has-background-overlap">{hasBackgroundOverlap.toString()}</div>
<div data-testid="is-background-overlap-disabled">{isBackgroundOverlapDisabled.toString()}</div>
</>
);
}

function renderApp(appLayoutProps?: AppLayoutProps) {
const { rerender } = render(<AppLayout {...appLayoutProps} />);
return {
hasBackgroundOverlap: () => screen.getByTestId('has-background-overlap').textContent,
isOverlapDisabled: () => screen.getByTestId('is-background-overlap-disabled').textContent,
rerender: (appLayoutProps?: AppLayoutProps) => rerender(<AppLayout {...appLayoutProps} />),
};
}

beforeEach(() => {
positiveHeight = true;
});

describe('is applied', () => {
test('when a child component sets the height dynamically with a height higher than 0', () => {
const { hasBackgroundOverlap, isOverlapDisabled } = renderApp({
content: <ComponentWithDynamicOverlap />,
});
expect(hasBackgroundOverlap()).toBe('true');
expect(isOverlapDisabled()).toBe('false');
});

test('when content header is present', () => {
const { hasBackgroundOverlap, isOverlapDisabled } = renderApp({
content: <ComponentWithoutDynamicOverlap />,
contentHeader: 'Content header',
});
expect(hasBackgroundOverlap()).toBe('true');
expect(isOverlapDisabled()).toBe('false');
});
});

describe('is not applied', () => {
test('when no content header is present and height is 0', () => {
positiveHeight = false;
const { hasBackgroundOverlap, isOverlapDisabled } = renderApp({
content: <ComponentWithDynamicOverlap />,
});
expect(hasBackgroundOverlap()).toBe('false');
expect(isOverlapDisabled()).toBe('true');
});

test('when no content header is present and no child component sets the height dynamically', () => {
const { hasBackgroundOverlap, isOverlapDisabled } = renderApp({
content: <ComponentWithoutDynamicOverlap />,
});
expect(hasBackgroundOverlap()).toBe('false');
expect(isOverlapDisabled()).toBe('true');
});
});

test('is disabled when explicitly specified in the app layout props', () => {
const { isOverlapDisabled } = renderApp({
content: <ComponentWithDynamicOverlap />,
disableContentHeaderOverlap: true,
});
expect(isOverlapDisabled()).toBe('true');
});

test('is updated accordingly when re-rendering', () => {
const { hasBackgroundOverlap, isOverlapDisabled, rerender } = renderApp({
content: <ComponentWithDynamicOverlap />,
});
expect(hasBackgroundOverlap()).toBe('true');
expect(isOverlapDisabled()).toBe('false');
rerender({ content: <ComponentWithoutDynamicOverlap /> });
expect(hasBackgroundOverlap()).toBe('false');
expect(isOverlapDisabled()).toBe('true');
});
});
5 changes: 2 additions & 3 deletions src/app-layout/visual-refresh/background.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@ import styles from './styles.css.js';
export default function Background() {
const {
breadcrumbs,
contentHeader,
dynamicOverlapHeight,
hasBackgroundOverlap,
hasNotificationsContent,
hasStickyBackground,
isMobile,
stickyNotifications,
} = useAppLayoutInternals();

if (!hasNotificationsContent && (!breadcrumbs || isMobile) && !contentHeader && dynamicOverlapHeight <= 0) {
if (!hasNotificationsContent && (!breadcrumbs || isMobile) && !hasBackgroundOverlap) {
return null;
}

Expand Down
24 changes: 14 additions & 10 deletions src/app-layout/visual-refresh/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { warnOnce } from '@cloudscape-design/component-toolkit/internal';
import useResize from '../utils/use-resize';
import styles from './styles.css.js';
import { useContainerQuery } from '@cloudscape-design/component-toolkit';
import useBackgroundOverlap from './use-background-overlap';

interface AppLayoutInternals extends AppLayoutProps {
activeDrawerId?: string | null;
Expand All @@ -40,18 +41,19 @@ interface AppLayoutInternals extends AppLayoutProps {
drawerRef: React.Ref<HTMLElement>;
resizeHandle: React.ReactElement;
drawersTriggerCount: number;
dynamicOverlapHeight: number;
handleDrawersClick: (activeDrawerId: string | null, skipFocusControl?: boolean) => void;
handleSplitPanelClick: () => void;
handleNavigationClick: (isOpen: boolean) => void;
handleSplitPanelPreferencesChange: (detail: AppLayoutProps.SplitPanelPreferences) => void;
handleSplitPanelResize: (detail: { size: number }) => void;
handleToolsClick: (value: boolean, skipFocusControl?: boolean) => void;
hasBackgroundOverlap: boolean;
hasDefaultToolsWidth: boolean;
hasDrawerViewportOverlay: boolean;
hasNotificationsContent: boolean;
hasOpenDrawer?: boolean;
hasStickyBackground: boolean;
isBackgroundOverlapDisabled: boolean;
isMobile: boolean;
isNavigationOpen: boolean;
isSplitPanelForcedPosition: boolean;
Expand Down Expand Up @@ -130,13 +132,6 @@ export const AppLayoutInternalsProvider = React.forwardRef(
}
}

/**
* The overlap height has a default set in CSS but can also be dynamically overridden
* for content types (such as Table and Wizard) that have variable size content in the overlap.
* If a child component utilizes a sticky header the hasStickyBackground property will determine
* if the background remains in the same vertical position.
*/
const [dynamicOverlapHeight, setDynamicOverlapHeight] = useState(0);
const [hasStickyBackground, setHasStickyBackground] = useState(false);

/**
Expand Down Expand Up @@ -487,6 +482,12 @@ export const AppLayoutInternalsProvider = React.forwardRef(
const mainElement = useRef<HTMLDivElement>(null);
const [mainOffsetLeft, setMainOffsetLeft] = useState(0);

const { hasBackgroundOverlap, updateBackgroundOverlapHeight } = useBackgroundOverlap({
contentHeader: props.contentHeader,
disableContentHeaderOverlap: props.disableContentHeaderOverlap,
layoutElement,
});

useLayoutEffect(
function handleMainOffsetLeft() {
setMainOffsetLeft(mainElement?.current?.offsetLeft ?? 0);
Expand Down Expand Up @@ -600,7 +601,6 @@ export const AppLayoutInternalsProvider = React.forwardRef(
drawerRef,
resizeHandle,
drawersTriggerCount,
dynamicOverlapHeight,
headerHeight,
footerHeight,
hasDefaultToolsWidth,
Expand All @@ -611,9 +611,11 @@ export const AppLayoutInternalsProvider = React.forwardRef(
handleSplitPanelPreferencesChange,
handleSplitPanelResize,
handleToolsClick,
hasBackgroundOverlap,
hasNotificationsContent,
hasOpenDrawer,
hasStickyBackground,
isBackgroundOverlapDisabled: props.disableContentHeaderOverlap || !hasBackgroundOverlap,
isMobile,
isNavigationOpen: isNavigationOpen ?? false,
isSplitPanelForcedPosition,
Expand Down Expand Up @@ -660,7 +662,9 @@ export const AppLayoutInternalsProvider = React.forwardRef(
setHasStickyBackground,
}}
>
<DynamicOverlapContext.Provider value={setDynamicOverlapHeight}>{children}</DynamicOverlapContext.Provider>
<DynamicOverlapContext.Provider value={updateBackgroundOverlapHeight}>
{children}
</DynamicOverlapContext.Provider>
</AppLayoutContext.Provider>
</AppLayoutInternalsContext.Provider>
);
Expand Down
19 changes: 4 additions & 15 deletions src/app-layout/visual-refresh/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@ export default function Layout({ children }: LayoutProps) {
contentHeader,
contentType,
disableBodyScroll,
disableContentHeaderOverlap,
disableContentPaddings,
drawersTriggerCount,
dynamicOverlapHeight,
footerHeight,
hasNotificationsContent,
hasStickyBackground,
hasOpenDrawer,
headerHeight,
isBackgroundOverlapDisabled,
isMobile,
isNavigationOpen,
layoutElement,
Expand All @@ -53,14 +52,6 @@ export default function Layout({ children }: LayoutProps) {
const hasContentGapLeft = isNavigationOpen || navigationHide;
const hasContentGapRight = drawersTriggerCount <= 0 || hasOpenDrawer;

/**
* The disableContentHeaderOverlap property is absolute and will always disable the overlap
* if it is set to true. If there is no contentHeader then the overlap should be disabled
* unless there is a dynamicOverlapHeight. The dynamicOverlapHeight property is set by a
* component in the content slot that needs to manually control the overlap height.
*/
const isOverlapDisabled = disableContentHeaderOverlap || (!contentHeader && dynamicOverlapHeight <= 0);

return (
<main
className={clsx(
Expand All @@ -80,7 +71,7 @@ export default function Layout({ children }: LayoutProps) {
[styles['has-split-panel']]: splitPanelDisplayed,
[styles['has-sticky-background']]: hasStickyBackground,
[styles['has-sticky-notifications']]: stickyNotifications && hasNotificationsContent,
[styles['is-overlap-disabled']]: isOverlapDisabled,
[styles['is-overlap-disabled']]: isBackgroundOverlapDisabled,
},
testutilStyles.root
)}
Expand All @@ -93,8 +84,6 @@ export default function Layout({ children }: LayoutProps) {
...(maxContentWidth && { [customCssProps.maxContentWidth]: `${maxContentWidth}px` }),
...(minContentWidth && { [customCssProps.minContentWidth]: `${minContentWidth}px` }),
[customCssProps.notificationsHeight]: `${notificationsHeight}px`,
...(!isOverlapDisabled &&
dynamicOverlapHeight > 0 && { [customCssProps.overlapHeight]: `${dynamicOverlapHeight}px` }),
}}
>
{children}
Expand All @@ -104,8 +93,8 @@ export default function Layout({ children }: LayoutProps) {

/*
The Notifications, Breadcrumbs, Header, and Main are all rendered in the center
column of the grid layout. Any of these could be the first child to render in the
content area if the previous siblings do not exist. The grid gap before the first
column of the grid layout. Any of these could be the first child to render in the
content area if the previous siblings do not exist. The grid gap before the first
child will be different to ensure vertical alignment with the trigger buttons.
*/
function getContentFirstChild(
Expand Down
55 changes: 55 additions & 0 deletions src/app-layout/visual-refresh/use-background-overlap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import customCssProps from '../../internal/generated/custom-css-properties';

/**
* The overlap height has a default set in CSS but can also be dynamically overridden
* for content types (such as Table and Wizard) that have variable size content in the overlap.
* If a child component utilizes a sticky header the hasStickyBackground property will determine
* if the background remains in the same vertical position.
*/
export default function useBackgroundOverlap({
contentHeader,
disableContentHeaderOverlap,
layoutElement,
}: {
contentHeader: React.ReactNode;
disableContentHeaderOverlap?: boolean;
layoutElement: React.Ref<HTMLElement>;
}) {
const hasContentHeader = !!contentHeader;

const [hasBackgroundOverlap, setHasBackgroundOverlap] = useState(hasContentHeader);

const updateBackgroundOverlapHeight = useCallback(
(height: number) => {
const hasOverlap = hasContentHeader || height > 0;
setHasBackgroundOverlap(hasOverlap);

/**
* React 18 will trigger a paint before the state is correctly updated
* (see https://github.com/facebook/react/issues/24331).
* To work around this, we bypass React state updates and imperatively update the custom property on the DOM.
* An alternative would be to use `queueMicrotask` and `flushSync` in the ResizeObserver callback,
* but that would have some performance impact as it would delay the render.
*/
// Layout component uses RefObject, we don't expect a RefCallback
const element = typeof layoutElement !== 'function' && layoutElement?.current;
if (!element) {
return;
}
if (disableContentHeaderOverlap || !hasOverlap || height <= 0) {
element.style.removeProperty(customCssProps.overlapHeight);
} else {
element.style.setProperty(customCssProps.overlapHeight, `${height}px`);
}
},
[hasContentHeader, layoutElement, disableContentHeaderOverlap]
);

return {
hasBackgroundOverlap,
updateBackgroundOverlapHeight,
};
}
11 changes: 1 addition & 10 deletions src/internal/hooks/container-queries/use-resize-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ResizeObserver, ResizeObserverEntry } from '@juggle/resize-observer';
import React, { useEffect, useLayoutEffect } from 'react';
import { useStableEventHandler } from '../use-stable-event-handler';
import { ContainerQueryEntry } from '@cloudscape-design/component-toolkit';
import { convertResizeObserverEntry } from './utils';

type ElementReference = (() => Element | null) | React.RefObject<Element>;

Expand Down Expand Up @@ -58,13 +59,3 @@ export function useResizeObserver(elementRef: ElementReference, onObserve: (entr
}
}, [elementRef, stableOnObserve]);
}

function convertResizeObserverEntry(entry: ResizeObserverEntry): ContainerQueryEntry {
return {
target: entry.target,
contentBoxWidth: entry.contentBoxSize[0].inlineSize,
contentBoxHeight: entry.contentBoxSize[0].blockSize,
borderBoxWidth: entry.borderBoxSize[0].inlineSize,
borderBoxHeight: entry.borderBoxSize[0].blockSize,
};
}
Loading

0 comments on commit f6fe295

Please sign in to comment.