Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cards/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down
100 changes: 0 additions & 100 deletions src/container/__tests__/sticky-offset.test.ts

This file was deleted.

127 changes: 9 additions & 118 deletions src/container/use-sticky-header.ts
Original file line number Diff line number Diff line change
@@ -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<StickyHeaderContextProps>({
isStuck: false,
isStuckAtBottom: false,
Expand All @@ -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 <main>. If the closest overflow parent is also the closest <main> 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,
};
};
Original file line number Diff line number Diff line change
@@ -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<Element> }> = [];
const OriginalResizeObserver = window.ResizeObserver;

beforeEach(() => {
observers.length = 0;
window.ResizeObserver = class {
private entry: { callback: () => void; elements: Set<Element> };
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();
});
Loading
Loading