@carbon/ibm-products
Version:
Carbon for IBM Products
564 lines (548 loc) • 21.5 kB
JavaScript
/**
* Copyright IBM Corp. 2020, 2025
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
;
var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js');
var React = require('react');
var index = require('../../_virtual/index.js');
var reactWindow = require('react-window');
var cx = require('classnames');
var layout = require('@carbon/layout');
var settings = require('../../settings.js');
var deepCloneObject = require('../../global/js/utils/deepCloneObject.js');
var removeCellSelections = require('./utils/removeCellSelections.js');
var createCellSelectionArea = require('./utils/createCellSelectionArea.js');
var checkActiveHeaderCell = require('./utils/checkActiveHeaderCell.js');
var checkSelectedHeaderCell = require('./utils/checkSelectedHeaderCell.js');
var getSpreadsheetWidth = require('./utils/getSpreadsheetWidth.js');
var commonEventHandlers = require('./utils/commonEventHandlers.js');
var propsHelper = require('../../global/js/utils/props-helper.js');
var usePreviousValue = require('../../global/js/hooks/usePreviousValue.js');
var useSpreadsheetMouseUp = require('./hooks/useSpreadsheetMouseUp.js');
var _div;
const blockClass = `${settings.pkg.prefix}--data-spreadsheet`;
const DataSpreadsheetBody = /*#__PURE__*/React.forwardRef((_ref, ref) => {
let {
activeCellRef,
columns,
activeCellCoordinates,
defaultColumn,
defaultEmptyRowCount,
getTableBodyProps,
headerGroups,
setCurrentColumns,
id,
onDataUpdate,
renderRowHeader,
renderRowHeaderDirection,
hasCustomRowHeader,
prepareRow,
rows,
selectionAreaData,
setSelectionAreaData,
setActiveCellCoordinates,
selectedHeaderReorderActive,
setSelectedHeaderReorderActive,
selectionAreas,
setContainerHasFocus,
setSelectionAreas,
scrollBarSize,
totalColumnsWidth,
clickAndHoldActive,
setClickAndHoldActive,
currentMatcher,
setCurrentMatcher,
onSelectionAreaChange,
setActiveCellInsideSelectionArea,
totalVisibleColumns,
setHeaderCellHoldActive,
setColumnOrder,
visibleColumns
} = _ref;
const [validStartingPoint, setValidStartingPoint] = React.useState(false);
const contentScrollRef = React.useRef(undefined);
const previousState = usePreviousValue.usePreviousValue({
selectionAreaData,
clickAndHoldActive,
rowHeight: defaultColumn?.rowHeight
}) || {};
// Set custom css property containing the spreadsheet total width
React.useEffect(() => {
ref?.current.style.setProperty(`--${blockClass}--total-width`, layout.px((totalColumnsWidth || 0) + (scrollBarSize || 0)));
}, [ref, scrollBarSize, totalColumnsWidth]);
// Call the `onSelectionAreaChange` handler to send selection area data
// back to the consumer
React.useEffect(() => {
if (selectionAreaData?.length) {
let selectionChanged = false;
if (previousState?.selectionAreaData?.length !== selectionAreaData?.length || selectionAreaData?.[0]?.cells.length !== previousState?.selectionAreaData?.[0]?.cells.length) {
selectionChanged = true;
}
if (!clickAndHoldActive && previousState?.clickAndHoldActive || selectionChanged) {
onSelectionAreaChange?.(selectionAreaData);
}
}
}, [previousState?.selectionAreaData, selectionAreaData, onSelectionAreaChange, clickAndHoldActive, previousState?.clickAndHoldActive]);
// Create cell selection areas based on selectionAreas array
React.useEffect(() => {
if (selectionAreas && selectionAreas.length) {
selectionAreas.map(area => {
// Setup selection area data that will be sent back to consumer via onSelectionAreaChange prop
if (area.areaCreated) {
const rowStart = Math.min(area.point1.row, area.point2.row);
const rowEnd = Math.max(area.point1.row, area.point2.row);
const columnStart = Math.min(area.point1.column, area.point2.column);
const columnEnd = Math.max(area.point1.column, area.point2.column);
const selectionData = {
rows: {
start: rowStart,
end: rowEnd
},
columns: {
start: columnStart,
end: columnEnd
},
cells: populateSelectionAreaCellData({
rowStart,
rowEnd,
columnStart,
columnEnd
}),
selectionId: area.matcher
};
setSelectionAreaData?.(prev => {
const prevValues = deepCloneObject.deepCloneObject(prev);
const newAreaData = prevValues.filter(item => item.selectionId !== area.matcher);
return [...newAreaData, selectionData];
});
}
if (!area.areaCreated && area.point1 && area.point2 && area.matcher) {
createCellSelectionArea.createCellSelectionArea({
ref,
area,
blockClass,
defaultColumn,
selectionAreas,
setSelectionAreas,
setActiveCellInsideSelectionArea,
visibleColumns
});
}
return;
});
}
}, [selectionAreas, setSelectionAreas, defaultColumn, onSelectionAreaChange, setSelectionAreaData, ref, activeCellCoordinates, setActiveCellInsideSelectionArea, visibleColumns, hasCustomRowHeader]);
const populateSelectionAreaCellData = _ref2 => {
let {
rowStart,
rowEnd,
columnStart,
columnEnd
} = _ref2;
const cellContainer = [];
for (let rowIndex = rowStart; rowIndex <= rowEnd; rowIndex++) {
for (let columnIndex = columnStart; columnIndex <= columnEnd; columnIndex++) {
cellContainer.push([rowIndex, columnIndex, `${blockClass}__cell--${rowIndex}--${columnIndex}`]);
}
}
return cellContainer;
};
useSpreadsheetMouseUp.useSpreadsheetMouseUp({
currentMatcher,
setClickAndHoldActive,
setSelectionAreas,
setValidStartingPoint,
selectedHeaderReorderActive,
setSelectedHeaderReorderActive,
validStartingPoint,
ref,
setHeaderCellHoldActive,
setColumnOrder,
visibleColumns,
setActiveCellCoordinates,
rows,
activeCellCoordinates,
defaultColumn,
selectionAreas
});
// Make sure that if the cellSize prop changes, the active
// cell also gets updated with the new size and new top placement
// value. All of the cell selections will be updated as well
React.useEffect(() => {
let listContainer;
let activeCellButton;
if (spreadsheetBodyRef?.current) {
listContainer = spreadsheetBodyRef?.current;
activeCellButton = listContainer?.querySelector(`.${blockClass}__active-cell--highlight`);
}
if (activeCellButton && defaultColumn?.rowHeight !== previousState.rowHeight) {
activeCellButton.style.height = `${defaultColumn?.rowHeight}px`;
if (activeCellCoordinates) {
const activeTargetElement = ref?.current.querySelector(`[data-row-index="${activeCellCoordinates.row}"][data-column-index="${activeCellCoordinates.column}"]`);
const listContainer = ref?.current.querySelector(`.${blockClass}__list--container`);
let newActiveCellTopPosition;
if (activeTargetElement && listContainer) {
newActiveCellTopPosition = activeTargetElement?.getBoundingClientRect().top - listContainer.getBoundingClientRect().top;
}
activeCellButton.style.top = layout.px(newActiveCellTopPosition);
removeCellSelections.removeCellSelections({
matcher: undefined,
spreadsheetRef: ref
});
selectionAreas?.map(area => {
if (!area.areaCreated && area.point1 && area.point2 && area.matcher) {
return createCellSelectionArea.createCellSelectionArea({
ref,
area,
blockClass,
defaultColumn,
selectionAreas,
setSelectionAreas,
setActiveCellInsideSelectionArea,
visibleColumns
});
}
});
}
}
}, [defaultColumn, ref, activeCellCoordinates, previousState?.rowHeight, selectionAreas, setActiveCellInsideSelectionArea, setSelectionAreas, visibleColumns, hasCustomRowHeader]);
//this method will check for any duplicate selection area and remove.
//same selections are those have the same height, width, top, left styles. These inline styles are being set in createCellSelection util.
const removeDuplicateSelections = React.useCallback(() => {
const uniqueAttrArray = [],
removedSelectionAreaMatcherArr = [];
ref?.current.querySelectorAll(`.${blockClass}__selection-area--element`).forEach(selectorEl => {
const {
top,
left,
height,
width
} = selectorEl.style;
const uniqueAttrStr = `${top}${left}${height}${width}`; // eg: 20px30px70px90px
if (uniqueAttrArray.indexOf(uniqueAttrStr) == -1) {
uniqueAttrArray.push(uniqueAttrStr);
} else {
selectorEl.remove(); // this is identified as duplicate selection and hence removing.
removedSelectionAreaMatcherArr.push(selectorEl.getAttribute('data-matcher-id'));
}
});
//clean the duplicate selectionAreaData and selectionArea
if (removedSelectionAreaMatcherArr.length) {
setSelectionAreas?.(prev => {
const prevValues = deepCloneObject.deepCloneObject(prev);
return prevValues.filter(item => !removedSelectionAreaMatcherArr.includes(item.matcher));
});
setSelectionAreaData?.(prev => {
const prevValues = deepCloneObject.deepCloneObject(prev);
return prevValues.filter(item => !removedSelectionAreaMatcherArr.includes(item.selectionId));
});
}
}, [ref, setSelectionAreas, setSelectionAreaData]);
//selectionAreas will be set when ever a selection area is made.
React.useEffect(() => {
removeDuplicateSelections();
}, [selectionAreas, removeDuplicateSelections]);
// onClick fn for each cell in the data spreadsheet body,
// adds the active cell highlight
const handleBodyCellClickEvent = React.useCallback((cell, columnIndex) => {
return event => {
commonEventHandlers.handleBodyCellClick(cell, columnIndex, event, currentMatcher, activeCellCoordinates, selectionAreas, setActiveCellCoordinates, setSelectionAreas, setContainerHasFocus, setClickAndHoldActive, setCurrentMatcher, ref, setSelectionAreaData, setActiveCellInsideSelectionArea, activeCellRef, setValidStartingPoint);
};
}, [currentMatcher, activeCellCoordinates, selectionAreas, setActiveCellCoordinates, setSelectionAreas, setContainerHasFocus, setClickAndHoldActive, setCurrentMatcher, ref, setSelectionAreaData, setActiveCellInsideSelectionArea, activeCellRef]);
const handleBodyScroll = () => {
const headerRowElement = ref.current.querySelector(`
.${blockClass}__header--container .${blockClass}__tr`) || new HTMLDivElement();
headerRowElement.scrollLeft = contentScrollRef?.current.scrollLeft;
};
React.useEffect(() => {
contentScrollRef.current.addEventListener('scroll', () => handleBodyScroll());
const contentScrollElementRef = contentScrollRef.current || new HTMLElement();
return () => {
contentScrollElementRef.removeEventListener('scroll', handleBodyScroll);
};
});
const handleBodyCellHoverEvent = React.useCallback((cell, columnIndex) => {
return () => {
commonEventHandlers.handleBodyCellHover(cell, columnIndex, clickAndHoldActive, currentMatcher, setSelectionAreas);
};
}, [clickAndHoldActive, currentMatcher, setSelectionAreas]);
const handleRowHeaderClickEvent = React.useCallback(index => {
return event => {
commonEventHandlers.handleRowHeaderClick(index, event, columns, ref, setSelectionAreas, setCurrentMatcher, setActiveCellCoordinates, activeCellCoordinates, rows, setSelectionAreaData);
};
}, [columns, ref, setSelectionAreas, setCurrentMatcher, setActiveCellCoordinates, activeCellCoordinates, rows, setSelectionAreaData]);
// Builds the empty rows and calls `onDataUpdate` to set the new empty rows
// using defaultEmptyRowCount to determine how many empty rows are created.
React.useEffect(() => {
if (!rows?.length) {
const buildEmptyRows = () => {
const emptyRowData = [];
[...Array(defaultEmptyRowCount)].map(() => {
const emptyCell = {};
headerGroups?.[0]?.headers.map(header => {
emptyCell[header.id] = null;
});
emptyRowData.push(emptyCell);
});
onDataUpdate?.(emptyRowData);
};
buildEmptyRows();
}
if (headerGroups?.[0] && typeof setCurrentColumns === 'function') {
const headers = headerGroups[0].headers;
setCurrentColumns(headers);
}
}, [rows, headerGroups, defaultEmptyRowCount, onDataUpdate, setCurrentColumns]);
const RenderEmptyRows = () => {
return _div || (_div = /*#__PURE__*/React.createElement("div", null));
};
// Renders each row/cell in the spreadsheet body
const RenderRow = React.useCallback(_ref3 => {
let {
index,
style
} = _ref3;
const row = rows?.[index];
if (rows && rows.length) {
prepareRow?.(row);
const rowProps = propsHelper.prepareProps(row.getRowProps({
style
}), 'key');
return /*#__PURE__*/React.createElement("div", _rollupPluginBabelHelpers.extends({
key: {
...row.getRowProps().key
}
}, rowProps, {
className: cx(`${blockClass}__tr`),
"data-row-index": index,
"aria-rowindex": index + 1,
"aria-owns": `${blockClass}__cell-editor-text-area`
}), /*#__PURE__*/React.createElement("div", {
role: "rowheader",
className: `${blockClass}__td-th--cell-container`
}, /*#__PURE__*/React.createElement("button", {
id: `${blockClass}__cell--${index}--header`,
tabIndex: -1,
"data-row-index": index,
"data-column-index": "header",
type: "button",
onClick: handleRowHeaderClickEvent(index),
className: cx(`${blockClass}__td`, `${blockClass}__td-th`, `${blockClass}--interactive-cell-element`, {
[`${blockClass}__td_custom`]: hasCustomRowHeader ? true : false,
[`${blockClass}__td-th--active-header`]: !hasCustomRowHeader && (activeCellCoordinates?.row === index || checkActiveHeaderCell.checkActiveHeaderCell(index, selectionAreas, 'row')),
[`${blockClass}__td-th--selected-header`]: !hasCustomRowHeader && checkSelectedHeaderCell.checkSelectedHeaderCell(index, selectionAreas, 'row', columns)
}),
style: {
width: defaultColumn?.rowHeaderWidth,
flexDirection: hasCustomRowHeader ? renderRowHeaderDirection === 'Left' ? 'row-reverse' : row : undefined
}
}, index + 1, hasCustomRowHeader && typeof renderRowHeader === 'function' && renderRowHeader(index))), row.cells.map((cell, index) => {
const cellProps = propsHelper.prepareProps(cell.getCellProps(), 'key');
return /*#__PURE__*/React.createElement("div", _rollupPluginBabelHelpers.extends({
key: `cell_${index}`,
"aria-colindex": index + 1
}, cellProps, {
role: "gridcell",
style: {
...cell.getCellProps().style,
display: 'grid',
minWidth: cell?.column?.width || defaultColumn?.width
}
}), /*#__PURE__*/React.createElement("button", {
id: `${blockClass}__cell--${cell.row.index}--${index}`,
tabIndex: -1,
"data-row-index": cell.row.index,
"data-column-index": index,
className: cx(`${blockClass}__td`, `${blockClass}__body--td`, `${blockClass}--interactive-cell-element`),
onMouseDown: handleBodyCellClickEvent(cell, index),
onMouseOver: handleBodyCellHoverEvent(cell, index),
onFocus: () => {},
type: "button"
}, cell.render('Cell')));
}));
}
}, [prepareRow, renderRowHeader, rows, hasCustomRowHeader, activeCellCoordinates?.row, selectionAreas, handleRowHeaderClickEvent, handleBodyCellClickEvent, handleBodyCellHoverEvent, columns, defaultColumn, renderRowHeaderDirection]);
const spreadsheetBodyRef = React.useRef(undefined);
return /*#__PURE__*/React.createElement("div", _rollupPluginBabelHelpers.extends({
ref: spreadsheetBodyRef,
className: cx(`${blockClass}__body--container`)
}, getTableBodyProps?.()), /*#__PURE__*/React.createElement(reactWindow.FixedSizeList, {
className: cx(`${blockClass}__list--container`, `${blockClass}__list--container--${id}`),
height: 400,
itemCount: rows?.length || defaultEmptyRowCount,
itemSize: defaultColumn?.rowHeight,
width: getSpreadsheetWidth.getSpreadsheetWidth({
headerGroup: undefined,
type: undefined,
scrollBarSizeValue: scrollBarSize,
totalVisibleColumns,
defaultColumn,
totalColumnsWidth,
visibleColumns
}),
outerRef: contentScrollRef
}, rows?.length ? RenderRow : RenderEmptyRows));
});
DataSpreadsheetBody.propTypes = {
/**
* Object containing the active cell coordinates
*/
/**@ts-ignore */
activeCellCoordinates: index.default.shape({
row: index.default.oneOfType([index.default.string, index.default.number]),
column: index.default.oneOfType([index.default.string, index.default.number])
}),
/**
*This is the ref of the button input, which is the active cell element
*/
/**@ts-ignore */
activeCellRef: index.default.object,
/**
* Is the user clicking and holding in the data spreadsheet body
*/
clickAndHoldActive: index.default.bool,
/**
* All of the spreadsheet columns
*/
columns: index.default.array,
/**
* This represents the id of the current cell selection area
*/
currentMatcher: index.default.string,
/**
* Default spreadsheet sizing values
*/
/**@ts-ignore */
defaultColumn: index.default.shape({
rowHeight: index.default.number,
rowHeaderWidth: index.default.number,
width: index.default.number
}),
/**
* Sets the number of empty rows to be created when there is no data provided
*/
defaultEmptyRowCount: index.default.number,
/**
* Function to set table body prop values
*/
/**@ts-ignore */
getTableBodyProps: index.default.func,
/**
* Check if spreadsheet is using custom row header component attached
*/
hasCustomRowHeader: index.default.bool,
/**
* Headers provided from useTable hook
*/
headerGroups: index.default.arrayOf(index.default.object),
/**
* The spreadsheet id
*/
id: index.default.oneOfType([index.default.number, index.default.string]),
/**
* The event handler that is called when the active cell changes
*/
onActiveCellChange: index.default.func,
/**
* The event handler that is called to set the rows for the empty spreadsheet
*/
onDataUpdate: index.default.func,
/**
* The event handler that is called when the selection areas change
*/
onSelectionAreaChange: index.default.func,
/**
* Prepare row function from react-table
*/
prepareRow: index.default.func,
/**
* Component next to numbering rows
*/
renderRowHeader: index.default.func,
/**
* Component next to numbering rows
*/
renderRowHeaderDirection: index.default.string,
/**
* All of the spreadsheet row data
*/
rows: index.default.arrayOf(index.default.object),
/**
* The scrollbar width
*/
scrollBarSize: index.default.number,
/**
* Header reordering is active
*/
selectedHeaderReorderActive: index.default.bool,
/**
* Array of selection area data
*/
selectionAreaData: index.default.array,
/**
* Array of selection areas
*/
selectionAreas: index.default.array,
/**
* Setter fn for activeCellCoordinates state value
*/
setActiveCellCoordinates: index.default.func,
/**
* Setter fn for active cell inside of selection area
*/
setActiveCellInsideSelectionArea: index.default.func,
/**
* Setter fn for clickAndHold state value
*/
setClickAndHoldActive: index.default.func,
/**
* Setter fn for column ordering, provided from react-table
*/
setColumnOrder: index.default.func,
/**
* Setter fn for containerHasFocus state value
*/
setContainerHasFocus: index.default.func,
/**
* Set current columns after drag drop
*/
setCurrentColumns: index.default.func,
/**
* Setter fn for currentMatcher state value
*/
setCurrentMatcher: index.default.func,
/**
* Setter fn for header cell hold active value
*/
setHeaderCellHoldActive: index.default.func,
/**
* Set header reordering active or not
*/
setSelectedHeaderReorderActive: index.default.func,
/**
* Setter fn for selectionAreaData state value
*/
setSelectionAreaData: index.default.func,
/**
* Setter fn for selectionAreas state value
*/
setSelectionAreas: index.default.func,
/**
* The total columns width
*/
totalColumnsWidth: index.default.number,
/**
* The total number of columns to be initially visible, additional columns will be rendered and
* visible via horizontal scrollbar
*/
totalVisibleColumns: index.default.number,
/**
* Prop from react-table used to reorder columns
*/
/**@ts-ignore */
visibleColumns: index.default.array
};
exports.DataSpreadsheetBody = DataSpreadsheetBody;