Skip to content

Commit 11f0ec2

Browse files
authored
feat(theme-switcher): add new component to apply dark and light theme and switch between them easily (#1230)
Signed-off-by: David Edler <[email protected]>
1 parent 8b77d7f commit 11f0ec2

File tree

6 files changed

+174
-0
lines changed

6 files changed

+174
-0
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Meta, StoryObj } from "@storybook/react";
2+
3+
import ThemeSwitcher from "./ThemeSwitcher";
4+
5+
const meta: Meta<typeof ThemeSwitcher> = {
6+
component: ThemeSwitcher,
7+
tags: ["autodocs"],
8+
};
9+
10+
export default meta;
11+
12+
type Story = StoryObj<typeof ThemeSwitcher>;
13+
14+
export const Default: Story = {
15+
name: "Default",
16+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { render, screen } from "@testing-library/react";
2+
import React from "react";
3+
4+
import ThemeSwitcher from "./ThemeSwitcher";
5+
import userEvent from "@testing-library/user-event";
6+
7+
beforeEach(() => {
8+
jest.clearAllMocks();
9+
});
10+
11+
describe("ThemeSwitcher", () => {
12+
it("renders", () => {
13+
const { container } = render(<ThemeSwitcher />);
14+
expect(container.firstChild).toMatchSnapshot();
15+
});
16+
17+
it("applies theme on interaction", async () => {
18+
render(<ThemeSwitcher />);
19+
const localStorage = window.localStorage.__proto__;
20+
const localstorageSpy = jest.spyOn(localStorage, "setItem");
21+
const bodyClassAddSpy = jest.spyOn(document.body.classList, "add");
22+
const bodyClassRemoveSpy = jest.spyOn(document.body.classList, "remove");
23+
24+
// apply dark theme
25+
const darkBtn = screen.getByRole("button", { name: "dark" });
26+
await userEvent.click(darkBtn);
27+
expect(localstorageSpy).toHaveBeenCalledWith("theme", "dark");
28+
expect(bodyClassAddSpy).toHaveBeenCalledWith("is-dark");
29+
30+
// apply light theme
31+
const lightBtn = screen.getByRole("button", { name: "light" });
32+
await userEvent.click(lightBtn);
33+
expect(localstorageSpy).toHaveBeenCalledWith("theme", "light");
34+
expect(bodyClassRemoveSpy).toHaveBeenCalledWith("is-dark");
35+
});
36+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React from "react";
2+
import type { FC } from "react";
3+
import { useState } from "react";
4+
5+
const LOCAL_STORAGE_KEY = "theme";
6+
const THEME_SYSTEM = "system";
7+
const THEME_DARK = "dark";
8+
const THEME_LIGHT = "light";
9+
10+
export const loadTheme = (): string => {
11+
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
12+
return saved || THEME_SYSTEM;
13+
};
14+
15+
const saveTheme = (theme: string): void => {
16+
localStorage.setItem(LOCAL_STORAGE_KEY, theme);
17+
};
18+
19+
export const isDarkTheme = (theme: string) => {
20+
if (theme === THEME_SYSTEM) {
21+
return (
22+
window.matchMedia &&
23+
window.matchMedia("(prefers-color-scheme: dark)").matches
24+
);
25+
}
26+
return theme === THEME_DARK;
27+
};
28+
29+
export const applyTheme = (theme: string): void => {
30+
if (isDarkTheme(theme)) {
31+
document.body.classList.add("is-dark");
32+
} else {
33+
document.body.classList.remove("is-dark");
34+
}
35+
};
36+
37+
/**
38+
* This is a [React](https://reactjs.org/) component for the [Vanilla framework](https://docs.vanillaframework.io).
39+
*
40+
* The ThemeSwitcher component allows users to switch between different themes: dark, light, and system. It saves the selected theme in local storage and applies it to the document body. You can use it in user settings.
41+
*
42+
* In your root component, call the exported functions `loadTheme` and `applyTheme`, such as in the example below:
43+
* ```
44+
* useEffect(() => {
45+
* const theme = loadTheme();
46+
* applyTheme(theme);
47+
* }, []);
48+
* ```
49+
*/
50+
const ThemeSwitcher: FC = () => {
51+
const [activeTheme, setActiveTheme] = useState(loadTheme());
52+
53+
const themeButton = (theme: string) => {
54+
return (
55+
<button
56+
className="p-segmented-control__button"
57+
type="button"
58+
aria-selected={activeTheme === theme ? "true" : "false"}
59+
onClick={() => {
60+
saveTheme(theme);
61+
setActiveTheme(theme);
62+
applyTheme(theme);
63+
}}
64+
>
65+
{theme}
66+
</button>
67+
);
68+
};
69+
70+
return (
71+
<div className="p-segmented-control">
72+
<div className="p-segmented-control__list" aria-label="Theme switcher">
73+
{themeButton(THEME_DARK)}
74+
{themeButton(THEME_LIGHT)}
75+
{themeButton(THEME_SYSTEM)}
76+
</div>
77+
</div>
78+
);
79+
};
80+
81+
export default ThemeSwitcher;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`ThemeSwitcher renders 1`] = `
4+
<div
5+
class="p-segmented-control"
6+
>
7+
<div
8+
aria-label="Theme switcher"
9+
class="p-segmented-control__list"
10+
>
11+
<button
12+
aria-selected="false"
13+
class="p-segmented-control__button"
14+
type="button"
15+
>
16+
dark
17+
</button>
18+
<button
19+
aria-selected="false"
20+
class="p-segmented-control__button"
21+
type="button"
22+
>
23+
light
24+
</button>
25+
<button
26+
aria-selected="true"
27+
class="p-segmented-control__button"
28+
type="button"
29+
>
30+
system
31+
</button>
32+
</div>
33+
</div>
34+
`;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default, loadTheme, isDarkTheme, applyTheme } from "./ThemeSwitcher";

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ export { default as TableHeader } from "./components/TableHeader";
8383
export { default as TableRow } from "./components/TableRow";
8484
export { default as Tabs } from "./components/Tabs";
8585
export { default as Textarea } from "./components/Textarea";
86+
export {
87+
default as ThemeSwitcher,
88+
loadTheme,
89+
isDarkTheme,
90+
applyTheme,
91+
} from "./components/ThemeSwitcher";
8692
export {
8793
ToastNotification,
8894
ToastNotificationList,

0 commit comments

Comments
 (0)