diff --git a/CHANGELOG.md b/CHANGELOG.md index 823a2eff..f8f0458f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/docs/src/components/demos/RTLDemo.tsx b/apps/docs/src/components/demos/RTLDemo.tsx index 84a79f0e..d153f6f6 100644 --- a/apps/docs/src/components/demos/RTLDemo.tsx +++ b/apps/docs/src/components/demos/RTLDemo.tsx @@ -41,11 +41,16 @@ 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', @@ -53,6 +58,8 @@ export default function RTLDemo() { format: r => `$${r.salary.toLocaleString()}`, right: true, sortable: true, + width: '140px', + reorder: true, }, ]; @@ -86,6 +93,7 @@ export default function RTLDemo() { paginationPerPage={5} highlightOnHover striped + resizable /> ); diff --git a/apps/docs/src/pages/docs/rtl.astro b/apps/docs/src/pages/docs/rtl.astro index 13b0c3e0..6d153914 100644 --- a/apps/docs/src/pages/docs/rtl.astro +++ b/apps/docs/src/pages/docs/rtl.astro @@ -16,7 +16,7 @@ import DocsTable from '../../components/DocsTable.astro'; The wrapper receives dir="rtl", which shifts inline content and flexbox row direction for you.
  • Pagination first/prev/next/last buttons have their icons reversed via .rdt_paginationButtonRTL.
  • Column right: true alignment continues to work as expected. Align numbers relative to the reading direction of the cell content, not the page.
  • +
  • With resizable, 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.
  • Pairing with localization

    diff --git a/src/DataTable.css b/src/DataTable.css index e7117346..c4053459 100644 --- a/src/DataTable.css +++ b/src/DataTable.css @@ -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; @@ -378,7 +379,7 @@ .rdt_filterDot { position: absolute; top: 3px; - right: 3px; + inset-inline-end: 3px; width: 5px; height: 5px; border-radius: 50%; @@ -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; @@ -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 */ @@ -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; @@ -897,7 +911,7 @@ .rdt_groupCell + .rdt_groupCell::before { content: ''; position: absolute; - left: 0; + inset-inline-start: 0; top: 20%; height: 60%; width: 1px; @@ -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; @@ -1316,8 +1330,8 @@ 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; @@ -1325,7 +1339,6 @@ border: none; background-color: transparent; appearance: none; - direction: ltr; flex-shrink: 0; outline: none; } diff --git a/src/__tests__/DataTable.test.tsx b/src/__tests__/DataTable.test.tsx index 2e35991e..0706fcb0 100644 --- a/src/__tests__/DataTable.test.tsx +++ b/src/__tests__/DataTable.test.tsx @@ -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( + , + ); + 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', () => { diff --git a/src/__tests__/pinning.test.tsx b/src/__tests__/pinning.test.tsx index 9f8ffdc9..72f1810d 100644 --- a/src/__tests__/pinning.test.tsx +++ b/src/__tests__/pinning.test.tsx @@ -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(); 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', () => { @@ -92,9 +92,9 @@ describe('DataTable column pinning', () => { ]; const { container } = render(); 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); }); }); @@ -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(); @@ -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 () => { diff --git a/src/__tests__/util.test.ts b/src/__tests__/util.test.ts index 9777dc35..b1539d98 100644 --- a/src/__tests__/util.test.ts +++ b/src/__tests__/util.test.ts @@ -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'); }); @@ -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'); }); @@ -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', () => { diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index ac4f51eb..5f939476 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -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'; @@ -173,7 +174,8 @@ function DataTableInner(props: TableProps, ref: React.ForwardedRef 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 @@ -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 = () => { @@ -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], @@ -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], ); @@ -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} >
    ; onColumnResize?: (columnId: string | number, width: number, allWidths: Record) => void; + isRTL?: boolean; }; -export default function useColumnResize({ initialColumnWidths, onColumnResize }: UseColumnResizeOptions = {}) { +export default function useColumnResize({ + initialColumnWidths, + onColumnResize, + isRTL = false, +}: UseColumnResizeOptions = {}) { const [columnWidths, setColumnWidths] = React.useState>(initialColumnWidths ?? {}); const resizeRef = React.useRef(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) => { @@ -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 })); } diff --git a/src/util.ts b/src/util.ts index f1caa7c2..cf13c6ec 100644 --- a/src/util.ts +++ b/src/util.ts @@ -397,10 +397,12 @@ export function getPinnedCellMeta( const isLastLeftPin = pinnedLeft && id != null && offsets.left[id] === maxLeft; const isFirstRightPin = pinnedRight && id != null && offsets.right[id] === maxRight; + // Logical insets so pin bands mirror under RTL: 'left' pins stick to the + // inline-start edge (physical right in RTL), 'right' pins to inline-end. const style: React.CSSProperties = pinnedLeft - ? { position: 'sticky', left: offsets.left[id!], ...(zIndex != null && { zIndex }) } + ? { position: 'sticky', insetInlineStart: offsets.left[id!], ...(zIndex != null && { zIndex }) } : pinnedRight - ? { position: 'sticky', right: offsets.right[id!], ...(zIndex != null && { zIndex }) } + ? { position: 'sticky', insetInlineEnd: offsets.right[id!], ...(zIndex != null && { zIndex }) } : {}; const className = [