diff --git a/assets/react/v3/shared/atoms/Skeleton.tsx b/assets/react/v3/shared/atoms/Skeleton.tsx index af7d79c14a..b5c21f7eab 100644 --- a/assets/react/v3/shared/atoms/Skeleton.tsx +++ b/assets/react/v3/shared/atoms/Skeleton.tsx @@ -9,11 +9,29 @@ interface SkeletonProps extends React.HTMLAttributes { animation?: boolean; isMagicAi?: boolean; isRound?: boolean; + animationDuration?: number; } const Skeleton = forwardRef( - ({ width = '100%', height = 16, animation = false, isMagicAi = false, isRound = false, className }, ref) => { - return ; + ( + { + width = '100%', + height = 16, + animation = false, + isMagicAi = false, + isRound = false, + animationDuration = 1.6, + className, + }, + ref, + ) => { + return ( + + ); }, ); @@ -40,6 +58,7 @@ const styles = { animation: boolean, isMagicAi: boolean, isRound: boolean, + animationDuration: number, ) => css` display: block; width: ${isNumber(width) ? `${width}px` : width}; @@ -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}; } `} `, diff --git a/assets/react/v3/shared/atoms/VirtualList.tsx b/assets/react/v3/shared/atoms/VirtualList.tsx new file mode 100644 index 0000000000..33a58ea74e --- /dev/null +++ b/assets/react/v3/shared/atoms/VirtualList.tsx @@ -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 { + 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 = ({ items, height, itemHeight = DEFAULT_ITEM_HEIGHT, renderItem }: VirtualListProps) => { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const scrollingTimeoutRef = useRef(); + + useEffect(() => { + if (containerRef.current) { + setScrollTop(containerRef.current.scrollTop); + } + + return () => { + if (scrollingTimeoutRef.current) { + cancelAnimationFrame(scrollingTimeoutRef.current); + } + }; + }, []); + + const handleScroll = useCallback((e: React.UIEvent) => { + 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
; + } + + return ( +
+
+ {visibleItems.map((item, index) => ( + + {renderItem(item, startIndex + index, { + position: 'absolute', + top: (startIndex + index) * itemHeight, + height: itemHeight, + width: '100%', + })} + + ))} +
+
+ ); +}; + +export default VirtualList; + +const styles = { + wrapper: css` + ${styleUtils.overflowYAuto} + scrollbar-gutter: auto; + `, +}; diff --git a/assets/react/v3/shared/components/modals/BasicModalWrapper.tsx b/assets/react/v3/shared/components/modals/BasicModalWrapper.tsx index 7d051b605f..5b3f69d12d 100644 --- a/assets/react/v3/shared/components/modals/BasicModalWrapper.tsx +++ b/assets/react/v3/shared/components/modals/BasicModalWrapper.tsx @@ -19,6 +19,7 @@ interface BasicModalWrapperProps { fullScreen?: boolean; modalStyle?: SerializedStyles; maxWidth?: number; + isCloseAble?: boolean; } const BasicModalWrapper = ({ @@ -32,6 +33,7 @@ const BasicModalWrapper = ({ fullScreen, modalStyle, maxWidth = modal.BASIC_MODAL_MAX_WIDTH, + isCloseAble = true, }: BasicModalWrapperProps) => { useEffect(() => { document.body.style.overflow = 'hidden'; @@ -66,22 +68,24 @@ const BasicModalWrapper = ({
-
- - - - } + +
- {actions} - -
+ + + + } + > + {actions} + +
+
{children}
diff --git a/assets/react/v3/shared/components/modals/Modal.tsx b/assets/react/v3/shared/components/modals/Modal.tsx index 681eaf49af..bb4e8c6e43 100644 --- a/assets/react/v3/shared/components/modals/Modal.tsx +++ b/assets/react/v3/shared/components/modals/Modal.tsx @@ -58,14 +58,21 @@ type ModalContextType = { closeOnEscape?: boolean; isMagicAi?: boolean; depthIndex?: number; + id?: string; }): Promise[0]> | PromiseResolvePayload<'CLOSE'>>; closeModal(data?: PromiseResolvePayload): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateModal>( + id: string, + newProps: Partial, 'closeModal'>>, + ): void; hasModalOnStack?: boolean; }; const ModalContext = React.createContext({ showModal: () => Promise.resolve({ action: 'CLOSE' as const }), closeModal: noop, + updateModal: noop, hasModalOnStack: false, }); @@ -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) => ({ @@ -109,7 +117,7 @@ export const ModalProvider: React.FunctionComponent<{ children: ReactNode }> = ( resolve, closeOnOutsideClick, closeOnEscape, - id: nanoid(), + id: id || nanoid(), depthIndex, isMagicAi, }, @@ -131,7 +139,27 @@ export const ModalProvider: React.FunctionComponent<{ children: ReactNode }> = ( }); }, []); + const updateModal = useCallback((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, @@ -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); } @@ -166,15 +193,16 @@ export const ModalProvider: React.FunctionComponent<{ children: ReactNode }> = ( }, [state.modals.length, closeModal]); return ( - + {children} - {transitions((style, modal) => { + {transitions((style, modal, _, index) => { return (
diff --git a/assets/react/v3/shared/hooks/useAnimation.tsx b/assets/react/v3/shared/hooks/useAnimation.tsx index 4812d6d0c1..a42496a60d 100644 --- a/assets/react/v3/shared/hooks/useAnimation.tsx +++ b/assets/react/v3/shared/hooks/useAnimation.tsx @@ -24,6 +24,7 @@ interface AnimationProps { maxOpacity?: number; easing?: EasingFunction; debounceMeasure?: boolean; + keys?: (item: T) => string | number; } const MEASURE_DELAY_TIME = 100; @@ -37,6 +38,7 @@ export const useAnimation = ({ maxOpacity = 1, easing = easings.easeInOutQuad, debounceMeasure = false, + keys, }: AnimationProps) => { const isTriggered = Array.isArray(data) ? data.length > 0 : !!data; const [ref, position] = useMeasure({ debounce: debounceMeasure ? animationDuration + MEASURE_DELAY_TIME : 0 }); @@ -96,6 +98,11 @@ export const useAnimation = ({ } const transitions = useTransition(data, { + keys: + keys || + ((item) => { + return item as unknown as string; + }), from: { opacity: minOpacity, ...coordinates, @@ -130,7 +137,6 @@ export const useAnimation = ({ export const AnimatedDiv = ({ children, style, - css, hideOnOverflow = true, ...props }: { diff --git a/assets/react/v3/shared/molecules/Card.tsx b/assets/react/v3/shared/molecules/Card.tsx index c7c086a0e5..a04c63185a 100644 --- a/assets/react/v3/shared/molecules/Card.tsx +++ b/assets/react/v3/shared/molecules/Card.tsx @@ -21,7 +21,7 @@ interface CardProps { noSeparator?: boolean; hideArrow?: boolean; isAlternative?: boolean; - // biome-ignore lint/suspicious/noExplicitAny: + // eslint-disable-next-line @typescript-eslint/no-explicit-any collapsedAnimationDependencies?: any[]; } @@ -53,7 +53,6 @@ const Card = ({ [isCollapsed, ...(collapsedAnimationDependencies || [])], ); - // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { if (isDefined(cardRef.current)) { collapseAnimate.start({ @@ -61,6 +60,7 @@ const Card = ({ opacity: !isCollapsed ? 1 : 0, }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isCollapsed, ...(collapsedAnimationDependencies || [])]); return (