Skip to content

Commit a8477f0

Browse files
refactor: share sticky header internals between Table and Container (#5212)
1 parent cca247a commit a8477f0

15 files changed

Lines changed: 521 additions & 264 deletions

‎src/cards/index.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { InternalContainerAsSubstep } from '../container/internal';
1212
import { useInternalI18n } from '../i18n/context';
1313
import { AnalyticsFunnelSubStep } from '../internal/analytics/components/analytics-funnel';
1414
import { getBaseProps } from '../internal/base-component';
15+
import stickyScrolling from '../internal/components/sticky-header/sticky-scrolling';
1516
import { CollectionLabelContext } from '../internal/context/collection-label-context';
1617
import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context';
1718
import useBaseComponent from '../internal/hooks/use-base-component';
@@ -29,7 +30,6 @@ import {
2930
useSelection,
3031
useSelectionFocusMove,
3132
} from '../table/selection';
32-
import stickyScrolling from '../table/sticky-scrolling';
3333
import ToolsHeader from '../table/tools-header';
3434
import { getItemKey } from '../table/utils';
3535
import {

‎src/container/__tests__/sticky-offset.test.ts‎

Lines changed: 0 additions & 100 deletions
This file was deleted.

‎src/container/use-sticky-header.ts‎

Lines changed: 9 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,14 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import { createContext, RefObject, useCallback, useEffect, useLayoutEffect, useState } from 'react';
3+
import { createContext, RefObject } from 'react';
44

5-
import { findUpUntil } from '@cloudscape-design/component-toolkit/dom';
6-
7-
import * as tokens from '../internal/generated/styles/tokens';
8-
import { useMobile } from '../internal/hooks/use-mobile';
9-
import globalVars from '../internal/styles/global-vars';
10-
import { getOverflowParents } from '../internal/utils/scrollable-containers';
5+
import { useStickyPosition } from '../internal/components/sticky-header/use-sticky-position';
116

127
interface StickyHeaderContextProps {
138
isStuck: boolean;
149
isStuckAtBottom: boolean;
1510
}
1611

17-
interface ComputeOffsetProps {
18-
isMobile: boolean;
19-
__stickyOffset?: number;
20-
__mobileStickyOffset?: number;
21-
hasInnerOverflowParents: boolean;
22-
__additionalOffset?: boolean;
23-
}
24-
25-
export function computeOffset({
26-
isMobile,
27-
__stickyOffset,
28-
__mobileStickyOffset,
29-
hasInnerOverflowParents,
30-
__additionalOffset,
31-
}: ComputeOffsetProps): string {
32-
const localOffset = isMobile ? (__stickyOffset ?? 0) - (__mobileStickyOffset ?? 0) : (__stickyOffset ?? 0);
33-
if (hasInnerOverflowParents || __stickyOffset !== undefined) {
34-
return `${localOffset}px`;
35-
}
36-
const globalOffset = `var(${globalVars.stickyVerticalTopOffset}, 0px)`;
37-
38-
return `calc(${globalOffset} + ${localOffset}px + ${__additionalOffset ? tokens.spaceScaledS : '0px'})`;
39-
}
40-
4112
export const StickyHeaderContext = createContext<StickyHeaderContextProps>({
4213
isStuck: false,
4314
isStuckAtBottom: false,
@@ -51,91 +22,11 @@ export const useStickyHeader = (
5122
__mobileStickyOffset?: number,
5223
__disableMobile?: boolean,
5324
__additionalOffset = false
54-
) => {
55-
const isMobile = useMobile();
56-
const disableSticky = isMobile && __disableMobile;
57-
const isSticky = !disableSticky && !!__stickyHeader;
58-
59-
// If it has overflow parents inside the app layout, we shouldn't apply a sticky offset.
60-
const [hasInnerOverflowParents, setHasInnerOverflowParents] = useState(false);
61-
const [isStuck, setIsStuck] = useState(false);
62-
const [isStuckAtBottom, setIsStuckAtBottom] = useState(false);
63-
64-
useLayoutEffect(() => {
65-
if (rootRef.current) {
66-
const overflowParents = getOverflowParents(rootRef.current);
67-
const mainElement = findUpUntil(rootRef.current, elem => elem.tagName === 'MAIN');
68-
69-
// In both versions of the app layout, the scrolling element for disableBodyScroll
70-
// is the <main>. If the closest overflow parent is also the closest <main> and we have
71-
// offset values, it's safe to assume that it's the app layout scroll root and we
72-
// should stop there.
73-
setHasInnerOverflowParents(overflowParents.length > 0 && overflowParents[0] !== mainElement);
74-
}
75-
}, [rootRef]);
76-
77-
const computedOffset = computeOffset({
78-
isMobile,
79-
__stickyOffset,
80-
__mobileStickyOffset,
81-
hasInnerOverflowParents,
82-
__additionalOffset,
25+
) =>
26+
useStickyPosition(rootRef, headerRef, {
27+
sticky: __stickyHeader,
28+
offset: __stickyOffset,
29+
mobileOffset: __mobileStickyOffset,
30+
disableMobile: __disableMobile,
31+
additionalOffset: __additionalOffset,
8332
});
84-
85-
const stickyStyles = isSticky
86-
? {
87-
style: {
88-
top: computedOffset,
89-
},
90-
}
91-
: {};
92-
93-
// "stuck" state, when the header has moved from its original posititon has a
94-
// box-shadow, applied here by a "header-stuck" className
95-
const checkIfStuck = useCallback(
96-
({ isTrusted, target, type }) => {
97-
if (type === 'resize' && target === window && !isTrusted) {
98-
// The window size didn't actually change, it was a synthetic event
99-
return;
100-
}
101-
if (rootRef.current && headerRef.current) {
102-
const rootTopBorderWidth = parseFloat(getComputedStyle(rootRef.current).borderTopWidth) || 0;
103-
104-
// Using Math.round to adjust for rounding errors in floating-point arithmetic and timing issues
105-
const rootTop = Math.round(rootRef.current.getBoundingClientRect().top + rootTopBorderWidth);
106-
const headerTop = Math.round(headerRef.current.getBoundingClientRect().top);
107-
if (rootTop < headerTop) {
108-
setIsStuck(true);
109-
} else {
110-
setIsStuck(false);
111-
}
112-
113-
const rootBottom = Math.round(rootRef.current.getBoundingClientRect().bottom - rootTopBorderWidth);
114-
const headerBottom = Math.round(headerRef.current.getBoundingClientRect().bottom);
115-
if (rootBottom <= headerBottom) {
116-
setIsStuckAtBottom(true);
117-
} else {
118-
setIsStuckAtBottom(false);
119-
}
120-
}
121-
},
122-
[rootRef, headerRef]
123-
);
124-
125-
useEffect(() => {
126-
if (isSticky) {
127-
const controller = new AbortController();
128-
window.addEventListener('scroll', checkIfStuck, { capture: true, signal: controller.signal });
129-
window.addEventListener('resize', checkIfStuck, { signal: controller.signal });
130-
return () => {
131-
controller.abort();
132-
};
133-
}
134-
}, [isSticky, checkIfStuck]);
135-
return {
136-
isSticky,
137-
isStuck,
138-
isStuckAtBottom,
139-
stickyStyles,
140-
};
141-
};
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { renderHook } from '../../../../__tests__/render-hook';
4+
import { useCellsResizeObserver } from '../use-cells-resize-observer';
5+
6+
const observers: Array<{ callback: () => void; elements: Set<Element> }> = [];
7+
const OriginalResizeObserver = window.ResizeObserver;
8+
9+
beforeEach(() => {
10+
observers.length = 0;
11+
window.ResizeObserver = class {
12+
private entry: { callback: () => void; elements: Set<Element> };
13+
constructor(callback: () => void) {
14+
this.entry = { callback, elements: new Set() };
15+
observers.push(this.entry);
16+
}
17+
observe(element: Element) {
18+
this.entry.elements.add(element);
19+
}
20+
unobserve(element: Element) {
21+
this.entry.elements.delete(element);
22+
}
23+
disconnect() {
24+
this.entry.elements.clear();
25+
}
26+
} as unknown as typeof ResizeObserver;
27+
});
28+
29+
afterEach(() => {
30+
window.ResizeObserver = OriginalResizeObserver;
31+
});
32+
33+
function resize(element: Element) {
34+
observers.filter(observer => observer.elements.has(element)).forEach(observer => observer.callback());
35+
}
36+
37+
function renderCells(initialCells: HTMLElement[]) {
38+
const onResize = jest.fn();
39+
const { rerender, unmount } = renderHook((cells: HTMLElement[]) => useCellsResizeObserver(() => cells, onResize), {
40+
initialProps: initialCells,
41+
});
42+
return { onResize, rerender, unmount };
43+
}
44+
45+
test('reports a resize of any observed cell', () => {
46+
const cells = [document.createElement('th'), document.createElement('th')];
47+
const { onResize } = renderCells(cells);
48+
49+
resize(cells[1]);
50+
51+
expect(onResize).toHaveBeenCalledTimes(1);
52+
});
53+
54+
test('reports resizes of a cell that replaced another between renders', () => {
55+
const first = document.createElement('th');
56+
const remounted = document.createElement('th');
57+
const { onResize, rerender } = renderCells([first]);
58+
59+
rerender([remounted]);
60+
resize(first);
61+
expect(onResize).not.toHaveBeenCalled();
62+
63+
resize(remounted);
64+
expect(onResize).toHaveBeenCalledTimes(1);
65+
});
66+
67+
test('calls the latest callback', () => {
68+
const cell = document.createElement('th');
69+
const first = jest.fn();
70+
const latest = jest.fn();
71+
const { rerender } = renderHook((onResize: () => void) => useCellsResizeObserver(() => [cell], onResize), {
72+
initialProps: first,
73+
});
74+
75+
rerender(latest);
76+
resize(cell);
77+
78+
expect(first).not.toHaveBeenCalled();
79+
expect(latest).toHaveBeenCalledTimes(1);
80+
});
81+
82+
test('stops reporting after unmount', () => {
83+
const cell = document.createElement('th');
84+
const { onResize, unmount } = renderCells([cell]);
85+
86+
unmount();
87+
resize(cell);
88+
89+
expect(onResize).not.toHaveBeenCalled();
90+
});

0 commit comments

Comments
 (0)