Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
"@tiptap/starter-kit": "^3.0.0",
"@tiptap/suggestion": "^3.0.0",
"joi": "^17.0.0 || ^18.0.0",
"mode-watcher": "^1.0.0",
"svelte": "^5.0.0",
"tailwindcss": "^4.0.0",
"tiptap-markdown": "^0.9.0",
Expand Down Expand Up @@ -231,6 +230,7 @@
"embla-carousel-class-names": "^8.6.0",
"embla-carousel-fade": "^8.6.0",
"embla-carousel-svelte": "^8.6.0",
"mode-watcher": "^1.0.0",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
Expand Down
13 changes: 13 additions & 0 deletions src/lib/components/ThemeMode/ThemeMode.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts" module>
import type { ThemeModeProps } from './theme-mode.types.js'

export type Props = ThemeModeProps
</script>

<script lang="ts">
import { ModeWatcher } from 'mode-watcher'

let { ...restProps }: Props = $props()
</script>

<ModeWatcher {...restProps} />
2 changes: 2 additions & 0 deletions src/lib/components/ThemeMode/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as ThemeMode } from './ThemeMode.svelte'
export type { ThemeModeProps } from './theme-mode.types.js'
4 changes: 4 additions & 0 deletions src/lib/components/ThemeMode/theme-mode.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { ComponentProps } from 'svelte'
import type { ModeWatcher } from 'mode-watcher'

export type ThemeModeProps = ComponentProps<typeof ModeWatcher>
39 changes: 27 additions & 12 deletions src/lib/components/ThemeModeButton/ThemeModeButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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] })
}
})
</script>

{#if children}
<Button
{color}
{variant}
{size}
size={resolvedSize}
{loading}
{disabled}
{square}
{block}
class={baseClass}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
class={classes.base}
aria-label="Toggle theme"
onclick={toggleMode}
{...restProps}
>
Expand All @@ -60,15 +71,19 @@
<Button
{color}
{variant}
{size}
size={resolvedSize}
{loading}
{disabled}
{square}
{block}
icon={iconName}
class={baseClass}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
class={classes.base}
aria-label="Toggle theme"
onclick={toggleMode}
{...restProps}
/>
>
{#snippet leadingSlot()}
<Icon name={darkIcon} class={[classes.icon, classes.darkIcon]} />
<Icon name={lightIcon} class={[classes.icon, classes.lightIcon]} />
{/snippet}
</Button>
{/if}
87 changes: 82 additions & 5 deletions src/lib/components/ThemeModeButton/ThemeModeButton.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -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 ====================
Expand All @@ -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<void>) => {
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', () => {
Expand Down
14 changes: 12 additions & 2 deletions src/lib/components/ThemeModeButton/theme-mode-button.variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
})

Expand Down
13 changes: 12 additions & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
28 changes: 28 additions & 0 deletions src/lib/public-api.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
3 changes: 2 additions & 1 deletion src/lib/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 *));

Expand Down
Loading