From 187f672a76980015129e552f6d2e2412218f1452 Mon Sep 17 00:00:00 2001 From: Dennis Pitcock <14841504+dpitcock@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:47:56 -0500 Subject: [PATCH] fix: fixing broken corners of table with sticky header bottom when scrolled (#3291) --- .../__tests__/sticky-header.test.tsx | 154 +++++++++++++++--- src/container/internal.tsx | 5 +- src/container/styles.scss | 5 + src/container/use-sticky-header.ts | 13 ++ .../__tests__/sticky-responsiveness.test.tsx | 6 +- 5 files changed, 156 insertions(+), 27 deletions(-) diff --git a/src/container/__tests__/sticky-header.test.tsx b/src/container/__tests__/sticky-header.test.tsx index 25baa5f7cc..593d6eea61 100644 --- a/src/container/__tests__/sticky-header.test.tsx +++ b/src/container/__tests__/sticky-header.test.tsx @@ -26,12 +26,12 @@ test('should set isStuck to false when __stickyHeader is false', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100, bottom: 500 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -200 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -200, bottom: -100 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, false, 0, 0, false)); act(() => { @@ -39,18 +39,19 @@ test('should set isStuck to false when __stickyHeader is false', () => { }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should set isStuck to false when __disableMobile is false', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100, bottom: 500 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -200 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -200, bottom: -100 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, true)); act(() => { @@ -58,18 +59,19 @@ test('should set isStuck to false when __disableMobile is false', () => { }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should set isStuck to false when rootTop headerTop are equal and headerTop is not 0', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100, bottom: 500 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100, bottom: 200 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); act(() => { @@ -77,18 +79,19 @@ test('should set isStuck to false when rootTop headerTop are equal and headerTop }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should set isStuck to true when rootTop less than a headerTop at 0', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -100 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -100, bottom: 500 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0, bottom: 100 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); act(() => { @@ -96,18 +99,19 @@ test('should set isStuck to true when rootTop less than a headerTop at 0', () => }); expect(result.current.isStuck).toBe(true); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should set isStuck to false when rootTop and headerTop are equal at 0', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0, bottom: 500 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0, bottom: 100 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); act(() => { @@ -115,18 +119,19 @@ test('should set isStuck to false when rootTop and headerTop are equal at 0', () }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should set isStuck to true when rootTop is less than headerTop', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100, bottom: 500 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200, bottom: 100 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); act(() => { @@ -134,18 +139,19 @@ test('should set isStuck to true when rootTop is less than headerTop', () => { }); expect(result.current.isStuck).toBe(true); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should set isStuck to false when rootTop is larger than than nonZero headerTop', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200, bottom: 500 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100, bottom: 150 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); act(() => { @@ -153,18 +159,19 @@ test('should set isStuck to false when rootTop is larger than than nonZero heade }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should set isStuck to false when rootTop is larger than than zero headerTop', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200, bottom: 800 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0, bottom: 100 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); act(() => { @@ -172,19 +179,20 @@ test('should set isStuck to false when rootTop is larger than than zero headerTo }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should not set isStuck to true when rootTop has a border and is larger than than headerTop', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 199 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 199, bottom: 800 }); rootRef.current.style.borderTopWidth = '1px'; const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200, bottom: 100 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); act(() => { @@ -192,13 +200,14 @@ test('should not set isStuck to true when rootTop has a border and is larger tha }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); -test('should set isStuck to false when headerRef is null', () => { +test('should set isStuck and isStuckAtBottom to false when headerRef is null', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200, bottom: 500 }); const headerRef = { current: null, @@ -210,18 +219,19 @@ test('should set isStuck to false when headerRef is null', () => { }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); test('should not react to synthetic window resize events', () => { const rootRef = { current: document.createElement('div'), }; - rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100 }); + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100, bottom: 500 }); const headerRef = { current: document.createElement('div'), }; - headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200 }); + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 200, bottom: 100 }); const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); act(() => { @@ -229,4 +239,104 @@ test('should not react to synthetic window resize events', () => { }); expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); +}); + +test('should set isStuckAtBottom to true when rootBottom equals headerBottom', () => { + const rootRef = { + current: document.createElement('div'), + }; + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -500, bottom: 100 }); + + const headerRef = { + current: document.createElement('div'), + }; + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0, bottom: 100 }); + + const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.isStuck).toBe(true); + expect(result.current.isStuckAtBottom).toBe(true); +}); + +test('should set isStuckAtBottom to true when rootBottom less than headerBottom', () => { + const rootRef = { + current: document.createElement('div'), + }; + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -500, bottom: 100 }); + + const headerRef = { + current: document.createElement('div'), + }; + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0, bottom: 200 }); + + const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.isStuck).toBe(true); + expect(result.current.isStuckAtBottom).toBe(true); +}); + +test('should set isStuckAtBottom to false when rootBottom larger than headerBottom', () => { + const rootRef = { + current: document.createElement('div'), + }; + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -500, bottom: 200 }); + + const headerRef = { + current: document.createElement('div'), + }; + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 0, bottom: 100 }); + + const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.isStuck).toBe(true); + expect(result.current.isStuckAtBottom).toBe(false); +}); + +test('should set isStuckAtBottom to false when rootBottom larger than headerBottom and headerTop is not 0', () => { + const rootRef = { + current: document.createElement('div'), + }; + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -500, bottom: 200 }); + + const headerRef = { + current: document.createElement('div'), + }; + headerRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: 100, bottom: 100 }); + + const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.isStuck).toBe(true); + expect(result.current.isStuckAtBottom).toBe(false); +}); + +test('should set isStuckAtBottom to false when headerRef is null', () => { + const rootRef = { + current: document.createElement('div'), + }; + rootRef.current.getBoundingClientRect = jest.fn().mockReturnValue({ top: -500, bottom: 200 }); + + const headerRef = { + current: null, + }; + + const { result } = renderHook(() => useStickyHeader(rootRef, headerRef, true, 0, 0, false)); + act(() => { + window.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.isStuck).toBe(false); + expect(result.current.isStuckAtBottom).toBe(false); }); diff --git a/src/container/internal.tsx b/src/container/internal.tsx index 3a7999b34d..4ec6c993e7 100644 --- a/src/container/internal.tsx +++ b/src/container/internal.tsx @@ -84,7 +84,7 @@ export default function InternalContainer({ const baseProps = getBaseProps(restProps); const rootRef = useRef(null); const headerRef = useRef(null); - const { isSticky, isStuck, stickyStyles } = useStickyHeader( + const { isSticky, isStuck, isStuckAtBottom, stickyStyles } = useStickyHeader( rootRef, headerRef, __stickyHeader, @@ -117,6 +117,7 @@ export default function InternalContainer({ fitHeight && styles['fit-height'], hasMedia && (mediaPosition === 'side' ? styles['with-side-media'] : styles['with-top-media']), shouldHaveStickyStyles && [styles['sticky-enabled']], + shouldHaveStickyStyles && isStuck && isStuckAtBottom && [styles['with-stuck-sticky-header-at-bottom']], isRefresh && styles.refresh )} ref={mergedRef} @@ -139,7 +140,7 @@ export default function InternalContainer({ > {header && ( - +
({ isStuck: false, + isStuckAtBottom: false, }); export const useStickyHeader = ( @@ -57,6 +59,7 @@ export const useStickyHeader = ( // If it has overflow parents inside the app layout, we shouldn't apply a sticky offset. const [hasInnerOverflowParents, setHasInnerOverflowParents] = useState(false); const [isStuck, setIsStuck] = useState(false); + const [isStuckAtBottom, setIsStuckAtBottom] = useState(false); useLayoutEffect(() => { if (rootRef.current) { @@ -97,6 +100,7 @@ export const useStickyHeader = ( } if (rootRef.current && headerRef.current) { const rootTopBorderWidth = parseFloat(getComputedStyle(rootRef.current).borderTopWidth) || 0; + // Using Math.round to adjust for rounding errors in floating-point arithmetic and timing issues const rootTop = Math.round(rootRef.current.getBoundingClientRect().top + rootTopBorderWidth); const headerTop = Math.round(headerRef.current.getBoundingClientRect().top); @@ -105,6 +109,14 @@ export const useStickyHeader = ( } else { setIsStuck(false); } + + const rootBottom = Math.round(rootRef.current.getBoundingClientRect().bottom - rootTopBorderWidth); + const headerBottom = Math.round(headerRef.current.getBoundingClientRect().bottom); + if (rootBottom <= headerBottom) { + setIsStuckAtBottom(true); + } else { + setIsStuckAtBottom(false); + } } }, [rootRef, headerRef] @@ -123,6 +135,7 @@ export const useStickyHeader = ( return { isSticky, isStuck, + isStuckAtBottom, stickyStyles, }; }; diff --git a/src/header/__tests__/sticky-responsiveness.test.tsx b/src/header/__tests__/sticky-responsiveness.test.tsx index 7ae9db21d5..8bb4dbbc9d 100644 --- a/src/header/__tests__/sticky-responsiveness.test.tsx +++ b/src/header/__tests__/sticky-responsiveness.test.tsx @@ -21,7 +21,7 @@ function renderHeader(jsx: React.ReactElement) { test('renders constant header without visual refresh', () => { const wrapper = renderHeader( - +
test
); @@ -39,7 +39,7 @@ describe('in visual refresh', () => { test('renders h1 variant when header is not stuck', () => { const wrapper = renderHeader( - +
test
); @@ -48,7 +48,7 @@ describe('in visual refresh', () => { }); test('renders h2 variant when header is stuck', () => { const wrapper = renderHeader( - +
test
);