UNPKG

react-ts-tab-lib

Version:

A React Typescript library rendering a Table compenent receiving in props for rows an array of a generic type T (precised when using the component) and for columns an array of objects of type Column<T> precising the displayName, the key of the T type and

367 lines (366 loc) 25.1 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { ChevronDownIcon } from '@heroicons/react/24/solid'; import { Button } from '@heroui/button'; import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "@heroui/dropdown"; import { useEffect, useMemo, useRef, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; import '../../index.css'; /** * Generates an array of page numbers for the pagination component. * * The array will contain the first page number, the last page number, and the current page number, * as well as the two page numbers before and after the current page number. * If the total number of pages is greater than 5, the array will also contain the string '...'. * To avoid having multiple '...' in a row, the function will remove any '...' that are not * preceded by a number and not followed by a number. * * @param current - The current page number. * @param total - The total number of pages. * * @returns An array of page numbers and/or the string '...'. */ const generatePagesNumbers = (current, total) => { if (total <= 1) return []; const delta = 1; // Number of page numbers to show before and after the current page const pages = []; for (let i = 1; i <= total; i++) { if (i === 1 || i === total || (i >= current - delta && i <= current + delta)) { pages.push(i); } else if (i !== 1 && i <= current - delta - 1 || i !== total && i >= current + delta + 1) { pages.push('...'); } } const uniquePages = []; for (let i = 0; i < pages.length; i++) { if (pages[i] !== '...') { uniquePages.push(pages[i]); } else if (pages[i] === '...' && pages[i - 1] !== '...') { uniquePages.push(pages[i]); } } return uniquePages; }; /** * The Table component renders a table with a specified number of columns and rows. * * Props: * columns: Column<T>[] * rows: T[] * onRowHover: (row: T | null) => void * onRowClick: (row: T | null) => void * classNames: ClassNames * rangeLengthOptions: number[] | undefined * defaultOrder: ActiveOrderType<T> | null * textContent: TextContentType | null * * State: * allRows: T[] * columnsWidth: number[] * activeOrder: { property: keyof T; order: OrderType } * currentPage: number * pagesNumber: number * displayedRange: T[] * hoveredRow: T | null * selectedKeys: Selection * * Effects: * Updates the sample length when the user selects a new number of displayed entries. * Resets the current page to 1. * Filters the rows based on the search input value. * Generates an array of page numbers for the pagination component. * Updates the columns width when the component is mounted. * * Renders: * A table with the specified number of columns and rows. * A select menu for selecting the number of displayed entries. * A search bar for filtering the rows. * A pagination component for navigating between pages. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function Table({ columns = [], rows = [], onRowHover, onRowClick, styleClassNames, rangeLengthOptions = [10, 20, 50, 100], defaultOrder, textContent }) { const [rangeLength, setRangeLength] = useState(rangeLengthOptions[0]); // Max number of displayed rows at once const [allRows, setAllRows] = useState(rows); const [currentPage, setCurrentPage] = useState(1); const [pagesNumber, setPagesNumber] = useState(1); const [displayedRange, setDisplayedRange] = useState([]); // Rows currently displayed (on the current page) const [activeOrder, setActiveOrder] = useState({ property: defaultOrder?.property ?? columns[0].property, order: defaultOrder?.order ?? 'asc' }); const [columnsWidth, setColumnsWidth] = useState([]); const [hoveredRow, setHoveredRow] = useState(null); console.log(hoveredRow); const [selectedKeys, setSelectedKeys] = useState(new Set([10])); const columnHeaderRef = useRef(null); const columnNameRef = useRef(null); const rowRef = useRef(null); /** * Order all rows based on the active order and the property type */ useEffect(() => { const propertyType = columns.find((column) => column.property === activeOrder.property)?.type; const sortedRows = allRows.map(row => ({ row, value: row[activeOrder.property] })) .sort((a, b) => { if (activeOrder.order === 'asc') { if (propertyType === 'string') { return a.value.localeCompare(b.value); } else if (propertyType === 'number') { return a.value - b.value; } else if (propertyType === 'date') { const aDate = new Date(a.value); const bDate = new Date(b.value); return aDate.getTime() - bDate.getTime(); } else if (propertyType === 'boolean') { return b.value - a.value; } else { return 0; } } else { if (propertyType === 'string') { return b.value.localeCompare(a.value); } else if (propertyType === 'number') { return b.value - a.value; } else if (propertyType === 'date') { const aDate = new Date(a.value); const bDate = new Date(b.value); return bDate.getTime() - aDate.getTime(); } else if (propertyType === 'boolean') { return a.value - b.value; } else { return 0; } } }); setAllRows(sortedRows.map((tuple) => tuple.row)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeOrder]); /** * Toggles the order between ascending and descending order when the user clicks on a table header. * @param e - The mouse event triggered by clicking the table header. * @param property - The property to sort the rows by. */ const handleOrderChange = (e, property) => { e.preventDefault(); if (activeOrder?.property === property) { if (activeOrder?.order === 'asc') { setActiveOrder({ property, order: 'desc' }); } else { setActiveOrder({ property, order: 'asc' }); } } else { setActiveOrder({ property, order: defaultOrder?.order ?? 'asc' }); } }; /** * Updates the sample length when the user selects a new number of displayed entries. * Resets the current page to 1. * @param value The new number of displayed entries. */ const handleRangeLengthChange = (value) => { const newValue = parseInt(value); setRangeLength(newValue); setCurrentPage(1); }; /** * Filters the rows based on the search input value. * * @param e - The change event triggered by the search input field. * * This function iterates over all rows, concatenates the values of each row into a single * string, and checks if this string includes the trimmed and lowercased value from the * search input. If it does, the row is added to the rowsMatchingWithSearchInput. Updates the state * with the filtered rows. */ const handleSearchInput = (e) => { const rowsMatchingWithSearchInput = []; const displayedProperties = columns.map(column => column.property); rows.forEach(row => { let mergedRow = ''; Object.keys(row).forEach(key => { if (rowsMatchingWithSearchInput.includes(row)) { return; } else if (displayedProperties.includes(key)) { mergedRow += row[key].toString().toLowerCase().trim().concat(' '); } }); if (mergedRow.trim().includes(e.target.value.trim().toLowerCase())) { rowsMatchingWithSearchInput.push(row); } }); setAllRows(rowsMatchingWithSearchInput); setCurrentPage(1); }; /** * Updates the number of pages when the number of rows or the range length changes. */ useEffect(() => { const newPagesNumber = Math.ceil(allRows.length / rangeLength); if (newPagesNumber === 0) { setPagesNumber(1); return; } setPagesNumber(newPagesNumber); }, [allRows.length, rangeLength]); /** * Updates the displayed range when the current page changes. */ useEffect(() => { const startIndex = rangeLength * (currentPage - 1); const endIndex = startIndex + rangeLength; setDisplayedRange(allRows.slice(startIndex, endIndex)); }, [allRows, currentPage, rangeLength]); const pagesNumbers = useMemo(() => generatePagesNumbers(currentPage, pagesNumber), [currentPage, pagesNumber]); const rangeLengthOptionsTags = rangeLengthOptions.map((option) => ({ value: option, label: _jsx("span", { className: ` ${styleClassNames?.rangeLengthOptions?.menuTextColor ?? 'text-white'} ${styleClassNames?.rangeLengthOptions?.menuBackgroundColor ?? 'bg-gray-800 hover:bg-gray-700'} `, children: `${textContent?.sampleLabelPrefix ?? 'Show '} ${option} ${textContent?.sampleLabelSuffix ?? ' entries'}` }, option) })); useEffect(() => { const firstRow = document.getElementsByTagName('tbody')[0].children[0]; const cells = firstRow.getElementsByTagName('td'); for (let i = 0; i < cells.length; i++) { setColumnsWidth([...columnsWidth, cells[i].offsetWidth]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [columns]); /** * Creates a comma-separated string of selected keys from the selectedKeys Set. * Replaces underscores with empty strings. */ const selectedValue = useMemo(() => Array.from(selectedKeys).join(', ').replace(/_/g, ''), [selectedKeys]); return (_jsxs("div", { className: `my-5`, children: [_jsxs("div", { className: `flex flex-col justify-between items-center my-5 gap-y-3.5 ${styleClassNames?.rangeOptionsAndSearchBarArea ?? ''}`, children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "rangeLength", className: 'sr-only', children: "Displayed entries" }), _jsxs(Dropdown, { children: [_jsx(DropdownTrigger, { children: _jsx(Button, { variant: 'bordered', className: ` ${styleClassNames?.rangeLengthOptions?.buttonBorder ?? 'border-4'} ${styleClassNames?.rangeLengthOptions?.buttonBorderColor ?? 'border-gray-300'} ${styleClassNames?.rangeLengthOptions?.buttonRounded ?? 'rounded-[20px]'} ${styleClassNames?.rangeLengthOptions?.buttonText ?? 'text-center text-white'} ${styleClassNames?.rangeLengthOptions?.buttonBackgroundColor ?? 'bg-gray-800 hover:bg-gray-700'} ${styleClassNames?.rangeLengthOptions?.buttonPadding ?? 'px-[10px]'}`, children: _jsxs("span", { children: [`${textContent?.sampleLabelPrefix ?? 'Show'} ${selectedValue} ${textContent?.sampleLabelSuffix ?? 'entries'}`, _jsx(ChevronDownIcon, { className: "pl-[10px] inline-block h-5 w-[30px] text-white ui-open:rotate-180 transition-transform", "aria-hidden": "true" })] }) }) }), _jsx(DropdownMenu, { disallowEmptySelection: true, "aria-labelledby": "Sample Length", selectedKeys: selectedKeys, selectionMode: 'single', variant: 'flat', onSelectionChange: e => { if (!e.currentKey) return; setSelectedKeys(new Set([e.currentKey])); handleRangeLengthChange(e.currentKey); }, className: ` ${styleClassNames?.rangeLengthOptions?.menuBorder ?? 'border-4'} ${styleClassNames?.rangeLengthOptions?.menuBackgroundColor ?? 'border-gray-300'} ${styleClassNames?.rangeLengthOptions?.menuRounded ?? 'rounded-[20px]'} ${styleClassNames?.rangeLengthOptions?.menuPadding ?? 'p-[10px]'} ${styleClassNames?.rangeLengthOptions?.menuTextColor ?? 'text-white'} ${styleClassNames?.rangeLengthOptions?.menuBackgroundColor ?? 'bg-gray-800 hover:bg-gray-600'} `, children: rangeLengthOptionsTags.map(option => (_jsx(DropdownItem, { children: `${textContent?.sampleLabelPrefix ?? 'Show'} ${option.value} ${textContent?.sampleLabelSuffix ?? 'entries'}` }, option.value))) })] })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "search", className: styleClassNames?.searchBar?.label ?? 'mr-2.5', children: textContent?.searchLabel ?? 'Search' }), _jsx("input", { type: "text", name: "search", id: "search", placeholder: textContent?.searchPlaceholder ?? '', className: ` ${styleClassNames?.searchBar?.inputPadding ?? 'px-[10px] py-[5px]'} ${styleClassNames?.searchBar?.inputBackgroundColor ?? 'bg-white'} ${styleClassNames?.searchBar?.inputMarginL ?? 'ml-[15px]'} ${styleClassNames?.searchBar?.inputBorder ?? 'border-3'} ${styleClassNames?.searchBar?.inputBorderColor ?? 'border-gray-300 hover:border-gray-400'} ${styleClassNames?.searchBar?.inputRounded ?? 'rounded-[20px]'} ${styleClassNames?.searchBar?.inputFocusOutLine ?? 'focus:outline-sky-400'} ${styleClassNames?.searchBar?.inputTextColor ?? 'text-black'} `, onChange: (e) => handleSearchInput(e) })] })] }), _jsx("div", { className: ` ${styleClassNames?.tableBorders ?? 'border-4 border-gray-300'} ${styleClassNames?.tableRounded ?? 'rounded-[23px]'} ${styleClassNames?.tablePaddings ?? 'px-[5px] pt-[5px] pb-[15px]'} ${styleClassNames?.tableMargins ?? ''} ${styleClassNames?.tableBackgroundColor ?? ''} `, children: _jsxs("table", { className: `w-full`, role: 'table', children: [_jsx("thead", { children: _jsx("tr", { role: 'row', className: `sticky top-0 z-10`, children: columns.map((key, index) => (_jsx("th", { role: 'columnheader', className: "cursor-pointer", ref: columnHeaderRef, children: _jsx("div", { "aria-label": `Sort by ${key.displayName}`, role: 'button', className: ` flex justify-between items-center ${styleClassNames?.tableHeaders?.gap ?? 'gap-2.5'} ${styleClassNames?.tableHeaders?.padding ?? 'py-[5px]'} ${styleClassNames?.tableHeaders?.margin ?? 'mb-[10px]'} ${styleClassNames?.tableHeaders?.textColor ?? 'text-white'} ${styleClassNames?.tableHeaders?.backgroundColor ?? 'bg-gray-800 hover:bg-gray-700'} ${styleClassNames?.tableHeaders?.borderY ?? 'border-y-4'} ${styleClassNames?.tableHeaders?.borderColor ?? 'border-gray-300'} ${columnsWidth[index] ? `w-[${columnsWidth[index]}px]` : ''} ${index === 0 ? (styleClassNames?.tableHeaders?.borderL ?? 'border-l-4') : ''} ${index === 0 ? styleClassNames?.tableHeaders?.roundedL ?? 'rounded-tl-[20px] rounded-bl-[20px]' : ''} ${index === columns.length - 1 ? styleClassNames?.tableHeaders?.borderR ?? 'border-r-4' : ''} ${index === columns.length - 1 ? styleClassNames?.tableHeaders?.roundedR ?? 'rounded-tr-[20px] rounded-br-[20px]' : ''} `, onClick: e => handleOrderChange(e, key.property), children: _jsxs("div", { className: `flex items-center justify-between w-[100%] pl-[18px] pr-[5px]`, children: [_jsx("div", { className: 'flex-1 text-center overflow-hidden mr-[10px]', children: _jsx("p", { ref: columnNameRef, className: 'whitespace-nowrap', children: key.displayName ? key.displayName : String(key.property) }) }), _jsx("div", { children: _jsx("div", { className: `flex items-center justify-center w-[12px] h-[12px] mr-[10px]`, children: _jsx("svg", { className: ` ${!(activeOrder?.property === key.property) ? 'hidden' : ''} ${activeOrder?.property === key.property && activeOrder?.order !== 'asc' ? 'rotate-90' : '-rotate-90'} `, xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 320 512", fill: styleClassNames?.sortIndicatorColor ?? '#FFF', children: _jsx("path", { d: "M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" }) }) }) })] }) }, index) }, uuidv4()))) }, uuidv4()) }), _jsxs("tbody", { className: 'pb-[2.5] overflow-y-auto ', children: [displayedRange.length === 0 && (_jsx("tr", { role: 'row', "data-row": true, className: ` ${styleClassNames?.rows?.paddingB ?? ''} `, ref: rowRef, children: _jsx("td", { role: 'cell', colSpan: columns.length, className: ` ${styleClassNames?.rows?.paddingT ?? 'mt-[10px]'} ${'text-center truncate'} ${styleClassNames?.rows?.paddingX ?? 'px-[15px]'} `, children: _jsx("div", { className: ` ${styleClassNames?.rows?.oddRowBackgroundColor ?? 'bg-gray-500'} ${styleClassNames?.rows?.textColor ?? ''} `, children: textContent?.emptyTableText ?? 'No data available in table' }) }, uuidv4()) }, uuidv4())), displayedRange.length > 0 && displayedRange.map((row, rowIndex) => (_jsx("tr", { ref: rowRef, role: 'row', "data-row": true, onMouseEnter: () => { setHoveredRow(row); if (onRowHover) { onRowHover(row); } }, onMouseLeave: () => { setHoveredRow(null); if (onRowHover) { onRowHover(null); } }, onClick: () => { if (onRowClick) { onRowClick(row); } }, className: ` ${styleClassNames?.rows?.height ?? 'h-[30px]'} ${styleClassNames?.rows?.paddingB ?? 'last:pt-[15px]'} `, children: columns.map((column, colIndex) => (Object.keys(row).includes(String(column.property)) && (_jsx("td", { title: row.id ? `id: ${row.id}` : '', role: 'cell', className: ` ${styleClassNames?.cells ?? 'whitespace-nowrap'} py-[2.5px] `, children: _jsx("div", { className: ` flex justify-between items-center px-[10px] py-[5px] h-full transition-colors duration-200 ${styleClassNames?.rows?.textColor ?? ''} ${rowIndex % 2 === 0 ? styleClassNames?.rows?.oddRowBackgroundColor ?? 'bg-gray-500' : styleClassNames?.rows?.evenRowBackgroundColor ?? 'bg-gray-600'} ${columnsWidth[colIndex] ? `w-[${columnsWidth[colIndex]}px]` : ''} ${colIndex === 0 ? styleClassNames?.rows?.marginL ?? 'ml-[15px]' : ''} ${colIndex === columns.length - 1 ? styleClassNames?.rows?.marginR ?? 'mr-[15px]' : ''} `, children: column.render ? column.render(row[column.property]) : row[column.property] }) }, uuidv4())))) }, uuidv4())))] })] }) }), _jsxs("div", { hidden: pagesNumbers.length < 2, className: `flex flex-col lg:flex-row gap-y-3.5 justify-between items-center mt-5`, children: [_jsx("p", { className: 'inline-block', children: allRows.length > Math.min(rangeLength * currentPage, allRows.length) ? (_jsxs("span", { children: [textContent?.rangeInfoText?.showEntries_altText ?? 'Showing entries', _jsx("span", { className: 'font-bold', children: rangeLength * (currentPage - 1) + 1 }), textContent?.rangeInfoText?.to_altText ?? 'to', _jsx("span", { className: 'font-bold', children: Math.min(rangeLength * currentPage, allRows.length) }), textContent?.rangeInfoText?.of_altText ?? 'of', _jsx("span", { className: 'font-bold', children: allRows.length }), textContent?.rangeInfoText?.entries_altText ?? 'entries'] })) : (_jsxs("span", { children: [textContent?.rangeInfoText?.showEntries_altText ?? 'Showing entries', _jsx("span", { className: 'font-bold', children: rangeLength * (currentPage - 1) + 1 }), textContent?.rangeInfoText?.to_altText ?? 'to', _jsx("span", { className: 'font-bold', children: allRows.length }), textContent?.rangeInfoText?.entries_altText ?? 'entries'] })) }), _jsxs("div", { className: ` flex items-center ${styleClassNames?.pagination?.paginationBlockHover ?? ''} ${styleClassNames?.pagination?.rounded ?? 'rounded-[20px]'} ${styleClassNames?.pagination?.border ?? 'border-2'} ${styleClassNames?.pagination?.buttonBorderColor ?? 'border-gray-300'} ${styleClassNames?.pagination?.buttonBackgroundColor ?? 'bg-gray-800'} `, children: [currentPage - 1 >= 1 && (_jsx("button", { type: 'button', role: 'previousButton', onClick: () => setCurrentPage(currentPage - 1), className: ` disabled:opacity-50 cursor-pointer ${styleClassNames?.pagination?.previousButtonPadding ?? 'pl-[20px] pr-3'} ${styleClassNames?.pagination?.previousButtonRoundedL ?? 'rounded-l-[20px]'} ${styleClassNames?.pagination?.textColor ?? 'text-white'} ${styleClassNames?.pagination?.buttonBackgroundColorHover ?? 'hover:bg-gray-500 py-[9px]'} `, "aria-label": 'Previous page', children: _jsx("svg", { className: 'rotate-180 h-[15px]', xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 320 512", fill: styleClassNames?.pagination?.navButtonsColor ?? '#fff', children: _jsx("path", { d: "M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" }) }) })), pagesNumbers.map((page, index) => typeof page === 'number' ? (_jsx("button", { onClick: () => setCurrentPage(page), className: `px-3 py-[5px] ${page === currentPage ? styleClassNames?.pagination?.currentPageButton ?? 'bg-blue-500 text-white first:rounded-l-[20px] last:rounded-r-[20px]' : styleClassNames?.pagination?.otherpages ?? 'text-white hover:bg-gray-500 first:rounded-l-[20px] last:rounded-r-[20px] cursor-pointer'}`, "aria-current": page === currentPage ? 'page' : undefined, children: page }, index)) : (_jsx("div", { className: 'flex items-start justify-start ', children: _jsx("span", { className: 'px-2 pb-2 text-white align-top', children: "..." }, index) }, index))), currentPage + 1 <= pagesNumber && (_jsx("button", { role: 'nextButton', "aria-label": 'Next page', onClick: () => setCurrentPage(currentPage + 1), className: ` disabled:opacity-50 cursor-pointer ${styleClassNames?.pagination?.nextButtonPadding ?? 'pl-3 pr-[20px]'} ${styleClassNames?.pagination?.nextButtonRoundedR ?? 'rounded-r-[20px]'} ${styleClassNames?.pagination?.textColor ?? 'text-white'} ${styleClassNames?.pagination?.buttonBackgroundColorHover ?? 'hover:bg-gray-500 py-[9px]'} `, children: _jsx("svg", { className: 'h-[15px]', xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 320 512", fill: styleClassNames?.pagination?.navButtonsColor ?? '#fff', children: _jsx("path", { d: "M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" }) }) }))] })] })] })); } export default Table;