Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tricky-parts-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'nuka-carousel': patch
---

Add support for RTL (right-to-left) document direction.
38 changes: 38 additions & 0 deletions packages/nuka/src/Carousel/Carousel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,41 @@ export const AfterSlide: Story = {
),
},
};

const RTLRenderComponent = (props: CarouselProps) => {
const ref = useRef<SlideHandle>(null);
return (
<div dir="rtl">
<button
onClick={() => {
if (ref.current) ref.current.goBack();
}}
>
previous
</button>
<button
onClick={() => {
if (ref.current) ref.current.goForward();
}}
>
next
</button>
<Carousel ref={ref} {...props} />
</div>
);
};

export const RTL: Story = {
render: RTLRenderComponent,
args: {
scrollDistance: 'slide',
showDots: true,
children: (
<>
{[...Array(10)].map((_, index) => (
<ExampleSlide key={index} index={index} />
))}
</>
),
},
};
62 changes: 62 additions & 0 deletions packages/nuka/src/hooks/use-measurement.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react';

import { useMeasurement } from './use-measurement';
import * as hooks from './use-resize-observer';
import * as browser from '../utils/browser';

const domElement = {} as any;
jest.spyOn(hooks, 'useResizeObserver').mockImplementation(() => domElement);
Expand Down Expand Up @@ -209,4 +210,65 @@ describe('useMeasurement', () => {
expect(totalPages).toBe(3);
expect(scrollOffset).toEqual([0, 200, 400]);
});

describe('RTL support', () => {
let isRTLSpy: jest.SpyInstance;

const mockElement = {
current: {
scrollWidth: 900,
offsetWidth: 500,
querySelector: () => ({
children: [
{ offsetWidth: 200 },
{ offsetWidth: 300 },
{ offsetWidth: 400 },
],
}),
},
} as any;

beforeEach(() => {
isRTLSpy = jest.spyOn(browser, 'isRTL');
});

afterEach(() => {
isRTLSpy.mockRestore();
});

it.each([
['screen', 2, [0, -500]],
['slide', 3, [0, -200, -500]],
[200, 3, [0, -200, -400]],
])(
'should return negative scroll offsets for %s mode in RTL',
(scrollDistance, expectedPages, expectedOffsets) => {
isRTLSpy.mockReturnValue(true);

const { result } = renderHook(() =>
useMeasurement({
element: mockElement,
scrollDistance: scrollDistance as any,
}),
);

expect(result.current.totalPages).toBe(expectedPages);
expect(result.current.scrollOffset).toEqual(expectedOffsets);
},
);

it('should return positive scroll offsets in LTR mode', () => {
isRTLSpy.mockReturnValue(false);

const { result } = renderHook(() =>
useMeasurement({
element: mockElement,
scrollDistance: 'screen',
}),
);

expect(result.current.totalPages).toBe(2);
expect(result.current.scrollOffset).toEqual([0, 500]);
});
});
});
32 changes: 29 additions & 3 deletions packages/nuka/src/hooks/use-measurement.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';

import { arraySeq, arraySum } from '../utils';
import { isRTL } from '../utils/browser';
import { useResizeObserver } from './use-resize-observer';

type MeasurementProps = {
Expand All @@ -27,12 +28,21 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) {

if (visibleWidth === 0) return;

const rtl = isRTL(container);

switch (scrollDistance) {
case 'screen': {
const pageCount = Math.round(scrollWidth / visibleWidth);
let offsets = arraySeq(pageCount, visibleWidth);

// In RTL mode, scroll offsets must be negative (except for the first page at 0)
// because scrollLeft uses negative values to scroll right in RTL layouts
if (rtl) {
offsets = offsets.map((offset) => (offset === 0 ? 0 : -offset));
}

setTotalPages(pageCount);
setScrollOffset(arraySeq(pageCount, visibleWidth));
setScrollOffset(offsets);
break;
}
case 'slide': {
Expand All @@ -51,19 +61,35 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) {
// the remainder of the full width and window width
const pageCount =
scrollOffsets.findIndex((offset) => offset >= remainder) + 1;
let finalOffsets = scrollOffsets;

// In RTL mode, negate all offsets except the first (0) to match RTL scrollLeft behavior
if (rtl) {
finalOffsets = scrollOffsets.map((offset) =>
offset === 0 ? 0 : -offset,
);
}

setTotalPages(pageCount);
setScrollOffset(scrollOffsets);
setScrollOffset(finalOffsets);
break;
}
default: {
if (typeof scrollDistance === 'number' && scrollDistance > 0) {
// find the number of pages required to scroll all the slides
// to the end of the container
const pageCount = Math.ceil(remainder / scrollDistance) + 1;
let offsets = arraySeq(pageCount, scrollDistance);
// Clamp offsets to not exceed the total scrollable distance
offsets = offsets.map((offset) => Math.min(offset, remainder));

// Convert to negative offsets for RTL (first page stays at 0)
if (rtl) {
offsets = offsets.map((offset) => (offset === 0 ? 0 : -offset));
}

setTotalPages(pageCount);
setScrollOffset(arraySeq(pageCount, scrollDistance));
setScrollOffset(offsets);
}
}
}
Expand Down
59 changes: 59 additions & 0 deletions packages/nuka/src/utils/browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { isBrowser, isRTL } from './browser';

describe('browser utils', () => {
describe('isBrowser', () => {
it('should return true in a browser environment', () => {
expect(isBrowser()).toBe(true);
});
});

describe('isRTL', () => {
let originalDir: string;

beforeEach(() => {
originalDir = document.documentElement.dir;
});

afterEach(() => {
document.documentElement.dir = originalDir;
});

it.each([
['rtl', true],
['ltr', false],
['', false],
['auto', false],
])('should return %s when document direction is "%s"', (dir, expected) => {
document.documentElement.dir = dir;
expect(isRTL()).toBe(expected);
});

it.each([
['rtl', true],
['ltr', false],
])(
'should detect %s from element computed style',
(dir, expected) => {
const element = document.createElement('div');
element.dir = dir;
document.body.appendChild(element);

expect(isRTL(element)).toBe(expected);

document.body.removeChild(element);
},
);

it('should inherit RTL from parent element', () => {
const parent = document.createElement('div');
parent.dir = 'rtl';
const child = document.createElement('div');
parent.appendChild(child);
document.body.appendChild(parent);

expect(isRTL(child)).toBe(true);

document.body.removeChild(parent);
});
});
});
16 changes: 16 additions & 0 deletions packages/nuka/src/utils/browser.ts
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
export const isBrowser = () => typeof window !== 'undefined';

/**
* Detects if an element or the document is in right-to-left (RTL) mode.
* Checks the computed direction style to support both document-level and element-level RTL.
* This is used to adjust scroll offsets for RTL layouts.
*/
export function isRTL(element?: HTMLElement | null) {
if (!isBrowser()) return false;

if (element) {
const direction = window.getComputedStyle(element).direction;
return direction === 'rtl';
}

return document.documentElement.dir === 'rtl';
}