@xcelsior/ui-spreadsheets 1.1.9 → 1.1.11

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.
@@ -26,7 +26,7 @@ import { useSpreadsheetComments } from '../hooks/useSpreadsheetComments';
26
26
  import { useSpreadsheetUndoRedo } from '../hooks/useSpreadsheetUndoRedo';
27
27
  import { useSpreadsheetKeyboardShortcuts } from '../hooks/useSpreadsheetKeyboardShortcuts';
28
28
  import { useSpreadsheetSelection } from '../hooks/useSpreadsheetSelection';
29
- import type { CellEdit, SpreadsheetProps } from '../types';
29
+ import type { CellEdit, SpreadsheetColumn, SpreadsheetColumnGroup, SpreadsheetProps } from '../types';
30
30
 
31
31
  type SingleCellEdit = {
32
32
  rowId: string | number;
@@ -634,6 +634,172 @@ export function Spreadsheet<T extends Record<string, any>>({
634
634
  setHighlightPickerColumn(ROW_INDEX_COLUMN_ID);
635
635
  }, [setHighlightPickerColumn]);
636
636
 
637
+ // Build render items that include placeholder cells for collapsed groups
638
+ // Pinned columns are moved to the edges: left-pinned first, then unpinned, then right-pinned
639
+ const columnRenderItems = useMemo(() => {
640
+ if (!columnGroups || columnGroups.length === 0) {
641
+ return visibleColumns.map((col) => ({
642
+ type: 'column' as const,
643
+ column: col,
644
+ }));
645
+ }
646
+
647
+ type ColumnItem = { type: 'column'; column: SpreadsheetColumn<T> };
648
+ type PlaceholderItem = { type: 'collapsed-placeholder'; groupId: string; headerColor?: string };
649
+ type RenderItem = ColumnItem | PlaceholderItem;
650
+
651
+ const leftPinnedItems: ColumnItem[] = [];
652
+ const middleItems: RenderItem[] = [];
653
+ const rightPinnedItems: ColumnItem[] = [];
654
+
655
+ for (const group of columnGroups) {
656
+ const isCollapsed = collapsedGroups.has(group.id);
657
+
658
+ if (isCollapsed) {
659
+ middleItems.push({
660
+ type: 'collapsed-placeholder',
661
+ groupId: group.id,
662
+ headerColor: group.headerColor,
663
+ });
664
+ }
665
+
666
+ // Get columns from this group that are visible (preserving original order)
667
+ const groupVisibleCols = (columns || []).filter((c) => {
668
+ if (!group.columns.includes(c.id)) return false;
669
+ if (isCollapsed) return pinnedColumns.has(c.id);
670
+ return true;
671
+ });
672
+
673
+ for (const col of groupVisibleCols) {
674
+ const pinSide = pinnedColumns.get(col.id);
675
+ if (pinSide === 'left') {
676
+ leftPinnedItems.push({ type: 'column', column: col });
677
+ } else if (pinSide === 'right') {
678
+ rightPinnedItems.push({ type: 'column', column: col });
679
+ } else {
680
+ middleItems.push({ type: 'column', column: col });
681
+ }
682
+ }
683
+ }
684
+
685
+ // Add any columns not in any group
686
+ const allGroupedIds = new Set(
687
+ columnGroups.flatMap((g) => g.columns)
688
+ );
689
+ for (const col of visibleColumns) {
690
+ if (!allGroupedIds.has(col.id)) {
691
+ const pinSide = pinnedColumns.get(col.id);
692
+ if (pinSide === 'left') {
693
+ leftPinnedItems.push({ type: 'column', column: col });
694
+ } else if (pinSide === 'right') {
695
+ rightPinnedItems.push({ type: 'column', column: col });
696
+ } else {
697
+ middleItems.push({ type: 'column', column: col });
698
+ }
699
+ }
700
+ }
701
+
702
+ // Sort pinned items by their order in the pinnedColumns map
703
+ const pinnedLeftOrder = Array.from(pinnedColumns.entries())
704
+ .filter(([id, side]) => side === 'left' && id !== ROW_INDEX_COLUMN_ID)
705
+ .map(([id]) => id);
706
+ const pinnedRightOrder = Array.from(pinnedColumns.entries())
707
+ .filter(([, side]) => side === 'right')
708
+ .map(([id]) => id);
709
+
710
+ leftPinnedItems.sort(
711
+ (a, b) => pinnedLeftOrder.indexOf(a.column.id) - pinnedLeftOrder.indexOf(b.column.id)
712
+ );
713
+ rightPinnedItems.sort(
714
+ (a, b) => pinnedRightOrder.indexOf(a.column.id) - pinnedRightOrder.indexOf(b.column.id)
715
+ );
716
+
717
+ return [...leftPinnedItems, ...middleItems, ...rightPinnedItems];
718
+ }, [columnGroups, collapsedGroups, columns, pinnedColumns, visibleColumns]);
719
+
720
+ // Build group header items that account for pinned columns being moved to edges
721
+ const groupHeaderItems = useMemo(() => {
722
+ if (!columnGroups || columnGroups.length === 0) return null;
723
+
724
+ type PinnedGroupHeaderItem = {
725
+ type: 'pinned-column';
726
+ columnId: string;
727
+ headerColor?: string;
728
+ pinSide: 'left' | 'right';
729
+ };
730
+ type GroupHeaderItem = {
731
+ type: 'group';
732
+ group: SpreadsheetColumnGroup;
733
+ colSpan: number;
734
+ isCollapsed: boolean;
735
+ };
736
+
737
+ const leftPinned: PinnedGroupHeaderItem[] = [];
738
+ const groups: GroupHeaderItem[] = [];
739
+ const rightPinned: PinnedGroupHeaderItem[] = [];
740
+
741
+ for (const group of columnGroups) {
742
+ const isCollapsed = collapsedGroups.has(group.id);
743
+ const groupColumns = (columns || []).filter((c) => group.columns.includes(c.id));
744
+ const visibleGroupColumns = isCollapsed
745
+ ? groupColumns.filter((c) => pinnedColumns.has(c.id))
746
+ : groupColumns;
747
+
748
+ let movedLeftCount = 0;
749
+ let movedRightCount = 0;
750
+
751
+ for (const col of visibleGroupColumns) {
752
+ const pinSide = pinnedColumns.get(col.id);
753
+ if (pinSide === 'left') {
754
+ movedLeftCount++;
755
+ leftPinned.push({
756
+ type: 'pinned-column',
757
+ columnId: col.id,
758
+ headerColor: group.headerColor,
759
+ pinSide: 'left',
760
+ });
761
+ } else if (pinSide === 'right') {
762
+ movedRightCount++;
763
+ rightPinned.push({
764
+ type: 'pinned-column',
765
+ columnId: col.id,
766
+ headerColor: group.headerColor,
767
+ pinSide: 'right',
768
+ });
769
+ }
770
+ }
771
+
772
+ const remainingCols = visibleGroupColumns.length - movedLeftCount - movedRightCount;
773
+ const colSpan = remainingCols + (isCollapsed ? 1 : 0);
774
+
775
+ if (colSpan > 0) {
776
+ groups.push({
777
+ type: 'group',
778
+ group,
779
+ colSpan,
780
+ isCollapsed,
781
+ });
782
+ }
783
+ }
784
+
785
+ // Sort pinned items by their order in the pinnedColumns map
786
+ const pinnedLeftOrder = Array.from(pinnedColumns.entries())
787
+ .filter(([id, side]) => side === 'left' && id !== ROW_INDEX_COLUMN_ID)
788
+ .map(([id]) => id);
789
+ const pinnedRightOrder = Array.from(pinnedColumns.entries())
790
+ .filter(([, side]) => side === 'right')
791
+ .map(([id]) => id);
792
+
793
+ leftPinned.sort(
794
+ (a, b) => pinnedLeftOrder.indexOf(a.columnId) - pinnedLeftOrder.indexOf(b.columnId)
795
+ );
796
+ rightPinned.sort(
797
+ (a, b) => pinnedRightOrder.indexOf(a.columnId) - pinnedRightOrder.indexOf(b.columnId)
798
+ );
799
+
800
+ return [...leftPinned, ...groups, ...rightPinned];
801
+ }, [columnGroups, collapsedGroups, columns, pinnedColumns]);
802
+
637
803
  // ==================== RENDER ====================
638
804
 
639
805
  return (
@@ -673,15 +839,13 @@ export function Spreadsheet<T extends Record<string, any>>({
673
839
  <div ref={tableRef} className="flex-1 overflow-auto border border-gray-200 rounded">
674
840
  <div
675
841
  style={{
676
- transform: `scale(${zoom / 100})`,
677
- transformOrigin: 'top left',
678
- width: `${100 / (zoom / 100)}%`,
842
+ zoom: zoom / 100,
679
843
  }}
680
844
  >
681
845
  <table className="w-full border-separate border-spacing-0 text-xs select-none">
682
846
  <thead>
683
847
  {/* Column Group Headers */}
684
- {columnGroups && (
848
+ {columnGroups && groupHeaderItems && (
685
849
  <tr>
686
850
  {/* Row index column header (rowSpan=2 for groups) */}
687
851
  <RowIndexColumnHeader
@@ -693,19 +857,37 @@ export function Spreadsheet<T extends Record<string, any>>({
693
857
  hasColumnGroups={true}
694
858
  compactMode={effectiveCompactMode}
695
859
  />
696
- {columnGroups.map((group) => {
697
- const groupColumns = (columns || []).filter((c) =>
698
- group.columns.includes(c.id)
699
- );
700
- const isCollapsed = collapsedGroups.has(group.id);
701
- const visibleGroupColumns = isCollapsed
702
- ? groupColumns.filter((c) => pinnedColumns.has(c.id))
703
- : groupColumns;
704
- const colSpan = Math.max(
705
- 1,
706
- visibleGroupColumns.length + (isCollapsed ? 1 : 0)
707
- );
708
-
860
+ {groupHeaderItems.map((item) => {
861
+ if (item.type === 'pinned-column') {
862
+ const col = columns.find(
863
+ (c) => c.id === item.columnId
864
+ );
865
+ const isPinnedLeft = item.pinSide === 'left';
866
+ return (
867
+ <th
868
+ key={`pinned-group-${item.columnId}`}
869
+ className={cn(
870
+ 'border border-gray-200 px-2 py-1.5 text-center font-bold text-gray-700',
871
+ 'z-30'
872
+ )}
873
+ style={{
874
+ backgroundColor:
875
+ item.headerColor ||
876
+ 'rgb(243 244 246)',
877
+ position: 'sticky',
878
+ left: isPinnedLeft
879
+ ? `${getColumnLeftOffset(item.columnId)}px`
880
+ : undefined,
881
+ right: !isPinnedLeft
882
+ ? `${getColumnRightOffset(item.columnId)}px`
883
+ : undefined,
884
+ minWidth:
885
+ col?.minWidth || col?.width,
886
+ }}
887
+ />
888
+ );
889
+ }
890
+ const { group, colSpan, isCollapsed } = item;
709
891
  return (
710
892
  <th
711
893
  key={group.id}
@@ -753,7 +935,24 @@ export function Spreadsheet<T extends Record<string, any>>({
753
935
  compactMode={effectiveCompactMode}
754
936
  />
755
937
  )}
756
- {visibleColumns.map((column) => {
938
+ {columnRenderItems.map((item) => {
939
+ if (item.type === 'collapsed-placeholder') {
940
+ return (
941
+ <th
942
+ key={`${item.groupId}-placeholder`}
943
+ className="border border-gray-200 px-2 py-1 text-center text-gray-400"
944
+ style={{
945
+ backgroundColor:
946
+ item.headerColor ||
947
+ 'rgb(243 244 246)',
948
+ minWidth: '30px',
949
+ }}
950
+ >
951
+ ...
952
+ </th>
953
+ );
954
+ }
955
+ const column = item.column;
757
956
  const isPinnedLeft =
758
957
  isColumnPinned(column.id) &&
759
958
  getColumnPinSide(column.id) === 'left';
@@ -812,7 +1011,7 @@ export function Spreadsheet<T extends Record<string, any>>({
812
1011
  {isLoading ? (
813
1012
  <tr>
814
1013
  <td
815
- colSpan={visibleColumns.length + 1}
1014
+ colSpan={columnRenderItems.length + 1}
816
1015
  className="text-center py-8 text-gray-500"
817
1016
  >
818
1017
  <div className="flex items-center justify-center gap-2">
@@ -824,7 +1023,7 @@ export function Spreadsheet<T extends Record<string, any>>({
824
1023
  ) : paginatedData.length === 0 ? (
825
1024
  <tr>
826
1025
  <td
827
- colSpan={visibleColumns.length + 1}
1026
+ colSpan={columnRenderItems.length + 1}
828
1027
  className="text-center py-8 text-gray-500"
829
1028
  >
830
1029
  {emptyMessage}
@@ -1007,7 +1206,21 @@ export function Spreadsheet<T extends Record<string, any>>({
1007
1206
  </td>
1008
1207
 
1009
1208
  {/* Data Cells */}
1010
- {visibleColumns.map((column) => {
1209
+ {columnRenderItems.map((item) => {
1210
+ if (item.type === 'collapsed-placeholder') {
1211
+ return (
1212
+ <td
1213
+ key={`${item.groupId}-placeholder`}
1214
+ className="border border-gray-200 px-2 py-1 text-center text-gray-300"
1215
+ style={{
1216
+ backgroundColor:
1217
+ item.headerColor ||
1218
+ 'rgb(243 244 246)',
1219
+ }}
1220
+ />
1221
+ );
1222
+ }
1223
+ const column = item.column;
1011
1224
  const value = column.getValue
1012
1225
  ? column.getValue(row)
1013
1226
  : row[column.id];
@@ -1055,7 +1268,9 @@ export function Spreadsheet<T extends Record<string, any>>({
1055
1268
  isPinned={isColPinned}
1056
1269
  pinSide={colPinSide}
1057
1270
  leftOffset={getColumnLeftOffset(column.id)}
1058
- rightOffset={getColumnRightOffset(column.id)}
1271
+ rightOffset={getColumnRightOffset(
1272
+ column.id
1273
+ )}
1059
1274
  onClick={(e) =>
1060
1275
  handleCellClick(rowId, column.id, e)
1061
1276
  }