From 01190f0f783a5f382d3c35011b310de8d26439e8 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Thu, 17 Sep 2026 19:15:31 +0700 Subject: [PATCH 01/10] fix(portal): keep nested floating layers above inline containers Modal, Slideover, Drawer and Popover rendered with portal={false} hid every floating layer opened inside them (Select, DatePicker, DropdownMenu, Tooltip, a nested Modal, ...) whenever an ancestor set a z-index above 50. The layers were portalled to at z-50 while the inline panel belonged to the higher stacking context. While such a container is open, nested layers now mount into a host placed right before its panel, in the same stacking context and one level above it. The host is removed once the panel has unmounted, so the closed DOM is unchanged, and portalled containers are not affected. Closes #217 --- CHANGELOG.md | 4 ++ src/lib/components/Drawer/Drawer.svelte | 20 +++++--- .../components/Drawer/Drawer.svelte.spec.ts | 42 ++++++++++++++++ src/lib/components/Modal/Modal.svelte | 27 +++++++---- src/lib/components/Modal/Modal.svelte.spec.ts | 48 +++++++++++++++++++ src/lib/components/Popover/Popover.svelte | 3 +- .../components/Popover/Popover.svelte.spec.ts | 38 +++++++++++++++ src/lib/components/Slideover/Slideover.svelte | 13 ++++- .../Slideover/Slideover.svelte.spec.ts | 37 ++++++++++++++ src/lib/internal/PortalScope.svelte | 15 ++++++ 10 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 src/lib/internal/PortalScope.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index 5344d0e..fac98d7 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 + +- **Modal**, **Slideover**, **Drawer**, **Popover** — with `portal={false}`, a Select, DatePicker, DropdownMenu, Tooltip or other floating layer opened inside the container no longer renders behind it when an ancestor sets a `z-index` above 50. Nested layers now render in the container's stacking context, above its panel. Portalled containers are unchanged. ([#217](https://github.com/ndlabdev/sv5ui/issues/217)) + ## [2.7.0] - 2026-09-09 ### Added diff --git a/src/lib/components/Drawer/Drawer.svelte b/src/lib/components/Drawer/Drawer.svelte index 5875211..db79c68 100644 --- a/src/lib/components/Drawer/Drawer.svelte +++ b/src/lib/components/Drawer/Drawer.svelte @@ -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) @@ -48,6 +49,8 @@ ...rest }: Props = $props() + let contentEl = $state(null) + const hasTitle = $derived(!!title || !!titleSlot) const hasDescription = $derived(!!description || !!descriptionSlot) const hasHeading = $derived(hasTitle || hasDescription) @@ -167,12 +170,17 @@ {/if} - - {#if showHandle} - - {/if} - {@render drawerInner()} - + + + {#if showHandle} + + {/if} + {@render drawerInner()} + + {/snippet} {#snippet drawerBody()} diff --git a/src/lib/components/Drawer/Drawer.svelte.spec.ts b/src/lib/components/Drawer/Drawer.svelte.spec.ts index 8dfef1e..b3ffc69 100644 --- a/src/lib/components/Drawer/Drawer.svelte.spec.ts +++ b/src/lib/components/Drawer/Drawer.svelte.spec.ts @@ -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' @@ -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()) + }) + }) }) diff --git a/src/lib/components/Modal/Modal.svelte b/src/lib/components/Modal/Modal.svelte index a15f5b3..d738301 100644 --- a/src/lib/components/Modal/Modal.svelte +++ b/src/lib/components/Modal/Modal.svelte @@ -9,6 +9,7 @@ import { modalVariants, modalDefaults } from './modal.variants.js' import { getComponentConfig } from '../../config.js' import Button from '../Button/Button.svelte' + import PortalScope from '../../internal/PortalScope.svelte' const config = getComponentConfig('modal', modalDefaults) @@ -47,6 +48,8 @@ closeSlot }: Props = $props() + let contentEl = $state(null) + const resolvedSize = $derived(fullscreen ? 'full' : size) const resolvedTransition = $derived( transition === false ? 'none' : transition === true ? 'scale' : transition @@ -117,7 +120,11 @@ {/snippet} {#snippet modalContentInner()} - + {#if contentSlot} {#if hasHeading}
@@ -189,16 +196,18 @@ {/snippet} {#snippet modalPortalContent()} - {#if scrollable} - + {#if showOverlay && !scrollable} + + {/if} + + {#if scrollable} + + {@render modalContentInner()} + + {:else} {@render modalContentInner()} - - {:else} - {#if showOverlay} - {/if} - {@render modalContentInner()} - {/if} + {/snippet} diff --git a/src/lib/components/Modal/Modal.svelte.spec.ts b/src/lib/components/Modal/Modal.svelte.spec.ts index 7807e8d..586da34 100644 --- a/src/lib/components/Modal/Modal.svelte.spec.ts +++ b/src/lib/components/Modal/Modal.svelte.spec.ts @@ -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' @@ -563,4 +564,51 @@ describe('Modal', () => { }) }) }) + + describe('nested portals', () => { + const getHost = () => document.querySelector('[data-portal-host]') as HTMLElement | null + const getLayer = () => document.querySelector('[data-dialog-content]') as HTMLElement | null + const zIndex = (el: Element) => Number(getComputedStyle(el).zIndex) + + it('adds no portal host while closed', () => { + render(Modal, { portal: false, title: 'Test' }) + expect(getHost()).toBeNull() + }) + + it('adds no portal host when portalled', async () => { + render(Modal, { open: true, title: 'Test' }) + 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(Modal, { open: true, portal: false, title: 'Test' }) + 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(Modal, { open: true, portal: false, title: 'Test' }) + await vi.waitFor(() => expect(getHost()).not.toBeNull()) + await rerender({ open: false }) + await vi.waitFor(() => expect(getHost()).toBeNull()) + }) + + it('mounts nested layers above a scrollable overlay', async () => { + render(Modal, { open: true, portal: false, scrollable: true, title: 'Test' }) + await vi.waitFor(() => expect(getHost()).not.toBeNull()) + const host = getHost()! + const overlay = document.querySelector('[data-dialog-overlay]') as HTMLElement + expect( + host.compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + expect(zIndex(host)).toBeGreaterThan(zIndex(overlay)) + }) + }) }) diff --git a/src/lib/components/Popover/Popover.svelte b/src/lib/components/Popover/Popover.svelte index 3273e5c..bdbe708 100644 --- a/src/lib/components/Popover/Popover.svelte +++ b/src/lib/components/Popover/Popover.svelte @@ -8,6 +8,7 @@ import { Popover } from 'bits-ui' import { popoverVariants, popoverDefaults } from './popover.variants.js' import { getComponentConfig } from '../../config.js' + import PortalScope from '../../internal/PortalScope.svelte' const config = getComponentConfig('popover', popoverDefaults) @@ -147,6 +148,6 @@ {@render popoverContentEl()} {:else} - {@render popoverContentEl()} + {@render popoverContentEl()} {/if} diff --git a/src/lib/components/Popover/Popover.svelte.spec.ts b/src/lib/components/Popover/Popover.svelte.spec.ts index 5508f81..998ce86 100644 --- a/src/lib/components/Popover/Popover.svelte.spec.ts +++ b/src/lib/components/Popover/Popover.svelte.spec.ts @@ -1,3 +1,4 @@ +import '../../../routes/layout.css' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import Popover from './Popover.svelte' @@ -315,4 +316,41 @@ describe('Popover', () => { }) }) }) + + describe('nested portals', () => { + const getHost = () => document.querySelector('[data-portal-host]') as HTMLElement | null + const getLayer = () => + document.querySelector('[data-popover-content]')?.parentElement as HTMLElement | null + const zIndex = (el: Element) => Number(getComputedStyle(el).zIndex) + + it('adds no portal host while closed', () => { + render(Popover, { portal: false }) + expect(getHost()).toBeNull() + }) + + it('adds no portal host when portalled', async () => { + render(Popover, { open: true }) + 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(Popover, { open: true, portal: 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(Popover, { open: true, portal: false }) + await vi.waitFor(() => expect(getHost()).not.toBeNull()) + await rerender({ open: false }) + await vi.waitFor(() => expect(getHost()).toBeNull()) + }) + }) }) diff --git a/src/lib/components/Slideover/Slideover.svelte b/src/lib/components/Slideover/Slideover.svelte index 5ac1af9..e42496f 100644 --- a/src/lib/components/Slideover/Slideover.svelte +++ b/src/lib/components/Slideover/Slideover.svelte @@ -10,6 +10,7 @@ import { getComponentConfig } from '../../config.js' import Button from '../Button/Button.svelte' import ScrollArea from '../ScrollArea/ScrollArea.svelte' + import PortalScope from '../../internal/PortalScope.svelte' const config = getComponentConfig('slideover', slideoverDefaults) @@ -49,6 +50,8 @@ closeSlot }: Props = $props() + let contentEl = $state(null) + const showClose = $derived(!!closeProp) const closeProps = $derived(typeof closeProp === 'object' ? closeProp : {}) @@ -119,7 +122,11 @@ {/snippet} {#snippet slideoverContentInner()} - + {#if contentSlot} {#if hasHeading}
@@ -196,7 +203,9 @@ {#if showOverlay} {/if} - {@render slideoverContentInner()} + + {@render slideoverContentInner()} + {/snippet} diff --git a/src/lib/components/Slideover/Slideover.svelte.spec.ts b/src/lib/components/Slideover/Slideover.svelte.spec.ts index b748a9a..44c74a1 100644 --- a/src/lib/components/Slideover/Slideover.svelte.spec.ts +++ b/src/lib/components/Slideover/Slideover.svelte.spec.ts @@ -1,3 +1,4 @@ +import '../../../routes/layout.css' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import { createRawSnippet } from 'svelte' @@ -669,4 +670,40 @@ describe('Slideover', () => { }) }) }) + + describe('nested portals', () => { + const getHost = () => document.querySelector('[data-portal-host]') as HTMLElement | null + const getLayer = () => document.querySelector('[data-dialog-content]') as HTMLElement | null + const zIndex = (el: Element) => Number(getComputedStyle(el).zIndex) + + it('adds no portal host while closed', () => { + render(Slideover, { portal: false, title: 'Test' }) + expect(getHost()).toBeNull() + }) + + it('adds no portal host when portalled', async () => { + render(Slideover, { open: true, title: 'Test' }) + 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(Slideover, { open: true, portal: false, title: 'Test' }) + 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(Slideover, { open: true, portal: false, title: 'Test' }) + await vi.waitFor(() => expect(getHost()).not.toBeNull()) + await rerender({ open: false }) + await vi.waitFor(() => expect(getHost()).toBeNull()) + }) + }) }) diff --git a/src/lib/internal/PortalScope.svelte b/src/lib/internal/PortalScope.svelte new file mode 100644 index 0000000..edc4cff --- /dev/null +++ b/src/lib/internal/PortalScope.svelte @@ -0,0 +1,15 @@ + + +{#if active} +
+{/if} + + {@render children()} + From 46d24599c1d85ddf41a24af35e73fcdd5326c906 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Thu, 17 Sep 2026 20:53:57 +0700 Subject: [PATCH 02/10] fix(date-picker): anchor the calendar popover to the whole field DatePicker and DateRangePicker positioned their calendar against the calendar icon button, which sits at the end of the field. With the default align="start" the popover opened at the icon's left edge, hundreds of pixels right of the field and over neighbouring content. The popover is now anchored to the field root, so it opens flush under the field's left edge, matching how Select places its list. align="center" now centres on the field and align="end" is unchanged. Closes #218 --- CHANGELOG.md | 4 ++++ src/lib/components/DatePicker/DatePicker.svelte | 1 + .../DatePicker/DatePicker.svelte.spec.ts | 17 +++++++++++++++++ .../DateRangePicker/DateRangePicker.svelte | 1 + .../DateRangePicker.svelte.spec.ts | 17 +++++++++++++++++ 5 files changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5344d0e..53f4cb9 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 + +- **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)) + ## [2.7.0] - 2026-09-09 ### Added diff --git a/src/lib/components/DatePicker/DatePicker.svelte b/src/lib/components/DatePicker/DatePicker.svelte index d8e45cf..a8ccc39 100644 --- a/src/lib/components/DatePicker/DatePicker.svelte +++ b/src/lib/components/DatePicker/DatePicker.svelte @@ -241,6 +241,7 @@ {align} {alignOffset} collisionPadding={8} + customAnchor={rootEl} bind:ref={contentEl} class={classes.content} > diff --git a/src/lib/components/DatePicker/DatePicker.svelte.spec.ts b/src/lib/components/DatePicker/DatePicker.svelte.spec.ts index 0282bc5..033abfd 100644 --- a/src/lib/components/DatePicker/DatePicker.svelte.spec.ts +++ b/src/lib/components/DatePicker/DatePicker.svelte.spec.ts @@ -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' @@ -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) + }) + }) + }) }) diff --git a/src/lib/components/DateRangePicker/DateRangePicker.svelte b/src/lib/components/DateRangePicker/DateRangePicker.svelte index c1531a6..2cf48f7 100644 --- a/src/lib/components/DateRangePicker/DateRangePicker.svelte +++ b/src/lib/components/DateRangePicker/DateRangePicker.svelte @@ -318,6 +318,7 @@ {align} {alignOffset} collisionPadding={8} + customAnchor={rootEl} bind:ref={contentEl} class={classes.content} > diff --git a/src/lib/components/DateRangePicker/DateRangePicker.svelte.spec.ts b/src/lib/components/DateRangePicker/DateRangePicker.svelte.spec.ts index 1df0095..583e3f4 100644 --- a/src/lib/components/DateRangePicker/DateRangePicker.svelte.spec.ts +++ b/src/lib/components/DateRangePicker/DateRangePicker.svelte.spec.ts @@ -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' @@ -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) + }) + }) + }) }) From 06dc8af5bbc1d28afe7ef883db32688313ccaeff Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Thu, 17 Sep 2026 23:30:45 +0700 Subject: [PATCH 03/10] fix(navigation-menu): open the dropdown panel under its trigger A horizontal NavigationMenu rendered every dropdown in one shared panel pinned to the menu's left edge, so a trigger near the end opened a panel hundreds of pixels away, detached from it when the content was narrow. The panel now follows the open trigger. The component measures the trigger's inline start and width; CSS places the panel with clamp() so it stays inside the menu, using the panel width the viewport already exposes. It slides between triggers, appears in place when the menu first opens, follows the list when it scrolls, and respects dir="rtl". Below the sm breakpoint the panel keeps its full width. A new align prop ('start' | 'center' | 'end', default 'center') chooses where the panel sits relative to the trigger. The docs page gains a Dropdown Alignment section to switch it live. The list also reserved 8px under its items for the overlay scrollbar at all times. That room is now added only while the list overflows, so a menu that fits no longer shows the gap. Closes #220 --- CHANGELOG.md | 9 ++ .../NavigationMenu/NavigationMenu.svelte | 94 +++++++++++++++++- .../NavigationMenu.svelte.spec.ts | 96 +++++++++++++++++++ .../NavigationMenu/navigation-menu.types.ts | 7 ++ .../navigation-menu.variants.ts | 11 ++- src/routes/navigation-menu/+page.svelte | 53 ++++++++++ 6 files changed, 264 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5344d0e..d55c50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **NavigationMenu** — `align` prop (`'start'`, `'center'`, `'end'`, default `'center'`) places the dropdown panel of a horizontal menu relative to the open trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) + +### Fixed + +- **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)) + ## [2.7.0] - 2026-09-09 ### Added diff --git a/src/lib/components/NavigationMenu/NavigationMenu.svelte b/src/lib/components/NavigationMenu/NavigationMenu.svelte index 4e6beaf..642e1a1 100644 --- a/src/lib/components/NavigationMenu/NavigationMenu.svelte +++ b/src/lib/components/NavigationMenu/NavigationMenu.svelte @@ -11,6 +11,7 @@ import { slide } from 'svelte/transition' import { page } from '$app/state' import { useResizeObserver } from '../../hooks/useResizeObserver/index.js' + import { useEventListener } from '../../hooks/useEventListener/index.js' import { navigationMenuVariants, navigationMenuDefaults } from './navigation-menu.variants.js' import type { NavigationMenuItem, NavigationMenuChildItem } from './navigation-menu.types.js' import { getComponentConfig, iconsDefaults } from '../../config.js' @@ -50,6 +51,7 @@ tooltip = false, popover = false, contentOrientation = 'horizontal', + align = 'center', labelKey = 'label', exact = true, delayDuration = 0, @@ -220,6 +222,7 @@ collapsed: effectiveCollapsed, stacked, contentOrientation, + align, disabled }) ) @@ -329,7 +332,20 @@ rafId = requestAnimationFrame(updateIndicator) } - useResizeObserver(() => listEl, scheduleIndicator) + let listOverflow = $state(false) + + function updateListOverflow() { + const viewport = listEl?.closest('[data-scroll-area-viewport]') + listOverflow = !!viewport && viewport.scrollWidth > viewport.clientWidth + } + + useResizeObserver( + () => listEl, + () => { + scheduleIndicator() + updateListOverflow() + } + ) $effect(() => { void value @@ -340,7 +356,67 @@ if (showHighlight) tick().then(updateIndicator) }) - $effect(() => () => cancelAnimationFrame(rafId)) + const VIEWPORT_OPEN_MS = 250 + + let viewportAnchor = $state('') + let viewportInstant = $state(false) + let anchorRafId = 0 + let instantTimer: ReturnType | undefined + let wasOpen = false + + function updateViewportAnchor() { + const trigger = listEl?.querySelector( + '[data-navigation-menu-trigger][data-state="open"]' + ) + if (!ref || !trigger) return + const root = ref.getBoundingClientRect() + const rect = trigger.getBoundingClientRect() + const start = + getComputedStyle(ref).direction === 'rtl' + ? root.right - rect.right + : rect.left - root.left + viewportAnchor = `--sv5ui-nav-trigger-start:${start}px;--sv5ui-nav-trigger-width:${rect.width}px` + } + + function scheduleViewportAnchor() { + if (!horizontalValue) return + cancelAnimationFrame(anchorRafId) + anchorRafId = requestAnimationFrame(updateViewportAnchor) + } + + $effect(() => { + if (!horizontalValue) { + wasOpen = false + return + } + if (!wasOpen) { + wasOpen = true + viewportInstant = true + clearTimeout(instantTimer) + instantTimer = setTimeout(() => (viewportInstant = false), VIEWPORT_OPEN_MS) + } + tick().then(updateViewportAnchor) + }) + + useResizeObserver( + () => ref, + () => { + scheduleViewportAnchor() + updateListOverflow() + } + ) + useEventListener( + () => listEl?.closest('[data-scroll-area-viewport]'), + 'scroll', + scheduleViewportAnchor, + { passive: true } + ) + + $effect(() => () => { + cancelAnimationFrame(rafId) + cancelAnimationFrame(anchorRafId) + clearTimeout(instantTimer) + }) $effect(() => () => clearTimeout(flyoutTimer)) @@ -482,7 +558,12 @@ class={classes.root} {...restProps as Record} > - + {#if showHighlight}
- +
{/snippet} diff --git a/src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts b/src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts index 9aff531..826971f 100644 --- a/src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts +++ b/src/lib/components/NavigationMenu/NavigationMenu.svelte.spec.ts @@ -1,3 +1,4 @@ +import '../../../routes/layout.css' import { describe, expect, it, vi } from 'vitest' import { createRawSnippet } from 'svelte' import { render } from 'vitest-browser-svelte' @@ -498,4 +499,99 @@ describe('NavigationMenu', () => { expect(container.querySelectorAll('[data-test-action]').length).toBe(2) }) }) + + describe('dropdown panel position', () => { + const navItems: NavigationMenuItem[] = [ + 'Alpha', + 'Bravo', + 'Charlie', + 'Delta', + 'Echo', + 'Foxtrot' + ].map((label, i) => ({ + label, + children: [ + { label: `${label} one`, href: `#${i}-1` }, + { label: `${label} two`, href: `#${i}-2` } + ] + })) + + function measure(label: string) { + const trigger = [...document.querySelectorAll('[data-navigation-menu-trigger]')] + .find((el) => el.textContent?.trim() === label)! + .getBoundingClientRect() + const panel = document + .querySelector('[data-navigation-menu-viewport]')! + .getBoundingClientRect() + const nav = document.querySelector('nav')!.getBoundingClientRect() + return { trigger, panel, nav } + } + + async function openMenu(label: string, align?: 'start' | 'center' | 'end') { + await page.viewport(1280, 800) + render(NavigationMenu, { items: navItems, contentOrientation: 'vertical', align }) + await page.getByRole('button', { name: label, exact: true }).click() + await vi.waitFor(() => + expect(document.querySelector('[data-navigation-menu-viewport]')).not.toBeNull() + ) + } + + it('centres the panel on the open trigger by default', async () => { + await openMenu('Delta') + await vi.waitFor(() => { + const { trigger, panel } = measure('Delta') + const offset = panel.left + panel.width / 2 - (trigger.left + trigger.width / 2) + expect(Math.abs(offset)).toBeLessThanOrEqual(1) + }) + }) + + it('aligns the panel start with the trigger when align is start', async () => { + await openMenu('Delta', 'start') + await vi.waitFor(() => { + const { trigger, panel } = measure('Delta') + expect(Math.abs(panel.left - trigger.left)).toBeLessThanOrEqual(1) + }) + }) + + it('aligns the panel end with the trigger when align is end', async () => { + await openMenu('Delta', 'end') + await vi.waitFor(() => { + const { trigger, panel } = measure('Delta') + expect(Math.abs(panel.right - trigger.right)).toBeLessThanOrEqual(1) + }) + }) + + it('keeps the panel inside the menu for a trigger at its edge', async () => { + await openMenu('Alpha') + await vi.waitFor(() => { + const { panel, nav } = measure('Alpha') + expect(Math.abs(panel.left - nav.left)).toBeLessThanOrEqual(1) + expect(panel.right).toBeLessThanOrEqual(nav.right + 1) + }) + }) + }) + + describe('scroll spacing', () => { + const getScrollRoot = () => + document.querySelector('[data-scroll-area-viewport]')!.parentElement as HTMLElement + + it('reserves room for the scrollbar only while the list overflows', async () => { + await page.viewport(1280, 800) + const { container } = render(NavigationMenu, { items: linkItems }) + await vi.waitFor(() => { + expect(getScrollRoot().hasAttribute('data-overflow')).toBe(false) + expect(getComputedStyle(getScrollRoot()).paddingBottom).toBe('0px') + }) + container.style.width = '120px' + await vi.waitFor(() => { + expect(getScrollRoot().hasAttribute('data-overflow')).toBe(true) + expect(getComputedStyle(getScrollRoot()).paddingBottom).toBe('8px') + }) + container.style.width = '' + await vi.waitFor(() => { + expect(getScrollRoot().hasAttribute('data-overflow')).toBe(false) + expect(getComputedStyle(getScrollRoot()).paddingBottom).toBe('0px') + }) + }) + }) }) diff --git a/src/lib/components/NavigationMenu/navigation-menu.types.ts b/src/lib/components/NavigationMenu/navigation-menu.types.ts index 9ed2cc8..59fa693 100644 --- a/src/lib/components/NavigationMenu/navigation-menu.types.ts +++ b/src/lib/components/NavigationMenu/navigation-menu.types.ts @@ -227,6 +227,13 @@ export interface NavigationMenuProps extends Omit< /** Layout of the mega-menu content (horizontal only). @default 'horizontal' */ contentOrientation?: NonNullable + /** + * Where the dropdown panel sits relative to the open trigger (horizontal only). + * The panel stays inside the menu, so a wide panel shifts back when it would overflow. + * @default 'center' + */ + align?: NonNullable + /** Item field used as the label. @default 'label' */ labelKey?: string diff --git a/src/lib/components/NavigationMenu/navigation-menu.variants.ts b/src/lib/components/NavigationMenu/navigation-menu.variants.ts index 036992e..aa8a382 100644 --- a/src/lib/components/NavigationMenu/navigation-menu.variants.ts +++ b/src/lib/components/NavigationMenu/navigation-menu.variants.ts @@ -4,7 +4,7 @@ export const navigationMenuVariants = tv({ slots: { root: 'relative', list: 'relative isolate min-w-0', - scroll: 'w-full pb-2', + scroll: 'w-full data-[overflow]:pb-2', highlight: 'absolute z-0 rounded-full transition-all duration-200', item: 'min-w-0', label: 'w-full flex items-center gap-1.5 px-2.5 pt-3 pb-1 font-semibold text-sm text-on-surface-variant', @@ -55,7 +55,8 @@ export const navigationMenuVariants = tv({ viewport: [ 'relative h-(--bits-navigation-menu-viewport-height) w-full origin-[top_center] overflow-hidden sm:w-(--bits-navigation-menu-viewport-width)', 'rounded-xl border border-outline-variant bg-surface-container-low shadow-lg', - 'transition-[width,height] duration-200', + 'sm:[inset-inline-start:clamp(0px,calc(var(--sv5ui-nav-trigger-start)_+_(var(--sv5ui-nav-trigger-width)_-_var(--bits-navigation-menu-viewport-width))_*_var(--sv5ui-nav-align)),calc(100%_-_var(--bits-navigation-menu-viewport-width)))]', + 'transition-[width,height,inset-inline-start] duration-200 data-[instant]:transition-none', 'data-[state=open]:animate-[nav-scale-in_200ms_ease]', 'data-[state=closed]:animate-[nav-scale-out_200ms_ease]' ], @@ -120,6 +121,11 @@ export const navigationMenuVariants = tv({ horizontal: {}, vertical: {} }, + align: { + start: { viewport: '[--sv5ui-nav-align:0]' }, + center: { viewport: '[--sv5ui-nav-align:0.5]' }, + end: { viewport: '[--sv5ui-nav-align:1]' } + }, disabled: { true: '', false: '' @@ -283,6 +289,7 @@ export const navigationMenuVariants = tv({ collapsed: false, stacked: false, contentOrientation: 'horizontal', + align: 'center', disabled: false } }) diff --git a/src/routes/navigation-menu/+page.svelte b/src/routes/navigation-menu/+page.svelte index 87f8cdb..8cca8ae 100644 --- a/src/routes/navigation-menu/+page.svelte +++ b/src/routes/navigation-menu/+page.svelte @@ -295,6 +295,26 @@ } ] + const alignOptions = ['start', 'center', 'end'] as const + let alignValue = $state<(typeof alignOptions)[number]>('center') + + const alignItems: NavigationMenuItem[] = [ + ['Products', 'lucide:package'], + ['Solutions', 'lucide:lightbulb'], + ['Resources', 'lucide:book-open'], + ['Customers', 'lucide:users'], + ['Company', 'lucide:building-2'], + ['Support', 'lucide:life-buoy'] + ].map(([label, icon]) => ({ + label, + icon, + children: [ + { label: `${label} overview`, href: `#${label.toLowerCase()}-overview` }, + { label: `${label} updates`, href: `#${label.toLowerCase()}-updates` }, + { label: `${label} contact`, href: `#${label.toLowerCase()}-contact` } + ] + })) + const featuredItems: NavigationMenuItem[] = [ { label: 'Solutions', value: 'solutions', type: 'trigger' }, { label: 'Enterprise', href: '/enterprise' } @@ -598,6 +618,39 @@ + +
+

Dropdown Alignment

+

+ The dropdown panel opens under the trigger that opened it. Use align + to place it at the trigger's + start, + center + (default) or + end. + Near the edges of the menu the panel shifts back so it never overflows, and it slides + from one trigger to the next. +

+
+ {#each alignOptions as option (option)} +
+
+ +
+
+

Variants

From e9b97d92b55914d8897a886987c93a15580b2282 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Fri, 18 Sep 2026 15:59:27 +0700 Subject: [PATCH 04/10] 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) + }) + }) + }) }) From ccd89f6c4b985dd1499f42ce08a2473d2bdc74e2 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Fri, 18 Sep 2026 18:14:49 +0700 Subject: [PATCH 05/10] docs(changelog): tighten the unreleased entries --- CHANGELOG.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ce0a9..bff6dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **NavigationMenu** — `align` prop (`'start'`, `'center'`, `'end'`, default `'center'`) places the dropdown panel of a horizontal menu relative to the open trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) +- **NavigationMenu** — `align` prop (`start` | `center` | `end`, default `center`) places the dropdown under the open trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) ### Fixed -- **Modal**, **Slideover**, **Drawer**, **Popover** — with `portal={false}`, a Select, DatePicker, DropdownMenu, Tooltip or other floating layer opened inside the container no longer renders behind it when an ancestor sets a `z-index` above 50. Nested layers now render in the container's stacking context, above its panel. Portalled containers are unchanged. ([#217](https://github.com/ndlabdev/sv5ui/issues/217)) -- **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)) +- **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 From 6008c31d129feb471d8fdf4ec0927c66f3723265 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Thu, 24 Sep 2026 22:18:30 +0700 Subject: [PATCH 06/10] fix(theme-mode): export dark mode API, fix SSR icon mismatch mode-watcher was a required peer dependency while every other runtime library sits in dependencies. Users had to install it themselves, and package managers that do not auto install peers failed to resolve it at all. It moves to dependencies at the same range. toggleMode and resetConfig were referenced by the setup docs but never exported from the package root, so copying those snippets broke the build. A ThemeMode component now wraps the watcher and the mode functions are re-exported, which keeps the underlying library an implementation detail and leaves room to replace it later. ThemeModeButton chose its icon from mode.current, which is undefined during SSR, so the server always rendered the light branch. Svelte does not repair mismatched html blocks, so the wrong glyph stuck after a reload in dark mode. Both icons now render and CSS picks the visible one, and the accessible name no longer depends on the resolved mode. The docs pages are left untouched, so the findings about setup snippets stay open. Refs #224 --- CHANGELOG.md | 7 ++ package.json | 2 +- src/lib/components/ThemeMode/ThemeMode.svelte | 13 +++ src/lib/components/ThemeMode/index.ts | 2 + .../components/ThemeMode/theme-mode.types.ts | 4 + .../ThemeModeButton/ThemeModeButton.svelte | 39 ++++++--- .../ThemeModeButton.svelte.spec.ts | 87 +++++++++++++++++-- .../theme-mode-button.variants.ts | 14 ++- src/lib/index.ts | 13 ++- src/lib/public-api.svelte.spec.ts | 28 ++++++ src/lib/theme.css | 3 +- 11 files changed, 190 insertions(+), 22 deletions(-) create mode 100644 src/lib/components/ThemeMode/ThemeMode.svelte create mode 100644 src/lib/components/ThemeMode/index.ts create mode 100644 src/lib/components/ThemeMode/theme-mode.types.ts create mode 100644 src/lib/public-api.svelte.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bff6dfa..ead457e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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 +- **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)) diff --git a/package.json b/package.json index ffa8f61..68495be 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/lib/components/ThemeMode/ThemeMode.svelte b/src/lib/components/ThemeMode/ThemeMode.svelte new file mode 100644 index 0000000..7431cba --- /dev/null +++ b/src/lib/components/ThemeMode/ThemeMode.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/src/lib/components/ThemeMode/index.ts b/src/lib/components/ThemeMode/index.ts new file mode 100644 index 0000000..3349064 --- /dev/null +++ b/src/lib/components/ThemeMode/index.ts @@ -0,0 +1,2 @@ +export { default as ThemeMode } from './ThemeMode.svelte' +export type { ThemeModeProps } from './theme-mode.types.js' diff --git a/src/lib/components/ThemeMode/theme-mode.types.ts b/src/lib/components/ThemeMode/theme-mode.types.ts new file mode 100644 index 0000000..0f89745 --- /dev/null +++ b/src/lib/components/ThemeMode/theme-mode.types.ts @@ -0,0 +1,4 @@ +import type { ComponentProps } from 'svelte' +import type { ModeWatcher } from 'mode-watcher' + +export type ThemeModeProps = ComponentProps diff --git a/src/lib/components/ThemeModeButton/ThemeModeButton.svelte b/src/lib/components/ThemeModeButton/ThemeModeButton.svelte index ef597cc..7c04e23 100644 --- a/src/lib/components/ThemeModeButton/ThemeModeButton.svelte +++ b/src/lib/components/ThemeModeButton/ThemeModeButton.svelte @@ -11,9 +11,12 @@ themeModeButtonDefaults } from './theme-mode-button.variants.js' import { getComponentConfig, iconsDefaults } from '../../config.js' + import { buttonDefaults } from '../Button/button.variants.js' import Button from '../Button/Button.svelte' + import Icon from '../Icon/Icon.svelte' const config = getComponentConfig('themeModeButton', themeModeButtonDefaults) + const buttonConfig = getComponentConfig('button', buttonDefaults) const icons = getComponentConfig('icons', iconsDefaults) let { @@ -33,24 +36,32 @@ }: Props = $props() const isDark = $derived(mode.current === 'dark') + const resolvedSize = $derived( + size ?? config.defaultVariants.size ?? buttonConfig.defaultVariants.size + ) - const slots = themeModeButtonVariants() - const baseClass = $derived(slots.base({ class: [config.slots.base, className, ui?.base] })) - - const iconName = $derived(isDark ? lightIcon : darkIcon) + const classes = $derived.by(() => { + const slots = themeModeButtonVariants({ size: resolvedSize }) + return { + base: slots.base({ class: [config.slots.base, className, ui?.base] }), + icon: slots.icon({ class: [config.slots.icon, ui?.icon] }), + lightIcon: slots.lightIcon({ class: [config.slots.lightIcon, ui?.lightIcon] }), + darkIcon: slots.darkIcon({ class: [config.slots.darkIcon, ui?.darkIcon] }) + } + }) {#if children} {/if} diff --git a/src/lib/components/ThemeModeButton/ThemeModeButton.svelte.spec.ts b/src/lib/components/ThemeModeButton/ThemeModeButton.svelte.spec.ts index e5b3cfe..b6c96df 100644 --- a/src/lib/components/ThemeModeButton/ThemeModeButton.svelte.spec.ts +++ b/src/lib/components/ThemeModeButton/ThemeModeButton.svelte.spec.ts @@ -1,7 +1,10 @@ +import '../../../routes/layout.css' import { page } from 'vitest/browser' import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-svelte' import ThemeModeButton from './ThemeModeButton.svelte' +import { buttonVariants } from '../Button/button.variants.js' +import { themeModeButtonVariants } from './theme-mode-button.variants.js' describe('ThemeModeButton', () => { // ==================== RENDERING ==================== @@ -13,24 +16,98 @@ describe('ThemeModeButton', () => { await expect.element(btn).toBeInTheDocument() }) - it('should render with aria-label for switching mode', async () => { + it('should render with a mode-independent aria-label', async () => { render(ThemeModeButton) const btn = page.getByRole('button') const ariaLabel = btn.element().getAttribute('aria-label') - expect(ariaLabel).toMatch(/Switch to (light|dark) mode/) + expect(ariaLabel).toBe('Toggle theme') }) - it('should render an icon inside the button', async () => { + it('should render both mode icons so CSS picks the visible one', async () => { render(ThemeModeButton) const btn = page.getByRole('button') await expect.element(btn).toBeInTheDocument() await vi.waitFor(() => { - const svg = document.querySelector('button svg') - expect(svg).not.toBeNull() + const svgs = btn.element().querySelectorAll('svg') + expect(svgs.length).toBe(2) }) }) }) + // ==================== SSR-SAFE MODE ICONS ==================== + + describe('ssr-safe mode icons', () => { + const withDarkClass = async (fn: () => void | Promise) => { + document.documentElement.classList.add('dark') + try { + await fn() + } finally { + document.documentElement.classList.remove('dark') + } + } + + const iconsOf = async (el: HTMLElement) => { + await vi.waitFor(() => { + expect(el.querySelectorAll('svg').length).toBe(2) + }) + const [darkModeOff, darkModeOn] = Array.from(el.querySelectorAll('svg')) + return { darkModeOff, darkModeOn } + } + + it('should show only the dark-mode icon in light mode', async () => { + render(ThemeModeButton) + const btn = page.getByRole('button').element() as HTMLElement + const { darkModeOff, darkModeOn } = await iconsOf(btn) + + expect(getComputedStyle(darkModeOff).display).not.toBe('none') + expect(getComputedStyle(darkModeOn).display).toBe('none') + }) + + it('should swap the visible icon when the dark class is present', async () => { + render(ThemeModeButton) + const btn = page.getByRole('button').element() as HTMLElement + const { darkModeOff, darkModeOn } = await iconsOf(btn) + + await withDarkClass(() => { + expect(getComputedStyle(darkModeOff).display).toBe('none') + expect(getComputedStyle(darkModeOn).display).not.toBe('none') + }) + }) + + it('should size its icons for every size the button offers', () => { + expect(Object.keys(themeModeButtonVariants.variants.size)).toEqual( + Object.keys(buttonVariants.variants.size) + ) + }) + + it.each(['xs', 'sm', 'md', 'lg', 'xl'] as const)( + 'should give both icons a size class at size=%s', + async (size) => { + render(ThemeModeButton, { size }) + const btn = page.getByRole('button').element() as HTMLElement + const { darkModeOff, darkModeOn } = await iconsOf(btn) + + for (const icon of [darkModeOff, darkModeOn]) { + expect(icon.getAttribute('class')).toMatch(/\bsize-[\d.]+\b/) + } + } + ) + + it('should keep the same markup regardless of the resolved mode', async () => { + render(ThemeModeButton) + const btn = page.getByRole('button').element() as HTMLElement + await iconsOf(btn) + const lightHtml = btn.innerHTML + + let darkHtml = '' + await withDarkClass(() => { + darkHtml = btn.innerHTML + }) + + expect(darkHtml).toBe(lightHtml) + }) + }) + // ==================== DEFAULT PROPS ==================== describe('default props', () => { diff --git a/src/lib/components/ThemeModeButton/theme-mode-button.variants.ts b/src/lib/components/ThemeModeButton/theme-mode-button.variants.ts index 7a4bf80..1c47009 100644 --- a/src/lib/components/ThemeModeButton/theme-mode-button.variants.ts +++ b/src/lib/components/ThemeModeButton/theme-mode-button.variants.ts @@ -4,9 +4,19 @@ import type { ButtonVariantProps } from '../Button/button.variants.js' export const themeModeButtonVariants = tv({ slots: { base: '', - icon: 'shrink-0' + icon: 'shrink-0', + lightIcon: 'hidden dark:block', + darkIcon: 'block dark:hidden' + }, + variants: { + size: { + xs: { icon: 'size-3.5' }, + sm: { icon: 'size-4' }, + md: { icon: 'size-5' }, + lg: { icon: 'size-5' }, + xl: { icon: 'size-6' } + } }, - variants: {}, defaultVariants: {} }) diff --git a/src/lib/index.ts b/src/lib/index.ts index ac875e1..3403bb5 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -61,6 +61,7 @@ export * from './components/FileUpload/index.js' export * from './components/Slider/index.js' export * from './components/PinInput/index.js' export * from './components/ColorPicker/index.js' +export * from './components/ThemeMode/index.js' export * from './components/ThemeModeButton/index.js' export * from './components/Table/index.js' export * from './components/Toast/index.js' @@ -75,6 +76,16 @@ export * from './components/Tree/index.js' // Composables export * from './hooks/index.js' +// Theme mode +export { + toggleMode, + setMode, + resetMode, + mode, + userPrefersMode, + systemPrefersMode +} from 'mode-watcher' + // Configuration -export { defineConfig } from './config.js' +export { defineConfig, resetConfig } from './config.js' export type { UIConfig } from './config.js' diff --git a/src/lib/public-api.svelte.spec.ts b/src/lib/public-api.svelte.spec.ts new file mode 100644 index 0000000..b1865e5 --- /dev/null +++ b/src/lib/public-api.svelte.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import * as sv5ui from './index.js' + +describe('public API', () => { + describe('theme mode', () => { + it('should expose the theme mode handler component', () => { + expect(typeof sv5ui.ThemeMode).toBe('function') + }) + + it.each(['toggleMode', 'setMode', 'resetMode'] as const)( + 'should expose %s without requiring a separate package', + (name) => { + expect(typeof sv5ui[name]).toBe('function') + } + ) + + it('should expose the reactive mode state', () => { + expect(sv5ui.mode).toBeDefined() + expect('current' in sv5ui.mode).toBe(true) + }) + }) + + describe('configuration', () => { + it.each(['defineConfig', 'resetConfig'] as const)('should expose %s', (name) => { + expect(typeof sv5ui[name]).toBe('function') + }) + }) +}) diff --git a/src/lib/theme.css b/src/lib/theme.css index 7812767..8188402 100644 --- a/src/lib/theme.css +++ b/src/lib/theme.css @@ -12,7 +12,8 @@ /* ============================================ * DARK MODE CONFIGURATION - * Supports both class="dark" and prefers-color-scheme + * Matches the `dark` class on the root element. The system preference is + * resolved to that class by the ThemeMode component, so no media query here. * ============================================ */ @custom-variant dark (&:where(.dark, .dark *)); From 5e295af3d7f7ee65f22a61f520e8ce9bb5b216e7 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Fri, 25 Sep 2026 13:59:19 +0700 Subject: [PATCH 07/10] fix(table): make the column resize handle keyboard and touch accessible The handle was a bare span carrying only onmousedown, with no role, no tabindex and no key handler. Keyboard users could not reach it at all, let alone resize a column. A probe measured the column staying at 150px after three arrow presses while a mouse drag moved it to 230px, and one Tab from a button above the table jumped straight past the whole table. The drag also listened for mousemove and mouseup on document, so touch and pen never started a resize, even though the element already carried touch-none and therefore blocked scrolling that began on it. It is now a focusable separator with aria-orientation, aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext, resized by the arrow keys with a larger step while shift is held, and by Home and End for the bounds. The drag moves onto usePointerDrag, which ColorPicker and Resizable already use, so every pointer type works. The svelte-ignore for static element interactions is gone, since the element is now interactive. One remains for the noninteractive tabindex rule, which misreads a separator carrying aria-valuenow as passive. That is the same false positive Resizable already suppresses. Closes #226 --- CHANGELOG.md | 1 + src/lib/components/Table/Table.svelte | 116 ++++++++++++++---- src/lib/components/Table/Table.svelte.spec.ts | 97 +++++++++++++++ 3 files changed, 188 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff6dfa..6680970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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)) - **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)) diff --git a/src/lib/components/Table/Table.svelte b/src/lib/components/Table/Table.svelte index 7bab206..deb8b1f 100644 --- a/src/lib/components/Table/Table.svelte +++ b/src/lib/components/Table/Table.svelte @@ -14,6 +14,7 @@ import Checkbox from '../Checkbox/Checkbox.svelte' import ScrollArea from '../ScrollArea/ScrollArea.svelte' import { useThrottle } from '../../hooks/useThrottle/index.js' + import { usePointerDrag } from '../../hooks/usePointerDrag/index.js' import { autoGenerateColumns, getRowKey, @@ -353,35 +354,84 @@ // ========================================================================= // Column Resizing // ========================================================================= - let resizing = $state<{ key: string; startX: number; startWidth: number } | null>(null) + let resizing: { key: string; startWidth: number } | null = null const resizeThrottle = useThrottle({ delay: 16 }) + const resizeKeyStep = 16 - function onResizeStart(e: MouseEvent, col: TableColumn) { - e.preventDefault() - const currentWidth = columnSizing[col.key] ?? col.width ?? 150 - resizing = { key: col.key, startX: e.clientX, startWidth: currentWidth } - - const onMove = (ev: MouseEvent) => { - if (!resizing) return - const { key, startX, startWidth } = resizing - const diff = ev.clientX - startX - const min = col.minWidth ?? 50 - const max = col.maxWidth ?? Infinity - const newWidth = Math.max(min, Math.min(max, startWidth + diff)) - resizeThrottle.run(() => { - columnSizing = { ...columnSizing, [key]: newWidth } - onColumnSizingChange?.(columnSizing) - }) - } + function columnByKey(key: string): TableColumn | undefined { + return visibleColumns.find((col) => String(col.key) === key) + } + + function columnWidth(col: TableColumn): number { + return columnSizing[col.key] ?? col.width ?? 150 + } + + function columnBounds(col: TableColumn) { + return { min: col.minWidth ?? 50, max: col.maxWidth ?? Infinity } + } + + function applyColumnWidth(col: TableColumn, width: number) { + const { min, max } = columnBounds(col) + const next = Math.max(min, Math.min(max, Math.round(width))) + if (next === columnWidth(col)) return + + columnSizing = { ...columnSizing, [col.key]: next } + onColumnSizingChange?.(columnSizing) + } + + const resize = usePointerDrag({ + axis: 'x', + onStart: ({ event }) => { + const key = (event.currentTarget as HTMLElement).dataset.columnKey + if (!key) return false + + const col = columnByKey(key) + if (!col) return false + + resizing = { key, startWidth: columnWidth(col) } + }, + onMove: ({ dx }) => { + const current = resizing + if (!current) return + + const col = columnByKey(current.key) + if (!col) return - const onUp = () => { + resizeThrottle.run(() => applyColumnWidth(col, current.startWidth + dx)) + }, + onEnd: () => { resizing = null - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) } + }) + + function resizeKeyDelta(event: KeyboardEvent): number | null { + const step = event.shiftKey ? resizeKeyStep * 4 : resizeKeyStep + if (event.key === 'ArrowRight') return step + if (event.key === 'ArrowLeft') return -step + + return null + } + + function handleResizeKeydown(event: KeyboardEvent, col: TableColumn) { + const { min, max } = columnBounds(col) + + if (event.key === 'Home') { + event.preventDefault() + applyColumnWidth(col, min) + return + } + + if (event.key === 'End' && Number.isFinite(max)) { + event.preventDefault() + applyColumnWidth(col, max) + return + } + + const delta = resizeKeyDelta(event) + if (delta === null) return - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) + event.preventDefault() + applyColumnWidth(col, columnWidth(col) + delta) } // ========================================================================= @@ -665,10 +715,24 @@ {/if} {#if col.resizable} - + {@const width = columnWidth(col)} + {@const bounds = columnBounds(col)} + onResizeStart(e, col)} + role="separator" + tabindex="0" + data-column-key={col.key} + aria-orientation="vertical" + aria-label="Resize {col.label ?? col.key}" + aria-valuenow={width} + aria-valuemin={bounds.min} + aria-valuemax={Number.isFinite(bounds.max) + ? bounds.max + : undefined} + aria-valuetext="{width}px" + class="group/resize absolute top-0 -right-px flex h-full w-4 cursor-col-resize touch-none items-center justify-center select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary" + onkeydown={(event) => handleResizeKeydown(event, col)} + {...resize.handlers} > { // ==================== GLOBAL FILTER ==================== + // ==================== COLUMN RESIZE ==================== + + describe('column resize', () => { + const resizableColumns = [ + { key: 'name', label: 'Name', resizable: true, width: 200, minWidth: 80 }, + { key: 'age', label: 'Age' } + ] as any + + const rows = [{ id: 1, name: 'Ada', email: 'a@b.c', age: 36 }] + + const setup = () => { + const { container } = render(Table, { columns: resizableColumns, data: rows }) + const handle = container.querySelector('th [role="separator"]')! + const width = () => Number(handle.getAttribute('aria-valuenow')) + return { handle, width } + } + + const press = async (handle: HTMLElement, key: string, shiftKey = false) => { + handle.dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, bubbles: true })) + await vi.waitFor(() => expect(handle.getAttribute('aria-valuenow')).toBeTruthy()) + } + + it('should expose the handle as a focusable separator', () => { + const { handle } = setup() + + expect(handle.getAttribute('role')).toBe('separator') + expect(handle.getAttribute('tabindex')).toBe('0') + expect(handle.getAttribute('aria-orientation')).toBe('vertical') + expect(handle.getAttribute('aria-label')).toBe('Resize Name') + expect(handle.getAttribute('aria-valuenow')).toBe('200') + expect(handle.getAttribute('aria-valuemin')).toBe('80') + expect(handle.getAttribute('aria-valuetext')).toBe('200px') + }) + + it('should be reachable with the keyboard', () => { + const { handle } = setup() + handle.focus() + + expect(document.activeElement).toBe(handle) + }) + + it('should resize with the arrow keys', async () => { + const { handle, width } = setup() + const start = width() + + await press(handle, 'ArrowRight') + const grown = width() + await press(handle, 'ArrowLeft') + + expect(grown).toBeGreaterThan(start) + expect(width()).toBe(start) + }) + + it('should take a larger step while shift is held', async () => { + const { handle, width } = setup() + const start = width() + + await press(handle, 'ArrowRight') + const small = width() - start + + await press(handle, 'ArrowRight', true) + const large = width() - start - small + + expect(large).toBeGreaterThan(small) + }) + + it('should clamp to minWidth and not go below it', async () => { + const { handle, width } = setup() + + await press(handle, 'Home') + expect(width()).toBe(80) + + await press(handle, 'ArrowLeft') + expect(width()).toBe(80) + }) + + it('should resize from a touch pointer, not just a mouse', async () => { + const { handle, width } = setup() + const start = width() + const opts = { bubbles: true, pointerType: 'touch', pointerId: 7, isPrimary: true } + + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: 0 })) + handle.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: 70 })) + handle.dispatchEvent(new PointerEvent('pointerup', { ...opts, clientX: 70 })) + + await vi.waitFor(() => expect(width()).toBeGreaterThan(start)) + }) + + it('should render no handle for a column that is not resizable', () => { + const { container } = render(Table, { columns: resizableColumns, data: rows }) + const headers = Array.from(container.querySelectorAll('th')) + const withHandle = headers.filter((th) => th.querySelector('[role="separator"]')) + + expect(withHandle).toHaveLength(1) + }) + }) + describe('global filter', () => { it('should filter data by global filter', () => { render(Table, { From eeb06fcb1fb6d16eec01ce2d68f5a5291095f641 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sat, 26 Sep 2026 00:08:15 +0700 Subject: [PATCH 08/10] fix(input): make leading and trailing slots usable The two slots were part of the public API but had no test coverage, and three separate things were wrong with them. isLeading and isTrailing ignored the slots, so the padding compound variants never applied. The same icon measured 36px of padding through the leadingIcon prop and 12px through the slot, leaving the content overlapping the text by 22px. Both flags now account for the slots. The slot wrapper carries pointer-events-none so that a decorative icon stays click through and clicking it focuses the field. That also made interactive slot content impossible to click: a button in a slot never received a single click, while the cursor still changed on hover. Pointer events are now re-enabled only on a wrapper that actually holds a slot, and only while the field is neither disabled nor loading, so a control inside a disabled field stays inert. A slot also took precedence over the loading branch, so a field with a slot went disabled during loading with no spinner and no other indicator. Loading now wins, matching how Button swaps its leading icon for the spinner. Also corrected the loading prop documentation, which claimed it optionally disables interaction while the input is always disabled while loading. That text ships in the published type declarations. Closes #229 --- CHANGELOG.md | 1 + src/lib/components/Input/Input.svelte | 43 ++++++++---- src/lib/components/Input/Input.svelte.spec.ts | 66 +++++++++++++++++++ src/lib/components/Input/input.types.ts | 2 +- 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ec62e..62eff29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) diff --git a/src/lib/components/Input/Input.svelte b/src/lib/components/Input/Input.svelte index 41cb999..d05fb0c 100644 --- a/src/lib/components/Input/Input.svelte +++ b/src/lib/components/Input/Input.svelte @@ -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)) @@ -128,7 +135,9 @@ 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] }), @@ -136,7 +145,13 @@ 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] }) @@ -144,16 +159,16 @@
- {#if leadingSlot} - - {@render leadingSlot()} - - {:else if loadingLeading} + {#if loadingLeading} + {:else if leadingSlot} + + {@render leadingSlot()} + {:else if avatar} @@ -181,16 +196,16 @@ onfocus={handleFocus} /> - {#if trailingSlot} - - {@render trailingSlot()} - - {:else if loadingTrailing} + {#if loadingTrailing} + {:else if trailingSlot} + + {@render trailingSlot()} + {:else if trailingIconName} diff --git a/src/lib/components/Input/Input.svelte.spec.ts b/src/lib/components/Input/Input.svelte.spec.ts index a820f70..120d574 100644 --- a/src/lib/components/Input/Input.svelte.spec.ts +++ b/src/lib/components/Input/Input.svelte.spec.ts @@ -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==' @@ -402,6 +406,68 @@ describe('Input', () => { // ==================== ACCESSIBILITY ==================== + // ==================== LEADING & TRAILING SLOTS ==================== + + describe('leading and trailing slots', () => { + const button = () => snippet('') + + const wrapperOf = (container: Element, side: 'first' | 'last') => { + const spans = Array.from(container.querySelectorAll('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' }) diff --git a/src/lib/components/Input/input.types.ts b/src/lib/components/Input/input.types.ts index d99af69..922c0ba 100644 --- a/src/lib/components/Input/input.types.ts +++ b/src/lib/components/Input/input.types.ts @@ -66,7 +66,7 @@ export type InputProps = Omit< highlight?: boolean /** - * Renders a loading spinner and optionally disables interaction. + * Renders a loading spinner. The input is disabled while loading. * @default false */ loading?: boolean From 97c8bbd6875dea8dd0cea516af56827eb159e397 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sat, 26 Sep 2026 15:36:10 +0700 Subject: [PATCH 09/10] feat(input-password): add a password field with a caller scored meter Requested in #228. The first answer was to decline and document the composition instead, which was wrong on two counts: ThemeModeButton is 89 lines and does less, so thinness is not where this library draws its line, and the two extra bundled icons measure at about half a kilobyte before compression. The component wraps Input rather than reimplementing it the way InputTags and InputNumber do, because those two need to change what sits inside the field box while this one only needs a trailing control and blocks below. FormField and Form integration comes for free as a result: label association, error state and aria-describedby all flow through. What it handles: a visibility toggle whose accessible name follows the next action, purpose mapping to the correct autocomplete value for sign in, sign up and password change, a caps lock warning cleared on release or blur, and a guarantee that pasting is never intercepted. Scoring stays with the caller. strength takes a number and the component renders the meter, picks the colour and debounces the screen reader announcement so it fires on level changes rather than on every keystroke. A naive scorer is worse than no meter: counting character classes rates Password1! as strong while penalising a long passphrase for having no digit, and scoring properly needs a dictionary or a breach list, neither of which belongs in a UI library. Adds passwordShow and passwordHide to the icon defaults and regenerates the bundled set. Closes #228 --- CHANGELOG.md | 1 + src/lib/components/Icon/bundled.ts | 6 + .../InputPassword/InputPassword.svelte | 190 ++++++++++ .../InputPassword.svelte.spec.ts | 349 ++++++++++++++++++ src/lib/components/InputPassword/index.ts | 2 + .../InputPassword/input-password.types.ts | 111 ++++++ .../InputPassword/input-password.variants.ts | 27 ++ src/lib/config.ts | 4 +- src/lib/index.ts | 1 + 9 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/InputPassword/InputPassword.svelte create mode 100644 src/lib/components/InputPassword/InputPassword.svelte.spec.ts create mode 100644 src/lib/components/InputPassword/index.ts create mode 100644 src/lib/components/InputPassword/input-password.types.ts create mode 100644 src/lib/components/InputPassword/input-password.variants.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62eff29..b911f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) diff --git a/src/lib/components/Icon/bundled.ts b/src/lib/components/Icon/bundled.ts index cbfeafc..5623969 100644 --- a/src/lib/components/Icon/bundled.ts +++ b/src/lib/components/Icon/bundled.ts @@ -36,6 +36,12 @@ export const bundledIcons: BundledIconCollection[] = [ ellipsis: { body: '' }, + eye: { + body: '' + }, + 'eye-off': { + body: '' + }, file: { body: '' }, diff --git a/src/lib/components/InputPassword/InputPassword.svelte b/src/lib/components/InputPassword/InputPassword.svelte new file mode 100644 index 0000000..6cdc3cb --- /dev/null +++ b/src/lib/components/InputPassword/InputPassword.svelte @@ -0,0 +1,190 @@ + + + + +
+ {#snippet toggleButton()} +
diff --git a/src/lib/components/InputPassword/InputPassword.svelte.spec.ts b/src/lib/components/InputPassword/InputPassword.svelte.spec.ts new file mode 100644 index 0000000..5f403d0 --- /dev/null +++ b/src/lib/components/InputPassword/InputPassword.svelte.spec.ts @@ -0,0 +1,349 @@ +import '../../../routes/layout.css' +import { describe, expect, it, vi, afterEach } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { createRawSnippet } from 'svelte' +import { defineConfig, resetConfig } from '../../config.js' +import InputPassword from './InputPassword.svelte' + +afterEach(() => resetConfig()) + +const settle = async (container: Element) => { + await vi.waitFor(() => expect(container.querySelector('input')).not.toBeNull()) +} + +const parts = (container: Element) => ({ + input: container.querySelector('input')!, + toggle: container.querySelector('button'), + meter: container.querySelector('[role="progressbar"]'), + live: container.querySelector('[aria-live]'), + caps: container.querySelector('p') +}) + +describe('InputPassword', () => { + // ==================== RENDERING ==================== + + describe('rendering', () => { + it('should render a password field by default', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(parts(container).input.type).toBe('password') + }) + + it('should render the visibility toggle by default', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(parts(container).toggle).not.toBeNull() + }) + + it('should render no toggle when it is turned off', async () => { + const { container } = render(InputPassword, { toggle: false }) + await settle(container) + + expect(parts(container).toggle).toBeNull() + }) + }) + + // ==================== VISIBILITY TOGGLE ==================== + + describe('visibility toggle', () => { + it('should reveal and hide the value', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { input, toggle } = parts(container) + + toggle!.click() + await vi.waitFor(() => expect(input.type).toBe('text')) + + toggle!.click() + await vi.waitFor(() => expect(input.type).toBe('password')) + }) + + it('should name the toggle for its next action', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { toggle } = parts(container) + + expect(toggle!.getAttribute('aria-label')).toBe('Show password') + toggle!.click() + await vi.waitFor(() => expect(toggle!.getAttribute('aria-label')).toBe('Hide password')) + }) + + it('should report the toggle state through aria-pressed', async () => { + const { container } = render(InputPassword, { visible: true }) + await settle(container) + + expect(parts(container).toggle!.getAttribute('aria-pressed')).toBe('true') + }) + + it('should accept custom labels', async () => { + const { container } = render(InputPassword, { showLabel: 'Reveal' }) + await settle(container) + + expect(parts(container).toggle!.getAttribute('aria-label')).toBe('Reveal') + }) + + it.each([ + ['disabled', { disabled: true }], + ['loading', { loading: true }] + ])('should disable the toggle while %s', async (_label, props) => { + const { container } = render(InputPassword, props) + await settle(container) + + expect(parts(container).toggle!.disabled).toBe(true) + }) + }) + + // ==================== AUTOCOMPLETE ==================== + + describe('autocomplete', () => { + it.each([ + ['login', 'current-password'], + ['signup', 'new-password'], + ['change', 'new-password'] + ] as const)('should map purpose=%s to %s', async (purpose, expected) => { + const { container } = render(InputPassword, { purpose }) + await settle(container) + + expect(parts(container).input.getAttribute('autocomplete')).toBe(expected) + }) + + it('should default to the sign in value', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(parts(container).input.getAttribute('autocomplete')).toBe('current-password') + }) + }) + + // ==================== PASTE ==================== + + describe('paste', () => { + it('should never block pasting into the field', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { input } = parts(container) + + const event = new Event('paste', { bubbles: true, cancelable: true }) + input.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + expect(input.onpaste).toBeNull() + }) + }) + + // ==================== CAPS LOCK ==================== + + describe('caps lock warning', () => { + const keydown = (input: HTMLInputElement, capsLock: boolean) => { + const event = new KeyboardEvent('keydown', { key: 'a', bubbles: true }) + Object.defineProperty(event, 'getModifierState', { + value: (key: string) => key === 'CapsLock' && capsLock + }) + input.dispatchEvent(event) + } + + it('should warn while caps lock is on', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + keydown(parts(container).input, true) + + await vi.waitFor(() => + expect(parts(container).caps?.textContent).toContain('Caps Lock is on') + ) + }) + + it('should stay quiet while caps lock is off', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + keydown(parts(container).input, false) + + await vi.waitFor(() => expect(parts(container).caps).toBeNull()) + }) + + it('should clear the warning once caps lock is released', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { input } = parts(container) + + keydown(input, true) + await vi.waitFor(() => expect(parts(container).caps).not.toBeNull()) + + keydown(input, false) + await vi.waitFor(() => expect(parts(container).caps).toBeNull()) + }) + + it('should clear the warning when the field loses focus', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { input } = parts(container) + + keydown(input, true) + await vi.waitFor(() => expect(parts(container).caps).not.toBeNull()) + + input.dispatchEvent(new FocusEvent('blur', { bubbles: true })) + await vi.waitFor(() => expect(parts(container).caps).toBeNull()) + }) + + it('should accept a custom message', async () => { + const { container } = render(InputPassword, { capsLockMessage: 'Caps is active' }) + await settle(container) + keydown(parts(container).input, true) + + await vi.waitFor(() => + expect(parts(container).caps?.textContent).toContain('Caps is active') + ) + }) + + it('should render nothing when the warning is turned off', async () => { + const { container } = render(InputPassword, { capsLockWarning: false }) + await settle(container) + keydown(parts(container).input, true) + + await vi.waitFor(() => expect(parts(container).caps).toBeNull()) + }) + }) + + // ==================== STRENGTH METER ==================== + + describe('strength meter', () => { + it('should render no meter when no score is given', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(parts(container).meter).toBeNull() + }) + + it('should render no meter for a null score', async () => { + const { container } = render(InputPassword, { strength: null }) + await settle(container) + + expect(parts(container).meter).toBeNull() + }) + + it.each([0, 1, 2, 3])('should report score %i on the progressbar', async (score) => { + const { container } = render(InputPassword, { strength: score }) + await settle(container) + + const meter = parts(container).meter! + expect(meter.getAttribute('aria-valuenow')).toBe(String(score)) + expect(meter.getAttribute('aria-valuemax')).toBe('3') + }) + + it('should clamp a score above the last level', async () => { + const { container } = render(InputPassword, { strength: 99 }) + await settle(container) + + expect(parts(container).meter!.getAttribute('aria-valuenow')).toBe('3') + }) + + it('should clamp a negative score', async () => { + const { container } = render(InputPassword, { strength: -5 }) + await settle(container) + + expect(parts(container).meter!.getAttribute('aria-valuenow')).toBe('0') + }) + + it('should follow custom levels', async () => { + const { container } = render(InputPassword, { + strength: 1, + strengthLevels: ['Low', 'High'] + }) + await settle(container) + + expect(parts(container).meter!.getAttribute('aria-valuemax')).toBe('1') + }) + + it('should announce the level in a live region only when a meter exists', async () => { + const without = render(InputPassword, {}) + await settle(without.container) + expect(parts(without.container).live).toBeNull() + + const withMeter = render(InputPassword, { strength: 2 }) + await settle(withMeter.container) + expect(parts(withMeter.container).live).not.toBeNull() + }) + }) + + // ==================== SIZE ==================== + + describe('size', () => { + const padOf = (container: Element) => getComputedStyle(parts(container).toggle!).padding + + it('should keep the toggle in step with the field by default', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(padOf(container)).toBe('6px') + }) + + it('should follow an explicit size', async () => { + const { container } = render(InputPassword, { size: 'xs' }) + await settle(container) + + expect(padOf(container)).toBe('4px') + }) + + it('should follow the size configured for Input', async () => { + defineConfig({ input: { defaultVariants: { size: 'xl' } } } as never) + const { container } = render(InputPassword, {}) + await settle(container) + + expect(padOf(container)).toBe('10px') + }) + }) + + // ==================== UI OVERRIDES ==================== + + describe('ui overrides', () => { + it('should apply its own root class exactly once', async () => { + const { container } = render(InputPassword, { ui: { root: 'probe-root' } } as never) + await settle(container) + + expect(container.querySelectorAll('.probe-root')).toHaveLength(1) + }) + + it('should still forward slots that belong to the field', async () => { + const { container } = render(InputPassword, { ui: { base: 'probe-base' } } as never) + await settle(container) + + expect(parts(container).input.className).toContain('probe-base') + }) + }) + + // ==================== FOOTER ==================== + + describe('footer', () => { + it('should render footer content below the field', async () => { + const { container } = render(InputPassword, { + footer: createRawSnippet(() => ({ + render: () => '', + setup: () => {} + })) + } as never) + await settle(container) + + expect(container.querySelector('#probe-footer')).not.toBeNull() + }) + }) + + // ==================== REF ==================== + + describe('ref', () => { + it('should expose the underlying input element', async () => { + let el = $state(null) + const { container } = render(InputPassword, { + get ref() { + return el + }, + set ref(v: HTMLInputElement | null) { + el = v + } + } as never) + await settle(container) + + expect(el).toBe(parts(container).input) + }) + }) +}) diff --git a/src/lib/components/InputPassword/index.ts b/src/lib/components/InputPassword/index.ts new file mode 100644 index 0000000..5ccfc29 --- /dev/null +++ b/src/lib/components/InputPassword/index.ts @@ -0,0 +1,2 @@ +export { default as InputPassword } from './InputPassword.svelte' +export type { InputPasswordProps, InputPasswordPurpose } from './input-password.types.js' diff --git a/src/lib/components/InputPassword/input-password.types.ts b/src/lib/components/InputPassword/input-password.types.ts new file mode 100644 index 0000000..fa241ba --- /dev/null +++ b/src/lib/components/InputPassword/input-password.types.ts @@ -0,0 +1,111 @@ +import type { Snippet } from 'svelte' +import type { ClassNameValue } from 'tailwind-merge' +import type { InputProps } from '../Input/input.types.js' +import type { InputPasswordSlots } from './input-password.variants.js' + +/** + * What the field is for, which decides the `autocomplete` value browsers and + * password managers rely on. + * + * - `login` maps to `current-password` + * - `signup` and `change` map to `new-password`, which lets a password manager + * offer to generate a strong one + */ +export type InputPasswordPurpose = 'login' | 'signup' | 'change' + +export type InputPasswordProps = Omit< + InputProps, + 'type' | 'icon' | 'trailing' | 'trailingIcon' | 'trailingSlot' | 'autocomplete' +> & { + /** + * Bindable reference to the underlying input element. + */ + ref?: HTMLInputElement | null + + /** + * What the field is for. Sets `autocomplete` accordingly. + * @default 'login' + */ + purpose?: InputPasswordPurpose + + /** + * Whether the value is currently readable. Bindable, so the caller can + * drive or observe the toggle. + * @default false + */ + visible?: boolean + + /** + * Renders the visibility toggle button. + * @default true + */ + toggle?: boolean + + /** + * Icon for the button that reveals the value. + * @default Uses `icons.passwordShow` from app config + */ + showIcon?: string + + /** + * Icon for the button that hides the value. + * @default Uses `icons.passwordHide` from app config + */ + hideIcon?: string + + /** + * Accessible name of the toggle when the value is hidden. + * @default 'Show password' + */ + showLabel?: string + + /** + * Accessible name of the toggle when the value is visible. + * @default 'Hide password' + */ + hideLabel?: string + + /** + * Warns while caps lock is on, a common cause of failed sign in. + * @default true + */ + capsLockWarning?: boolean + + /** + * Message shown while caps lock is on. + * @default 'Caps Lock is on' + */ + capsLockMessage?: string + + /** + * Current strength, as an index into `strengthLevels`. Scoring is left to + * the caller: this component renders the result and never judges a password + * itself. Pass `null` or leave it out to hide the meter. + */ + strength?: number | null + + /** + * Labels for each strength step, in ascending order. Also decides how many + * steps the meter has. + * @default ['Weak', 'Fair', 'Good', 'Strong'] + */ + strengthLevels?: string[] + + /** + * Extra content rendered below the field, after the meter. Receives the + * current value so a caller can render their own hints. + */ + footer?: Snippet<[{ value: string; strength: number | null }]> + + /** + * Additional CSS classes for the outer wrapper, which holds the field, the + * strength meter, the caps lock warning and the footer. To style the field + * alone, use `ui.base`. + */ + class?: ClassNameValue + + /** + * Override styles for specific slots. + */ + ui?: Partial> & InputProps['ui'] +} diff --git a/src/lib/components/InputPassword/input-password.variants.ts b/src/lib/components/InputPassword/input-password.variants.ts new file mode 100644 index 0000000..e13b6f2 --- /dev/null +++ b/src/lib/components/InputPassword/input-password.variants.ts @@ -0,0 +1,27 @@ +import { tv, type VariantProps } from 'tailwind-variants' + +export const inputPasswordVariants = tv({ + slots: { + root: 'w-full', + toggle: [ + 'text-on-surface-variant/75 transition-colors', + 'hover:bg-transparent hover:text-on-surface', + 'active:bg-transparent active:text-on-surface', + 'focus-visible:bg-transparent focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary' + ], + meter: 'mt-1.5', + caps: 'mt-1.5 flex items-center gap-1.5 text-xs text-warning', + capsIcon: 'size-3.5 shrink-0', + footer: 'mt-1.5' + } +}) + +export type InputPasswordVariantProps = VariantProps +export type InputPasswordSlots = keyof ReturnType + +export const inputPasswordDefaults = { + defaultVariants: inputPasswordVariants.defaultVariants, + slots: {} as Partial>, + strengthLevels: ['Weak', 'Fair', 'Good', 'Strong'], + strengthColors: ['error', 'warning', 'info', 'success'] as const +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 5ffda79..7807b1b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -50,7 +50,9 @@ export const iconsDefaults = { sortDesc: 'lucide:chevron-down', sortDefault: 'lucide:chevrons-up-down', panelLeft: 'lucide:panel-left', - eyeDropper: 'lucide:pipette' + eyeDropper: 'lucide:pipette', + passwordShow: 'lucide:eye', + passwordHide: 'lucide:eye-off' } // ==================== TYPES ==================== diff --git a/src/lib/index.ts b/src/lib/index.ts index 3403bb5..c6ccaa7 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -47,6 +47,7 @@ export * from './components/Form/index.js' export * from './components/Input/index.js' export * from './components/InputNumber/index.js' export * from './components/InputTags/index.js' +export * from './components/InputPassword/index.js' export * from './components/Textarea/index.js' export * from './components/Select/index.js' export * from './components/SelectMenu/index.js' From 5bb33c792323837b3be03ddd525b462a4a96e6bb Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sun, 27 Sep 2026 14:31:55 +0700 Subject: [PATCH 10/10] chore(release): 2.8.0 --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b911f9e..734f5a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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)) diff --git a/package.json b/package.json index 68495be..5190923 100644 --- a/package.json +++ b/package.json @@ -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",