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 *));