@xcelsior/ui-spreadsheets 1.1.10 → 1.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcelsior/ui-spreadsheets",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -1591,6 +1591,127 @@ export const WithCheckboxColumns: Story = {
1591
1591
  },
1592
1592
  };
1593
1593
 
1594
+ // Pinned columns with many columns and column groups
1595
+ interface StockData {
1596
+ id: number;
1597
+ ticker: string;
1598
+ company: string;
1599
+ sector: string;
1600
+ industry: string;
1601
+ marketCap: number;
1602
+ price: number;
1603
+ change: number;
1604
+ changePercent: number;
1605
+ volume: number;
1606
+ avgVolume: number;
1607
+ pe: number;
1608
+ eps: number;
1609
+ dividend: number;
1610
+ beta: number;
1611
+ week52High: number;
1612
+ week52Low: number;
1613
+ revenue: number;
1614
+ profit: number;
1615
+ }
1616
+
1617
+ const tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 'TSLA', 'NVDA', 'JPM', 'V', 'JNJ', 'WMT', 'PG', 'MA', 'UNH', 'HD', 'DIS', 'BAC', 'XOM', 'PFE', 'KO', 'PEP', 'CSCO', 'ADBE', 'NFLX', 'INTC', 'CRM', 'ABT', 'NKE', 'MRK', 'TMO', 'ORCL', 'ACN', 'MDT', 'COST', 'LLY', 'AVGO', 'TXN', 'NEE', 'UNP', 'DHR', 'QCOM', 'PM', 'BMY', 'RTX', 'HON', 'UPS', 'LOW', 'AMGN', 'SBUX', 'IBM'];
1618
+ const companies = ['Apple Inc.', 'Microsoft Corp.', 'Alphabet Inc.', 'Amazon.com Inc.', 'Meta Platforms', 'Tesla Inc.', 'NVIDIA Corp.', 'JPMorgan Chase', 'Visa Inc.', 'Johnson & Johnson', 'Walmart Inc.', 'Procter & Gamble', 'Mastercard', 'UnitedHealth', 'Home Depot', 'Walt Disney', 'Bank of America', 'Exxon Mobil', 'Pfizer Inc.', 'Coca-Cola', 'PepsiCo Inc.', 'Cisco Systems', 'Adobe Inc.', 'Netflix Inc.', 'Intel Corp.', 'Salesforce', 'Abbott Labs', 'Nike Inc.', 'Merck & Co.', 'Thermo Fisher', 'Oracle Corp.', 'Accenture', 'Medtronic', 'Costco', 'Eli Lilly', 'Broadcom', 'Texas Instruments', 'NextEra Energy', 'Union Pacific', 'Danaher', 'Qualcomm', 'Philip Morris', 'Bristol-Myers', 'Raytheon', 'Honeywell', 'UPS', 'Lowes', 'Amgen', 'Starbucks', 'IBM'];
1619
+ const sectors = ['Technology', 'Healthcare', 'Financials', 'Consumer', 'Energy', 'Industrials'];
1620
+ const industries = ['Software', 'Hardware', 'Biotech', 'Banking', 'Retail', 'Semiconductors', 'Pharma', 'Media', 'Oil & Gas', 'Aerospace'];
1621
+
1622
+ const sampleStocks: StockData[] = Array.from({ length: 50 }, (_, i) => ({
1623
+ id: i + 1,
1624
+ ticker: tickers[i],
1625
+ company: companies[i],
1626
+ sector: sectors[i % sectors.length],
1627
+ industry: industries[i % industries.length],
1628
+ marketCap: Math.round((50 + Math.random() * 2950) * 1e9),
1629
+ price: Math.round((20 + Math.random() * 480) * 100) / 100,
1630
+ change: Math.round((Math.random() * 20 - 10) * 100) / 100,
1631
+ changePercent: Math.round((Math.random() * 10 - 5) * 100) / 100,
1632
+ volume: Math.round(Math.random() * 50e6),
1633
+ avgVolume: Math.round(Math.random() * 40e6),
1634
+ pe: Math.round((5 + Math.random() * 60) * 100) / 100,
1635
+ eps: Math.round((0.5 + Math.random() * 15) * 100) / 100,
1636
+ dividend: Math.round(Math.random() * 5 * 100) / 100,
1637
+ beta: Math.round((0.5 + Math.random() * 1.5) * 100) / 100,
1638
+ week52High: Math.round((100 + Math.random() * 400) * 100) / 100,
1639
+ week52Low: Math.round((20 + Math.random() * 200) * 100) / 100,
1640
+ revenue: Math.round(Math.random() * 400e9),
1641
+ profit: Math.round(Math.random() * 80e9),
1642
+ }));
1643
+
1644
+ const stockColumns: SpreadsheetColumn<StockData>[] = [
1645
+ { id: 'ticker', label: 'Ticker', width: 80, sortable: true, filterable: true },
1646
+ { id: 'company', label: 'Company', width: 180, sortable: true, filterable: true },
1647
+ { id: 'sector', label: 'Sector', width: 120, sortable: true, filterable: true, type: 'select', options: sectors },
1648
+ { id: 'industry', label: 'Industry', width: 140, sortable: true, filterable: true },
1649
+ { id: 'marketCap', label: 'Market Cap', width: 130, sortable: true, type: 'number', align: 'right', render: (v) => `$${(v / 1e9).toFixed(1)}B` },
1650
+ { id: 'price', label: 'Price', width: 90, sortable: true, type: 'number', align: 'right', render: (v) => `$${v.toFixed(2)}` },
1651
+ { id: 'change', label: 'Change', width: 90, sortable: true, type: 'number', align: 'right', render: (v) => `${v >= 0 ? '+' : ''}${v.toFixed(2)}` },
1652
+ { id: 'changePercent', label: 'Change %', width: 100, sortable: true, type: 'number', align: 'right', render: (v) => `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` },
1653
+ { id: 'volume', label: 'Volume', width: 110, sortable: true, type: 'number', align: 'right', render: (v) => `${(v / 1e6).toFixed(1)}M` },
1654
+ { id: 'avgVolume', label: 'Avg Volume', width: 110, sortable: true, type: 'number', align: 'right', render: (v) => `${(v / 1e6).toFixed(1)}M` },
1655
+ { id: 'pe', label: 'P/E', width: 80, sortable: true, type: 'number', align: 'right' },
1656
+ { id: 'eps', label: 'EPS', width: 80, sortable: true, type: 'number', align: 'right', render: (v) => `$${v.toFixed(2)}` },
1657
+ { id: 'dividend', label: 'Dividend %', width: 100, sortable: true, type: 'number', align: 'right', render: (v) => `${v.toFixed(2)}%` },
1658
+ { id: 'beta', label: 'Beta', width: 70, sortable: true, type: 'number', align: 'right' },
1659
+ { id: 'week52High', label: '52W High', width: 100, sortable: true, type: 'number', align: 'right', render: (v) => `$${v.toFixed(2)}` },
1660
+ { id: 'week52Low', label: '52W Low', width: 100, sortable: true, type: 'number', align: 'right', render: (v) => `$${v.toFixed(2)}` },
1661
+ { id: 'revenue', label: 'Revenue', width: 120, sortable: true, type: 'number', align: 'right', render: (v) => `$${(v / 1e9).toFixed(1)}B` },
1662
+ { id: 'profit', label: 'Net Income', width: 120, sortable: true, type: 'number', align: 'right', render: (v) => `$${(v / 1e9).toFixed(1)}B` },
1663
+ ];
1664
+
1665
+ const stockColumnGroups: SpreadsheetColumnGroup[] = [
1666
+ { id: 'identity', label: 'Identity', columns: ['ticker', 'company'], collapsible: true, headerColor: '#dbeafe' },
1667
+ { id: 'classification', label: 'Classification', columns: ['sector', 'industry'], collapsible: true, headerColor: '#fef3c7' },
1668
+ { id: 'market', label: 'Market Data', columns: ['marketCap', 'price', 'change', 'changePercent', 'volume', 'avgVolume'], collapsible: true, headerColor: '#d1fae5' },
1669
+ { id: 'fundamentals', label: 'Fundamentals', columns: ['pe', 'eps', 'dividend', 'beta'], collapsible: true, headerColor: '#fce7f3' },
1670
+ { id: 'range', label: '52-Week Range', columns: ['week52High', 'week52Low'], collapsible: true, headerColor: '#e0e7ff' },
1671
+ { id: 'financials', label: 'Financials', columns: ['revenue', 'profit'], collapsible: true, headerColor: '#fef9c3' },
1672
+ ];
1673
+
1674
+ export const PinnedColumnsWithGroups: Story = {
1675
+ render: () => {
1676
+ return (
1677
+ <div className="p-4">
1678
+ <div className="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
1679
+ <h3 className="font-semibold text-blue-900 mb-2">
1680
+ Pinned Columns with Column Groups
1681
+ </h3>
1682
+ <p className="text-sm text-blue-700 mb-3">
1683
+ This example tests pinned columns with many columns and column groups.
1684
+ Ticker and Company are pinned to the left. Scroll horizontally to verify
1685
+ pinned columns stay in place. Try collapsing groups and pinning/unpinning
1686
+ columns.
1687
+ </p>
1688
+ <ul className="text-sm text-blue-700 space-y-1 ml-4 list-disc">
1689
+ <li><strong>Ticker & Company</strong> are pinned to the left</li>
1690
+ <li><strong>19 columns</strong> across 6 collapsible groups</li>
1691
+ <li>Click the pin icon on any column header to pin/unpin</li>
1692
+ <li>Collapse groups to verify pinned columns from collapsed groups still work</li>
1693
+ </ul>
1694
+ </div>
1695
+
1696
+ <Spreadsheet
1697
+ data={sampleStocks}
1698
+ columns={stockColumns}
1699
+ columnGroups={stockColumnGroups}
1700
+ getRowId={(row) => row.id}
1701
+ settings={{
1702
+ defaultPinnedColumns: ['__row_index__', 'ticker', 'company'],
1703
+ }}
1704
+ showToolbar
1705
+ showPagination
1706
+ enableRowSelection
1707
+ enableCellEditing={false}
1708
+ pageSizeOptions={[25, 50]}
1709
+ />
1710
+ </div>
1711
+ );
1712
+ },
1713
+ };
1714
+
1594
1715
  // With Right-Pinned Columns
1595
1716
  export const WithRightPinnedColumns: Story = {
1596
1717
  render: () => {
@@ -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, SpreadsheetColumn, SpreadsheetProps } from '../types';
29
+ import type { CellEdit, SpreadsheetColumn, SpreadsheetColumnGroup, SpreadsheetProps } from '../types';
30
30
 
31
31
  type SingleCellEdit = {
32
32
  rowId: string | number;
@@ -635,6 +635,7 @@ export function Spreadsheet<T extends Record<string, any>>({
635
635
  }, [setHighlightPickerColumn]);
636
636
 
637
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
638
639
  const columnRenderItems = useMemo(() => {
639
640
  if (!columnGroups || columnGroups.length === 0) {
640
641
  return visibleColumns.map((col) => ({
@@ -643,20 +644,19 @@ export function Spreadsheet<T extends Record<string, any>>({
643
644
  }));
644
645
  }
645
646
 
646
- const items: Array<
647
- | { type: 'column'; column: SpreadsheetColumn<T> }
648
- | {
649
- type: 'collapsed-placeholder';
650
- groupId: string;
651
- headerColor?: string;
652
- }
653
- > = [];
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
654
 
655
655
  for (const group of columnGroups) {
656
656
  const isCollapsed = collapsedGroups.has(group.id);
657
657
 
658
658
  if (isCollapsed) {
659
- items.push({
659
+ middleItems.push({
660
660
  type: 'collapsed-placeholder',
661
661
  groupId: group.id,
662
662
  headerColor: group.headerColor,
@@ -671,7 +671,14 @@ export function Spreadsheet<T extends Record<string, any>>({
671
671
  });
672
672
 
673
673
  for (const col of groupVisibleCols) {
674
- items.push({ type: 'column', column: col });
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
+ }
675
682
  }
676
683
  }
677
684
 
@@ -681,13 +688,118 @@ export function Spreadsheet<T extends Record<string, any>>({
681
688
  );
682
689
  for (const col of visibleColumns) {
683
690
  if (!allGroupedIds.has(col.id)) {
684
- items.push({ type: 'column', column: col });
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
+ }
685
699
  }
686
700
  }
687
701
 
688
- return items;
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];
689
718
  }, [columnGroups, collapsedGroups, columns, pinnedColumns, visibleColumns]);
690
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
+
691
803
  // ==================== RENDER ====================
692
804
 
693
805
  return (
@@ -733,7 +845,7 @@ export function Spreadsheet<T extends Record<string, any>>({
733
845
  <table className="w-full border-separate border-spacing-0 text-xs select-none">
734
846
  <thead>
735
847
  {/* Column Group Headers */}
736
- {columnGroups && (
848
+ {columnGroups && groupHeaderItems && (
737
849
  <tr>
738
850
  {/* Row index column header (rowSpan=2 for groups) */}
739
851
  <RowIndexColumnHeader
@@ -745,19 +857,41 @@ export function Spreadsheet<T extends Record<string, any>>({
745
857
  hasColumnGroups={true}
746
858
  compactMode={effectiveCompactMode}
747
859
  />
748
- {columnGroups.map((group) => {
749
- const groupColumns = (columns || []).filter((c) =>
750
- group.columns.includes(c.id)
751
- );
752
- const isCollapsed = collapsedGroups.has(group.id);
753
- const visibleGroupColumns = isCollapsed
754
- ? groupColumns.filter((c) => pinnedColumns.has(c.id))
755
- : groupColumns;
756
- const colSpan = Math.max(
757
- 1,
758
- visibleGroupColumns.length + (isCollapsed ? 1 : 0)
759
- );
760
-
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
+ width:
887
+ col?.minWidth || col?.width,
888
+ maxWidth:
889
+ col?.minWidth || col?.width,
890
+ }}
891
+ />
892
+ );
893
+ }
894
+ const { group, colSpan, isCollapsed } = item;
761
895
  return (
762
896
  <th
763
897
  key={group.id}
@@ -273,6 +273,11 @@ const SpreadsheetCell: React.FC<SpreadsheetCellProps> = ({
273
273
  style={{
274
274
  backgroundColor: isInSelection ? 'rgb(239 246 255)' : getBackgroundColor(),
275
275
  minWidth: column.minWidth || column.width,
276
+ // Pinned columns need fixed width so sticky offset calculations are accurate
277
+ ...(isPinned && {
278
+ width: column.minWidth || column.width,
279
+ maxWidth: column.minWidth || column.width,
280
+ }),
276
281
  ...positionStyles,
277
282
  ...selectionBorderStyles,
278
283
  }}
@@ -71,6 +71,11 @@ export const SpreadsheetHeader: React.FC<
71
71
  style={{
72
72
  backgroundColor: highlightColor || 'rgb(243 244 246)', // gray-100
73
73
  minWidth: column.minWidth || column.width,
74
+ // Pinned columns need fixed width so sticky offset calculations are accurate
75
+ ...(isPinned && {
76
+ width: column.minWidth || column.width,
77
+ maxWidth: column.minWidth || column.width,
78
+ }),
74
79
  top: 0, // For sticky header
75
80
  ...positionStyles,
76
81
  }}