I am working on a React table UI where each row can be expanded to show additional details. The design requires:
- When a row expands, it should visually merge with the expanded content below it.
- The expanded content should maintain the same border as the parent row.
- No extra spacing or gaps should appear between the main row and the expanded row.
The challenge I am facing is:
- Extra spacing appears between the main row and the expanded content.
- Borders don’t align properly when expanded.
- If I remove border-spacing, then the entire table styling breaks.
I have tried:
- Setting
border-spacing: 0;andborder-collapse: collapse;(caused styling issues). - Using negative margins (
margin-top: -1px;) on the expanded row (still inconsistent). - Ensuring
border-bottom: none;on the main row when expanded (didn’t fully solve it).
import React, { useCallback, useMemo, useState } from "react";
import "./index.scss";
import InfiniteScroll from "react-infinite-scroll-component";
import Lottie from "react-lottie";
import { fourDotsAnimations } from "../../assets/lottie-animations/animationOptions";
import CustomIcon from "../../assets/icons";
import {
getFromLocalStorage,
LocalStorageEnum,
} from "../../utils/local-storage";
import { useTranslation } from "react-i18next";
import moment from "moment-timezone";
const itemMasterTableHeaderKeys = [
{
title: "prNumber",
key: "prNumber",
sortable: true,
styles: {
width: "10%",
maxWidth: "10%",
},
},
{
title: "Description",
key: "Description", // Sorting key
sortable: false,
styles: {
width: "20%",
maxWidth: "20%",
},
},
{
title: "Date",
key: "date",
sortable: true,
styles: {
width: "15%",
maxWidth: "15%",
},
},
{
title: "Status",
key: "Status",
sortable: true,
styles: {
width: "10%",
maxWidth: "10%",
},
},
{
title: "TotalCost",
key: "TotalCost",
sortable: true,
styles: {
width: "10%",
maxWidth: "10%",
},
},
{
title: "Actions",
key: "Actions",
sortable: false,
styles: {
width: "5%",
maxWidth: "5%",
},
},
];
function PrScreen() {
const { t } = useTranslation();
const timeZone =
JSON.parse(
getFromLocalStorage(LocalStorageEnum.SELECTED_STORE) || ""
)?.country?.toUpperCase() === "CANADA"
? "America/Toronto"
: "Africa/Tunis";
const [sortConfig, setSortConfig] = useState<{
key: string | null;
direction: "asc" | "desc" | null;
}>({
key: null,
direction: null,
});
const [expandedRows, setExpandedRows] = useState<number[]>([]);
const toggleExpandedRow = (index: number) => {
setExpandedRows((prev) => {
if (prev.includes(index)) {
return prev.filter((row) => row !== index);
}
return [...prev, index];
});
};
const handleSort = useCallback((key: string) => {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === "asc" ? "desc" : "asc" }
: { key, direction: "asc" }
);
}, []);
const transactionsHistory = Array.from({ length: 10 }, (_, i) => ({
prNumber: `20359${i + 1}`,
description: "Lorem ipsum dolor sit amet...",
date: moment().subtract(i, "days").format("YYYY-MM-DD"),
status: i % 2 === 0 ? "Placed" : "Completed",
totalCost: "352 tnd",
}));
const sortedData = useMemo(() => {
if (!sortConfig.key || !sortConfig.direction) return transactionsHistory;
return [...transactionsHistory].sort((a: any, b: any) => {
const aValue = a[sortConfig?.key || ""];
const bValue = b[sortConfig?.key || ""];
if (typeof aValue === "string" && typeof bValue === "string") {
return sortConfig.direction === "asc"
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
if (typeof aValue === "number" && typeof bValue === "number") {
return sortConfig.direction === "asc"
? aValue - bValue
: bValue - aValue;
}
return 0;
});
}, [
// transactionsHistory,
sortConfig,
]);
const handleLoadMoreData = () => {
console.log("load more");
};
return (
<div className="pr-screen-container">
<div className="pr-screen-header-container">
<span>select store here</span>
<form>
<span>search here</span>
</form>
</div>
<div className="pr-screen-content-container" id="scrollableDiv">
<InfiniteScroll
dataLength={10}
next={handleLoadMoreData}
hasMore={true}
loader={
<div className="item-master-table-loading-pagination">
{true ? (
<></>
) : (
<Lottie
options={fourDotsAnimations}
height={"10%"}
width={"8%"}
/>
)}
</div>
}
scrollableTarget="scrollableDiv"
>
<table className="pr-screen-table-container">
<thead>
<tr className="pr-screen-table-tr">
{itemMasterTableHeaderKeys.map((key, index) => (
<th
key={index}
className={`pr-screen-table-th ${
key.sortable ? "cursor" : ""
}`}
style={key.styles}
onClick={() => key.sortable && handleSort(key.key)}
>
<div className="pr-screen-table-th-icons-container">
<span>{t(key.title)}</span>
{key.sortable && (
<CustomIcon
className="pr-screen-table-th-icons-style"
icon={
sortConfig.key === key.key
? sortConfig.direction === "asc"
? "sort-asc"
: "sort-desc"
: "sort-default"
}
size={9}
/>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{!sortedData?.length ? (
<tr>
<td
colSpan={1000}
rowSpan={4}
className="pr-screen-table-tr-no-items"
>
<div className="pr-screen-table-no-items-container">
<CustomIcon icon="no-data-found" size={100} />
<span className="pr-screen-table-no-items-txt">
{t("No pr found !")}
</span>
</div>
</td>
</tr>
) : (
sortedData.map((transaction: any, index) => (
<React.Fragment key={index}>
<tr
className={`pr-screen-table-tr-body ${
expandedRows.includes(index) ? "expanded" : ""
} ${
expandedRows.includes(index - 1) ? "beforeExpanded" : ""
} `}
>
<td
onClick={() => toggleExpandedRow(index)}
className="pr-screen-table-td-body"
>
<CustomIcon
icon={
expandedRows.includes(index)
? "chevron-up"
: "chevron-down"
}
size={10}
/>
{transaction.prNumber}
</td>
<td className="pr-screen-table-td-body">
{transaction.description}
</td>
<td className="pr-screen-table-td-body">
{moment
.utc(transaction.date)
.tz(timeZone)
.format("DD/MM/YYYY")}
</td>
<td className="pr-screen-table-td-body">
{transaction.status}
</td>
<td className="pr-screen-table-td-body">
{transaction.totalCost}
</td>
<td className="pr-screen-table-td-body-actions">
<div
className={`pr-screen-table-td-actions-container ${
false ? "disabled" : ""
}`}
>
{true ? (
<CustomIcon icon="pr-action-cancel" size={18} />
) : (
<>
<div className="pr-icon-cancel-no-hover">
<CustomIcon icon="pr-action-cancel" size={18} />
</div>
<div className="pr-icon-cancel-with-hover">
<CustomIcon
icon="pr-action-cancel-white"
size={18}
/>
</div>
</>
)}
</div>
</td>
</tr>
{expandedRows.includes(index) && (
<tr
style={{
borderSpacing: 100,
padding: 1000,
}}
>
<td colSpan={5}>
<div className={`pr-table-expanded-row`}>
<div>
<h4>Details</h4>
<p>
This is a collapsible section where you can add
anything—extra details, forms, buttons, etc.
</p>
<button className="action-button">
Do Something
</button>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
))
)}
</tbody>
</table>
</InfiniteScroll>
</div>
</div>
);
}
export default PrScreen;
.pr-screen-container {
display: flex;
flex-direction: column;
margin: 1rem 1rem 0rem 4rem;
height: 86vh;
.pr-screen-header-container {
display: flex;
flex-direction: column;
}
.pr-screen-content-container {
overflow: auto;
overflow-y: auto; // Allows scrolling for both header and items
height: 100vh;
.pr-screen-table-container {
width: 60%;
text-align: left;
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
border-width: 100px;
.pr-screen-table-tr {
.pr-screen-table-th {
font-weight: 300;
font-size: 12px;
color: #aaaaaa;
text-overflow: ellipsis;
padding: 0.5rem;
padding-left: 1rem;
background: white;
&.cursor {
cursor: pointer;
}
.pr-screen-table-th-icons-container {
display: flex;
align-items: center;
.pr-screen-table-th-icons-style {
margin-left: 0.7rem;
}
}
}
}
.pr-table-expanded-row {
border: 1px solid red;
padding: 1rem;
width: 100%;
}
.pr-screen-table-tr-body {
border-radius: 10px;
&.expanded {
vertical-align: text-top;
&:nth-child(odd) {
border-color: transparent;
border-bottom: 0px solid transparent;
}
&:nth-child(even) {
border-color: transparent;
border-bottom: 10px solid transparent;
}
td:not(:last-child) {
&:first-child {
border-top-left-radius: 0rem;
border-bottom-left-radius: 0rem;
}
&:nth-child(5) {
border-top-right-radius: 0rem;
border-bottom-right-radius: 0rem;
}
}
td:first-child {
border-left: 0.5px solid #ad9e89;
border-top: 0.5px solid #ad9e89;
}
td:nth-child(5) {
border-right: 0.5px solid #ad9e89;
border-top: 0.5px solid #ad9e89;
border-top-right-radius: 0rem;
border-bottom-right-radius: 0rem;
border-radius: 0rem;
}
td:not(:last-child) {
border-top: 0.5px solid #ad9e89;
}
}
&:nth-child(odd) {
background: rgba(252, 251, 251, 1);
border-color: transparent;
border-bottom: 20px solid transparent;
}
&:nth-child(even) {
border-color: transparent;
border-bottom: 20px solid transparent;
}
td:not(:last-child) {
&:first-child {
border-top-left-radius: 0.4rem;
border-bottom-left-radius: 0.4rem;
}
&:nth-child(5) {
border-top-right-radius: 0.4rem;
border-bottom-right-radius: 0.4rem;
}
}
td:last-child {
background: white;
}
td:not(:nth-last-child(-n + 2)) {
border-right: 1px solid #e0e0e0;
}
.pr-screen-table-td-body {
font-weight: 400;
font-size: 14px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 0;
padding: 0.5rem 0.4rem;
padding-left: 1rem;
&.center {
text-align: center;
padding-left: 0rem;
}
&.bold {
font-weight: 600;
}
}
.pr-screen-table-td-body-actions {
font-weight: 400;
font-size: 14px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 0;
padding: 0rem;
padding-left: 1rem;
.pr-screen-table-td-actions-container {
display: flex;
align-content: center;
align-items: center;
border: 1px solid #eeeeee;
padding: 0.6rem;
border-radius: 0.4rem;
width: fit-content;
background-color: transparent;
cursor: pointer;
&.disabled {
cursor: not-allowed;
border: 1px solid #ffffffb9;
}
.pr-icon-cancel-no-hover {
display: flex;
}
.pr-icon-cancel-with-hover {
display: none;
}
&:hover {
background-color: #ea504f;
.pr-icon-cancel-no-hover {
display: none;
}
.pr-icon-cancel-with-hover {
display: flex;
}
}
}
}
}
.pr-screen-table-tr-no-items {
background-color: #fbfbfb;
padding: 6rem;
text-align: center;
border-radius: 0.5rem;
.pr-screen-table-no-items-container {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
.pr-screen-table-no-items-txt {
margin-top: 1rem;
font-weight: 200;
font-size: 18px;
color: #aaaaaa;
}
}
}
}
}
}



