diff --git a/.changeset/tricky-parts-battle.md b/.changeset/tricky-parts-battle.md new file mode 100644 index 00000000..221cbb17 --- /dev/null +++ b/.changeset/tricky-parts-battle.md @@ -0,0 +1,5 @@ +--- +'nuka-carousel': patch +--- + +Add support for RTL (right-to-left) document direction. diff --git a/packages/nuka/src/Carousel/Carousel.stories.tsx b/packages/nuka/src/Carousel/Carousel.stories.tsx index be2b5c62..0c3b173b 100644 --- a/packages/nuka/src/Carousel/Carousel.stories.tsx +++ b/packages/nuka/src/Carousel/Carousel.stories.tsx @@ -214,3 +214,41 @@ export const AfterSlide: Story = { ), }, }; + +const RTLRenderComponent = (props: CarouselProps) => { + const ref = useRef(null); + return ( +
+ + + +
+ ); +}; + +export const RTL: Story = { + render: RTLRenderComponent, + args: { + scrollDistance: 'slide', + showDots: true, + children: ( + <> + {[...Array(10)].map((_, index) => ( + + ))} + + ), + }, +}; diff --git a/packages/nuka/src/hooks/use-measurement.test.tsx b/packages/nuka/src/hooks/use-measurement.test.tsx index 44290577..e5e9165d 100644 --- a/packages/nuka/src/hooks/use-measurement.test.tsx +++ b/packages/nuka/src/hooks/use-measurement.test.tsx @@ -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); @@ -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]); + }); + }); }); diff --git a/packages/nuka/src/hooks/use-measurement.tsx b/packages/nuka/src/hooks/use-measurement.tsx index 6b3b7ef2..4d9c935c 100644 --- a/packages/nuka/src/hooks/use-measurement.tsx +++ b/packages/nuka/src/hooks/use-measurement.tsx @@ -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 = { @@ -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': { @@ -51,9 +61,17 @@ 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: { @@ -61,9 +79,17 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { // 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); } } } diff --git a/packages/nuka/src/utils/browser.test.ts b/packages/nuka/src/utils/browser.test.ts new file mode 100644 index 00000000..c9668872 --- /dev/null +++ b/packages/nuka/src/utils/browser.test.ts @@ -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); + }); + }); +}); diff --git a/packages/nuka/src/utils/browser.ts b/packages/nuka/src/utils/browser.ts index 5408914e..6f310964 100644 --- a/packages/nuka/src/utils/browser.ts +++ b/packages/nuka/src/utils/browser.ts @@ -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'; +}