diff --git a/CHANGELOG.md b/CHANGELOG.md index 04190a9..60ce0a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **DatePicker**, **DateRangePicker** — the calendar is now anchored to the whole field instead of the calendar icon at its end, so with the default `align="start"` it opens flush under the field's left edge rather than floating to the right over neighbouring content. `align="center"` now centres on the field; `align="end"` is unchanged. ([#218](https://github.com/ndlabdev/sv5ui/issues/218)) - **NavigationMenu** — the dropdown panel of a horizontal menu now opens under the trigger that opened it instead of always at the menu's left edge. It slides between triggers, stays inside the menu when the trigger sits near an edge, follows the list when it scrolls, and respects `dir="rtl"`. Below the `sm` breakpoint the panel keeps its full width. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) - **NavigationMenu** — a horizontal menu no longer leaves an 8px gap under its items. The room for the scrollbar is now reserved only while the list overflows and can scroll, which also brings the dropdown 8px closer to the trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) +- **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 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) + }) + }) + }) })