Skip to content

Commit 8bb6cd6

Browse files
committed
refactor: share sticky header internals between Table and Container
1 parent cc35fe2 commit 8bb6cd6

13 files changed

Lines changed: 385 additions & 184 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/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-
};

src/container/__tests__/sticky-offset.test.ts renamed to src/internal/components/sticky-header/__tests__/sticky-offset.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import { computeOffset } from '../../../lib/components/container/use-sticky-header';
4-
import * as tokens from '../../../lib/components/internal/generated/styles/tokens';
5-
import globalVars from '../../../lib/components/internal/styles/global-vars';
3+
import { computeOffset } from '../../../../../lib/components/internal/components/sticky-header/use-sticky-position';
4+
import * as tokens from '../../../../../lib/components/internal/generated/styles/tokens';
5+
import globalVars from '../../../../../lib/components/internal/styles/global-vars';
66

77
describe('computeOffset', () => {
88
test('should calculate offset for mobile outside overflow parent', () => {
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+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// The sticky copy scrolls horizontally in step with the body, which owns the visible scrollbar.
5+
@mixin copy-scroller {
6+
overflow: auto;
7+
scrollbar-width: none; /* Firefox */
8+
&::-webkit-scrollbar {
9+
display: none; /* Safari and Chrome */
10+
}
11+
}

src/table/sticky-scrolling.ts renamed to src/internal/components/sticky-header/sticky-scrolling.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33
import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal';
44

5-
import { getOverflowParents } from '../internal/utils/scrollable-containers';
5+
import { getOverflowParents } from '../../utils/scrollable-containers';
66

77
/**
88
* @param containerRef ref to surrounding container with sticky element
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { useEffect, useLayoutEffect, useRef } from 'react';
4+
5+
import { useStableCallback } from '@cloudscape-design/component-toolkit/internal';
6+
7+
// Content-sized header cells change width without their row, head or table resizing, so each cell
8+
// is observed individually. The set is re-collected after every render because cells can remount.
9+
export function useCellsResizeObserver(getCells: () => ReadonlyArray<HTMLElement>, onResize: () => void) {
10+
const stableOnResize = useStableCallback(onResize);
11+
const observerRef = useRef<ResizeObserver | null>(null);
12+
const observedRef = useRef<ReadonlyArray<HTMLElement>>([]);
13+
14+
useLayoutEffect(() => {
15+
if (typeof ResizeObserver === 'undefined') {
16+
return;
17+
}
18+
const cells = getCells();
19+
const observed = observedRef.current;
20+
if (cells.length === observed.length && cells.every((cell, index) => cell === observed[index])) {
21+
return;
22+
}
23+
if (!observerRef.current) {
24+
observerRef.current = new ResizeObserver(() => stableOnResize());
25+
}
26+
const observer = observerRef.current;
27+
observed.filter(cell => !cells.includes(cell)).forEach(cell => observer.unobserve(cell));
28+
cells.filter(cell => !observed.includes(cell)).forEach(cell => observer.observe(cell));
29+
observedRef.current = cells;
30+
});
31+
32+
useEffect(
33+
() => () => {
34+
observerRef.current?.disconnect();
35+
observerRef.current = null;
36+
observedRef.current = [];
37+
},
38+
[]
39+
);
40+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { RefObject, useCallback, useEffect, useState } from 'react';
4+
5+
interface UseHeaderStuckResult {
6+
isStuck: boolean;
7+
isStuckAtBottom: boolean;
8+
}
9+
10+
// "Stuck" means the header has moved from its original position, which shows its box-shadow.
11+
// Scroll is listened to in the capture phase so a scroll in any container is caught.
12+
export function useHeaderStuck(
13+
rootRef: RefObject<HTMLElement>,
14+
headerRef: RefObject<HTMLElement>,
15+
isSticky: boolean
16+
): UseHeaderStuckResult {
17+
const [isStuck, setIsStuck] = useState(false);
18+
const [isStuckAtBottom, setIsStuckAtBottom] = useState(false);
19+
20+
const checkIfStuck = useCallback(
21+
({ isTrusted, target, type }) => {
22+
if (type === 'resize' && target === window && !isTrusted) {
23+
// The window size didn't actually change, it was a synthetic event
24+
return;
25+
}
26+
if (rootRef.current && headerRef.current) {
27+
const rootTopBorderWidth = parseFloat(getComputedStyle(rootRef.current).borderTopWidth) || 0;
28+
29+
// Using Math.round to adjust for rounding errors in floating-point arithmetic and timing issues
30+
const rootTop = Math.round(rootRef.current.getBoundingClientRect().top + rootTopBorderWidth);
31+
const headerTop = Math.round(headerRef.current.getBoundingClientRect().top);
32+
if (rootTop < headerTop) {
33+
setIsStuck(true);
34+
} else {
35+
setIsStuck(false);
36+
}
37+
38+
const rootBottom = Math.round(rootRef.current.getBoundingClientRect().bottom - rootTopBorderWidth);
39+
const headerBottom = Math.round(headerRef.current.getBoundingClientRect().bottom);
40+
if (rootBottom <= headerBottom) {
41+
setIsStuckAtBottom(true);
42+
} else {
43+
setIsStuckAtBottom(false);
44+
}
45+
}
46+
},
47+
[rootRef, headerRef]
48+
);
49+
50+
useEffect(() => {
51+
if (isSticky) {
52+
const controller = new AbortController();
53+
window.addEventListener('scroll', checkIfStuck, { capture: true, signal: controller.signal });
54+
window.addEventListener('resize', checkIfStuck, { signal: controller.signal });
55+
return () => {
56+
controller.abort();
57+
};
58+
}
59+
}, [isSticky, checkIfStuck]);
60+
61+
return { isStuck, isStuckAtBottom };
62+
}

0 commit comments

Comments
 (0)