Skip to content

Commit 0450580

Browse files
authored
fix: Column resizing, pinning and reorder in RTL mode (jbetancur#1338)
1 parent 67c5fc6 commit 0450580

11 files changed

Lines changed: 112 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
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).
44

5+
## 8.5.1
6+
7+
### Bug fixes
8+
9+
- 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)
10+
- 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)
11+
- 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)
12+
- 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.
13+
14+
---
15+
516
## 8.5.0
617

718
### New features

apps/docs/src/components/demos/RTLDemo.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,25 @@ export default function RTLDemo() {
4141
name: isRTL ? 'الاسم' : 'Name',
4242
selector: r => isRTL ? r.nameAr : r.name,
4343
sortable: true,
44+
width: '200px',
45+
minWidth: '120px',
46+
reorder: true,
4447
},
4548
{
4649
name: isRTL ? 'القسم' : 'Department',
4750
selector: r => isRTL ? r.departmentAr : r.department,
4851
sortable: true,
52+
minWidth: '140px',
53+
reorder: true,
4954
},
5055
{
5156
name: isRTL ? 'الراتب' : 'Salary',
5257
selector: r => r.salary,
5358
format: r => `$${r.salary.toLocaleString()}`,
5459
right: true,
5560
sortable: true,
61+
width: '140px',
62+
reorder: true,
5663
},
5764
];
5865

@@ -86,6 +93,7 @@ export default function RTLDemo() {
8693
paginationPerPage={5}
8794
highlightOnHover
8895
striped
96+
resizable
8997
/>
9098
</div>
9199
);

apps/docs/src/pages/docs/rtl.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import DocsTable from '../../components/DocsTable.astro';
1616

1717
<Demo
1818
title="Direction: LTR / RTL / AUTO"
19-
description="Toggle between LTR, RTL, and AUTO. In RTL mode the pagination arrows reverse and column text aligns right-to-left."
19+
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."
2020
code={`import DataTable, { Direction } from 'react-data-table-component';
2121
2222
<DataTable
@@ -69,6 +69,7 @@ import DocsTable from '../../components/DocsTable.astro';
6969
<li>The wrapper receives <code>dir="rtl"</code>, which shifts inline content and flexbox row direction for you.</li>
7070
<li>Pagination first/prev/next/last buttons have their icons reversed via <code>.rdt_paginationButtonRTL</code>.</li>
7171
<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>
72+
<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>
7273
</ul>
7374

7475
<h2>Pairing with localization</h2>

src/DataTable.css

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@
187187
.rdt_colSeparatorFull .rdt_cellBase:not(.rdt_cellBaseHead) + .rdt_cellBase:not(.rdt_cellBaseHead)::before {
188188
content: '';
189189
position: absolute;
190-
left: 0;
190+
/* inline-start = the boundary shared with the previous DOM sibling in both LTR and RTL */
191+
inset-inline-start: 0;
191192
top: var(--_sep-top, 20%);
192193
height: var(--_sep-h, 60%);
193194
width: 1px;
@@ -378,7 +379,7 @@
378379
.rdt_filterDot {
379380
position: absolute;
380381
top: 3px;
381-
right: 3px;
382+
inset-inline-end: 3px;
382383
width: 5px;
383384
height: 5px;
384385
border-radius: 50%;
@@ -676,7 +677,7 @@
676677
.rdt_cellEditing:has(.rdt_editSelect)::after {
677678
content: '';
678679
position: absolute;
679-
right: var(--rdt-cell-padding-x, 16px);
680+
inset-inline-end: var(--rdt-cell-padding-x, 16px);
680681
top: 50%;
681682
transform: translateY(-50%);
682683
width: 12px;
@@ -690,7 +691,7 @@
690691
}
691692

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

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

799+
/* box-shadow/clip-path have no logical variants — mirror the pin-band edge
800+
shadows by hand under RTL, where inline-start pins sit on the physical right. */
801+
.rdt_pinLeftLast:dir(rtl) {
802+
box-shadow: -2px 0 4px -1px var(--rdt-color-pin-shadow, rgba(0, 0, 0, 0.12));
803+
clip-path: inset(0 0 0 -8px);
804+
}
805+
806+
.rdt_pinRightFirst:dir(rtl) {
807+
box-shadow: 2px 0 4px -1px var(--rdt-color-pin-shadow, rgba(0, 0, 0, 0.12));
808+
clip-path: inset(0 -8px 0 0);
809+
}
810+
798811
/* ─── Column resize handle ──────────────────────────────────────────────────── */
799812
/*
800813
* The handle straddles the cell boundary so its hit area is centered on the
801-
* separator line rather than offset to one side. width/2 = 3px → right: -3px
802-
* centers a 6px-wide handle on the column edge, matching the position of
814+
* separator line rather than offset to one side. width/2 = 3px → inset-inline-end: -3px
815+
* centers a 6px-wide handle on the column's end edge, matching the position of
803816
* .rdt_headSeparator/.rdt_colSeparator pseudo-elements drawn at the boundary.
817+
* inset-inline-end resolves to the left edge under RTL so the handle mirrors correctly.
804818
*/
805819
.rdt_resizeHandle {
806820
position: absolute;
807-
right: -3px;
821+
inset-inline-end: -3px;
808822
top: 0;
809823
height: 100%;
810824
width: 6px;
@@ -897,7 +911,7 @@
897911
.rdt_groupCell + .rdt_groupCell::before {
898912
content: '';
899913
position: absolute;
900-
left: 0;
914+
inset-inline-start: 0;
901915
top: 20%;
902916
height: 60%;
903917
width: 1px;
@@ -1298,7 +1312,7 @@
12981312

12991313
.rdt_selectWrapper svg {
13001314
top: 50%;
1301-
right: 2px;
1315+
inset-inline-end: 2px;
13021316
transform: translateY(-50%);
13031317
color: var(--rdt-color-text-secondary, rgba(0, 0, 0, 0.54));
13041318
position: absolute;
@@ -1316,16 +1330,15 @@
13161330
height: 100%;
13171331
max-width: 100%;
13181332
user-select: none;
1319-
padding-left: 8px;
1320-
padding-right: 22px;
1333+
padding-inline-start: 8px;
1334+
padding-inline-end: 22px;
13211335
box-sizing: border-box;
13221336
font-size: inherit;
13231337
font-family: inherit;
13241338
color: inherit;
13251339
border: none;
13261340
background-color: transparent;
13271341
appearance: none;
1328-
direction: ltr;
13291342
flex-shrink: 0;
13301343
outline: none;
13311344
}

src/__tests__/DataTable.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2821,6 +2821,23 @@ describe('DataTable::columnResize', () => {
28212821
// Column width is applied via maxWidth (buildCellStyle sets maxWidth from width prop)
28222822
expect(headerCell.style.maxWidth).toBe('60px');
28232823
});
2824+
2825+
test('inverts drag delta in RTL so dragging the handle left widens the column', () => {
2826+
const mock = dataMock();
2827+
const { container } = render(
2828+
<DataTable data={mock.data} columns={mock.columns} resizable direction={Direction.RTL} />,
2829+
);
2830+
const handle = container.querySelector('.rdt_resizeHandle') as HTMLElement;
2831+
const headerCell = handle.closest('[data-column-id]') as HTMLElement;
2832+
2833+
// Dragging left (clientX 100 → 40) is a -60 delta; in RTL it inverts to +60.
2834+
// startWidth is 0 in jsdom → newWidth = max(40, 0 + 60) = 60
2835+
fireEvent.mouseDown(handle, { clientX: 100 });
2836+
fireEvent.mouseMove(document, { clientX: 40 });
2837+
fireEvent.mouseUp(document);
2838+
2839+
expect(headerCell.style.maxWidth).toBe('60px');
2840+
});
28242841
});
28252842

28262843
describe('DataTable::columnGroups', () => {

src/__tests__/pinning.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ describe('DataTable column pinning', () => {
5252
expect(cell?.style.position).toBe('sticky');
5353
});
5454

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

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

@@ -126,7 +126,7 @@ describe('PinnedScrollbar', () => {
126126
expect(track).not.toBeNull();
127127
});
128128

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

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

137137
const track = container.querySelector('.rdt_pinnedScrollbarTrack') as HTMLElement | null;
138-
expect(track?.style.marginLeft).toBe('150px');
139-
expect(track?.style.marginRight).toBe('120px');
138+
expect(track?.style.marginInlineStart).toBe('150px');
139+
expect(track?.style.marginInlineEnd).toBe('120px');
140140
});
141141

142142
test('thumb width is proportional to viewport/scroll ratio', async () => {

src/__tests__/util.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ describe('getPinnedCellMeta', () => {
582582
const result = getPinnedCellMeta(col('b', 'left'), offsets);
583583
expect(result.pinnedLeft).toBe(true);
584584
expect(result.isLastLeftPin).toBe(true);
585-
expect(result.style).toEqual({ position: 'sticky', left: 100 });
585+
expect(result.style).toEqual({ position: 'sticky', insetInlineStart: 100 });
586586
expect(result.className).toContain('rdt_pinLeft');
587587
expect(result.className).toContain('rdt_pinLeftLast');
588588
});
@@ -598,7 +598,7 @@ describe('getPinnedCellMeta', () => {
598598
const result = getPinnedCellMeta(col('y', 'right'), offsets);
599599
expect(result.pinnedRight).toBe(true);
600600
expect(result.isFirstRightPin).toBe(true);
601-
expect(result.style).toEqual({ position: 'sticky', right: 80 });
601+
expect(result.style).toEqual({ position: 'sticky', insetInlineEnd: 80 });
602602
expect(result.className).toContain('rdt_pinRight');
603603
expect(result.className).toContain('rdt_pinRightFirst');
604604
});
@@ -724,7 +724,7 @@ describe('getPinnedCellMeta with zIndex', () => {
724724

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

730730
test('omits zIndex for unpinned columns', () => {

src/components/DataTable.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import useTableState from '../hooks/useTableState';
2323
import useTableData from '../hooks/useTableData';
2424
import useColumnFilter from '../hooks/useColumnFilter';
2525
import useColumnResize from '../hooks/useColumnResize';
26+
import useRTL from '../hooks/useRTL';
2627
import useRowContextValue from '../hooks/useRowContextValue';
2728
import useHeadContextValue from '../hooks/useHeadContextValue';
2829
import useIsomorphicLayoutEffect from '../hooks/useIsomorphicLayoutEffect';
@@ -173,7 +174,8 @@ function DataTableInner<T>(props: TableProps<T>, ref: React.ForwardedRef<DataTab
173174
);
174175

175176
// ── Column resize state ────────────────────────────────────────────────────
176-
const { columnWidths, handleResizeStart } = useColumnResize({ initialColumnWidths, onColumnResize });
177+
const isRTL = useRTL(direction);
178+
const { columnWidths, handleResizeStart } = useColumnResize({ initialColumnWidths, onColumnResize, isRTL });
177179

178180
const {
179181
tableColumns,

src/components/PinnedScrollbar.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,14 @@ export default function PinnedScrollbar({
3737
const tw = Math.max(ratio * trackWidth, 30);
3838
const maxThumbLeft = trackWidth - tw;
3939
const maxScroll = scrollWidth - clientWidth;
40+
// In RTL scrollLeft runs 0 → -maxScroll; shift into the 0 → maxScroll range
41+
// so the thumb's physical position falls out of the same formula.
42+
const isRTL = getComputedStyle(el).direction === 'rtl';
43+
const scrolled = isRTL ? scrollLeft + maxScroll : scrollLeft;
4044
setThumbWidth(tw);
41-
setThumbLeft((scrollLeft / maxScroll) * maxThumbLeft);
42-
setScrollPercent(maxScroll > 0 ? Math.round((scrollLeft / maxScroll) * 100) : 0);
45+
setThumbLeft((scrolled / maxScroll) * maxThumbLeft);
46+
const fromStart = isRTL ? -scrollLeft : scrollLeft;
47+
setScrollPercent(maxScroll > 0 ? Math.round((fromStart / maxScroll) * 100) : 0);
4348
}, [scrollRef]);
4449

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

8493
const onUp = () => {
@@ -109,7 +118,8 @@ export default function PinnedScrollbar({
109118
el.scrollLeft = 0;
110119
} else if (e.key === 'End') {
111120
e.preventDefault();
112-
el.scrollLeft = el.scrollWidth;
121+
// End = end of content: fully negative in RTL; the browser clamps either way
122+
el.scrollLeft = getComputedStyle(el).direction === 'rtl' ? -el.scrollWidth : el.scrollWidth;
113123
}
114124
},
115125
[scrollRef],
@@ -127,7 +137,11 @@ export default function PinnedScrollbar({
127137
const clickX = e.clientX - rect.left;
128138
const { scrollWidth, clientWidth } = el;
129139
const direction = clickX < thumbLeft ? -1 : 1;
130-
el.scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, el.scrollLeft + direction * clientWidth * 0.8));
140+
const maxScroll = scrollWidth - clientWidth;
141+
const isRTL = getComputedStyle(el).direction === 'rtl';
142+
const min = isRTL ? -maxScroll : 0;
143+
const max = isRTL ? 0 : maxScroll;
144+
el.scrollLeft = Math.max(min, Math.min(max, el.scrollLeft + direction * clientWidth * 0.8));
131145
},
132146
[scrollRef, thumbLeft],
133147
);
@@ -139,7 +153,7 @@ export default function PinnedScrollbar({
139153
className="rdt_pinnedScrollbarTrack"
140154
ref={trackRef}
141155
role="presentation"
142-
style={{ marginLeft: leftInset, marginRight: rightInset }}
156+
style={{ marginInlineStart: leftInset, marginInlineEnd: rightInset }}
143157
onClick={handleTrackClick}
144158
>
145159
<div

src/hooks/useColumnResize.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,21 @@ type ResizeRef = { columnId: string | number; startX: number; startWidth: number
66
export type UseColumnResizeOptions = {
77
initialColumnWidths?: Record<string | number, number>;
88
onColumnResize?: (columnId: string | number, width: number, allWidths: Record<string | number, number>) => void;
9+
isRTL?: boolean;
910
};
1011

11-
export default function useColumnResize({ initialColumnWidths, onColumnResize }: UseColumnResizeOptions = {}) {
12+
export default function useColumnResize({
13+
initialColumnWidths,
14+
onColumnResize,
15+
isRTL = false,
16+
}: UseColumnResizeOptions = {}) {
1217
const [columnWidths, setColumnWidths] = React.useState<Record<string | number, number>>(initialColumnWidths ?? {});
1318
const resizeRef = React.useRef<ResizeRef | null>(null);
1419
const onColumnResizeRef = React.useRef(onColumnResize);
20+
const isRTLRef = React.useRef(isRTL);
1521
useIsomorphicLayoutEffect(() => {
1622
onColumnResizeRef.current = onColumnResize;
23+
isRTLRef.current = isRTL;
1724
});
1825

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

0 commit comments

Comments
 (0)