Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions src/lib/components/Tabs/Tabs.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@

function updateIndicator() {
if (!listEl) return
const activeTrigger = listEl.querySelector('[data-state="active"]') as HTMLElement
const activeTrigger = listEl.querySelector<HTMLElement>('[data-state="active"]')
if (!activeTrigger) {
indicatorStyle = 'opacity: 0;'
return
Expand Down Expand Up @@ -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)
}
})
</script>

<Tabs.Root
Expand Down
54 changes: 54 additions & 0 deletions src/lib/components/Tabs/tabs.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../../../routes/layout.css'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-svelte'
import { page } from 'vitest/browser'
Expand Down Expand Up @@ -513,4 +514,57 @@ describe('Tabs', () => {
})
})
})

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)
})
})
})
})
Loading