From e9b97d92b55914d8897a886987c93a15580b2282 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Fri, 18 Sep 2026 15:59:27 +0700 Subject: [PATCH] fix(tabs): move the indicator when the text direction changes The sliding indicator was positioned from the active trigger's offset, and only recomputed when the value, orientation or items changed, or when the list resized. Flipping the page from ltr to rtl mirrors the triggers without any of those, so the indicator kept its old offset and sat under the wrong tab, with the active label rendered in the on indicator color over the plain background. The indicator is now recomputed when a dir attribute changes anywhere in the document. A direction set only through CSS is still not detected. Closes #216 --- CHANGELOG.md | 4 ++ src/lib/components/Tabs/Tabs.svelte | 16 +++++- src/lib/components/Tabs/tabs.svelte.spec.ts | 54 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5344d0e..ef2b4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Tabs** — the sliding indicator now follows the active tab when the text direction changes at runtime, for example a language switch that flips the page from `ltr` to `rtl`. The triggers moved to their mirrored positions while the indicator kept its old offset, so it sat under the wrong tab. ([#216](https://github.com/ndlabdev/sv5ui/issues/216)) + ## [2.7.0] - 2026-09-09 ### Added diff --git a/src/lib/components/Tabs/Tabs.svelte b/src/lib/components/Tabs/Tabs.svelte index d3b5e2a..f35a2b7 100644 --- a/src/lib/components/Tabs/Tabs.svelte +++ b/src/lib/components/Tabs/Tabs.svelte @@ -92,7 +92,7 @@ function updateIndicator() { if (!listEl) return - const activeTrigger = listEl.querySelector('[data-state="active"]') as HTMLElement + const activeTrigger = listEl.querySelector('[data-state="active"]') if (!activeTrigger) { indicatorStyle = 'opacity: 0;' return @@ -120,7 +120,19 @@ // Handle resize with rAF debouncing useResizeObserver(() => listEl, scheduleIndicatorUpdate) - $effect(() => () => cancelAnimationFrame(rafId)) + + $effect(() => { + const observer = new MutationObserver(scheduleIndicatorUpdate) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['dir'], + subtree: true + }) + return () => { + observer.disconnect() + cancelAnimationFrame(rafId) + } + }) { }) }) }) + + describe('text direction', () => { + const dirItems = [ + { label: 'Day', value: 'day' }, + { label: 'Week (long label)', value: 'week' }, + { label: 'Month', value: 'month' } + ] + + function measure() { + const active = document.querySelector( + '[data-tabs-trigger][data-state="active"]' + ) as HTMLElement + const indicator = document.querySelector( + '[data-tabs-list] [aria-hidden="true"]' + ) as HTMLElement + return { + activeLeft: active.offsetLeft, + activeWidth: active.offsetWidth, + left: parseFloat(indicator.style.left), + width: parseFloat(indicator.style.width) + } + } + + it('follows the active trigger when the text direction flips', async () => { + await page.viewport(1280, 800) + const { container } = render(Tabs, { + items: dirItems, + value: 'day', + content: false + }) + await vi.waitFor(() => { + const m = measure() + expect(m.left).toBe(m.activeLeft) + expect(m.width).toBe(m.activeWidth) + }) + + const ltr = measure() + container.setAttribute('dir', 'rtl') + await vi.waitFor(() => { + const m = measure() + expect(m.activeLeft).not.toBe(ltr.activeLeft) + expect(m.left).toBe(m.activeLeft) + expect(m.width).toBe(m.activeWidth) + }) + + container.setAttribute('dir', 'ltr') + await vi.waitFor(() => { + const m = measure() + expect(m.left).toBe(m.activeLeft) + expect(m.width).toBe(m.activeWidth) + }) + }) + }) })