diff --git a/src/docs/constants/docs.constants.ts b/src/docs/constants/docs.constants.ts index f5336d56b..6f3bd5b93 100644 --- a/src/docs/constants/docs.constants.ts +++ b/src/docs/constants/docs.constants.ts @@ -239,6 +239,14 @@ export const COMPONENT_ROUTES: ComponentRoute[] = [ "Displays content in a column on mobile devices and in a row on desktops.", }, + { + path: "/components/system-theme-listener", + title: "System Theme Listener", + + description: + "A component that listens to changes in the users system/OS theme..", + }, + { path: "/components/tag", title: "Tag", diff --git a/src/lib/components.ts b/src/lib/components.ts index 73da112ab..306abae50 100644 --- a/src/lib/components.ts +++ b/src/lib/components.ts @@ -42,6 +42,7 @@ export { default as Spinner } from "./components/Spinner.svelte"; export { default as SplitBlock } from "./components/SplitBlock.svelte"; export { default as SplitContent } from "./components/SplitContent.svelte"; export { default as SplitPane } from "./components/SplitPane.svelte"; +export { default as SystemThemeListener } from "./components/SystemThemeListener.svelte"; export { default as Tag } from "./components/Tag.svelte"; export { default as ThemeToggle } from "./components/ThemeToggle.svelte"; export { default as ThemeToggleButton } from "./components/ThemeToggleButton.svelte"; diff --git a/src/lib/components/SystemThemeListener.svelte b/src/lib/components/SystemThemeListener.svelte new file mode 100644 index 000000000..2a2339718 --- /dev/null +++ b/src/lib/components/SystemThemeListener.svelte @@ -0,0 +1,30 @@ + + + diff --git a/src/lib/utils/theme.utils.ts b/src/lib/utils/theme.utils.ts index be18fdc78..0ff8b0b23 100644 --- a/src/lib/utils/theme.utils.ts +++ b/src/lib/utils/theme.utils.ts @@ -1,5 +1,6 @@ import { Theme } from "$lib/types/theme"; import { isNode } from "$lib/utils/env.utils"; +import { notEmptyString } from "@dfinity/utils"; import { enumFromStringExists } from "./enum.utils"; export const THEME_ATTRIBUTE = "theme"; @@ -64,3 +65,6 @@ export const resetTheme = (theme: Theme) => { applyTheme({ theme, preserve: false }); }; + +export const isThemeSelected = (): boolean => + notEmptyString(localStorage.getItem(LOCALSTORAGE_THEME_KEY)); diff --git a/src/routes/(split)/components/system-theme-listener/+page.md b/src/routes/(split)/components/system-theme-listener/+page.md new file mode 100644 index 000000000..3b02c2853 --- /dev/null +++ b/src/routes/(split)/components/system-theme-listener/+page.md @@ -0,0 +1,33 @@ +# System Theme Listener + +A component that listens to changes in the users system/OS theme. + +# Usage + +```javascript + +``` + +> By default, the newly selected theme will be saved in themeStore (as long as a theme has not been explicitly selected / system setting is selected). +> +> You may also pass a custom event handler to extend/add to this behavior: + +```javascript +) => + console.log(`User selected ${e.detail.matches ? "dark" : "light"} mode`) + } +/> +``` + +## Slots + +| Slot name | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Default slot | Renders wrapped components

**Note:** Even though you can wrap other elements, the event listener that gets registered is global and not only applied to child elements, so it will always trigger as long as the SystemThemeListener component is mounted. | + +## Events + +| Event | Description | Detail | +| ---------------------- | --------------------------------------- | ---------------------------------- | +| `nnsSystemThemeChange` | Triggered when the system theme changes | `CustomEvent` | diff --git a/src/tests/lib/components/SystemThemeListener.spec.ts b/src/tests/lib/components/SystemThemeListener.spec.ts new file mode 100644 index 000000000..d3a223eb7 --- /dev/null +++ b/src/tests/lib/components/SystemThemeListener.spec.ts @@ -0,0 +1,142 @@ +import { Theme, themeStore } from "$lib"; +import SystemThemeListener from "$lib/components/SystemThemeListener.svelte"; +import { render } from "@testing-library/svelte"; +import { get } from "svelte/store"; +import { vi } from "vitest"; + +describe("SystemThemeListener", () => { + // Mock match media window events + const listeners: { + [key: string]: ((e: Partial) => void) | undefined; + } = {}; + + const mockMatchMedia = vi.fn((query) => ({ + matches: query === "(prefers-color-scheme: dark)", + media: query, + addEventListener: ( + name: string, + handler: (e: Partial) => void, + ) => { + listeners[name] = handler; + }, + removeEventListener: ( + name: string, + handler: (e: Partial) => void, + ) => { + if (listeners[name] === handler) listeners[name] = undefined; + }, + dispatchEvent: (event: Partial) => + listeners[event.type || ""]?.({ matches: true }), + })); + + vi.stubGlobal("matchMedia", mockMatchMedia); + + it("should set theme to dark if no theme selected", () => { + render(SystemThemeListener); + + // Init change event + const changeEvent = new Event("change", { + bubbles: true, + cancelable: true, + }); + + // Modify the event object to simulate the new theme state (e.g., matches = true for dark mode) + Object.defineProperty(changeEvent, "matches", { + value: true, // Simulate dark mode + }); + + const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQueryList.dispatchEvent(changeEvent); + + expect(get(themeStore)).toEqual(Theme.DARK); + }); + + it("should leave theme on light if light theme explicitly selected", () => { + render(SystemThemeListener); + + // Set theme to light initially + themeStore.select(Theme.LIGHT); + + // Init change event + const changeEvent = new Event("change", { + bubbles: true, + cancelable: true, + }); + + // Modify the event object to simulate the new theme state (e.g., matches = true for dark mode) + Object.defineProperty(changeEvent, "matches", { + value: true, // Simulate dark mode + }); + + const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQueryList.dispatchEvent(changeEvent); + + expect(get(themeStore)).toEqual(Theme.LIGHT); + }); + + it("should not change theme and run custom event handler", () => { + const initialTestValue = "unchanged"; + const expectedTestValue = "changed"; + let testValue = initialTestValue; + + const listenerRender = render(SystemThemeListener); + + listenerRender.component.$on("nnsSystemThemeChange", () => { + testValue = expectedTestValue; + }); + + // Set theme to light initially + themeStore.select(Theme.LIGHT); + + // Init change event + const changeEvent = new Event("change", { + bubbles: true, + cancelable: true, + }); + + // Modify the event object to simulate the new theme state (e.g., matches = true for dark mode) + Object.defineProperty(changeEvent, "matches", { + value: true, // Simulate dark mode + }); + + const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQueryList.dispatchEvent(changeEvent); + + expect(get(themeStore)).toEqual(Theme.LIGHT); + expect(testValue).toEqual(expectedTestValue); + }); + + it("should run custom event handler and receive media query event data", () => { + const initialTestValue = ""; + const expectedTestValue = "dark mode"; + let testValue = initialTestValue; + + const listenerRender = render(SystemThemeListener); + + listenerRender.component.$on( + "nnsSystemThemeChange", + (e: CustomEvent) => { + testValue = e.detail.matches ? "dark mode" : "light mode"; + }, + ); + + // Set theme to light initially + themeStore.select(Theme.LIGHT); + + // Init change event + const changeEvent = new Event("change", { + bubbles: true, + cancelable: true, + }); + + // Modify the event object to simulate the new theme state (e.g., matches = true for dark mode) + Object.defineProperty(changeEvent, "matches", { + value: true, // Simulate dark mode + }); + + const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQueryList.dispatchEvent(changeEvent); + + expect(testValue).toEqual(expectedTestValue); + }); +});