Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
01190f0
fix(portal): keep nested floating layers above inline containers
ndlabdev Sep 17, 2026
46d2459
fix(date-picker): anchor the calendar popover to the whole field
ndlabdev Sep 17, 2026
06dc8af
fix(navigation-menu): open the dropdown panel under its trigger
ndlabdev Sep 17, 2026
e9b97d9
fix(tabs): move the indicator when the text direction changes
ndlabdev Sep 18, 2026
8788663
Merge pull request #219 from ndlabdev/fix/217-nested-floating-layers
ndlabdev Sep 18, 2026
ca68f94
Merge remote-tracking branch 'origin/dev' into fix/218-picker-popover…
ndlabdev Sep 18, 2026
963ac33
Merge pull request #221 from ndlabdev/fix/218-picker-popover-anchor
ndlabdev Sep 18, 2026
88a758a
Merge remote-tracking branch 'origin/dev' into fix/220-navigation-men…
ndlabdev Sep 18, 2026
8abe758
Merge pull request #222 from ndlabdev/fix/220-navigation-menu-viewpor…
ndlabdev Sep 18, 2026
ef90cef
Merge remote-tracking branch 'origin/dev' into fix/216-tabs-direction
ndlabdev Sep 18, 2026
f54c329
Merge pull request #223 from ndlabdev/fix/216-tabs-direction
ndlabdev Sep 18, 2026
ccd89f6
docs(changelog): tighten the unreleased entries
ndlabdev Sep 18, 2026
6008c31
fix(theme-mode): export dark mode API, fix SSR icon mismatch
ndlabdev Sep 24, 2026
5e295af
fix(table): make the column resize handle keyboard and touch accessible
ndlabdev Sep 25, 2026
c85ac8c
Merge pull request #225 from ndlabdev/fix/224-dark-mode-public-api
ndlabdev Sep 25, 2026
76e1731
Merge remote-tracking branch 'origin/dev' into fix/226-table-resize-a11y
ndlabdev Sep 25, 2026
21a2416
Merge pull request #227 from ndlabdev/fix/226-table-resize-a11y
ndlabdev Sep 25, 2026
eeb06fc
fix(input): make leading and trailing slots usable
ndlabdev Sep 25, 2026
97c8bbd
feat(input-password): add a password field with a caller scored meter
ndlabdev Sep 26, 2026
0bd6334
Merge pull request #230 from ndlabdev/fix/229-input-slots
ndlabdev Sep 27, 2026
45125c3
Merge remote-tracking branch 'origin/dev' into feat/228-input-password
ndlabdev Sep 27, 2026
4e7e9cc
Merge pull request #231 from ndlabdev/feat/228-input-password
ndlabdev Sep 27, 2026
5bb33c7
chore(release): 2.8.0
ndlabdev Sep 27, 2026
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.8.0] - 2026-09-27

### Added

- **InputPassword** β€” password field built on `Input`. A visibility toggle whose accessible name follows the next action, `purpose` mapping to the right `autocomplete` value for sign in, sign up and password change, a caps lock warning, and a guarantee that pasting is never blocked. An optional strength meter renders a score you supply: the library never judges a password itself, since scoring without a dictionary is misleading and a rule set that suits one policy penalises another. The screen reader announcement is debounced and fires on level changes rather than on every keystroke. ([#228](https://github.com/ndlabdev/sv5ui/issues/228))
- **ThemeMode** β€” dark mode handler for the root layout, replacing the setup that required a second package. ([#224](https://github.com/ndlabdev/sv5ui/issues/224))
- **Exports** β€” `toggleMode`, `setMode`, `resetMode`, `mode`, `userPrefersMode` and `systemPrefersMode` are re-exported from the package root, alongside `resetConfig`. The docs already referenced these; now they resolve. ([#224](https://github.com/ndlabdev/sv5ui/issues/224))
- **NavigationMenu** β€” `align` prop (`start` | `center` | `end`, default `center`) places the dropdown under the open trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220))

### Changed

- `mode-watcher` moved from `peerDependencies` to `dependencies`, matching every other runtime library. Dark mode now works without installing anything beyond `sv5ui`, including on package managers that do not auto install peers. ([#224](https://github.com/ndlabdev/sv5ui/issues/224))

### Fixed

- **Input** β€” `leadingSlot` and `trailingSlot` now reserve room in the field, so their content no longer sits on top of the text, and pointer events reach them, so a button placed in a slot is clickable. A decorative `leadingIcon` or `trailingIcon` stays click through, and slot content is inert while the field is disabled or loading. The loading spinner also takes precedence over a slot instead of being swallowed by it. ([#229](https://github.com/ndlabdev/sv5ui/issues/229))
- **Table** β€” the column resize handle is a focusable `separator` with `aria-valuenow`, arrow key resizing and a larger step while shift is held, plus `Home` and `End` for the bounds. It was mouse only before: no role, no tabindex and no key handler, so keyboard users could not resize a column at all. The drag also moves to the shared pointer hook, so touch and pen work rather than mouse alone. ([#226](https://github.com/ndlabdev/sv5ui/issues/226))
- **ThemeModeButton** β€” renders both mode icons and lets CSS pick the visible one, so server and client markup are identical. The wrong glyph no longer sticks after a reload in dark mode. The accessible name is now a mode-independent `Toggle theme`. ([#224](https://github.com/ndlabdev/sv5ui/issues/224))
- **Modal**, **Slideover**, **Drawer**, **Popover** β€” with `portal={false}`, nested floating layers (Select, DatePicker, DropdownMenu, Tooltip, ...) no longer hide behind the container when an ancestor has a `z-index` above 50. ([#217](https://github.com/ndlabdev/sv5ui/issues/217))
- **DatePicker**, **DateRangePicker** β€” the calendar is anchored to the field instead of the calendar icon, so it opens under the field's start edge. ([#218](https://github.com/ndlabdev/sv5ui/issues/218))
- **NavigationMenu** β€” the dropdown opens under the trigger that opened it, slides between triggers and stays inside the menu. The 8px gap under the items is now reserved only while the list overflows. ([#220](https://github.com/ndlabdev/sv5ui/issues/220))
- **Tabs** β€” the indicator follows the active tab when the text direction flips between `ltr` and `rtl`. ([#216](https://github.com/ndlabdev/sv5ui/issues/216))

## [2.7.0] - 2026-09-09

### Added
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sv5ui",
"version": "2.7.0",
"version": "2.8.0",
"description": "A modern Svelte 5 UI component library with Tailwind CSS",
"author": "ndlabdev",
"license": "MIT",
Expand Down Expand Up @@ -76,7 +76,6 @@
"@tiptap/starter-kit": "^3.0.0",
"@tiptap/suggestion": "^3.0.0",
"joi": "^17.0.0 || ^18.0.0",
"mode-watcher": "^1.0.0",
"svelte": "^5.0.0",
"tailwindcss": "^4.0.0",
"tiptap-markdown": "^0.9.0",
Expand Down Expand Up @@ -231,6 +230,7 @@
"embla-carousel-class-names": "^8.6.0",
"embla-carousel-fade": "^8.6.0",
"embla-carousel-svelte": "^8.6.0",
"mode-watcher": "^1.0.0",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
Expand Down
1 change: 1 addition & 0 deletions src/lib/components/DatePicker/DatePicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@
{align}
{alignOffset}
collisionPadding={8}
customAnchor={rootEl}
bind:ref={contentEl}
class={classes.content}
>
Expand Down
17 changes: 17 additions & 0 deletions src/lib/components/DatePicker/DatePicker.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 DatePicker from './DatePicker.svelte'
Expand Down Expand Up @@ -865,4 +866,20 @@ describe('DatePicker', () => {
})
})
})

describe('popover anchor', () => {
it('aligns the calendar with the start of the field instead of the trigger icon', async () => {
const { container } = render(DatePicker)
container.style.paddingLeft = '48px'
await vi.waitFor(() => expect(getTrigger()).not.toBeNull())
getTrigger()!.click()
await vi.waitFor(() => {
const field = getTrigger()!.parentElement!.getBoundingClientRect()
const trigger = getTrigger()!.getBoundingClientRect()
const content = getContent()!.getBoundingClientRect()
expect(trigger.left - field.left).toBeGreaterThan(100)
expect(Math.abs(content.left - field.left)).toBeLessThanOrEqual(1)
})
})
})
})
1 change: 1 addition & 0 deletions src/lib/components/DateRangePicker/DateRangePicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@
{align}
{alignOffset}
collisionPadding={8}
customAnchor={rootEl}
bind:ref={contentEl}
class={classes.content}
>
Expand Down
17 changes: 17 additions & 0 deletions src/lib/components/DateRangePicker/DateRangePicker.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 DateRangePicker from './DateRangePicker.svelte'
Expand Down Expand Up @@ -850,4 +851,20 @@ describe('DateRangePicker', () => {
})
})
})

describe('popover anchor', () => {
it('aligns the calendar with the start of the field instead of the trigger icon', async () => {
const { container } = render(DateRangePicker)
container.style.paddingLeft = '48px'
await vi.waitFor(() => expect(getTrigger()).not.toBeNull())
getTrigger()!.click()
await vi.waitFor(() => {
const field = getTrigger()!.parentElement!.getBoundingClientRect()
const trigger = getTrigger()!.getBoundingClientRect()
const content = getContent()!.getBoundingClientRect()
expect(trigger.left - field.left).toBeGreaterThan(100)
expect(Math.abs(content.left - field.left)).toBeLessThanOrEqual(1)
})
})
})
})
20 changes: 14 additions & 6 deletions src/lib/components/Drawer/Drawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { Drawer } from 'vaul-svelte'
import { drawerVariants, drawerDefaults } from './drawer.variants.js'
import { getComponentConfig } from '../../config.js'
import PortalScope from '../../internal/PortalScope.svelte'

const config = getComponentConfig('drawer', drawerDefaults)

Expand Down Expand Up @@ -48,6 +49,8 @@
...rest
}: Props = $props()

let contentEl = $state<HTMLElement | null>(null)

const hasTitle = $derived(!!title || !!titleSlot)
const hasDescription = $derived(!!description || !!descriptionSlot)
const hasHeading = $derived(hasTitle || hasDescription)
Expand Down Expand Up @@ -167,12 +170,17 @@
<Drawer.Overlay class={classes.overlay} />
{/if}

<Drawer.Content class={[classes.content, !children ? className : undefined]}>
{#if showHandle}
<Drawer.Handle class={classes.handle} />
{/if}
{@render drawerInner()}
</Drawer.Content>
<PortalScope active={!portal && (open || !!contentEl)}>
<Drawer.Content
bind:ref={contentEl}
class={[classes.content, !children ? className : undefined]}
>
{#if showHandle}
<Drawer.Handle class={classes.handle} />
{/if}
{@render drawerInner()}
</Drawer.Content>
</PortalScope>
{/snippet}

{#snippet drawerBody()}
Expand Down
42 changes: 42 additions & 0 deletions src/lib/components/Drawer/Drawer.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 @@ -743,4 +744,45 @@ describe('Drawer', () => {
})
})
})

describe('nested portals', () => {
const getHost = () => document.querySelector('[data-portal-host]') as HTMLElement | null
const getLayer = () => document.querySelector('[data-vaul-drawer]') as HTMLElement | null
const zIndex = (el: Element) => Number(getComputedStyle(el).zIndex)

it('adds no portal host while closed', () => {
render(Drawer, { portal: false, title: 'Test' })
expect(getHost()).toBeNull()
})

it('adds no portal host when portalled', async () => {
render(Drawer, { open: true, title: 'Test', modal: false })
await vi.waitFor(() => expect(getLayer()).not.toBeNull())
expect(getHost()).toBeNull()
})

it('mounts nested layers above the inline content without becoming the last child', async () => {
render(Drawer, { open: true, portal: false, title: 'Test', modal: false })
await vi.waitFor(() => expect(getHost()).not.toBeNull())
const host = getHost()!
const layer = getLayer()!
expect(
host.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(host.parentElement!.lastElementChild).not.toBe(host)
expect(zIndex(host)).toBeGreaterThan(zIndex(layer))
})

it('removes the portal host once closed', async () => {
const { rerender } = render(Drawer, {
open: true,
portal: false,
title: 'Test',
modal: false
})
await vi.waitFor(() => expect(getHost()).not.toBeNull())
await rerender({ open: false })
await vi.waitFor(() => expect(getHost()).toBeNull())
})
})
})
6 changes: 6 additions & 0 deletions src/lib/components/Icon/bundled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export const bundledIcons: BundledIconCollection[] = [
ellipsis: {
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></g>'
},
eye: {
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></g>'
},
'eye-off': {
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575a1 1 0 0 1 0 .696a10.8 10.8 0 0 1-1.444 2.49m-6.41-.679a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 4.446-5.143M2 2l20 20"/></g>'
},
file: {
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/></g>'
},
Expand Down
43 changes: 29 additions & 14 deletions src/lib/components/Input/Input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,15 @@
const loadingLeading = $derived(loading && !trailing)
const loadingTrailing = $derived(loading && trailing)

const isLeading = $derived((!!icon && !trailing) || !!leadingIcon || !!avatar || loadingLeading)
const isTrailing = $derived((!!icon && trailing) || !!trailingIcon || loadingTrailing)
const isInert = $derived(disabled || loading)
const leadingInteractive = $derived(!!leadingSlot && !isInert)
const trailingInteractive = $derived(!!trailingSlot && !isInert)
const isLeading = $derived(
!!leadingSlot || (!!icon && !trailing) || !!leadingIcon || !!avatar || loadingLeading
)
const isTrailing = $derived(
!!trailingSlot || (!!icon && trailing) || !!trailingIcon || loadingTrailing
)

const leadingIconName = $derived(leadingIcon || (!!icon && !trailing ? icon : undefined))
const trailingIconName = $derived(trailingIcon || (!!icon && trailing ? icon : undefined))
Expand Down Expand Up @@ -128,32 +135,40 @@
base: variantSlots.base({
class: [config.slots.base, fieldGroupClass?.base, ui?.base]
}),
leading: variantSlots.leading({ class: [config.slots.leading, ui?.leading] }),
leading: variantSlots.leading({
class: [config.slots.leading, leadingInteractive && 'pointer-events-auto', ui?.leading]
}),
leadingIcon: variantSlots.leadingIcon({
class: [config.slots.leadingIcon, ui?.leadingIcon]
}),
leadingAvatar: variantSlots.leadingAvatar({
class: [config.slots.leadingAvatar, ui?.leadingAvatar]
}),
leadingAvatarSize: variantSlots.leadingAvatarSize() as AvatarSize,
trailing: variantSlots.trailing({ class: [config.slots.trailing, ui?.trailing] }),
trailing: variantSlots.trailing({
class: [
config.slots.trailing,
trailingInteractive && 'pointer-events-auto',
ui?.trailing
]
}),
trailingIcon: variantSlots.trailingIcon({
class: [config.slots.trailingIcon, ui?.trailingIcon]
})
})
</script>

<div class={classes.root}>
{#if leadingSlot}
<span class={classes.leading}>
{@render leadingSlot()}
</span>
{:else if loadingLeading}
{#if loadingLeading}
<span class={classes.leading}>
<span class="inline-flex animate-spin">
<Icon name={loadingIcon} class={classes.leadingIcon} />
</span>
</span>
{:else if leadingSlot}
<span class={classes.leading}>
{@render leadingSlot()}
</span>
{:else if avatar}
<span class={classes.leading}>
<Avatar {...avatar} size={classes.leadingAvatarSize} class={classes.leadingAvatar} />
Expand Down Expand Up @@ -181,16 +196,16 @@
onfocus={handleFocus}
/>

{#if trailingSlot}
<span class={classes.trailing}>
{@render trailingSlot()}
</span>
{:else if loadingTrailing}
{#if loadingTrailing}
<span class={classes.trailing}>
<span class="inline-flex animate-spin">
<Icon name={loadingIcon} class={classes.trailingIcon} />
</span>
</span>
{:else if trailingSlot}
<span class={classes.trailing}>
{@render trailingSlot()}
</span>
{:else if trailingIconName}
<span class={classes.trailing}>
<Icon name={trailingIconName} class={classes.trailingIcon} />
Expand Down
66 changes: 66 additions & 0 deletions src/lib/components/Input/Input.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import '../../../routes/layout.css'
import { page } from 'vitest/browser'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-svelte'
import { createRawSnippet } from 'svelte'
import Input from './Input.svelte'

const snippet = (html: string) => createRawSnippet(() => ({ render: () => html, setup: () => {} }))

const AVATAR_SRC =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='

Expand Down Expand Up @@ -402,6 +406,68 @@ describe('Input', () => {

// ==================== ACCESSIBILITY ====================

// ==================== LEADING & TRAILING SLOTS ====================

describe('leading and trailing slots', () => {
const button = () => snippet('<button type="button" id="slot-btn">x</button>')

const wrapperOf = (container: Element, side: 'first' | 'last') => {
const spans = Array.from(container.querySelectorAll<HTMLElement>('div > span'))
return side === 'first' ? spans[0] : spans[spans.length - 1]
}

it('should reserve room for a leading slot, like the leadingIcon prop does', () => {
const { container } = render(Input, { leadingSlot: button() })
const input = container.querySelector('input')!

expect(input.className).toMatch(/\bps-\d/)
})

it('should reserve room for a trailing slot', () => {
const { container } = render(Input, { trailingSlot: button() })
const input = container.querySelector('input')!

expect(input.className).toMatch(/\bpe-\d/)
})

it('should let pointer events reach interactive slot content', () => {
const { container } = render(Input, { trailingSlot: button() })

expect(getComputedStyle(wrapperOf(container, 'last')).pointerEvents).toBe('auto')
})

it('should keep a decorative icon click through so it focuses the input', () => {
const { container } = render(Input, { trailingIcon: 'lucide:check' })

expect(getComputedStyle(wrapperOf(container, 'last')).pointerEvents).toBe('none')
})

it.each([
['disabled', { disabled: true }],
['loading', { loading: true }]
])('should block slot interaction while the field is %s', (_label, props) => {
const { container } = render(Input, { ...props, trailingSlot: button() })

expect(getComputedStyle(wrapperOf(container, 'last')).pointerEvents).toBe('none')
})

it('should still show the loading spinner when a slot is present', async () => {
const { container } = render(Input, { loading: true, leadingSlot: button() })

await vi.waitFor(() => {
expect(container.querySelector('.animate-spin')).not.toBeNull()
})
expect(container.querySelector('#slot-btn')).toBeNull()
})

it('should render slot content when not loading', () => {
const { container } = render(Input, { leadingSlot: button() })

expect(container.querySelector('#slot-btn')).not.toBeNull()
expect(container.querySelector('.animate-spin')).toBeNull()
})
})

describe('accessibility', () => {
it('should support aria-label', () => {
render(Input, { 'aria-label': 'Search input' })
Expand Down
Loading
Loading