diff --git a/CHANGELOG.md b/CHANGELOG.md index 5344d0e..734f5a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.8.0] - 2026-09-27 + +### Added + +- **InputPassword** — password field built on `Input`. A visibility toggle whose accessible name follows the next action, `purpose` mapping to the right `autocomplete` value for sign in, sign up and password change, a caps lock warning, and a guarantee that pasting is never blocked. An optional strength meter renders a score you supply: the library never judges a password itself, since scoring without a dictionary is misleading and a rule set that suits one policy penalises another. The screen reader announcement is debounced and fires on level changes rather than on every keystroke. ([#228](https://github.com/ndlabdev/sv5ui/issues/228)) +- **ThemeMode** — dark mode handler for the root layout, replacing the setup that required a second package. ([#224](https://github.com/ndlabdev/sv5ui/issues/224)) +- **Exports** — `toggleMode`, `setMode`, `resetMode`, `mode`, `userPrefersMode` and `systemPrefersMode` are re-exported from the package root, alongside `resetConfig`. The docs already referenced these; now they resolve. ([#224](https://github.com/ndlabdev/sv5ui/issues/224)) +- **NavigationMenu** — `align` prop (`start` | `center` | `end`, default `center`) places the dropdown under the open trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) + +### Changed + +- `mode-watcher` moved from `peerDependencies` to `dependencies`, matching every other runtime library. Dark mode now works without installing anything beyond `sv5ui`, including on package managers that do not auto install peers. ([#224](https://github.com/ndlabdev/sv5ui/issues/224)) + +### Fixed + +- **Input** — `leadingSlot` and `trailingSlot` now reserve room in the field, so their content no longer sits on top of the text, and pointer events reach them, so a button placed in a slot is clickable. A decorative `leadingIcon` or `trailingIcon` stays click through, and slot content is inert while the field is disabled or loading. The loading spinner also takes precedence over a slot instead of being swallowed by it. ([#229](https://github.com/ndlabdev/sv5ui/issues/229)) +- **Table** — the column resize handle is a focusable `separator` with `aria-valuenow`, arrow key resizing and a larger step while shift is held, plus `Home` and `End` for the bounds. It was mouse only before: no role, no tabindex and no key handler, so keyboard users could not resize a column at all. The drag also moves to the shared pointer hook, so touch and pen work rather than mouse alone. ([#226](https://github.com/ndlabdev/sv5ui/issues/226)) +- **ThemeModeButton** — renders both mode icons and lets CSS pick the visible one, so server and client markup are identical. The wrong glyph no longer sticks after a reload in dark mode. The accessible name is now a mode-independent `Toggle theme`. ([#224](https://github.com/ndlabdev/sv5ui/issues/224)) +- **Modal**, **Slideover**, **Drawer**, **Popover** — with `portal={false}`, nested floating layers (Select, DatePicker, DropdownMenu, Tooltip, ...) no longer hide behind the container when an ancestor has a `z-index` above 50. ([#217](https://github.com/ndlabdev/sv5ui/issues/217)) +- **DatePicker**, **DateRangePicker** — the calendar is anchored to the field instead of the calendar icon, so it opens under the field's start edge. ([#218](https://github.com/ndlabdev/sv5ui/issues/218)) +- **NavigationMenu** — the dropdown opens under the trigger that opened it, slides between triggers and stays inside the menu. The 8px gap under the items is now reserved only while the list overflows. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) +- **Tabs** — the indicator follows the active tab when the text direction flips between `ltr` and `rtl`. ([#216](https://github.com/ndlabdev/sv5ui/issues/216)) + ## [2.7.0] - 2026-09-09 ### Added diff --git a/package.json b/package.json index ffa8f61..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", @@ -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/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) + }) + }) + }) }) 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/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/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 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/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/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/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/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, { 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) + }) + }) + }) }) 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/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 ac875e1..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' @@ -61,6 +62,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 +77,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/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()} + 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 *)); 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