diff --git a/src/cards/index.tsx b/src/cards/index.tsx index 1a00190906b..cee2c255459 100644 --- a/src/cards/index.tsx +++ b/src/cards/index.tsx @@ -12,6 +12,7 @@ import { InternalContainerAsSubstep } from '../container/internal'; import { useInternalI18n } from '../i18n/context'; import { AnalyticsFunnelSubStep } from '../internal/analytics/components/analytics-funnel'; import { getBaseProps } from '../internal/base-component'; +import stickyScrolling from '../internal/components/sticky-header/sticky-scrolling'; import { CollectionLabelContext } from '../internal/context/collection-label-context'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; import useBaseComponent from '../internal/hooks/use-base-component'; @@ -29,7 +30,6 @@ import { useSelection, useSelectionFocusMove, } from '../table/selection'; -import stickyScrolling from '../table/sticky-scrolling'; import ToolsHeader from '../table/tools-header'; import { getItemKey } from '../table/utils'; import { diff --git a/src/container/__tests__/sticky-offset.test.ts b/src/container/__tests__/sticky-offset.test.ts deleted file mode 100644 index 2c2f3c9363a..00000000000 --- a/src/container/__tests__/sticky-offset.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { computeOffset } from '../../../lib/components/container/use-sticky-header'; -import * as tokens from '../../../lib/components/internal/generated/styles/tokens'; -import globalVars from '../../../lib/components/internal/styles/global-vars'; - -describe('computeOffset', () => { - test('should calculate offset for mobile outside overflow parent', () => { - const result = computeOffset({ - isMobile: true, - __mobileStickyOffset: 10, - hasInnerOverflowParents: false, - }); - - expect(result).toBe(`calc(var(${globalVars.stickyVerticalTopOffset}, 0px) + -10px + 0px)`); - }); - - test('should calculate offset for mobile inside overflow parent', () => { - const result = computeOffset({ - isMobile: true, - __mobileStickyOffset: 10, - hasInnerOverflowParents: true, - }); - - expect(result).toBe('-10px'); - }); - - test('should apply explicit sticky offset inside overflow parent', () => { - const result = computeOffset({ - isMobile: true, - __stickyOffset: 30, - __mobileStickyOffset: 10, - hasInnerOverflowParents: true, - }); - - expect(result).toBe('20px'); - }); - - test('should calculate offset for non-mobile without inner overflow parents', () => { - const result = computeOffset({ - isMobile: false, - __mobileStickyOffset: 10, - hasInnerOverflowParents: false, - }); - - expect(result).toBe(`calc(var(${globalVars.stickyVerticalTopOffset}, 0px) + 0px + 0px)`); - }); - - test('should calculate include additional offset', () => { - const result = computeOffset({ - isMobile: false, - __mobileStickyOffset: 10, - hasInnerOverflowParents: false, - __additionalOffset: true, - }); - - expect(result).toBe(`calc(var(${globalVars.stickyVerticalTopOffset}, 0px) + 0px + ${tokens.spaceScaledS})`); - }); - - test('should calculate offset for non-mobile with inner overflow parents', () => { - const result = computeOffset({ - isMobile: false, - __mobileStickyOffset: 20, - hasInnerOverflowParents: true, - }); - - expect(result).toBe('0px'); - }); - - test('should calculate offset for non-mobile using __stickyOffset and with inner overflow parents', () => { - const result = computeOffset({ - isMobile: false, - __stickyOffset: 10, - hasInnerOverflowParents: true, - }); - - expect(result).toBe('10px'); - }); - - test('should calculate offset for with __stickyOffset, stickyOffsetTop and __mobileStickyOffset', () => { - const result = computeOffset({ - isMobile: true, - __stickyOffset: 100, - __mobileStickyOffset: 40, - hasInnerOverflowParents: false, - }); - - expect(result).toBe('60px'); - }); - - test('should apply zero sticky offset value', () => { - const result = computeOffset({ - isMobile: false, - __stickyOffset: 0, - hasInnerOverflowParents: false, - }); - - expect(result).toBe('0px'); - }); -}); diff --git a/src/container/use-sticky-header.ts b/src/container/use-sticky-header.ts index 8520bc02e4d..7d0bd858c60 100644 --- a/src/container/use-sticky-header.ts +++ b/src/container/use-sticky-header.ts @@ -1,43 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { createContext, RefObject, useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { createContext, RefObject } from 'react'; -import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; - -import * as tokens from '../internal/generated/styles/tokens'; -import { useMobile } from '../internal/hooks/use-mobile'; -import globalVars from '../internal/styles/global-vars'; -import { getOverflowParents } from '../internal/utils/scrollable-containers'; +import { useStickyPosition } from '../internal/components/sticky-header/use-sticky-position'; interface StickyHeaderContextProps { isStuck: boolean; isStuckAtBottom: boolean; } -interface ComputeOffsetProps { - isMobile: boolean; - __stickyOffset?: number; - __mobileStickyOffset?: number; - hasInnerOverflowParents: boolean; - __additionalOffset?: boolean; -} - -export function computeOffset({ - isMobile, - __stickyOffset, - __mobileStickyOffset, - hasInnerOverflowParents, - __additionalOffset, -}: ComputeOffsetProps): string { - const localOffset = isMobile ? (__stickyOffset ?? 0) - (__mobileStickyOffset ?? 0) : (__stickyOffset ?? 0); - if (hasInnerOverflowParents || __stickyOffset !== undefined) { - return `${localOffset}px`; - } - const globalOffset = `var(${globalVars.stickyVerticalTopOffset}, 0px)`; - - return `calc(${globalOffset} + ${localOffset}px + ${__additionalOffset ? tokens.spaceScaledS : '0px'})`; -} - export const StickyHeaderContext = createContext({ isStuck: false, isStuckAtBottom: false, @@ -51,91 +22,11 @@ export const useStickyHeader = ( __mobileStickyOffset?: number, __disableMobile?: boolean, __additionalOffset = false -) => { - const isMobile = useMobile(); - const disableSticky = isMobile && __disableMobile; - const isSticky = !disableSticky && !!__stickyHeader; - - // 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) { - const overflowParents = getOverflowParents(rootRef.current); - const mainElement = findUpUntil(rootRef.current, elem => elem.tagName === 'MAIN'); - - // In both versions of the app layout, the scrolling element for disableBodyScroll - // is the
. If the closest overflow parent is also the closest
and we have - // offset values, it's safe to assume that it's the app layout scroll root and we - // should stop there. - setHasInnerOverflowParents(overflowParents.length > 0 && overflowParents[0] !== mainElement); - } - }, [rootRef]); - - const computedOffset = computeOffset({ - isMobile, - __stickyOffset, - __mobileStickyOffset, - hasInnerOverflowParents, - __additionalOffset, +) => + useStickyPosition(rootRef, headerRef, { + sticky: __stickyHeader, + offset: __stickyOffset, + mobileOffset: __mobileStickyOffset, + disableMobile: __disableMobile, + additionalOffset: __additionalOffset, }); - - const stickyStyles = isSticky - ? { - style: { - top: computedOffset, - }, - } - : {}; - - // "stuck" state, when the header has moved from its original posititon has a - // box-shadow, applied here by a "header-stuck" className - const checkIfStuck = useCallback( - ({ isTrusted, target, type }) => { - if (type === 'resize' && target === window && !isTrusted) { - // The window size didn't actually change, it was a synthetic event - return; - } - 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); - if (rootTop < headerTop) { - setIsStuck(true); - } 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] - ); - - useEffect(() => { - if (isSticky) { - const controller = new AbortController(); - window.addEventListener('scroll', checkIfStuck, { capture: true, signal: controller.signal }); - window.addEventListener('resize', checkIfStuck, { signal: controller.signal }); - return () => { - controller.abort(); - }; - } - }, [isSticky, checkIfStuck]); - return { - isSticky, - isStuck, - isStuckAtBottom, - stickyStyles, - }; -}; diff --git a/src/internal/components/sticky-header/__tests__/use-cells-resize-observer.test.tsx b/src/internal/components/sticky-header/__tests__/use-cells-resize-observer.test.tsx new file mode 100644 index 00000000000..f287ee63f55 --- /dev/null +++ b/src/internal/components/sticky-header/__tests__/use-cells-resize-observer.test.tsx @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../../../__tests__/render-hook'; +import { useCellsResizeObserver } from '../use-cells-resize-observer'; + +const observers: Array<{ callback: () => void; elements: Set }> = []; +const OriginalResizeObserver = window.ResizeObserver; + +beforeEach(() => { + observers.length = 0; + window.ResizeObserver = class { + private entry: { callback: () => void; elements: Set }; + constructor(callback: () => void) { + this.entry = { callback, elements: new Set() }; + observers.push(this.entry); + } + observe(element: Element) { + this.entry.elements.add(element); + } + unobserve(element: Element) { + this.entry.elements.delete(element); + } + disconnect() { + this.entry.elements.clear(); + } + } as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + window.ResizeObserver = OriginalResizeObserver; +}); + +function resize(element: Element) { + observers.filter(observer => observer.elements.has(element)).forEach(observer => observer.callback()); +} + +function renderCells(initialCells: HTMLElement[]) { + const onResize = jest.fn(); + const { rerender, unmount } = renderHook((cells: HTMLElement[]) => useCellsResizeObserver(() => cells, onResize), { + initialProps: initialCells, + }); + return { onResize, rerender, unmount }; +} + +test('reports a resize of any observed cell', () => { + const cells = [document.createElement('th'), document.createElement('th')]; + const { onResize } = renderCells(cells); + + resize(cells[1]); + + expect(onResize).toHaveBeenCalledTimes(1); +}); + +test('reports resizes of a cell that replaced another between renders', () => { + const first = document.createElement('th'); + const remounted = document.createElement('th'); + const { onResize, rerender } = renderCells([first]); + + rerender([remounted]); + resize(first); + expect(onResize).not.toHaveBeenCalled(); + + resize(remounted); + expect(onResize).toHaveBeenCalledTimes(1); +}); + +test('calls the latest callback', () => { + const cell = document.createElement('th'); + const first = jest.fn(); + const latest = jest.fn(); + const { rerender } = renderHook((onResize: () => void) => useCellsResizeObserver(() => [cell], onResize), { + initialProps: first, + }); + + rerender(latest); + resize(cell); + + expect(first).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledTimes(1); +}); + +test('stops reporting after unmount', () => { + const cell = document.createElement('th'); + const { onResize, unmount } = renderCells([cell]); + + unmount(); + resize(cell); + + expect(onResize).not.toHaveBeenCalled(); +}); diff --git a/src/internal/components/sticky-header/__tests__/use-sticky-header-copy.test.tsx b/src/internal/components/sticky-header/__tests__/use-sticky-header-copy.test.tsx new file mode 100644 index 00000000000..68eecba14bf --- /dev/null +++ b/src/internal/components/sticky-header/__tests__/use-sticky-header-copy.test.tsx @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../../../__tests__/render-hook'; +import { useMobile } from '../../../hooks/use-mobile'; +import { useStickyHeaderCopy } from '../use-sticky-header-copy'; + +jest.mock('../../../hooks/use-mobile', () => ({ + useMobile: jest.fn().mockReturnValue(false), +})); + +function elementAt(top: number, height = 40) { + const element = document.createElement('div'); + element.getBoundingClientRect = () => + ({ top, bottom: top + height, left: 0, right: 0, width: 0, height, x: 0, y: top }) as DOMRect; + document.body.appendChild(element); + return element; +} + +function renderStickyHeaderCopy({ theadTop, secondaryTheadTop }: { theadTop: number; secondaryTheadTop: number }) { + const refs = { + tableRef: { current: elementAt(0) }, + theadRef: { current: elementAt(theadTop) }, + secondaryTheadRef: { current: elementAt(secondaryTheadTop) }, + secondaryTableRef: { current: elementAt(0) }, + tableWrapperRef: { current: elementAt(0) }, + }; + return { refs, ...renderHook(() => useStickyHeaderCopy(refs)) }; +} + +beforeEach(() => { + window.scrollTo = jest.fn(); + jest.mocked(useMobile).mockReturnValue(false); +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +test('pulls the table wrapper up by the header height', () => { + const { refs } = renderStickyHeaderCopy({ theadTop: 0, secondaryTheadTop: 0 }); + expect(refs.tableWrapperRef.current.style.marginBlockStart).toBe('-40px'); +}); + +describe('scrollToTop', () => { + test('scrolls up by the distance between the header and its sticky copy', () => { + const { result } = renderStickyHeaderCopy({ theadTop: 100, secondaryTheadTop: 250 }); + result.current.scrollToTop(); + expect(window.scrollTo).toHaveBeenCalledWith({ top: window.pageYOffset - 150 }); + }); + + test('does not scroll when the header is already in view', () => { + const { result } = renderStickyHeaderCopy({ theadTop: 250, secondaryTheadTop: 250 }); + result.current.scrollToTop(); + expect(window.scrollTo).not.toHaveBeenCalled(); + }); + + test('does not scroll on mobile', () => { + jest.mocked(useMobile).mockReturnValue(true); + const { result } = renderStickyHeaderCopy({ theadTop: 100, secondaryTheadTop: 250 }); + result.current.scrollToTop(); + expect(window.scrollTo).not.toHaveBeenCalled(); + }); +}); + +describe('scrollToRow', () => { + test('scrolls a row hidden under the copy back into view', () => { + const { result } = renderStickyHeaderCopy({ theadTop: 0, secondaryTheadTop: 100 }); + result.current.scrollToRow(elementAt(120)); + expect(window.scrollTo).toHaveBeenCalledWith({ top: window.pageYOffset - 20 }); + }); + + test('does not scroll on mobile', () => { + jest.mocked(useMobile).mockReturnValue(true); + const { result } = renderStickyHeaderCopy({ theadTop: 0, secondaryTheadTop: 100 }); + result.current.scrollToRow(elementAt(120)); + expect(window.scrollTo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/internal/components/sticky-header/__tests__/use-sticky-position.test.tsx b/src/internal/components/sticky-header/__tests__/use-sticky-position.test.tsx new file mode 100644 index 00000000000..b30691a0f5f --- /dev/null +++ b/src/internal/components/sticky-header/__tests__/use-sticky-position.test.tsx @@ -0,0 +1,112 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../../../__tests__/render-hook'; +import * as tokens from '../../../generated/styles/tokens'; +import { useMobile } from '../../../hooks/use-mobile'; +import globalVars from '../../../styles/global-vars'; +import { useStickyPosition } from '../use-sticky-position'; + +jest.mock('../../../hooks/use-mobile', () => ({ + useMobile: jest.fn().mockReturnValue(false), +})); + +const globalOffset = `var(${globalVars.stickyVerticalTopOffset}, 0px)`; + +afterEach(() => { + document.body.innerHTML = ''; + jest.mocked(useMobile).mockReturnValue(false); +}); + +describe('sticky top offset', () => { + test('is not applied when the header is not sticky', () => { + expect(stickyStylesFor({ sticky: false })).toEqual({}); + }); + + test('sits below the global offset on mobile, moved up by the mobile offset', () => { + expect(stickyTopFor({ mobile: true, mobileOffset: 10 })).toBe(`calc(${globalOffset} + -10px + 0px)`); + }); + + test('ignores the mobile offset outside mobile', () => { + expect(stickyTopFor({ mobileOffset: 10 })).toBe(`calc(${globalOffset} + 0px + 0px)`); + }); + + test('adds the additional offset', () => { + expect(stickyTopFor({ mobileOffset: 10, additionalOffset: true })).toBe( + `calc(${globalOffset} + 0px + ${tokens.spaceScaledS})` + ); + }); + + test('uses an explicit offset instead of the global offset', () => { + expect(stickyTopFor({ mobile: true, offset: 100, mobileOffset: 40 })).toBe('60px'); + }); + + test('uses an explicit zero offset instead of the global offset', () => { + expect(stickyTopFor({ offset: 0 })).toBe('0px'); + }); + + describe('inside a scrolling parent', () => { + test('drops the global offset', () => { + expect(stickyTopFor({ mobileOffset: 20, parent: 'scrolling-div' })).toBe('0px'); + }); + + test('drops the global offset on mobile, keeping the mobile offset', () => { + expect(stickyTopFor({ mobile: true, mobileOffset: 10, parent: 'scrolling-div' })).toBe('-10px'); + }); + + test('applies an explicit offset', () => { + expect(stickyTopFor({ offset: 10, parent: 'scrolling-div' })).toBe('10px'); + }); + + test('applies an explicit offset on mobile, moved up by the mobile offset', () => { + expect(stickyTopFor({ mobile: true, offset: 30, mobileOffset: 10, parent: 'scrolling-div' })).toBe('20px'); + }); + }); + + test('keeps the global offset when the scrolling parent is the app layout
', () => { + expect(stickyTopFor({ parent: 'scrolling-main' })).toBe(`calc(${globalOffset} + 0px + 0px)`); + }); +}); + +interface Scenario { + sticky?: boolean; + mobile?: boolean; + offset?: number; + mobileOffset?: number; + additionalOffset?: boolean; + parent?: 'none' | 'scrolling-div' | 'scrolling-main'; +} + +function stickyTopFor(scenario: Omit) { + return stickyStylesFor({ ...scenario, sticky: true }).style?.top; +} + +function stickyStylesFor({ + sticky, + mobile = false, + offset, + mobileOffset, + additionalOffset, + parent = 'none', +}: Scenario) { + jest.mocked(useMobile).mockReturnValue(mobile); + const root = document.createElement('div'); + const header = document.createElement('div'); + root.appendChild(header); + mountIn(root, parent); + + const { result } = renderHook(() => + useStickyPosition({ current: root }, { current: header }, { sticky, offset, mobileOffset, additionalOffset }) + ); + return result.current.stickyStyles; +} + +function mountIn(root: HTMLElement, parent: Scenario['parent']) { + if (parent === 'none') { + document.body.appendChild(root); + return; + } + const scrollingParent = document.createElement(parent === 'scrolling-main' ? 'main' : 'div'); + scrollingParent.style.overflow = 'auto'; + scrollingParent.appendChild(root); + document.body.appendChild(scrollingParent); +} diff --git a/src/internal/components/sticky-header/mixins.scss b/src/internal/components/sticky-header/mixins.scss new file mode 100644 index 00000000000..f83fd0e588c --- /dev/null +++ b/src/internal/components/sticky-header/mixins.scss @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// The sticky copy scrolls horizontally in step with the body, which owns the visible scrollbar. +@mixin copy-scroller { + overflow: auto; + scrollbar-width: none; /* Firefox */ + &::-webkit-scrollbar { + display: none; /* Safari and Chrome */ + } +} diff --git a/src/table/sticky-scrolling.ts b/src/internal/components/sticky-header/sticky-scrolling.ts similarity index 86% rename from src/table/sticky-scrolling.ts rename to src/internal/components/sticky-header/sticky-scrolling.ts index 6f876f63c29..a2100cc0a33 100644 --- a/src/table/sticky-scrolling.ts +++ b/src/internal/components/sticky-header/sticky-scrolling.ts @@ -2,13 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal'; -import { getOverflowParents } from '../internal/utils/scrollable-containers'; +import { getOverflowParents } from '../../utils/scrollable-containers'; /** * @param containerRef ref to surrounding container with sticky element * @param stickyRef ref to sticky element scrolled inside of containerRef - * @param containerOffset offset between header and container - * originating borders or paddings */ export default function stickyScrolling( containerRef: React.MutableRefObject, @@ -44,12 +42,9 @@ export default function stickyScrolling( } /** - * Calculates the scrolling offset between container and - * sticky element with container offset caused by border - * or padding + * Calculates the scrolling offset between container and sticky element * @param container * @param sticky element inside of container - * @param containerOffset caused by borders or paddings */ export function calculateScrollingOffset(container: HTMLElement, sticky: HTMLElement) { const stickyRect = getLogicalBoundingClientRect(sticky); diff --git a/src/internal/components/sticky-header/use-cells-resize-observer.ts b/src/internal/components/sticky-header/use-cells-resize-observer.ts new file mode 100644 index 00000000000..7a23e65e7dd --- /dev/null +++ b/src/internal/components/sticky-header/use-cells-resize-observer.ts @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useLayoutEffect, useRef } from 'react'; + +import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; + +export function useCellsResizeObserver(getCells: () => ReadonlyArray, onResize: () => void) { + const stableOnResize = useStableCallback(onResize); + const observerRef = useRef(null); + const observedCellsRef = useRef>([]); + + // Cells can be replaced on any render, so the observed set is checked after each one. + useLayoutEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return; + } + const cells = getCells(); + const observedCells = observedCellsRef.current; + if (haveSameElements(cells, observedCells)) { + return; + } + if (!observerRef.current) { + observerRef.current = new ResizeObserver(() => stableOnResize()); + } + const observer = observerRef.current; + observedCells.filter(cell => !cells.includes(cell)).forEach(cell => observer.unobserve(cell)); + cells.filter(cell => !observedCells.includes(cell)).forEach(cell => observer.observe(cell)); + observedCellsRef.current = cells; + }); + + useEffect(() => { + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + observedCellsRef.current = []; + }; + }, []); +} + +function haveSameElements(a: ReadonlyArray, b: ReadonlyArray) { + return a.length === b.length && a.every((element, index) => element === b[index]); +} diff --git a/src/internal/components/sticky-header/use-header-stuck.ts b/src/internal/components/sticky-header/use-header-stuck.ts new file mode 100644 index 00000000000..cfbb367e60f --- /dev/null +++ b/src/internal/components/sticky-header/use-header-stuck.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { RefObject, useCallback, useEffect, useState } from 'react'; + +interface UseHeaderStuckResult { + isStuck: boolean; + isStuckAtBottom: boolean; +} + +export function useHeaderStuck( + rootRef: RefObject, + headerRef: RefObject, + isSticky: boolean +): UseHeaderStuckResult { + const [isStuck, setIsStuck] = useState(false); + const [isStuckAtBottom, setIsStuckAtBottom] = useState(false); + + const checkIfStuck = useCallback( + ({ isTrusted, target, type }) => { + if (type === 'resize' && target === window && !isTrusted) { + // The window size didn't actually change, it was a synthetic event + return; + } + 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); + setIsStuck(rootTop < headerTop); + + const rootBottom = Math.round(rootRef.current.getBoundingClientRect().bottom - rootTopBorderWidth); + const headerBottom = Math.round(headerRef.current.getBoundingClientRect().bottom); + setIsStuckAtBottom(rootBottom <= headerBottom); + } + }, + [rootRef, headerRef] + ); + + useEffect(() => { + if (isSticky) { + const controller = new AbortController(); + window.addEventListener('scroll', checkIfStuck, { capture: true, signal: controller.signal }); + window.addEventListener('resize', checkIfStuck, { signal: controller.signal }); + return () => { + controller.abort(); + }; + } + }, [isSticky, checkIfStuck]); + + return { isStuck, isStuckAtBottom }; +} diff --git a/src/table/use-sticky-header.ts b/src/internal/components/sticky-header/use-sticky-header-copy.ts similarity index 72% rename from src/table/use-sticky-header.ts rename to src/internal/components/sticky-header/use-sticky-header-copy.ts index bbf516ece3d..94239eeb668 100644 --- a/src/table/use-sticky-header.ts +++ b/src/internal/components/sticky-header/use-sticky-header-copy.ts @@ -4,19 +4,26 @@ import { RefObject, useCallback, useLayoutEffect } from 'react'; import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; -import { useMobile } from '../internal/hooks/use-mobile'; +import { useMobile } from '../../hooks/use-mobile'; import stickyScrolling, { calculateScrollingOffset, scrollUpBy } from './sticky-scrolling'; -export const useStickyHeader = ( - tableRef: RefObject, - theadRef: RefObject, - secondaryTheadRef: RefObject, - secondaryTableRef: RefObject, - tableWrapperRef: RefObject -) => { +interface StickyHeaderCopyProps { + tableRef: RefObject; + theadRef: RefObject; + secondaryTheadRef: RefObject; + secondaryTableRef: RefObject; + tableWrapperRef: RefObject; +} + +export function useStickyHeaderCopy({ + tableRef, + theadRef, + secondaryTheadRef, + secondaryTableRef, + tableWrapperRef, +}: StickyHeaderCopyProps) { const isMobile = useMobile(); - // Sync the sizes of the column header copies in the sticky header with the originals - const syncColumnHeaderWidths = useCallback(() => { + const hideHeaderUnderCopy = useCallback(() => { if ( tableRef.current && theadRef.current && @@ -30,9 +37,9 @@ export const useStickyHeader = ( } }, [theadRef, secondaryTheadRef, secondaryTableRef, tableWrapperRef, tableRef]); useLayoutEffect(() => { - syncColumnHeaderWidths(); + hideHeaderUnderCopy(); }); - useResizeObserver(theadRef, syncColumnHeaderWidths); + useResizeObserver(theadRef, hideHeaderUnderCopy); const scrollToTop = () => { if (!isMobile && theadRef.current && secondaryTheadRef.current && tableWrapperRef.current) { const scrollDist = calculateScrollingOffset(theadRef.current, secondaryTheadRef.current); @@ -48,4 +55,4 @@ export const useStickyHeader = ( } }; return { scrollToRow, scrollToTop }; -}; +} diff --git a/src/internal/components/sticky-header/use-sticky-position.ts b/src/internal/components/sticky-header/use-sticky-position.ts new file mode 100644 index 00000000000..4514c3295b0 --- /dev/null +++ b/src/internal/components/sticky-header/use-sticky-position.ts @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { RefObject, useLayoutEffect, useState } from 'react'; + +import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; + +import * as tokens from '../../generated/styles/tokens'; +import { useMobile } from '../../hooks/use-mobile'; +import globalVars from '../../styles/global-vars'; +import { getOverflowParents } from '../../utils/scrollable-containers'; +import { useHeaderStuck } from './use-header-stuck'; + +interface ComputeOffsetProps { + isMobile: boolean; + __stickyOffset?: number; + __mobileStickyOffset?: number; + hasInnerOverflowParents: boolean; + __additionalOffset?: boolean; +} + +function computeOffset({ + isMobile, + __stickyOffset, + __mobileStickyOffset, + hasInnerOverflowParents, + __additionalOffset, +}: ComputeOffsetProps): string { + const localOffset = isMobile ? (__stickyOffset ?? 0) - (__mobileStickyOffset ?? 0) : (__stickyOffset ?? 0); + if (hasInnerOverflowParents || __stickyOffset !== undefined) { + return `${localOffset}px`; + } + const globalOffset = `var(${globalVars.stickyVerticalTopOffset}, 0px)`; + + return `calc(${globalOffset} + ${localOffset}px + ${__additionalOffset ? tokens.spaceScaledS : '0px'})`; +} + +interface StickyPositionOptions { + sticky?: boolean; + offset?: number; + mobileOffset?: number; + disableMobile?: boolean; + additionalOffset?: boolean; +} + +export function useStickyPosition( + rootRef: RefObject, + headerRef: RefObject, + { sticky, offset, mobileOffset, disableMobile, additionalOffset = false }: StickyPositionOptions +) { + const isMobile = useMobile(); + const disableSticky = isMobile && disableMobile; + const isSticky = !disableSticky && !!sticky; + + // If it has overflow parents inside the app layout, we shouldn't apply a sticky offset. + const [hasInnerOverflowParents, setHasInnerOverflowParents] = useState(false); + + useLayoutEffect(() => { + if (rootRef.current) { + const overflowParents = getOverflowParents(rootRef.current); + const mainElement = findUpUntil(rootRef.current, elem => elem.tagName === 'MAIN'); + + // In both versions of the app layout, the scrolling element for disableBodyScroll + // is the
. If the closest overflow parent is also the closest
and we have + // offset values, it's safe to assume that it's the app layout scroll root and we + // should stop there. + setHasInnerOverflowParents(overflowParents.length > 0 && overflowParents[0] !== mainElement); + } + }, [rootRef]); + + const computedOffset = computeOffset({ + isMobile, + __stickyOffset: offset, + __mobileStickyOffset: mobileOffset, + hasInnerOverflowParents, + __additionalOffset: additionalOffset, + }); + + const stickyStyles = isSticky + ? { + style: { + top: computedOffset, + }, + } + : {}; + + const { isStuck, isStuckAtBottom } = useHeaderStuck(rootRef, headerRef, isSticky); + + return { + isSticky, + isStuck, + isStuckAtBottom, + stickyStyles, + }; +} diff --git a/src/table/sticky-header.tsx b/src/table/sticky-header.tsx index c7b2616a9e4..db7f8cc19d9 100644 --- a/src/table/sticky-header.tsx +++ b/src/table/sticky-header.tsx @@ -4,12 +4,12 @@ import React, { forwardRef, useContext, useImperativeHandle, useRef, useState } import clsx from 'clsx'; import { StickyHeaderContext } from '../container/use-sticky-header'; +import { useStickyHeaderCopy } from '../internal/components/sticky-header/use-sticky-header-copy'; import { getVisualContextClassname } from '../internal/components/visual-context'; import { TableColGroup } from './column-groups/col-group'; import { TableProps } from './interfaces'; import { getTableRoleProps, TableRole } from './table-role'; import Thead, { TheadProps } from './thead'; -import { useStickyHeader } from './use-sticky-header'; import styles from './styles.css.js'; @@ -60,13 +60,13 @@ function StickyHeader( const { isStuck } = useContext(StickyHeaderContext); const [focusedComponent, setFocusedComponent] = useState(null); - const { scrollToRow, scrollToTop } = useStickyHeader( + const { scrollToRow, scrollToTop } = useStickyHeaderCopy({ tableRef, theadRef, secondaryTheadRef, secondaryTableRef, - wrapperRef - ); + tableWrapperRef: wrapperRef, + }); useImperativeHandle(ref, () => ({ scrollToTop, diff --git a/src/table/styles.scss b/src/table/styles.scss index 886644833e3..d8bda10fd09 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -9,6 +9,7 @@ @use '../container/shared' as container; @use '../internal/generated/custom-css-properties/index.scss' as custom-props; @use '../internal/styles/typography/constants.scss' as constants; +@use '../internal/components/sticky-header/mixins' as sticky-header; .root { @include styles.default-text-style; @@ -164,8 +165,7 @@ filter search icon. } .header-secondary { - overflow: auto; - scrollbar-width: none; /* Firefox */ + @include sticky-header.copy-scroller; box-sizing: border-box; inline-size: 100%; border-start-start-radius: 0; @@ -187,9 +187,6 @@ filter search icon. border-start-end-radius: awsui.$border-radius-container; } } - &::-webkit-scrollbar { - display: none; /* Safari and Chrome */ - } &.table-has-header { border-block-start: awsui.$border-divider-list-width solid awsui.$color-border-container-divider; } diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index bc515df887f..7591adccb29 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -5,6 +5,7 @@ import React, { createContext, useContext, useEffect, useRef, useState } from 'r import { useResizeObserver, useStableCallback } from '@cloudscape-design/component-toolkit/internal'; import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal'; +import { useCellsResizeObserver } from '../internal/components/sticky-header/use-cells-resize-observer'; import { ColumnWidthStyle, setElementWidths } from './column-widths-utils'; export const DEFAULT_COLUMN_WIDTH = 120; @@ -216,22 +217,9 @@ export function ColumnWidthsProvider({ syncStickyColumnWidths(); }); - // Re-syncs the sticky header copies when the primary cells change width without a - // corresponding change to the column definitions. This happens with auto-layout - // columns whose widths depend on the rendered content (e.g. paginating or filtering). - // Fixed-width and resizable columns don't resize on data changes, so this stays idle. - // The effect re-observes when the set of visible columns changes, since that swaps - // the underlying cell elements. - useEffect(() => { - if (typeof ResizeObserver === 'undefined') { - return; - } - const observer = new ResizeObserver(() => syncStickyColumnWidths()); - for (const element of cellsRef.current.values()) { - observer.observe(element); - } - return () => observer.disconnect(); - }, [syncStickyColumnWidths, visibleColumns]); + // Auto-layout columns change width with their content (e.g. paginating or filtering) + // without any change to the column definitions. + useCellsResizeObserver(() => Array.from(cellsRef.current.values()), syncStickyColumnWidths); // Observes container size and requests an update to the last cell width as it depends on the container's width. useResizeObserver(containerRef, ({ contentBoxWidth: containerWidth }) => {