Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

A summary of notable changes per release. For the full commit history see the [repository on GitHub](https://github.com/jbetancur/react-data-table-component/commits/master).

## 8.5.1

### Bug fixes

- Fixed column resizing under RTL. The handle now sits on the column's end (left) edge and dragging it away from the column widens it, mirroring LTR. Applies to both `direction={Direction.RTL}` and an inherited `dir="rtl"`. → [Resizable columns](/docs/resizable)
- Fixed column and group-header separators rendering at the wrong boundaries under RTL. Small header/cell chrome (filter-indicator dot, inline-edit select arrow, rows-per-page chevron) now mirrors to the correct side as well. → [RTL support](/docs/rtl)
- Fixed column pinning under RTL: pinned columns now stick to logical edges (`pinned: 'left'` pins to the inline-start edge, which is the right side in RTL), and the pin-band shadows mirror accordingly. → [Column pinning](/docs/column-pinning)
- Fixed the pinned-columns horizontal scrollbar under RTL: thumb position, drag, track clicks, and the End key now account for RTL scroll coordinates, and the track insets follow the mirrored pin bands.

---

## 8.5.0

### New features
Expand Down
8 changes: 8 additions & 0 deletions apps/docs/src/components/demos/RTLDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,25 @@ export default function RTLDemo() {
name: isRTL ? 'الاسم' : 'Name',
selector: r => isRTL ? r.nameAr : r.name,
sortable: true,
width: '200px',
minWidth: '120px',
reorder: true,
},
{
name: isRTL ? 'القسم' : 'Department',
selector: r => isRTL ? r.departmentAr : r.department,
sortable: true,
minWidth: '140px',
reorder: true,
},
{
name: isRTL ? 'الراتب' : 'Salary',
selector: r => r.salary,
format: r => `$${r.salary.toLocaleString()}`,
right: true,
sortable: true,
width: '140px',
reorder: true,
},
];

Expand Down Expand Up @@ -86,6 +93,7 @@ export default function RTLDemo() {
paginationPerPage={5}
highlightOnHover
striped
resizable
/>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/src/pages/docs/rtl.astro
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import DocsTable from '../../components/DocsTable.astro';

<Demo
title="Direction: LTR / RTL / AUTO"
description="Toggle between LTR, RTL, and AUTO. In RTL mode the pagination arrows reverse and column text aligns right-to-left."
description="Toggle between LTR, RTL, and AUTO. In RTL mode the pagination arrows reverse, column text aligns right-to-left, and the resize handles mirror to each column's left edge. Drag a column boundary to try it."
code={`import DataTable, { Direction } from 'react-data-table-component';

<DataTable
Expand Down Expand Up @@ -69,6 +69,7 @@ import DocsTable from '../../components/DocsTable.astro';
<li>The wrapper receives <code>dir="rtl"</code>, which shifts inline content and flexbox row direction for you.</li>
<li>Pagination first/prev/next/last buttons have their icons reversed via <code>.rdt_paginationButtonRTL</code>.</li>
<li>Column <code>right: true</code> alignment continues to work as expected. Align numbers relative to the reading direction of the cell content, not the page.</li>
<li>With <code>resizable</code>, the resize handle moves to each column's left (end) edge and drag direction mirrors, so dragging away from the column widens it just like in LTR.</li>
</ul>

<h2>Pairing with localization</h2>
Expand Down
37 changes: 25 additions & 12 deletions src/DataTable.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@
.rdt_colSeparatorFull .rdt_cellBase:not(.rdt_cellBaseHead) + .rdt_cellBase:not(.rdt_cellBaseHead)::before {
content: '';
position: absolute;
left: 0;
/* inline-start = the boundary shared with the previous DOM sibling in both LTR and RTL */
inset-inline-start: 0;
top: var(--_sep-top, 20%);
height: var(--_sep-h, 60%);
width: 1px;
Expand Down Expand Up @@ -378,7 +379,7 @@
.rdt_filterDot {
position: absolute;
top: 3px;
right: 3px;
inset-inline-end: 3px;
width: 5px;
height: 5px;
border-radius: 50%;
Expand Down Expand Up @@ -676,7 +677,7 @@
.rdt_cellEditing:has(.rdt_editSelect)::after {
content: '';
position: absolute;
right: var(--rdt-cell-padding-x, 16px);
inset-inline-end: var(--rdt-cell-padding-x, 16px);
top: 50%;
transform: translateY(-50%);
width: 12px;
Expand All @@ -690,7 +691,7 @@
}

.rdt_editSelect {
padding-right: calc(var(--rdt-cell-padding-x, 16px) + 24px);
padding-inline-end: calc(var(--rdt-cell-padding-x, 16px) + 24px);
}

/* Dense rows shrink the editor height to match */
Expand Down Expand Up @@ -795,16 +796,29 @@
clip-path: inset(0 0 0 -8px);
}

/* box-shadow/clip-path have no logical variants — mirror the pin-band edge
shadows by hand under RTL, where inline-start pins sit on the physical right. */
.rdt_pinLeftLast:dir(rtl) {
box-shadow: -2px 0 4px -1px var(--rdt-color-pin-shadow, rgba(0, 0, 0, 0.12));
clip-path: inset(0 0 0 -8px);
}

.rdt_pinRightFirst:dir(rtl) {
box-shadow: 2px 0 4px -1px var(--rdt-color-pin-shadow, rgba(0, 0, 0, 0.12));
clip-path: inset(0 -8px 0 0);
}

/* ─── Column resize handle ──────────────────────────────────────────────────── */
/*
* The handle straddles the cell boundary so its hit area is centered on the
* separator line rather than offset to one side. width/2 = 3px → right: -3px
* centers a 6px-wide handle on the column edge, matching the position of
* separator line rather than offset to one side. width/2 = 3px → inset-inline-end: -3px
* centers a 6px-wide handle on the column's end edge, matching the position of
* .rdt_headSeparator/.rdt_colSeparator pseudo-elements drawn at the boundary.
* inset-inline-end resolves to the left edge under RTL so the handle mirrors correctly.
*/
.rdt_resizeHandle {
position: absolute;
right: -3px;
inset-inline-end: -3px;
top: 0;
height: 100%;
width: 6px;
Expand Down Expand Up @@ -897,7 +911,7 @@
.rdt_groupCell + .rdt_groupCell::before {
content: '';
position: absolute;
left: 0;
inset-inline-start: 0;
top: 20%;
height: 60%;
width: 1px;
Expand Down Expand Up @@ -1298,7 +1312,7 @@

.rdt_selectWrapper svg {
top: 50%;
right: 2px;
inset-inline-end: 2px;
transform: translateY(-50%);
color: var(--rdt-color-text-secondary, rgba(0, 0, 0, 0.54));
position: absolute;
Expand All @@ -1316,16 +1330,15 @@
height: 100%;
max-width: 100%;
user-select: none;
padding-left: 8px;
padding-right: 22px;
padding-inline-start: 8px;
padding-inline-end: 22px;
box-sizing: border-box;
font-size: inherit;
font-family: inherit;
color: inherit;
border: none;
background-color: transparent;
appearance: none;
direction: ltr;
flex-shrink: 0;
outline: none;
}
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/DataTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2821,6 +2821,23 @@ describe('DataTable::columnResize', () => {
// Column width is applied via maxWidth (buildCellStyle sets maxWidth from width prop)
expect(headerCell.style.maxWidth).toBe('60px');
});

test('inverts drag delta in RTL so dragging the handle left widens the column', () => {
const mock = dataMock();
const { container } = render(
<DataTable data={mock.data} columns={mock.columns} resizable direction={Direction.RTL} />,
);
const handle = container.querySelector('.rdt_resizeHandle') as HTMLElement;
const headerCell = handle.closest('[data-column-id]') as HTMLElement;

// Dragging left (clientX 100 → 40) is a -60 delta; in RTL it inverts to +60.
// startWidth is 0 in jsdom → newWidth = max(40, 0 + 60) = 60
fireEvent.mouseDown(handle, { clientX: 100 });
fireEvent.mouseMove(document, { clientX: 40 });
fireEvent.mouseUp(document);

expect(headerCell.style.maxWidth).toBe('60px');
});
});

describe('DataTable::columnGroups', () => {
Expand Down
16 changes: 8 additions & 8 deletions src/__tests__/pinning.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ describe('DataTable column pinning', () => {
expect(cell?.style.position).toBe('sticky');
});

test('right-pinned cell has position:sticky with right offset', () => {
test('right-pinned cell has position:sticky with inline-end offset', () => {
const { container } = render(<DataTable columns={columns} data={data} />);
const cell = container.querySelector('.rdt_pinRight') as HTMLElement;
expect(cell?.style.position).toBe('sticky');
expect(cell?.style.right).toBe('0px');
expect(cell?.style.insetInlineEnd).toBe('0px');
});

test('strips pinned from columns when columnGroups are active', () => {
Expand Down Expand Up @@ -92,9 +92,9 @@ describe('DataTable column pinning', () => {
];
const { container } = render(<DataTable columns={multiPin} data={data} />);
const cells = Array.from(container.querySelectorAll('.rdt_pinLeft')) as HTMLElement[];
const lefts = cells.map(el => parseFloat(el.style.left)).filter(v => !isNaN(v));
expect(lefts).toContain(0);
expect(lefts.some(v => v > 0)).toBe(true);
const offsets = cells.map(el => parseFloat(el.style.insetInlineStart)).filter(v => !isNaN(v));
expect(offsets).toContain(0);
expect(offsets.some(v => v > 0)).toBe(true);
});
});

Expand Down Expand Up @@ -126,7 +126,7 @@ describe('PinnedScrollbar', () => {
expect(track).not.toBeNull();
});

test('applies left and right margin insets to the track element', async () => {
test('applies logical margin insets to the track element', async () => {
const ref = makeScrollRef(1000, 400);
const { container } = renderWithTheme(<PinnedScrollbar scrollRef={ref} leftInset={150} rightInset={120} />);

Expand All @@ -135,8 +135,8 @@ describe('PinnedScrollbar', () => {
});

const track = container.querySelector('.rdt_pinnedScrollbarTrack') as HTMLElement | null;
expect(track?.style.marginLeft).toBe('150px');
expect(track?.style.marginRight).toBe('120px');
expect(track?.style.marginInlineStart).toBe('150px');
expect(track?.style.marginInlineEnd).toBe('120px');
});

test('thumb width is proportional to viewport/scroll ratio', async () => {
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ describe('getPinnedCellMeta', () => {
const result = getPinnedCellMeta(col('b', 'left'), offsets);
expect(result.pinnedLeft).toBe(true);
expect(result.isLastLeftPin).toBe(true);
expect(result.style).toEqual({ position: 'sticky', left: 100 });
expect(result.style).toEqual({ position: 'sticky', insetInlineStart: 100 });
expect(result.className).toContain('rdt_pinLeft');
expect(result.className).toContain('rdt_pinLeftLast');
});
Expand All @@ -598,7 +598,7 @@ describe('getPinnedCellMeta', () => {
const result = getPinnedCellMeta(col('y', 'right'), offsets);
expect(result.pinnedRight).toBe(true);
expect(result.isFirstRightPin).toBe(true);
expect(result.style).toEqual({ position: 'sticky', right: 80 });
expect(result.style).toEqual({ position: 'sticky', insetInlineEnd: 80 });
expect(result.className).toContain('rdt_pinRight');
expect(result.className).toContain('rdt_pinRightFirst');
});
Expand Down Expand Up @@ -724,7 +724,7 @@ describe('getPinnedCellMeta with zIndex', () => {

test('includes zIndex in the sticky style when provided', () => {
const result = getPinnedCellMeta(col('left'), offsets, 2);
expect(result.style).toEqual({ position: 'sticky', left: 0, zIndex: 2 });
expect(result.style).toEqual({ position: 'sticky', insetInlineStart: 0, zIndex: 2 });
});

test('omits zIndex for unpinned columns', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/components/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import useTableState from '../hooks/useTableState';
import useTableData from '../hooks/useTableData';
import useColumnFilter from '../hooks/useColumnFilter';
import useColumnResize from '../hooks/useColumnResize';
import useRTL from '../hooks/useRTL';
import useRowContextValue from '../hooks/useRowContextValue';
import useHeadContextValue from '../hooks/useHeadContextValue';
import useIsomorphicLayoutEffect from '../hooks/useIsomorphicLayoutEffect';
Expand Down Expand Up @@ -173,7 +174,8 @@ function DataTableInner<T>(props: TableProps<T>, ref: React.ForwardedRef<DataTab
);

// ── Column resize state ────────────────────────────────────────────────────
const { columnWidths, handleResizeStart } = useColumnResize({ initialColumnWidths, onColumnResize });
const isRTL = useRTL(direction);
const { columnWidths, handleResizeStart } = useColumnResize({ initialColumnWidths, onColumnResize, isRTL });

const {
tableColumns,
Expand Down
26 changes: 20 additions & 6 deletions src/components/PinnedScrollbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,14 @@ export default function PinnedScrollbar({
const tw = Math.max(ratio * trackWidth, 30);
const maxThumbLeft = trackWidth - tw;
const maxScroll = scrollWidth - clientWidth;
// In RTL scrollLeft runs 0 → -maxScroll; shift into the 0 → maxScroll range
// so the thumb's physical position falls out of the same formula.
const isRTL = getComputedStyle(el).direction === 'rtl';
const scrolled = isRTL ? scrollLeft + maxScroll : scrollLeft;
setThumbWidth(tw);
setThumbLeft((scrollLeft / maxScroll) * maxThumbLeft);
setScrollPercent(maxScroll > 0 ? Math.round((scrollLeft / maxScroll) * 100) : 0);
setThumbLeft((scrolled / maxScroll) * maxThumbLeft);
const fromStart = isRTL ? -scrollLeft : scrollLeft;
setScrollPercent(maxScroll > 0 ? Math.round((fromStart / maxScroll) * 100) : 0);
}, [scrollRef]);

// Sync scrollbar when scroll container scrolls or resizes
Expand Down Expand Up @@ -78,7 +83,11 @@ export default function PinnedScrollbar({
const maxScroll = scrollWidth - clientWidth;
const dx = ev.clientX - dragStartX.current;
const scrollDelta = (dx / maxThumbLeft) * maxScroll;
el.scrollLeft = Math.max(0, Math.min(maxScroll, dragStartScroll.current + scrollDelta));
// scrollLeft's valid range is [-maxScroll, 0] in RTL, [0, maxScroll] in LTR
const isRTL = getComputedStyle(el).direction === 'rtl';
const min = isRTL ? -maxScroll : 0;
const max = isRTL ? 0 : maxScroll;
el.scrollLeft = Math.max(min, Math.min(max, dragStartScroll.current + scrollDelta));
};

const onUp = () => {
Expand Down Expand Up @@ -109,7 +118,8 @@ export default function PinnedScrollbar({
el.scrollLeft = 0;
} else if (e.key === 'End') {
e.preventDefault();
el.scrollLeft = el.scrollWidth;
// End = end of content: fully negative in RTL; the browser clamps either way
el.scrollLeft = getComputedStyle(el).direction === 'rtl' ? -el.scrollWidth : el.scrollWidth;
}
},
[scrollRef],
Expand All @@ -127,7 +137,11 @@ export default function PinnedScrollbar({
const clickX = e.clientX - rect.left;
const { scrollWidth, clientWidth } = el;
const direction = clickX < thumbLeft ? -1 : 1;
el.scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, el.scrollLeft + direction * clientWidth * 0.8));
const maxScroll = scrollWidth - clientWidth;
const isRTL = getComputedStyle(el).direction === 'rtl';
const min = isRTL ? -maxScroll : 0;
const max = isRTL ? 0 : maxScroll;
el.scrollLeft = Math.max(min, Math.min(max, el.scrollLeft + direction * clientWidth * 0.8));
},
[scrollRef, thumbLeft],
);
Expand All @@ -139,7 +153,7 @@ export default function PinnedScrollbar({
className="rdt_pinnedScrollbarTrack"
ref={trackRef}
role="presentation"
style={{ marginLeft: leftInset, marginRight: rightInset }}
style={{ marginInlineStart: leftInset, marginInlineEnd: rightInset }}
onClick={handleTrackClick}
>
<div
Expand Down
13 changes: 11 additions & 2 deletions src/hooks/useColumnResize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ type ResizeRef = { columnId: string | number; startX: number; startWidth: number
export type UseColumnResizeOptions = {
initialColumnWidths?: Record<string | number, number>;
onColumnResize?: (columnId: string | number, width: number, allWidths: Record<string | number, number>) => void;
isRTL?: boolean;
};

export default function useColumnResize({ initialColumnWidths, onColumnResize }: UseColumnResizeOptions = {}) {
export default function useColumnResize({
initialColumnWidths,
onColumnResize,
isRTL = false,
}: UseColumnResizeOptions = {}) {
const [columnWidths, setColumnWidths] = React.useState<Record<string | number, number>>(initialColumnWidths ?? {});
const resizeRef = React.useRef<ResizeRef | null>(null);
const onColumnResizeRef = React.useRef(onColumnResize);
const isRTLRef = React.useRef(isRTL);
useIsomorphicLayoutEffect(() => {
onColumnResizeRef.current = onColumnResize;
isRTLRef.current = isRTL;
});

const handleResizeStart = React.useCallback((columnId: string | number, e: React.MouseEvent) => {
Expand All @@ -26,7 +33,9 @@ export default function useColumnResize({ initialColumnWidths, onColumnResize }:
function onMouseMove(mv: MouseEvent) {
if (!resizeRef.current) return;
const { columnId } = resizeRef.current;
const delta = mv.clientX - resizeRef.current.startX;
// In RTL the handle sits on the column's left (end) edge, so dragging left
// (negative clientX delta) should widen the column — invert the delta.
const delta = (mv.clientX - resizeRef.current.startX) * (isRTLRef.current ? -1 : 1);
const newWidth = Math.max(40, resizeRef.current.startWidth + delta);
setColumnWidths(prev => ({ ...prev, [columnId]: newWidth }));
}
Expand Down
Loading
Loading