From 6e5aa57423a016408f496b11a866539df4ad449c Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 11:28:08 -0800 Subject: [PATCH 01/10] Enhance Carousel smooth scrolling and RTL support - Updated Carousel component to handle smooth scrolling behavior based on initial load state. - Introduced RTL support in useMeasurement hook for accurate scroll offsets. - Added isRTL utility function to determine text direction for proper layout handling. --- packages/nuka/src/Carousel/Carousel.tsx | 30 ++++++++++++++++++--- packages/nuka/src/hooks/use-measurement.tsx | 27 +++++++++++++++---- packages/nuka/src/utils/browser.ts | 4 +++ 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/nuka/src/Carousel/Carousel.tsx b/packages/nuka/src/Carousel/Carousel.tsx index bd8de810..22183f7a 100644 --- a/packages/nuka/src/Carousel/Carousel.tsx +++ b/packages/nuka/src/Carousel/Carousel.tsx @@ -143,14 +143,36 @@ export const Carousel = forwardRef( if (containerRef.current) { const currentSlideIndex = previousPageRef.current; const endSlideIndex = currentPage; + const targetScrollLeft = scrollOffset[currentPage]; + + const isInitialLoad = previousPageRef.current === -1; + + if ( + isInitialLoad && + initialPage !== undefined && + currentPage !== initialPage + ) { + return; + } + beforeSlide && beforeSlide(currentSlideIndex, endSlideIndex); - containerRef.current.scrollLeft = scrollOffset[currentPage]; - afterSlide && setTimeout(() => afterSlide(endSlideIndex), 0); - previousPageRef.current = currentPage; - if (initialPage === undefined || currentPage === initialPage) { + + if (!isInitialLoad) { containerRef.current.classList.remove('scroll-auto'); containerRef.current.classList.add('scroll-smooth'); } + + try { + containerRef.current.scrollTo({ + left: targetScrollLeft, + behavior: isInitialLoad ? 'auto' : 'smooth', + }); + } catch (e) { + containerRef.current.scrollLeft = targetScrollLeft; + } + + afterSlide && setTimeout(() => afterSlide(endSlideIndex), 0); + previousPageRef.current = currentPage; } }, [currentPage, scrollOffset, beforeSlide, afterSlide, initialPage]); diff --git a/packages/nuka/src/hooks/use-measurement.tsx b/packages/nuka/src/hooks/use-measurement.tsx index 6b3b7ef2..08ec267b 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,19 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { if (visibleWidth === 0) return; + const rtl = isRTL(); + switch (scrollDistance) { case 'screen': { const pageCount = Math.round(scrollWidth / visibleWidth); + let offsets = arraySeq(pageCount, visibleWidth); + + if (rtl) { + offsets = offsets.map((offset) => -offset); + } setTotalPages(pageCount); - setScrollOffset(arraySeq(pageCount, visibleWidth)); + setScrollOffset(offsets); break; } case 'slide': { @@ -51,19 +59,28 @@ 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; + + if (rtl) { + finalOffsets = scrollOffsets.map((offset) => -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); + offsets = offsets.map((offset) => Math.min(offset, remainder)); + + if (rtl) { + offsets = offsets.map((offset) => -offset); + } setTotalPages(pageCount); - setScrollOffset(arraySeq(pageCount, scrollDistance)); + setScrollOffset(offsets); } } } diff --git a/packages/nuka/src/utils/browser.ts b/packages/nuka/src/utils/browser.ts index 5408914e..2ea8dfba 100644 --- a/packages/nuka/src/utils/browser.ts +++ b/packages/nuka/src/utils/browser.ts @@ -1 +1,5 @@ export const isBrowser = () => typeof window !== 'undefined'; + +export function isRTL() { + return isBrowser() && document.documentElement.dir === 'rtl'; +} From f214e778c289e98a8234b5a0cd6e9ee1a797bf26 Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 12:21:27 -0800 Subject: [PATCH 02/10] Add RTL support tests and refine offset calculations in useMeasurement hook --- .../nuka/src/hooks/use-measurement.test.tsx | 133 ++++++++++++++++++ packages/nuka/src/hooks/use-measurement.tsx | 10 +- packages/nuka/src/utils/browser.test.ts | 41 ++++++ 3 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 packages/nuka/src/utils/browser.test.ts diff --git a/packages/nuka/src/hooks/use-measurement.test.tsx b/packages/nuka/src/hooks/use-measurement.test.tsx index 44290577..b8946be6 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,136 @@ describe('useMeasurement', () => { expect(totalPages).toBe(3); expect(scrollOffset).toEqual([0, 200, 400]); }); + + describe('RTL support', () => { + let isRTLSpy: jest.SpyInstance; + + beforeEach(() => { + isRTLSpy = jest.spyOn(browser, 'isRTL'); + }); + + afterEach(() => { + isRTLSpy.mockRestore(); + }); + + it('should return negative scroll offsets for screen mode in RTL', () => { + isRTLSpy.mockReturnValue(true); + + const element = { + current: { + scrollWidth: 900, + offsetWidth: 500, + querySelector: () => ({ + children: [ + { offsetWidth: 200 }, + { offsetWidth: 300 }, + { offsetWidth: 400 }, + ], + }), + }, + } as any; + + const { result } = renderHook(() => + useMeasurement({ + element, + scrollDistance: 'screen', + }), + ); + + const { totalPages, scrollOffset } = result.current; + + expect(totalPages).toBe(2); + expect(scrollOffset).toEqual([0, -500]); + }); + + it('should return negative scroll offsets for slide mode in RTL', () => { + isRTLSpy.mockReturnValue(true); + + const element = { + current: { + scrollWidth: 900, + offsetWidth: 500, + querySelector: () => ({ + children: [ + { offsetWidth: 200 }, + { offsetWidth: 300 }, + { offsetWidth: 400 }, + ], + }), + }, + } as any; + + const { result } = renderHook(() => + useMeasurement({ + element, + scrollDistance: 'slide', + }), + ); + + const { totalPages, scrollOffset } = result.current; + + expect(totalPages).toBe(3); + expect(scrollOffset).toEqual([0, -200, -500]); + }); + + it('should return negative scroll offsets for numbered distance in RTL', () => { + isRTLSpy.mockReturnValue(true); + + const element = { + current: { + scrollWidth: 900, + offsetWidth: 500, + querySelector: () => ({ + children: [ + { offsetWidth: 200 }, + { offsetWidth: 300 }, + { offsetWidth: 400 }, + ], + }), + }, + } as any; + + const { result } = renderHook(() => + useMeasurement({ + element, + scrollDistance: 200, + }), + ); + + const { totalPages, scrollOffset } = result.current; + + expect(totalPages).toBe(3); + expect(scrollOffset).toEqual([0, -200, -400]); + }); + + it('should return positive scroll offsets in LTR mode', () => { + isRTLSpy.mockReturnValue(false); + + const element = { + current: { + scrollWidth: 900, + offsetWidth: 500, + querySelector: () => ({ + children: [ + { offsetWidth: 200 }, + { offsetWidth: 300 }, + { offsetWidth: 400 }, + ], + }), + }, + } as any; + + const { result } = renderHook(() => + useMeasurement({ + element, + scrollDistance: 'screen', + }), + ); + + const { totalPages, scrollOffset } = result.current; + + expect(totalPages).toBe(2); + expect(scrollOffset).toEqual([0, 500]); + }); + }); }); diff --git a/packages/nuka/src/hooks/use-measurement.tsx b/packages/nuka/src/hooks/use-measurement.tsx index 08ec267b..80b65579 100644 --- a/packages/nuka/src/hooks/use-measurement.tsx +++ b/packages/nuka/src/hooks/use-measurement.tsx @@ -36,7 +36,7 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { let offsets = arraySeq(pageCount, visibleWidth); if (rtl) { - offsets = offsets.map((offset) => -offset); + offsets = offsets.map((offset) => (offset === 0 ? 0 : -offset)); } setTotalPages(pageCount); @@ -62,7 +62,9 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { let finalOffsets = scrollOffsets; if (rtl) { - finalOffsets = scrollOffsets.map((offset) => -offset); + finalOffsets = scrollOffsets.map((offset) => + offset === 0 ? 0 : -offset, + ); } setTotalPages(pageCount); @@ -71,12 +73,14 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { } 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); offsets = offsets.map((offset) => Math.min(offset, remainder)); if (rtl) { - offsets = offsets.map((offset) => -offset); + offsets = offsets.map((offset) => (offset === 0 ? 0 : -offset)); } setTotalPages(pageCount); diff --git a/packages/nuka/src/utils/browser.test.ts b/packages/nuka/src/utils/browser.test.ts new file mode 100644 index 00000000..108002bc --- /dev/null +++ b/packages/nuka/src/utils/browser.test.ts @@ -0,0 +1,41 @@ +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('should return true when document direction is rtl', () => { + document.documentElement.dir = 'rtl'; + expect(isRTL()).toBe(true); + }); + + it('should return false when document direction is ltr', () => { + document.documentElement.dir = 'ltr'; + expect(isRTL()).toBe(false); + }); + + it('should return false when document direction is not set', () => { + document.documentElement.dir = ''; + expect(isRTL()).toBe(false); + }); + + it('should return false when document direction is auto', () => { + document.documentElement.dir = 'auto'; + expect(isRTL()).toBe(false); + }); + }); +}); From 8df29840389b417caded9dc14a79882913686572 Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 12:44:02 -0800 Subject: [PATCH 03/10] Enhance Carousel and useMeasurement for RTL support - Improved Carousel component to prevent unnecessary scroll animations during initial load. - Updated useMeasurement hook to correctly handle negative scroll offsets in RTL mode. - Added utility function to detect RTL layout for better scroll behavior management. --- packages/nuka/src/Carousel/Carousel.tsx | 6 ++++++ packages/nuka/src/hooks/use-measurement.tsx | 5 +++++ packages/nuka/src/utils/browser.ts | 2 ++ 3 files changed, 13 insertions(+) diff --git a/packages/nuka/src/Carousel/Carousel.tsx b/packages/nuka/src/Carousel/Carousel.tsx index 22183f7a..1c3e90b1 100644 --- a/packages/nuka/src/Carousel/Carousel.tsx +++ b/packages/nuka/src/Carousel/Carousel.tsx @@ -145,8 +145,11 @@ export const Carousel = forwardRef( const endSlideIndex = currentPage; const targetScrollLeft = scrollOffset[currentPage]; + // Check if this is the initial render (previousPageRef starts at -1) const isInitialLoad = previousPageRef.current === -1; + // On initial load with initialPage set, skip if we're not at the target page yet + // This prevents unnecessary scroll animations during initialization if ( isInitialLoad && initialPage !== undefined && @@ -157,11 +160,14 @@ export const Carousel = forwardRef( beforeSlide && beforeSlide(currentSlideIndex, endSlideIndex); + // Only enable smooth scrolling after the initial load to avoid animation on mount if (!isInitialLoad) { containerRef.current.classList.remove('scroll-auto'); containerRef.current.classList.add('scroll-smooth'); } + // Use scrollTo with behavior option for better control over scroll animation + // Fall back to direct scrollLeft assignment if scrollTo is not supported (older browsers) try { containerRef.current.scrollTo({ left: targetScrollLeft, diff --git a/packages/nuka/src/hooks/use-measurement.tsx b/packages/nuka/src/hooks/use-measurement.tsx index 80b65579..ce6305d4 100644 --- a/packages/nuka/src/hooks/use-measurement.tsx +++ b/packages/nuka/src/hooks/use-measurement.tsx @@ -35,6 +35,8 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { 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)); } @@ -61,6 +63,7 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { 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, @@ -77,8 +80,10 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { // 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)); } diff --git a/packages/nuka/src/utils/browser.ts b/packages/nuka/src/utils/browser.ts index 2ea8dfba..526da5f9 100644 --- a/packages/nuka/src/utils/browser.ts +++ b/packages/nuka/src/utils/browser.ts @@ -1,5 +1,7 @@ export const isBrowser = () => typeof window !== 'undefined'; +// Detects if the document is in right-to-left (RTL) mode by checking the dir attribute +// on the root HTML element. This is used to adjust scroll offsets for RTL layouts. export function isRTL() { return isBrowser() && document.documentElement.dir === 'rtl'; } From 49cd4e079688fac23b897e31d8dadbf9a6e9cf6e Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 12:45:54 -0800 Subject: [PATCH 04/10] Refactor RTL tests in useMeasurement and browser utilities - Consolidated RTL direction tests in browser utility to use parameterized tests for better readability and maintainability. - Simplified RTL offset tests in useMeasurement by utilizing a mock element and parameterized test cases for various scroll distances. --- .../nuka/src/hooks/use-measurement.test.tsx | 141 +++++------------- packages/nuka/src/utils/browser.test.ts | 31 ++-- 2 files changed, 47 insertions(+), 125 deletions(-) diff --git a/packages/nuka/src/hooks/use-measurement.test.tsx b/packages/nuka/src/hooks/use-measurement.test.tsx index b8946be6..e5e9165d 100644 --- a/packages/nuka/src/hooks/use-measurement.test.tsx +++ b/packages/nuka/src/hooks/use-measurement.test.tsx @@ -214,6 +214,20 @@ describe('useMeasurement', () => { 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'); }); @@ -222,124 +236,39 @@ describe('useMeasurement', () => { isRTLSpy.mockRestore(); }); - it('should return negative scroll offsets for screen mode in RTL', () => { - isRTLSpy.mockReturnValue(true); - - const element = { - current: { - scrollWidth: 900, - offsetWidth: 500, - querySelector: () => ({ - children: [ - { offsetWidth: 200 }, - { offsetWidth: 300 }, - { offsetWidth: 400 }, - ], - }), - }, - } as any; - - const { result } = renderHook(() => - useMeasurement({ - element, - scrollDistance: 'screen', - }), - ); - - const { totalPages, scrollOffset } = result.current; - - expect(totalPages).toBe(2); - expect(scrollOffset).toEqual([0, -500]); - }); - - it('should return negative scroll offsets for slide mode in RTL', () => { - isRTLSpy.mockReturnValue(true); - - const element = { - current: { - scrollWidth: 900, - offsetWidth: 500, - querySelector: () => ({ - children: [ - { offsetWidth: 200 }, - { offsetWidth: 300 }, - { offsetWidth: 400 }, - ], - }), - }, - } as any; - - const { result } = renderHook(() => - useMeasurement({ - element, - scrollDistance: 'slide', - }), - ); - - const { totalPages, scrollOffset } = result.current; - - expect(totalPages).toBe(3); - expect(scrollOffset).toEqual([0, -200, -500]); - }); - - it('should return negative scroll offsets for numbered distance in RTL', () => { - isRTLSpy.mockReturnValue(true); - - const element = { - current: { - scrollWidth: 900, - offsetWidth: 500, - querySelector: () => ({ - children: [ - { offsetWidth: 200 }, - { offsetWidth: 300 }, - { offsetWidth: 400 }, - ], + 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, }), - }, - } as any; + ); - const { result } = renderHook(() => - useMeasurement({ - element, - scrollDistance: 200, - }), - ); - - const { totalPages, scrollOffset } = result.current; - - expect(totalPages).toBe(3); - expect(scrollOffset).toEqual([0, -200, -400]); - }); + 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 element = { - current: { - scrollWidth: 900, - offsetWidth: 500, - querySelector: () => ({ - children: [ - { offsetWidth: 200 }, - { offsetWidth: 300 }, - { offsetWidth: 400 }, - ], - }), - }, - } as any; - const { result } = renderHook(() => useMeasurement({ - element, + element: mockElement, scrollDistance: 'screen', }), ); - const { totalPages, scrollOffset } = result.current; - - expect(totalPages).toBe(2); - expect(scrollOffset).toEqual([0, 500]); + expect(result.current.totalPages).toBe(2); + expect(result.current.scrollOffset).toEqual([0, 500]); }); }); }); diff --git a/packages/nuka/src/utils/browser.test.ts b/packages/nuka/src/utils/browser.test.ts index 108002bc..fd20961c 100644 --- a/packages/nuka/src/utils/browser.test.ts +++ b/packages/nuka/src/utils/browser.test.ts @@ -18,24 +18,17 @@ describe('browser utils', () => { document.documentElement.dir = originalDir; }); - it('should return true when document direction is rtl', () => { - document.documentElement.dir = 'rtl'; - expect(isRTL()).toBe(true); - }); - - it('should return false when document direction is ltr', () => { - document.documentElement.dir = 'ltr'; - expect(isRTL()).toBe(false); - }); - - it('should return false when document direction is not set', () => { - document.documentElement.dir = ''; - expect(isRTL()).toBe(false); - }); - - it('should return false when document direction is auto', () => { - document.documentElement.dir = 'auto'; - expect(isRTL()).toBe(false); - }); + 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); + }, + ); }); }); From a30c66e0fb81a8fa1a09dd4ece076909dd1c51d2 Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 12:46:49 -0800 Subject: [PATCH 05/10] Refactor browser utility tests for improved readability - Streamlined the parameterized test for RTL direction in browser utility by consolidating the test structure, enhancing clarity and maintainability. --- packages/nuka/src/utils/browser.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/nuka/src/utils/browser.test.ts b/packages/nuka/src/utils/browser.test.ts index fd20961c..faddcfc7 100644 --- a/packages/nuka/src/utils/browser.test.ts +++ b/packages/nuka/src/utils/browser.test.ts @@ -23,12 +23,9 @@ describe('browser utils', () => { ['ltr', false], ['', false], ['auto', false], - ])( - 'should return %s when document direction is "%s"', - (dir, expected) => { - document.documentElement.dir = dir; - expect(isRTL()).toBe(expected); - }, - ); + ])('should return %s when document direction is "%s"', (dir, expected) => { + document.documentElement.dir = dir; + expect(isRTL()).toBe(expected); + }); }); }); From fe724df787b40a06f48af9a1237f59e627187f0a Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 12:50:25 -0800 Subject: [PATCH 06/10] Add changeset for RTL support in nuka-carousel --- .changeset/tricky-parts-battle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tricky-parts-battle.md 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. From bb0466a1444129dd31c9ee25f3c3e3a27059db3c Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 12:56:57 -0800 Subject: [PATCH 07/10] Add RTL story for Carousel component - Introduced a new story for the Carousel component that demonstrates its functionality in a right-to-left (RTL) layout. - Added buttons for navigating between slides in the RTL context, enhancing the component's usability for RTL users. --- .../nuka/src/Carousel/Carousel.stories.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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) => ( + + ))} + + ), + }, +}; From 012810cad2928cc3689ffa83b0e4a2e67e933966 Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 15:52:29 -0800 Subject: [PATCH 08/10] Refactor isRTL utility to support element-level detection - Updated the isRTL function to accept an optional HTMLElement parameter, allowing detection of RTL mode based on computed styles. - Enhanced the useMeasurement hook to utilize the updated isRTL function with the container element. - Added tests to verify RTL detection from element styles and inheritance from parent elements. --- packages/nuka/src/hooks/use-measurement.tsx | 2 +- packages/nuka/src/utils/browser.test.ts | 28 +++++++++++++++++++++ packages/nuka/src/utils/browser.ts | 16 +++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/nuka/src/hooks/use-measurement.tsx b/packages/nuka/src/hooks/use-measurement.tsx index ce6305d4..4d9c935c 100644 --- a/packages/nuka/src/hooks/use-measurement.tsx +++ b/packages/nuka/src/hooks/use-measurement.tsx @@ -28,7 +28,7 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) { if (visibleWidth === 0) return; - const rtl = isRTL(); + const rtl = isRTL(container); switch (scrollDistance) { case 'screen': { diff --git a/packages/nuka/src/utils/browser.test.ts b/packages/nuka/src/utils/browser.test.ts index faddcfc7..c9668872 100644 --- a/packages/nuka/src/utils/browser.test.ts +++ b/packages/nuka/src/utils/browser.test.ts @@ -27,5 +27,33 @@ describe('browser utils', () => { 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 526da5f9..55cf8ee4 100644 --- a/packages/nuka/src/utils/browser.ts +++ b/packages/nuka/src/utils/browser.ts @@ -1,7 +1,15 @@ export const isBrowser = () => typeof window !== 'undefined'; -// Detects if the document is in right-to-left (RTL) mode by checking the dir attribute -// on the root HTML element. This is used to adjust scroll offsets for RTL layouts. -export function isRTL() { - return isBrowser() && document.documentElement.dir === 'rtl'; +// 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'; } From bdb01c75203265c13456c71961bf568cb59d4816 Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 15:54:21 -0800 Subject: [PATCH 09/10] Improve documentation for isRTL utility function in browser.ts - Added JSDoc comments to the isRTL function to clarify its purpose and usage for detecting right-to-left (RTL) layouts. - Enhanced code readability and maintainability by providing detailed descriptions of the function's behavior. --- packages/nuka/src/utils/browser.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/nuka/src/utils/browser.ts b/packages/nuka/src/utils/browser.ts index 55cf8ee4..6f310964 100644 --- a/packages/nuka/src/utils/browser.ts +++ b/packages/nuka/src/utils/browser.ts @@ -1,8 +1,10 @@ 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. +/** + * 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; From 5642edfb02fa0343484326c237888a7b287bb5dd Mon Sep 17 00:00:00 2001 From: Jules Youngberg Date: Tue, 2 Dec 2025 16:09:28 -0800 Subject: [PATCH 10/10] Refactor Carousel component for improved scrolling behavior - Simplified the scrolling logic in the Carousel component to enhance smooth scrolling after the initial load. - Removed unnecessary checks and streamlined the handling of the initial page load to prevent unwanted animations. - Ensured that the scroll behavior is consistent and responsive to the current page state. --- packages/nuka/src/Carousel/Carousel.tsx | 36 +++---------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/packages/nuka/src/Carousel/Carousel.tsx b/packages/nuka/src/Carousel/Carousel.tsx index 1c3e90b1..bd8de810 100644 --- a/packages/nuka/src/Carousel/Carousel.tsx +++ b/packages/nuka/src/Carousel/Carousel.tsx @@ -143,42 +143,14 @@ export const Carousel = forwardRef( if (containerRef.current) { const currentSlideIndex = previousPageRef.current; const endSlideIndex = currentPage; - const targetScrollLeft = scrollOffset[currentPage]; - - // Check if this is the initial render (previousPageRef starts at -1) - const isInitialLoad = previousPageRef.current === -1; - - // On initial load with initialPage set, skip if we're not at the target page yet - // This prevents unnecessary scroll animations during initialization - if ( - isInitialLoad && - initialPage !== undefined && - currentPage !== initialPage - ) { - return; - } - beforeSlide && beforeSlide(currentSlideIndex, endSlideIndex); - - // Only enable smooth scrolling after the initial load to avoid animation on mount - if (!isInitialLoad) { + containerRef.current.scrollLeft = scrollOffset[currentPage]; + afterSlide && setTimeout(() => afterSlide(endSlideIndex), 0); + previousPageRef.current = currentPage; + if (initialPage === undefined || currentPage === initialPage) { containerRef.current.classList.remove('scroll-auto'); containerRef.current.classList.add('scroll-smooth'); } - - // Use scrollTo with behavior option for better control over scroll animation - // Fall back to direct scrollLeft assignment if scrollTo is not supported (older browsers) - try { - containerRef.current.scrollTo({ - left: targetScrollLeft, - behavior: isInitialLoad ? 'auto' : 'smooth', - }); - } catch (e) { - containerRef.current.scrollLeft = targetScrollLeft; - } - - afterSlide && setTimeout(() => afterSlide(endSlideIndex), 0); - previousPageRef.current = currentPage; } }, [currentPage, scrollOffset, beforeSlide, afterSlide, initialPage]);