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
8 changes: 8 additions & 0 deletions src/docs/constants/docs.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/lib/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
30 changes: 30 additions & 0 deletions src/lib/components/SystemThemeListener.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script lang="ts">
import { onDestroy, onMount, createEventDispatcher } from "svelte";
import { themeStore } from "$lib/stores/theme.store";
import { isThemeSelected } from "$lib/utils/theme.utils";

const dispatch = createEventDispatcher();

// Set up our MediaQueryList
const matchMediaDark = window.matchMedia("(prefers-color-scheme: dark)");

// Update the store if OS preference changes
const updateThemeOnChange = (e: MediaQueryListEvent) => {
dispatch("nnsSystemThemeChange", e);

// Only reset to system theme if no specific preference has been selected
if (isThemeSelected()) return;

themeStore.resetToSystemSettings();
};

// Register change event on mount
onMount(() => matchMediaDark.addEventListener("change", updateThemeOnChange));

// Clean up if this component is destroyed
onDestroy(() =>
matchMediaDark.removeEventListener("change", updateThemeOnChange),
);
</script>

<slot />
4 changes: 4 additions & 0 deletions src/lib/utils/theme.utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -64,3 +65,6 @@ export const resetTheme = (theme: Theme) => {

applyTheme({ theme, preserve: false });
};

export const isThemeSelected = (): boolean =>
notEmptyString(localStorage.getItem(LOCALSTORAGE_THEME_KEY));
33 changes: 33 additions & 0 deletions src/routes/(split)/components/system-theme-listener/+page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# System Theme Listener

A component that listens to changes in the users system/OS theme.

# Usage

```javascript
<SystemThemeListener />
```

> 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
<SystemThemeListener
on:nnsSystemThemeChange={(e: CustomEvent<MediaQueryListEvent>) =>
console.log(`User selected ${e.detail.matches ? "dark" : "light"} mode`)
}
/>
```

## Slots

| Slot name | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Default slot | Renders wrapped components<br/><br/>**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<MediaQueryListEvent>` |
142 changes: 142 additions & 0 deletions src/tests/lib/components/SystemThemeListener.spec.ts
Original file line number Diff line number Diff line change
@@ -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<MediaQueryListEvent>) => void) | undefined;
} = {};

const mockMatchMedia = vi.fn((query) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
addEventListener: (
name: string,
handler: (e: Partial<MediaQueryListEvent>) => void,
) => {
listeners[name] = handler;
},
removeEventListener: (
name: string,
handler: (e: Partial<MediaQueryListEvent>) => void,
) => {
if (listeners[name] === handler) listeners[name] = undefined;
},
dispatchEvent: (event: Partial<MediaQueryListEvent>) =>
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<MediaQueryListEvent>) => {
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);
});
});
Loading