import { useCallback, useEffect, useRef, type ReactNode, type RefObject, } from "react"; import { Box, useUiCapabilities, type ScrollBoxRenderable } from "../ui"; import { isPlainKeyboardEvent } from "../utils/keyboard"; import type { DataTableProps } from "./ui/data-table/types"; function listenToScrollBarChange( scrollBar: ScrollBoxRenderable["verticalScrollBar"], handler: () => void, ): (() => void) | null { if ( !scrollBar || typeof scrollBar.on !== "function" || typeof scrollBar.off !== "function" ) { return null; } scrollBar.on("change", handler); return () => scrollBar.off?.("change", handler); } export interface TableViewKeyEvent { name?: string; ctrl?: boolean; meta?: boolean; super?: boolean; alt?: boolean; option?: boolean; shift?: boolean; readonly defaultPrevented?: boolean; readonly propagationStopped?: boolean; preventDefault?: () => void; stopPropagation?: () => void; } export interface TableViewFrameProps { width?: number; height?: number; backgroundColor?: string; before?: ReactNode; after?: ReactNode; children: ReactNode; } export function TableViewFrame({ width, height, backgroundColor, before, after, children, }: TableViewFrameProps) { const { nativePaneChrome } = useUiCapabilities(); const nativeFlexibleFrame = nativePaneChrome === true; return ( {before} {children} {after} ); } export function isTableActivationKey(name: string | undefined): boolean { return name === "enter" || name === "return"; } function getTableKeyName(event: TableViewKeyEvent | string | undefined): string | undefined { return typeof event === "string" ? event : event?.name; } function isPlainTableKey(event: TableViewKeyEvent | string | undefined): boolean { return typeof event !== "object" || isPlainKeyboardEvent(event); } export function isNextTableRowKey(event: TableViewKeyEvent | string | undefined): boolean { const name = getTableKeyName(event); if (!isPlainTableKey(event)) return false; return name === "j" || name === "down"; } export function isPreviousTableRowKey(event: TableViewKeyEvent | string | undefined): boolean { const name = getTableKeyName(event); if (!isPlainTableKey(event)) return false; return name === "k" || name === "up"; } export function stopTableKey(event: TableViewKeyEvent) { event.stopPropagation?.(); event.preventDefault?.(); } export function useTableViewState({ headerScrollRef, scrollRef, syncHeaderScroll, }: { headerScrollRef?: RefObject; scrollRef?: RefObject; syncHeaderScroll?: () => void; }) { const internalHeaderScrollRef = useRef(null); const internalScrollRef = useRef(null); const effectiveHeaderScrollRef = headerScrollRef ?? internalHeaderScrollRef; const effectiveScrollRef = scrollRef ?? internalScrollRef; const defaultSyncHeaderScroll = useCallback(() => { const body = effectiveScrollRef.current; const header = effectiveHeaderScrollRef.current; if (!body || !header) return; if (header.scrollLeft !== body.scrollLeft) { header.scrollLeft = body.scrollLeft; } }, [effectiveHeaderScrollRef, effectiveScrollRef]); return { effectiveHeaderScrollRef, effectiveScrollRef, effectiveSyncHeaderScroll: syncHeaderScroll ?? defaultSyncHeaderScroll, }; } export function isTableScrollNearEnd( scrollBox: { scrollTop: number; scrollHeight: number; viewport?: { height: number } | null; } | null, threshold = 8, ): boolean { if (!scrollBox?.viewport) return false; const viewportHeight = Math.max(1, scrollBox.viewport.height); return scrollBox.scrollTop + viewportHeight >= scrollBox.scrollHeight - threshold; } export function useTableLoadMore( scrollRef: RefObject, canLoadMore: boolean, loadMore: () => void, threshold = 8, ) { return useCallback(() => { if (!canLoadMore) return; if (!isTableScrollNearEnd(scrollRef.current, threshold)) return; loadMore(); }, [canLoadMore, loadMore, scrollRef, threshold]); } export function useTableBodyScrollActivity({ onBodyScrollActivity, syncHeaderScroll, afterScroll, }: { onBodyScrollActivity?: DataTableProps["onBodyScrollActivity"]; syncHeaderScroll: () => void; afterScroll?: () => void; }) { return useCallback["onBodyScrollActivity"]>((source) => { if (onBodyScrollActivity) { onBodyScrollActivity(source); } else { queueMicrotask(syncHeaderScroll); } if (afterScroll) { queueMicrotask(afterScroll); } }, [afterScroll, onBodyScrollActivity, syncHeaderScroll]); } export function useScrollBoxScrollActivity({ scrollRef, onVerticalScroll, onHorizontalScroll, }: { scrollRef: RefObject; onVerticalScroll?: () => void; onHorizontalScroll?: () => void; }) { const onVerticalScrollRef = useRef(onVerticalScroll); const onHorizontalScrollRef = useRef(onHorizontalScroll); const verticalScrollScheduledRef = useRef(false); const horizontalScrollScheduledRef = useRef(false); useEffect(() => { onVerticalScrollRef.current = onVerticalScroll; onHorizontalScrollRef.current = onHorizontalScroll; }, [onHorizontalScroll, onVerticalScroll]); useEffect(() => { const scrollBox = scrollRef.current; if (!scrollBox) return; let active = true; const handleVerticalChange = () => { if (verticalScrollScheduledRef.current) return; verticalScrollScheduledRef.current = true; queueMicrotask(() => { verticalScrollScheduledRef.current = false; if (!active) return; onVerticalScrollRef.current?.(); }); }; const handleHorizontalChange = () => { if (horizontalScrollScheduledRef.current) return; horizontalScrollScheduledRef.current = true; queueMicrotask(() => { horizontalScrollScheduledRef.current = false; if (!active) return; onHorizontalScrollRef.current?.(); }); }; const removeVerticalListener = listenToScrollBarChange( scrollBox.verticalScrollBar, handleVerticalChange, ); const removeHorizontalListener = listenToScrollBarChange( scrollBox.horizontalScrollBar, handleHorizontalChange, ); return () => { active = false; removeVerticalListener?.(); removeHorizontalListener?.(); }; }, [scrollRef]); } export function useResetTableScroll({ headerScrollRef, scrollRef, resetScrollKey, afterReset, }: { headerScrollRef: RefObject; scrollRef: RefObject; resetScrollKey?: unknown; afterReset?: () => void; }) { const afterResetRef = useRef(afterReset); useEffect(() => { afterResetRef.current = afterReset; }, [afterReset]); useEffect(() => { if (resetScrollKey === undefined) return; const body = scrollRef.current; if (body) { body.scrollTop = 0; body.scrollLeft = 0; } const header = headerScrollRef.current; if (header) { header.scrollLeft = 0; } if (afterResetRef.current) { queueMicrotask(afterResetRef.current); } }, [headerScrollRef, resetScrollKey, scrollRef]); }