Skip to content
Closed
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
31 changes: 9 additions & 22 deletions packages/gamut/src/List/List.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DotLoose } from '@codecademy/gamut-patterns';
import { timingValues } from '@codecademy/gamut-styles';
import isArray from 'lodash/isArray';
import { ComponentProps, forwardRef, useEffect, useRef, useState } from 'react';
import { ComponentProps, forwardRef, useEffect } from 'react';
import * as React from 'react';

import { Box, BoxProps, FlexBox } from '../Box';
Expand All @@ -12,6 +12,7 @@ import {
shadowVariant,
StaticListWrapper,
} from './elements';
import { useScrollabilityCheck } from './hooks';
import { ListProvider, useList } from './ListProvider';
import { AllListProps } from './types';

Expand Down Expand Up @@ -72,11 +73,6 @@ export const List = forwardRef<HTMLUListElement, ListProps>(
const isEmpty = !children || (isArray(children) && children.length === 0);
const isTable = as === 'table';

const [isEnd, setIsEnd] = useState(false);
const ListWrapper = shadow ? AnimatedListWrapper : StaticListWrapper;
const showShadow = shadow && scrollable && !isEnd && !isEmpty;
const animationVar = showShadow ? 'shadow' : 'hidden';

const value = useList({
listType: as,
rowBreakpoint,
Expand All @@ -85,16 +81,12 @@ export const List = forwardRef<HTMLUListElement, ListProps>(
variant,
});

const wrapperRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const wrapperWidth =
wrapperRef?.current?.getBoundingClientRect()?.width ?? 0;
const tableWidth = tableRef?.current?.getBoundingClientRect().width ?? 0;
const { isEnd, tableRef, setWrapperRef, handleScroll } =
useScrollabilityCheck({ shadow, scrollable, children, loading, isEmpty });

setIsEnd(tableWidth < wrapperWidth);
}, []);
const ListWrapper = shadow ? AnimatedListWrapper : StaticListWrapper;
const showShadow = shadow && scrollable && !isEnd && !isEmpty;
const animationVar = showShadow ? 'shadow' : 'hidden';

useEffect(() => {
if (scrollToTopOnUpdate && tableRef.current !== null) {
Expand All @@ -113,11 +105,6 @@ export const List = forwardRef<HTMLUListElement, ListProps>(
</ListEl>
);

const scrollHandler = (event: React.UIEvent<HTMLDivElement>) => {
const { offsetWidth, scrollLeft, scrollWidth } = event.currentTarget;
setIsEnd(offsetWidth + Math.ceil(scrollLeft) >= scrollWidth);
};

const listContents = (
<>
{header}
Expand Down Expand Up @@ -153,7 +140,7 @@ export const List = forwardRef<HTMLUListElement, ListProps>(
maxHeight={height}
overflow={overflow}
position="relative"
ref={wrapperRef}
ref={setWrapperRef}
transition={{
background: { duration: timingValues.fast, ease: 'easeInOut' },
}}
Expand All @@ -162,7 +149,7 @@ export const List = forwardRef<HTMLUListElement, ListProps>(
shadow: shadowVariant,
}}
width={1}
onScroll={scrollable ? scrollHandler : undefined}
onScroll={scrollable ? handleScroll : undefined}
>
<Box
as={isTable && !isEmpty && !loading ? 'table' : 'div'}
Expand Down
137 changes: 137 additions & 0 deletions packages/gamut/src/List/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react';

import type { ListProps } from './List';

const checkIsAtScrollEnd = (wrapper: HTMLDivElement): boolean => {
const { offsetWidth, scrollLeft, scrollWidth } = wrapper;
const isAtHorizontalEnd = offsetWidth + Math.ceil(scrollLeft) >= scrollWidth;
const hasNoHorizontalScroll = scrollWidth <= offsetWidth;

return hasNoHorizontalScroll || isAtHorizontalEnd;
};

type ScrollabilityCheckParams = Pick<
ListProps,
'shadow' | 'scrollable' | 'children' | 'loading'
> & {
isEmpty: boolean;
};

/**
* Custom hook to manage scrollability state for shadow indicators.
* Only runs when shadow and scrollable props are enabled for performance.
*
* @param params - Parameters from ListProps needed for scrollability checking
* @returns Object containing isEnd state and refs for wrapper and table elements
*/
export const useScrollabilityCheck = ({
shadow = false,
scrollable = false,
children,
loading,
isEmpty,
}: ScrollabilityCheckParams) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLDivElement>(null);

const needsScrollabilityCheck = shadow && scrollable;

const [isEnd, setIsEnd] = useState(true);

const checkScrollability = useCallback(() => {
if (!needsScrollabilityCheck) {
return;
}

const wrapper = wrapperRef?.current;

if (!wrapper) {
setIsEnd(false);
return;
}

setIsEnd(checkIsAtScrollEnd(wrapper));
}, [needsScrollabilityCheck]);

// Helper to check scrollability after next frame (for DOM readiness)
const checkScrollAvailable = useCallback(() => {
requestAnimationFrame(() => {
checkScrollability();
});
}, [checkScrollability]);

const setWrapperRef = useCallback(
(node: HTMLDivElement | null) => {
(wrapperRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
if (node && needsScrollabilityCheck) {
checkScrollAvailable();
}
},
[needsScrollabilityCheck, checkScrollAvailable]
);

const handleResize = useCallback(() => {
checkScrollability();
}, [checkScrollability]);

const resizeObserverCallback = useCallback(() => {
checkScrollAvailable();
}, [checkScrollAvailable]);

useEffect(() => {
if (!needsScrollabilityCheck) {
return;
}

checkScrollAvailable();

window.addEventListener('resize', handleResize, { passive: true });

let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(resizeObserverCallback);

if (wrapperRef.current) {
resizeObserver.observe(wrapperRef.current);
}
if (tableRef.current) {
resizeObserver.observe(tableRef.current);
}
}

return () => {
window.removeEventListener('resize', handleResize);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [
children,
loading,
isEmpty,
needsScrollabilityCheck,
checkScrollability,
handleResize,
resizeObserverCallback,
checkScrollAvailable,
]);

const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (!needsScrollabilityCheck) {
return;
}
const { offsetWidth, scrollLeft, scrollWidth } = event.currentTarget;
setIsEnd(offsetWidth + Math.ceil(scrollLeft) >= scrollWidth);
},
[needsScrollabilityCheck]
);

return {
isEnd,
tableRef,
setWrapperRef,
handleScroll,
};
};
5 changes: 0 additions & 5 deletions packages/gamut/src/Modals/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@ export interface DialogProps extends ModalBaseProps, CloseButtonProps {
>;
confirmCta: DialogButtonProps;
cancelCta?: DialogButtonProps;
/**
* TEMPORARY: a stopgap solution to avoid zIndex conflicts -
* will be reworked with: GM-624
*/
zIndex?: number;
}

export const Dialog: React.FC<DialogProps> = ({
Expand Down
5 changes: 0 additions & 5 deletions packages/gamut/src/Modals/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,6 @@ export interface MultiViewModalProps
* Optional array of multiple screens
*/
views: ModalViewProps[];
/**
* TEMPORARY: a stopgap solution to avoid zIndex conflicts -
* will be reworked with: GM-624
*/
zIndex?: number;
}

export type ModalProps = SingleViewModalProps | MultiViewModalProps;
Expand Down
1 change: 1 addition & 0 deletions packages/gamut/src/Modals/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface ModalOverlayProps
| 'clickOutsideCloses'
| 'escapeCloses'
| 'shroud'
| 'zIndex'
> {}

export interface ModalBaseProps
Expand Down
6 changes: 3 additions & 3 deletions packages/gamut/src/Overlay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export type OverlayProps = {
/** Whether the overlay allows scroll */
allowScroll?: boolean;
/**
* TEMPORARY: a stopgap solution to avoid zIndex conflicts -
* will be reworked with: GM-624
* z-index for the Overlay. Defaults to 3 to appear above common UI elements
* like headers . Can be overridden when needed for custom stacking orders.
*/
zIndex?: number;
};
Expand All @@ -61,7 +61,7 @@ export const Overlay: React.FC<OverlayProps> = ({
onRequestClose,
isOpen,
allowScroll = false,
zIndex = 1,
zIndex = 3,
}) => {
const handleOutsideClick = useCallback(() => {
if (clickOutsideCloses) {
Expand Down
14 changes: 10 additions & 4 deletions packages/gamut/src/PopoverContainer/PopoverContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { BodyPortal } from '../BodyPortal';
import { FocusTrap } from '../FocusTrap';
import { useResizingParentEffect, useScrollingParentsEffect } from './hooks';
import { ContainerState, PopoverContainerProps } from './types';
import { getContainers, getPosition, isInView } from './utils';
import { getContainers, getPosition, isOutOfView } from './utils';

const PopoverContent = styled.div(
variance.compose(
Expand All @@ -37,6 +37,7 @@ export const PopoverContainer: React.FC<PopoverContainerProps> = ({
onRequestClose,
targetRef,
allowPageInteraction,
closeOnViewportExit = false,
...rest
}) => {
const popoverRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -98,17 +99,22 @@ export const PopoverContainer: React.FC<PopoverContainerProps> = ({
useResizingParentEffect(targetRef, setTargetRect);

useIsomorphicLayoutEffect(() => {
if (!closeOnViewportExit) return;

if (
containers?.viewport &&
!isInView(containers?.viewport) &&
isOutOfView(containers?.viewport, targetRef?.current as HTMLElement) &&
!hasRequestedCloseRef.current
) {
hasRequestedCloseRef.current = true;
onRequestClose?.();
} else if (containers?.viewport && isInView(containers?.viewport)) {
} else if (
containers?.viewport &&
!isOutOfView(containers?.viewport, targetRef?.current as HTMLElement)
) {
hasRequestedCloseRef.current = false;
}
}, [containers?.viewport, onRequestClose]);
}, [containers?.viewport, onRequestClose, targetRef, closeOnViewportExit]);

/**
* Allows targetRef to be or contain a button that toggles the popover open and closed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,21 +120,22 @@ describe('Popover', () => {
expect(onRequestClose).toBeCalledTimes(1);
});

it('triggers onRequestClose callback when popover is out of viewport', () => {
/* element is inside the viewport if the top and left value is greater than or equal to 0,
and right value is less than or equal to window.innerWidth
and bottom value is less than or equal to window.innerHeight */
const targetRefObj = mockTargetRef({}, { top: -1, x: 41, y: -1 });
it('triggers onRequestClose callback when popover is out of viewport and closeOnViewportExit is true', () => {
const targetRefObj = mockTargetRef(
{},
{ top: -201, bottom: -1, left: 150, right: 350, x: 150, y: -201 }
);

const onRequestClose = jest.fn();
renderView({
targetRef: targetRefObj,
onRequestClose,
closeOnViewportExit: true,
});
expect(onRequestClose).toBeCalledTimes(1);
});

it('does not onRequestClose callback when popover is out of viewport', () => {
it('does not trigger onRequestClose callback when popover is in viewport', () => {
const targetRefObj = mockTargetRef({}, { top: 1, x: 41, y: 1 });

const onRequestClose = jest.fn();
Expand All @@ -145,6 +146,35 @@ describe('Popover', () => {
expect(onRequestClose).toBeCalledTimes(0);
});

it('does not trigger onRequestClose callback when closeOnViewportExit is false (default)', () => {
const targetRefObj = mockTargetRef(
{},
{ top: -201, bottom: -1, left: 150, right: 350, x: 150, y: -201 }
);

const onRequestClose = jest.fn();
renderView({
targetRef: targetRefObj,
onRequestClose,
});
expect(onRequestClose).toBeCalledTimes(0);
});

it('triggers onRequestClose callback when closeOnViewportExit is true', () => {
const targetRefObj = mockTargetRef(
{},
{ top: -201, bottom: -1, left: 150, right: 350, x: 150, y: -201 }
);

const onRequestClose = jest.fn();
renderView({
targetRef: targetRefObj,
onRequestClose,
closeOnViewportExit: true,
});
expect(onRequestClose).toBeCalledTimes(1);
});

describe('alignments', () => {
describe('render context', () => {
describe('portal - viewport', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/gamut/src/PopoverContainer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,9 @@ export interface PopoverContainerProps
* If true, it will allow outside page interaction. Popover container will still close when clicking outside of the popover or hitting the escape key.
*/
allowPageInteraction?: boolean;
/**
* If true, the popover will automatically close when the target element moves out of viewport.
* Defaults to false.
*/
closeOnViewportExit?: boolean;
}
Loading
Loading