@ehfuse/mui-virtual-data-table 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,6 +108,10 @@ function App() {
108
108
  columnHeight={number} // Header height in px (default: 56, auto 2x for grouped headers)
109
109
  showPaper={boolean} // Wrap in Paper component (default: true)
110
110
  paddingX={string | number} // Horizontal padding (default: "1rem")
111
+ paddingTop={string | number} // Top padding (default: 0)
112
+ paddingBottom={string | number} // Bottom padding (default: 0)
113
+ rowHoverColor={string} // Row hover background color (default: "#000000", auto-inverted brightness in dark mode)
114
+ rowHoverOpacity={number} // Row hover opacity 0-1 (default: 0.06)
111
115
 
112
116
  // Optional - Infinite Scroll
113
117
  loading={boolean} // Loading state (default: false)
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * MIT License
5
5
  *
6
- import { VirtuosoScrollbar } from './components/Scrollbar'; Copyright (c) 2025 KIM YOUNG JIN (ehfuse@gmail.com)
6
+ * Copyright (c) 2025 KIM YOUNG JIN (ehfuse@gmail.com)
7
7
  *
8
8
  * Permission is hereby granted, free of charge, to any person obtaining a copy
9
9
  * of this software and associated documentation files (the "Software"), to deal
package/dist/index.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
- import { useRef, useState, useEffect, forwardRef, useMemo, useImperativeHandle, useCallback, memo } from 'react';
2
+ import { useRef, useState, useEffect, forwardRef, useMemo, useCallback, useImperativeHandle, useLayoutEffect, memo } from 'react';
3
3
  import { Box, CircularProgress, TableContainer, TableRow, TableCell, TableSortLabel, TableBody, TableHead, Table, Typography, Paper } from '@mui/material';
4
4
  import { TableVirtuoso } from 'react-virtuoso';
5
5
 
@@ -531,6 +531,21 @@ showScrollbar = true, }, ref) => {
531
531
  const thumbMinHeight = finalThumbConfig.minHeight;
532
532
  const showArrows = finalArrowsConfig.visible;
533
533
  const arrowStep = finalArrowsConfig.step;
534
+ // 포커스 유지 함수 (키보드 입력이 계속 작동하도록)
535
+ const maintainFocus = useCallback(() => {
536
+ if (!containerRef.current)
537
+ return;
538
+ // 현재 포커스된 요소 확인
539
+ const activeElement = document.activeElement;
540
+ // 오버레이 스크롤바 내부에 이미 포커스된 요소가 있으면 스킵
541
+ if (activeElement &&
542
+ containerRef.current.contains(activeElement) &&
543
+ activeElement !== containerRef.current) {
544
+ return;
545
+ }
546
+ // 포커스된 요소가 없거나 외부에 있으면 컨테이너에 포커스
547
+ containerRef.current.focus();
548
+ }, []);
534
549
  // ref를 통해 외부에서 스크롤 컨테이너에 접근할 수 있도록 함
535
550
  useImperativeHandle(ref, () => ({
536
551
  getScrollContainer: () => containerRef.current,
@@ -654,7 +669,6 @@ showScrollbar = true, }, ref) => {
654
669
  ]);
655
670
  // 썸 드래그 시작
656
671
  const handleThumbMouseDown = useCallback((event) => {
657
- var _a;
658
672
  event.preventDefault();
659
673
  event.stopPropagation();
660
674
  const actualScrollContainer = findScrollableElement();
@@ -669,8 +683,8 @@ showScrollbar = true, }, ref) => {
669
683
  clearHideTimer();
670
684
  setScrollbarVisible(true);
671
685
  // 포커스 유지 (키보드 입력이 계속 작동하도록)
672
- (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.focus();
673
- }, [findScrollableElement, clearHideTimer]);
686
+ maintainFocus();
687
+ }, [findScrollableElement, clearHideTimer, maintainFocus]);
674
688
  // 썸 드래그 중
675
689
  const handleMouseMove = useCallback((event) => {
676
690
  if (!isDragging)
@@ -704,7 +718,6 @@ showScrollbar = true, }, ref) => {
704
718
  }, [isScrollable, setHideTimer, finalAutoHideConfig.delay]);
705
719
  // 트랙 클릭으로 스크롤 점프
706
720
  const handleTrackClick = useCallback((event) => {
707
- var _a;
708
721
  if (!scrollbarRef.current) {
709
722
  return;
710
723
  }
@@ -724,16 +737,16 @@ showScrollbar = true, }, ref) => {
724
737
  setScrollbarVisible(true);
725
738
  setHideTimer(finalAutoHideConfig.delay);
726
739
  // 포커스 유지 (키보드 입력이 계속 작동하도록)
727
- (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.focus();
740
+ maintainFocus();
728
741
  }, [
729
742
  updateScrollbar,
730
743
  setHideTimer,
731
744
  finalAutoHideConfig.delay,
732
745
  findScrollableElement,
746
+ maintainFocus,
733
747
  ]);
734
748
  // 위쪽 화살표 클릭 핸들러
735
749
  const handleUpArrowClick = useCallback((event) => {
736
- var _a;
737
750
  event.preventDefault();
738
751
  event.stopPropagation();
739
752
  if (!containerRef.current)
@@ -744,16 +757,16 @@ showScrollbar = true, }, ref) => {
744
757
  setScrollbarVisible(true);
745
758
  setHideTimer(finalAutoHideConfig.delay);
746
759
  // 포커스 유지 (키보드 입력이 계속 작동하도록)
747
- (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.focus();
760
+ maintainFocus();
748
761
  }, [
749
762
  updateScrollbar,
750
763
  setHideTimer,
751
764
  arrowStep,
752
765
  finalAutoHideConfig.delay,
766
+ maintainFocus,
753
767
  ]);
754
768
  // 아래쪽 화살표 클릭 핸들러
755
769
  const handleDownArrowClick = useCallback((event) => {
756
- var _a;
757
770
  event.preventDefault();
758
771
  event.stopPropagation();
759
772
  if (!containerRef.current || !contentRef.current)
@@ -767,12 +780,13 @@ showScrollbar = true, }, ref) => {
767
780
  setScrollbarVisible(true);
768
781
  setHideTimer(finalAutoHideConfig.delay);
769
782
  // 포커스 유지 (키보드 입력이 계속 작동하도록)
770
- (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.focus();
783
+ maintainFocus();
771
784
  }, [
772
785
  updateScrollbar,
773
786
  setHideTimer,
774
787
  arrowStep,
775
788
  finalAutoHideConfig.delay,
789
+ maintainFocus,
776
790
  ]);
777
791
  // 드래그 스크롤 시작
778
792
  const handleDragScrollStart = useCallback((event) => {
@@ -1020,7 +1034,7 @@ showScrollbar = true, }, ref) => {
1020
1034
  return () => clearTimeout(timer);
1021
1035
  }, [updateScrollbar]);
1022
1036
  // 컴포넌트 초기화 완료 표시 (hover 이벤트 활성화용)
1023
- useEffect(() => {
1037
+ useLayoutEffect(() => {
1024
1038
  const timer = setTimeout(() => {
1025
1039
  setIsInitialized(true);
1026
1040
  // 초기화 후 스크롤바 업데이트 (썸 높이 정확하게 계산)
@@ -1029,11 +1043,7 @@ showScrollbar = true, }, ref) => {
1029
1043
  if (!finalAutoHideConfig.enabled && isScrollable()) {
1030
1044
  setScrollbarVisible(true);
1031
1045
  }
1032
- // 스크롤 컨테이너에 자동 포커스 (키보드 네비게이션 활성화)
1033
- if (containerRef.current) {
1034
- containerRef.current.focus();
1035
- }
1036
- }, 100);
1046
+ }, 0);
1037
1047
  return () => clearTimeout(timer);
1038
1048
  }, [isScrollable, updateScrollbar, finalAutoHideConfig.enabled]);
1039
1049
  // Resize observer로 크기 변경 감지
@@ -1263,7 +1273,7 @@ const OVERLAY_SCROLLBAR_TRACK_CONFIG = {
1263
1273
  /**
1264
1274
  * 데이터 기반 무한 스크롤 및 가상화를 지원하는 테이블 컴포넌트
1265
1275
  */
1266
- function VirtualDataTableComponent({ data, loading = false, columns, onRowClick, rowHeight = 50, columnHeight = 56, striped, rowDivider = true, onSort, onLoadMore, sortBy, sortDirection, showPaper = true, paddingX = "1rem", scrollbars, emptyMessage = "NO DATA", LoadingComponent, }) {
1276
+ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick, rowHeight = 50, columnHeight = 56, striped, rowDivider = true, onSort, onLoadMore, sortBy, sortDirection, showPaper = true, paddingX = "1rem", paddingTop = 0, paddingBottom = 0, rowHoverColor, rowHoverOpacity, scrollbars, emptyMessage = "NO DATA", LoadingComponent, }) {
1267
1277
  // console.log("=== VirtualDataTable 렌더링 ===", {
1268
1278
  // dataLength: data.length,
1269
1279
  // loading,
@@ -1303,11 +1313,13 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1303
1313
  },
1304
1314
  "& .MuiTable-root": {
1305
1315
  paddingRight: paddingX,
1316
+ paddingTop: paddingTop,
1317
+ paddingBottom: paddingBottom,
1306
1318
  },
1307
1319
  } }) }));
1308
1320
  }),
1309
1321
  // eslint-disable-next-line react-hooks/exhaustive-deps
1310
- [] // 빈 배열: 최초 마운트 시에만 생성, scrollbars paddingX 클로저로 고정
1322
+ [] // 빈 배열: 최초 마운트 시에만 생성, scrollbars, paddingX, paddingTop, paddingBottom은 클로저로 고정
1311
1323
  );
1312
1324
  // Striped row 배경색 계산
1313
1325
  const stripedRowColor = useMemo(() => {
@@ -1350,6 +1362,8 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1350
1362
  const isMouseDownRef = useRef(false);
1351
1363
  const initialScrollTopRef = useRef(0);
1352
1364
  const totalDragDistanceRef = useRef(0);
1365
+ const isScrollDraggingRef = useRef(false); // OverlayScrollbar 드래그 스크롤 감지용
1366
+ useRef(null);
1353
1367
  /**
1354
1368
  * 마우스 버튼 누름 이벤트 핸들러
1355
1369
  * OverlayScrollbar 사용시에는 기본 드래그 스크롤을 비활성화
@@ -1374,7 +1388,6 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1374
1388
  // DOM 스타일 직접 변경 (리렌더링 방지)
1375
1389
  if (scrollContainerRef.current) {
1376
1390
  scrollContainerRef.current.style.userSelect = "none";
1377
- scrollContainerRef.current.style.webkitUserSelect = "none";
1378
1391
  }
1379
1392
  }
1380
1393
  if (isDraggingRef.current) {
@@ -1418,7 +1431,6 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1418
1431
  // DOM 스타일 초기화
1419
1432
  if (scrollContainerRef.current) {
1420
1433
  scrollContainerRef.current.style.userSelect = "auto";
1421
- scrollContainerRef.current.style.webkitUserSelect = "auto";
1422
1434
  }
1423
1435
  }, []);
1424
1436
  // 정렬이 변경될 때 모든 TableSortLabel의 hover 상태 초기화
@@ -1538,7 +1550,6 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1538
1550
  width: col.width,
1539
1551
  minWidth: col.width,
1540
1552
  ...col.style,
1541
- backgroundColor: "#ffffff",
1542
1553
  fontWeight: "bold",
1543
1554
  position: "sticky",
1544
1555
  top: 0,
@@ -1599,7 +1610,6 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1599
1610
  width: col.width,
1600
1611
  minWidth: col.width,
1601
1612
  ...col.style,
1602
- backgroundColor: "#ffffff",
1603
1613
  fontWeight: "bold",
1604
1614
  position: "sticky",
1605
1615
  top: 0,
@@ -1661,7 +1671,6 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1661
1671
  },
1662
1672
  } })] }) })) : (col.text) }, String(col.id)))),
1663
1673
  ...Object.entries(groupMap).map(([group, cols]) => (jsx(TableCell, { align: "center", colSpan: cols.length, style: {
1664
- backgroundColor: "#ffffff",
1665
1674
  fontWeight: "bold",
1666
1675
  position: "sticky",
1667
1676
  top: 0,
@@ -1676,7 +1685,6 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1676
1685
  width: col.width,
1677
1686
  minWidth: col.width,
1678
1687
  ...col.style,
1679
- backgroundColor: "#ffffff",
1680
1688
  fontWeight: "bold",
1681
1689
  position: "sticky",
1682
1690
  top: 0,
@@ -1776,7 +1784,6 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1776
1784
  height: columnHeight,
1777
1785
  "& th": {
1778
1786
  padding: "16px",
1779
- backgroundColor: "#ffffff",
1780
1787
  position: "sticky",
1781
1788
  top: 0,
1782
1789
  zIndex: 2,
@@ -1791,10 +1798,22 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1791
1798
  // react-virtuoso는 'data-index' 속성으로 index를 전달합니다
1792
1799
  const rowIndex = rest["data-index"] ?? 0;
1793
1800
  const isOddRow = rowIndex % 2 === 1;
1794
- return (jsx(TableRow, { ...rest, onClick: () => {
1795
- if (!isDraggingRef.current && item && onRowClick) {
1801
+ return (jsx(TableRow, { ...rest, onMouseDown: () => {
1802
+ // 마우스 다운 드래그 플래그 초기화
1803
+ isScrollDraggingRef.current = false;
1804
+ }, onMouseMove: () => {
1805
+ // 마우스가 눌린 상태로 움직이면 드래그로 간주
1806
+ isScrollDraggingRef.current = true;
1807
+ }, onClick: () => {
1808
+ // 드래그 스크롤이 아니고, 아이템이 있고, onRowClick이 있을 때만 실행
1809
+ if (!isScrollDraggingRef.current &&
1810
+ !isDraggingRef.current &&
1811
+ item &&
1812
+ onRowClick) {
1796
1813
  onRowClick(item, rowIndex);
1797
1814
  }
1815
+ // 클릭 후 플래그 리셋
1816
+ isScrollDraggingRef.current = false;
1798
1817
  }, sx: {
1799
1818
  userSelect: "none",
1800
1819
  height: rowHeight,
@@ -1813,7 +1832,85 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1813
1832
  },
1814
1833
  "&:hover": onRowClick
1815
1834
  ? {
1816
- backgroundColor: "#E3EEFA",
1835
+ backgroundColor: (theme) => {
1836
+ const isDark = theme.palette.mode === "dark";
1837
+ const defaultColor = "#000000";
1838
+ const color = rowHoverColor ?? defaultColor;
1839
+ const opacity = rowHoverOpacity ?? 0.06;
1840
+ // hex를 rgb로 변환
1841
+ const hex = color.replace("#", "");
1842
+ let r = parseInt(hex.substring(0, 2), 16) / 255;
1843
+ let g = parseInt(hex.substring(2, 4), 16) / 255;
1844
+ let b = parseInt(hex.substring(4, 6), 16) / 255;
1845
+ // 다크 모드일 때 밝기만 반전 (HSL 변환)
1846
+ if (isDark) {
1847
+ // RGB to HSL
1848
+ const max = Math.max(r, g, b);
1849
+ const min = Math.min(r, g, b);
1850
+ let h = 0, s = 0, l = (max + min) / 2;
1851
+ if (max !== min) {
1852
+ const d = max - min;
1853
+ s =
1854
+ l > 0.5
1855
+ ? d / (2 - max - min)
1856
+ : d / (max + min);
1857
+ switch (max) {
1858
+ case r:
1859
+ h =
1860
+ ((g - b) / d +
1861
+ (g < b
1862
+ ? 6
1863
+ : 0)) /
1864
+ 6;
1865
+ break;
1866
+ case g:
1867
+ h =
1868
+ ((b - r) / d +
1869
+ 2) /
1870
+ 6;
1871
+ break;
1872
+ case b:
1873
+ h =
1874
+ ((r - g) / d +
1875
+ 4) /
1876
+ 6;
1877
+ break;
1878
+ }
1879
+ }
1880
+ // 밝기만 반전 (0.0 <-> 1.0)
1881
+ l = 1 - l;
1882
+ // HSL to RGB
1883
+ const hue2rgb = (p, q, t) => {
1884
+ if (t < 0)
1885
+ t += 1;
1886
+ if (t > 1)
1887
+ t -= 1;
1888
+ if (t < 1 / 6)
1889
+ return (p + (q - p) * 6 * t);
1890
+ if (t < 1 / 2)
1891
+ return q;
1892
+ if (t < 2 / 3)
1893
+ return (p +
1894
+ (q - p) *
1895
+ (2 / 3 - t) *
1896
+ 6);
1897
+ return p;
1898
+ };
1899
+ if (s === 0) {
1900
+ r = g = b = l;
1901
+ }
1902
+ else {
1903
+ const q = l < 0.5
1904
+ ? l * (1 + s)
1905
+ : l + s - l * s;
1906
+ const p = 2 * l - q;
1907
+ r = hue2rgb(p, q, h + 1 / 3);
1908
+ g = hue2rgb(p, q, h);
1909
+ b = hue2rgb(p, q, h - 1 / 3);
1910
+ }
1911
+ }
1912
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${opacity})`;
1913
+ },
1817
1914
  transition: "background-color 0.2s ease",
1818
1915
  }
1819
1916
  : {},
@@ -1828,6 +1925,8 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1828
1925
  stripedRowColor,
1829
1926
  rowDivider,
1830
1927
  columnHeight,
1928
+ rowHoverColor,
1929
+ rowHoverOpacity,
1831
1930
  VirtuosoScroller,
1832
1931
  ]);
1833
1932
  // 공통 테이블 내용
@@ -1835,6 +1934,11 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1835
1934
  position: "relative",
1836
1935
  height: "100%",
1837
1936
  width: "100%",
1937
+ "& .MuiTableHead-root": {
1938
+ backgroundColor: (theme) => theme.palette.mode === "dark"
1939
+ ? "#1e1e1e !important"
1940
+ : "#ffffff !important",
1941
+ },
1838
1942
  }, children: [jsx(TableVirtuoso, { ref: virtuosoRef, data: data, totalCount: onLoadMore ? data.length + 1 : data.length, fixedHeaderContent: fixedHeaderContent, itemContent: rowContent, rangeChanged: handleRangeChange, components: VirtuosoTableComponents, style: { height: "100%" }, increaseViewportBy: { top: 100, bottom: 300 }, overscan: 5, followOutput: false }, tableKey), data.length === 0 && !loading && (jsx(Box, { sx: {
1839
1943
  position: "absolute",
1840
1944
  top: 0,
@@ -1863,7 +1967,7 @@ function VirtualDataTableComponent({ data, loading = false, columns, onRowClick,
1863
1967
  show: data.length === 0, // 최초 로딩에만 배경 표시
1864
1968
  opacity: 0.8,
1865
1969
  } })) }))] }));
1866
- return showPaper ? (jsx(Paper, { className: "grow", style: {
1970
+ return showPaper ? (jsx(Paper, { className: "grow", elevation: 1, sx: {
1867
1971
  padding: 0,
1868
1972
  paddingLeft: paddingX,
1869
1973
  height: "100%",