Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

🚀 Feat: Virtual List and Modal Update Feature Implemented #1570

Merged
merged 5 commits into from
Feb 10, 2025
Merged
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
25 changes: 22 additions & 3 deletions assets/react/v3/shared/atoms/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,29 @@ interface SkeletonProps extends React.HTMLAttributes<HTMLSpanElement> {
animation?: boolean;
isMagicAi?: boolean;
isRound?: boolean;
animationDuration?: number;
}

const Skeleton = forwardRef<HTMLSpanElement, SkeletonProps>(
({ width = '100%', height = 16, animation = false, isMagicAi = false, isRound = false, className }, ref) => {
return <span ref={ref} css={styles.skeleton(width, height, animation, isMagicAi, isRound)} className={className} />;
(
{
width = '100%',
height = 16,
animation = false,
isMagicAi = false,
isRound = false,
animationDuration = 1.6,
className,
},
ref,
) => {
return (
<span
ref={ref}
css={styles.skeleton(width, height, animation, isMagicAi, isRound, animationDuration)}
className={className}
/>
);
},
);

Expand All @@ -40,6 +58,7 @@ const styles = {
animation: boolean,
isMagicAi: boolean,
isRound: boolean,
animationDuration: number,
) => css`
display: block;
width: ${isNumber(width) ? `${width}px` : width};
Expand Down Expand Up @@ -68,7 +87,7 @@ const styles = {
background: linear-gradient(89.17deg, #fef4ff 0.2%, #f9d3ff 50.09%, #fef4ff 96.31%);
`}

animation: 1.6s linear 0.5s infinite normal none running ${animations.wave};
animation: ${animationDuration}s linear 0.5s infinite normal none running ${animations.wave};
}
`}
`,
Expand Down
101 changes: 101 additions & 0 deletions assets/react/v3/shared/atoms/VirtualList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { css } from '@emotion/react';
import { styleUtils } from '@TutorShared/utils/style-utils';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface VirtualListProps<T> {
items: T[];
height: number;
itemHeight: number;
renderItem: (item: T, index: number, style: React.CSSProperties) => React.ReactNode;
}

const DEFAULT_ITEM_HEIGHT = 40;
const DEFAULT_BUFFER = 8;

const VirtualList = <T,>({ items, height, itemHeight = DEFAULT_ITEM_HEIGHT, renderItem }: VirtualListProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const scrollingTimeoutRef = useRef<number>();

useEffect(() => {
b-l-i-n-d marked this conversation as resolved.
Show resolved Hide resolved
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}

return () => {
if (scrollingTimeoutRef.current) {
cancelAnimationFrame(scrollingTimeoutRef.current);
}
};
}, []);

const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
if (!target) return;

if (scrollingTimeoutRef.current) {
cancelAnimationFrame(scrollingTimeoutRef.current);
}

scrollingTimeoutRef.current = requestAnimationFrame(() => {
setScrollTop(target.scrollTop);
});
}, []);

const { visibleItems, startIndex, totalHeight } = useMemo(() => {
const buffer = DEFAULT_BUFFER;
const startIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
const visibleCount = Math.ceil(height / itemHeight) + buffer * 2;
const endIdx = Math.min(startIdx + visibleCount, items.length);

return {
visibleItems: items.slice(startIdx, endIdx),
startIndex: startIdx,
totalHeight: items.length * itemHeight,
};
}, [items, scrollTop, height, itemHeight]);

if (height <= 0 || itemHeight <= 0) {
console.warn('VirtualList: Invalid height or itemHeight provided');
return null;
}

if (!items?.length) {
return <div ref={containerRef} style={{ height }} />;
}

return (
<div
ref={containerRef}
css={styles.wrapper}
style={{
height,
width: '100%',
position: 'relative',
}}
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, index) => (
<React.Fragment key={startIndex + index}>
{renderItem(item, startIndex + index, {
position: 'absolute',
top: (startIndex + index) * itemHeight,
height: itemHeight,
width: '100%',
})}
</React.Fragment>
))}
</div>
</div>
);
};

export default VirtualList;

const styles = {
wrapper: css`
${styleUtils.overflowYAuto}
scrollbar-gutter: auto;
`,
};
34 changes: 19 additions & 15 deletions assets/react/v3/shared/components/modals/BasicModalWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface BasicModalWrapperProps {
fullScreen?: boolean;
modalStyle?: SerializedStyles;
maxWidth?: number;
isCloseAble?: boolean;
}

const BasicModalWrapper = ({
Expand All @@ -32,6 +33,7 @@ const BasicModalWrapper = ({
fullScreen,
modalStyle,
maxWidth = modal.BASIC_MODAL_MAX_WIDTH,
isCloseAble = true,
}: BasicModalWrapperProps) => {
useEffect(() => {
document.body.style.overflow = 'hidden';
Expand Down Expand Up @@ -66,22 +68,24 @@ const BasicModalWrapper = ({
</Show>
</div>
</Show>
<div
css={styles.actionsWrapper({
hasEntireHeader: !!entireHeader,
})}
>
<Show
when={actions}
fallback={
<button type="button" css={styles.closeButton} onClick={onClose}>
<SVGIcon name="timesThin" width={24} height={24} />
</button>
}
<Show when={isCloseAble}>
<div
css={styles.actionsWrapper({
hasEntireHeader: !!entireHeader,
})}
>
{actions}
</Show>
</div>
<Show
when={actions}
fallback={
<button type="button" css={styles.closeButton} onClick={onClose}>
<SVGIcon name="timesThin" width={24} height={24} />
</button>
}
>
{actions}
</Show>
</div>
</Show>
</div>
<div css={styles.content({ isFullScreen: fullScreen })}>{children}</div>
</div>
Expand Down
38 changes: 33 additions & 5 deletions assets/react/v3/shared/components/modals/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,21 @@ type ModalContextType = {
closeOnEscape?: boolean;
isMagicAi?: boolean;
depthIndex?: number;
id?: string;
}): Promise<NonNullable<Parameters<P['closeModal']>[0]> | PromiseResolvePayload<'CLOSE'>>;
closeModal(data?: PromiseResolvePayload): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateModal<C extends React.FunctionComponent<any>>(
id: string,
newProps: Partial<Omit<React.ComponentProps<C>, 'closeModal'>>,
): void;
hasModalOnStack?: boolean;
};

const ModalContext = React.createContext<ModalContextType>({
showModal: () => Promise.resolve({ action: 'CLOSE' as const }),
closeModal: noop,
updateModal: noop,
hasModalOnStack: false,
});

Expand Down Expand Up @@ -97,6 +104,7 @@ export const ModalProvider: React.FunctionComponent<{ children: ReactNode }> = (
closeOnEscape = true,
isMagicAi = false,
depthIndex = zIndex.modal,
id,
}) => {
return new Promise((resolve) => {
setState((previousState) => ({
Expand All @@ -109,7 +117,7 @@ export const ModalProvider: React.FunctionComponent<{ children: ReactNode }> = (
resolve,
closeOnOutsideClick,
closeOnEscape,
id: nanoid(),
id: id || nanoid(),
depthIndex,
isMagicAi,
},
Expand All @@ -131,7 +139,27 @@ export const ModalProvider: React.FunctionComponent<{ children: ReactNode }> = (
});
}, []);

const updateModal = useCallback<ModalContextType['updateModal']>((id, newProps) => {
setState((prevState) => {
const modalIndex = prevState.modals.findIndex((modal) => modal.id === id);
if (modalIndex === -1) return prevState;

const updatedModals = [...prevState.modals];
const modal = updatedModals[modalIndex];
updatedModals[modalIndex] = {
...modal,
props: {
...modal.props,
...newProps,
},
};

return { ...prevState, modals: updatedModals };
});
}, []);

const { transitions } = useAnimation({
keys: (modal) => modal.id,
data: state.modals,
animationType: AnimationType.slideUp,
animationDuration: 250,
Expand All @@ -154,7 +182,6 @@ export const ModalProvider: React.FunctionComponent<{ children: ReactNode }> = (
}
};

// Use capture phase to ensure this event is caught even when input is focused
if (state.modals.length > 0) {
document.addEventListener('keydown', handleKeyDown, true);
}
Expand All @@ -166,15 +193,16 @@ export const ModalProvider: React.FunctionComponent<{ children: ReactNode }> = (
}, [state.modals.length, closeModal]);

return (
<ModalContext.Provider value={{ showModal, closeModal, hasModalOnStack }}>
<ModalContext.Provider value={{ showModal, closeModal, updateModal, hasModalOnStack }}>
{children}
{transitions((style, modal) => {
{transitions((style, modal, _, index) => {
return (
<div
key={modal.id}
css={[
styles.container,
{
zIndex: modal.depthIndex,
zIndex: modal.depthIndex || zIndex.modal + index,
},
]}
>
Expand Down
8 changes: 7 additions & 1 deletion assets/react/v3/shared/hooks/useAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface AnimationProps<T> {
maxOpacity?: number;
easing?: EasingFunction;
debounceMeasure?: boolean;
keys?: (item: T) => string | number;
}

const MEASURE_DELAY_TIME = 100;
Expand All @@ -37,6 +38,7 @@ export const useAnimation = <T,>({
maxOpacity = 1,
easing = easings.easeInOutQuad,
debounceMeasure = false,
keys,
}: AnimationProps<T>) => {
const isTriggered = Array.isArray(data) ? data.length > 0 : !!data;
const [ref, position] = useMeasure({ debounce: debounceMeasure ? animationDuration + MEASURE_DELAY_TIME : 0 });
Expand Down Expand Up @@ -96,6 +98,11 @@ export const useAnimation = <T,>({
}

const transitions = useTransition(data, {
keys:
keys ||
((item) => {
return item as unknown as string;
}),
from: {
opacity: minOpacity,
...coordinates,
Expand Down Expand Up @@ -130,7 +137,6 @@ export const useAnimation = <T,>({
export const AnimatedDiv = ({
children,
style,
css,
hideOnOverflow = true,
...props
}: {
Expand Down
4 changes: 2 additions & 2 deletions assets/react/v3/shared/molecules/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface CardProps {
noSeparator?: boolean;
hideArrow?: boolean;
isAlternative?: boolean;
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
collapsedAnimationDependencies?: any[];
}

Expand Down Expand Up @@ -53,14 +53,14 @@ const Card = ({
[isCollapsed, ...(collapsedAnimationDependencies || [])],
);

// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
if (isDefined(cardRef.current)) {
collapseAnimate.start({
height: !isCollapsed ? cardRef.current.scrollHeight : 0,
opacity: !isCollapsed ? 1 : 0,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCollapsed, ...(collapsedAnimationDependencies || [])]);

return (
Expand Down
Loading