-
Notifications
You must be signed in to change notification settings - Fork 155
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: Render Visual Refresh background height in sync with content #1331
Changes from 55 commits
04df79d
f7234e2
a5a705b
64179d5
efd419e
87aea84
323bf6e
e04950d
4829a91
f1cd56a
d247a03
8b4aebc
82efcef
054c66f
cc3329d
c5a4966
b04acd5
411f2c8
25f007a
b9cbf12
859e30f
3660271
a6f4985
41dc5d3
8156a2d
a1512cd
8deb19b
676c45d
0c40286
b26733b
4066d64
23cddba
f9e9a92
7eded5e
489608c
5e9f003
c21efd3
9ce6e26
e80138a
97d2baf
cca23f2
1c5ee2b
bb15330
d531559
67e426a
df6471c
95b72c5
9482d66
53e0848
908294e
7079c82
688c29c
fe96a65
dfe14a6
cf26752
9a79533
0bc9b7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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} | ||
/> | ||
} | ||
/> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
// 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('Dynamic overlap', () => { | ||
function ComponentWithDynamicOverlap() { | ||
const ref = useDynamicOverlap(); | ||
const { isDynamicOverlapSet, isDynamicOverlapDisabled } = useAppLayoutInternals(); | ||
return ( | ||
<> | ||
<div ref={ref} /> | ||
<div data-testid="is-dynamic-overlap-set">{isDynamicOverlapSet.toString()}</div> | ||
<div data-testid="is-dynamic-overlap-disabled">{isDynamicOverlapDisabled.toString()}</div> | ||
</> | ||
); | ||
} | ||
|
||
function ComponentWithoutDynamicOverlap() { | ||
const { isDynamicOverlapSet, isDynamicOverlapDisabled } = useAppLayoutInternals(); | ||
return ( | ||
<> | ||
<div data-testid="is-dynamic-overlap-set">{isDynamicOverlapSet.toString()}</div> | ||
<div data-testid="is-dynamic-overlap-disabled">{isDynamicOverlapDisabled.toString()}</div> | ||
</> | ||
); | ||
} | ||
|
||
function renderApp(appLayoutProps?: AppLayoutProps) { | ||
const { rerender } = render(<AppLayout {...appLayoutProps} />); | ||
return { | ||
isDynamicOverlapSet: () => screen.getByTestId('is-dynamic-overlap-set').textContent, | ||
isDynamicOverlapDisabled: () => screen.getByTestId('is-dynamic-overlap-disabled').textContent, | ||
rerender: (appLayoutProps?: AppLayoutProps) => rerender(<AppLayout {...appLayoutProps} />), | ||
}; | ||
} | ||
|
||
beforeEach(() => { | ||
positiveHeight = true; | ||
}); | ||
|
||
test('sets dynamic overlap when content header is present', () => { | ||
const { isDynamicOverlapSet, isDynamicOverlapDisabled } = renderApp({ | ||
content: <ComponentWithDynamicOverlap />, | ||
contentHeader: 'Content header', | ||
}); | ||
expect(isDynamicOverlapSet()).toBe('true'); | ||
expect(isDynamicOverlapDisabled()).toBe('false'); | ||
}); | ||
|
||
test('sets dynamic overlap when height is higher than 0', () => { | ||
const { isDynamicOverlapSet, isDynamicOverlapDisabled } = renderApp({ content: <ComponentWithDynamicOverlap /> }); | ||
expect(isDynamicOverlapSet()).toBe('true'); | ||
expect(isDynamicOverlapDisabled()).toBe('false'); | ||
}); | ||
|
||
test('does not set dynamic overlap when no content header is present and height is 0', () => { | ||
positiveHeight = false; | ||
const { isDynamicOverlapSet, isDynamicOverlapDisabled } = renderApp({ content: <ComponentWithDynamicOverlap /> }); | ||
expect(isDynamicOverlapSet()).toBe('false'); | ||
expect(isDynamicOverlapDisabled()).toBe('true'); | ||
}); | ||
|
||
test('does not set dynamic overlap when the useDynamicOverlap hook is not used', () => { | ||
const { isDynamicOverlapSet, isDynamicOverlapDisabled } = renderApp({ | ||
content: <ComponentWithoutDynamicOverlap />, | ||
disableContentHeaderOverlap: true, | ||
}); | ||
expect(isDynamicOverlapSet()).toBe('false'); | ||
expect(isDynamicOverlapDisabled()).toBe('true'); | ||
}); | ||
|
||
test('disables dynamic overlap when explicitly specified in the app layout props', () => { | ||
const { isDynamicOverlapDisabled } = renderApp({ | ||
content: <ComponentWithDynamicOverlap />, | ||
disableContentHeaderOverlap: true, | ||
}); | ||
expect(isDynamicOverlapDisabled()).toBe('true'); | ||
}); | ||
|
||
test('updates state accordingly when re-rendering', () => { | ||
const { isDynamicOverlapSet, isDynamicOverlapDisabled, rerender } = renderApp({ | ||
content: <ComponentWithDynamicOverlap />, | ||
}); | ||
expect(isDynamicOverlapSet()).toBe('true'); | ||
expect(isDynamicOverlapDisabled()).toBe('false'); | ||
rerender({ content: <ComponentWithoutDynamicOverlap /> }); | ||
expect(isDynamicOverlapSet()).toBe('false'); | ||
expect(isDynamicOverlapDisabled()).toBe('true'); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 customCssProps from '../../internal/generated/custom-css-properties'; | ||
|
||
interface AppLayoutInternals extends AppLayoutProps { | ||
activeDrawerId?: string | null; | ||
|
@@ -40,7 +41,6 @@ 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; | ||
|
@@ -52,6 +52,8 @@ interface AppLayoutInternals extends AppLayoutProps { | |
hasNotificationsContent: boolean; | ||
hasOpenDrawer?: boolean; | ||
hasStickyBackground: boolean; | ||
isDynamicOverlapDisabled: boolean; | ||
isDynamicOverlapSet: boolean; | ||
isMobile: boolean; | ||
isNavigationOpen: boolean; | ||
isSplitPanelForcedPosition: boolean; | ||
|
@@ -136,7 +138,8 @@ export const AppLayoutInternalsProvider = React.forwardRef( | |
* 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 [isDynamicOverlapSet, setIsDynamicOverlapSet] = useState(false); | ||
const [isDynamicOverlapDisabled, setIsDynamicOverlapDisabled] = useState(true); | ||
const [hasStickyBackground, setHasStickyBackground] = useState(false); | ||
|
||
/** | ||
|
@@ -487,6 +490,38 @@ export const AppLayoutInternalsProvider = React.forwardRef( | |
const mainElement = useRef<HTMLDivElement>(null); | ||
const [mainOffsetLeft, setMainOffsetLeft] = useState(0); | ||
|
||
const updateDynamicOverlapHeight = useCallback( | ||
(height: number) => { | ||
/** | ||
* 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 isOverlapSet = !!props.contentHeader || height > 0; | ||
const isOverlapDisabled = props.disableContentHeaderOverlap || !isOverlapSet; | ||
setIsDynamicOverlapSet(isOverlapSet); | ||
setIsDynamicOverlapDisabled(isOverlapDisabled); | ||
|
||
/** | ||
* 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. | ||
* Using React state for `isDynamicOverlapSet` and `isDynamicOverlapDisabled` is less problematic | ||
* because they will rarely change within the lifecycle of an application. | ||
*/ | ||
const element = typeof layoutElement === 'function' ? layoutElement(null) : layoutElement?.current; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure what There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This variable is returned by If it looks better, we could as well just do nothing if it is a function. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not ideal, but it does have a comment and it works for now. Not going to treat it as blocking. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should Anyway, it seems unlikely, so it is also non-blocking for me. |
||
if (isOverlapDisabled || height <= 0) { | ||
element?.style.removeProperty(customCssProps.overlapHeight); | ||
} else { | ||
element?.style.setProperty(customCssProps.overlapHeight, `${height}px`); | ||
} | ||
}, | ||
[layoutElement, props.contentHeader, props.disableContentHeaderOverlap] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If it's just provided to check for the existence of the property rather than the contents, maybe you could define a variable outside the function and use it internally? That would make it safer.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated as per your suggestion. |
||
); | ||
|
||
useLayoutEffect( | ||
function handleMainOffsetLeft() { | ||
setMainOffsetLeft(mainElement?.current?.offsetLeft ?? 0); | ||
|
@@ -600,7 +635,6 @@ export const AppLayoutInternalsProvider = React.forwardRef( | |
drawerRef, | ||
resizeHandle, | ||
drawersTriggerCount, | ||
dynamicOverlapHeight, | ||
headerHeight, | ||
footerHeight, | ||
hasDefaultToolsWidth, | ||
|
@@ -616,6 +650,8 @@ export const AppLayoutInternalsProvider = React.forwardRef( | |
hasStickyBackground, | ||
isMobile, | ||
isNavigationOpen: isNavigationOpen ?? false, | ||
isDynamicOverlapDisabled, | ||
isDynamicOverlapSet, | ||
isSplitPanelForcedPosition, | ||
isSplitPanelOpen, | ||
isToolsOpen, | ||
|
@@ -660,7 +696,7 @@ export const AppLayoutInternalsProvider = React.forwardRef( | |
setHasStickyBackground, | ||
}} | ||
> | ||
<DynamicOverlapContext.Provider value={setDynamicOverlapHeight}>{children}</DynamicOverlapContext.Provider> | ||
<DynamicOverlapContext.Provider value={updateDynamicOverlapHeight}>{children}</DynamicOverlapContext.Provider> | ||
</AppLayoutContext.Provider> | ||
</AppLayoutInternalsContext.Provider> | ||
); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
import { ResizeObserverEntry } from '@juggle/resize-observer'; | ||
import { ContainerQueryEntry } from '@cloudscape-design/component-toolkit'; | ||
|
||
export 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, | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we have a test case where the height dynamically changes (for example, from
800
to0
) and we check for the expected values ofisDynamicOverlapSet
andisDynamicOverlapDisabled
?Or is this not something that can happen?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not impossible that these state variables change during the lifecycle of an application but I think it should be rather rare. Some of these events should happen:
contentHeader
is passed and then not passed anymore, or the other way arounddisableDynamicOverlap
property passed to the app layout changesI just added a test that checks these state variables after re-rendering, based on the case 2 above. Case 1 would be tricky to test because we would need to simulate a call to the resize observer hook.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, that makes sense!