Skip to content

Commit

Permalink
feat(hooks): use-theme hook (#3169)
Browse files Browse the repository at this point in the history
* feat(docs): update dark mode content

* feat(hooks): @nextui-org/use-theme

* chore(docs): revise ThemeSwitcher code

* refactor(hooks): simplify useTheme and support custom theme names

* feat(hooks): add use-theme test cases

* feat(changeset): add changeset

* refactor(hooks): make localStorageMock globally and clear before each test

* fix(docs): typo

* fix(hooks): coderabbitai comments

* chore(hooks): remove unnecessary +

* chore(changeset): change to minor

* feat(hooks): handle system theme

* chore(hooks): add EOL

* refactor(hooks): add default theme

* refactor(hooks): revise useTheme

* refactor(hooks): resolve pr comments

* refactor(hooks): resolve pr comments

* refactor(hooks): resolve pr comments

* refactor(hooks): remove unused theme in dependency array

* chore(docs): typos

* refactor(hooks): mark system as key for system theme

* chore: merged with canary

---------

Co-authored-by: Junior Garcia <[email protected]>
  • Loading branch information
wingkwong and jrgarciadev authored Nov 4, 2024
1 parent 3f0d81b commit ad7e261
Show file tree
Hide file tree
Showing 9 changed files with 379 additions and 26 deletions.
5 changes: 5 additions & 0 deletions .changeset/light-needles-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nextui-org/use-theme": minor
---

introduce `use-theme` hook
3 changes: 2 additions & 1 deletion apps/docs/config/routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@
{
"key": "dark-mode",
"title": "Dark mode",
"path": "/docs/customization/dark-mode.mdx"
"path": "/docs/customization/dark-mode.mdx",
"updated": true
},
{
"key": "override-styles",
Expand Down
37 changes: 17 additions & 20 deletions apps/docs/content/docs/customization/dark-mode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -191,24 +191,22 @@ export const ThemeSwitcher = () => {
</Steps>


## Using use-dark-mode hook
## Using use-theme hook

In case you're using plain React with [Vite](/docs/frameworks/vite) or [Create React App](https://create-react-app.dev/)
you can use the [use-dark-mode](https://github.com/donavon/use-dark-mode) hook to switch between themes.
you can use the [@nextui-org/use-theme](https://github.com/nextui-org/nextui/tree/canary/packages/hooks/use-theme) hook to switch between themes.

> See the [use-dark-mode](https://github.com/donavon/use-dark-mode) documentation for more details.
<Steps>

<Steps>

### Install use-dark-mode
### Install @nextui-org/use-theme

Install `use-dark-mode` in your project.
Install `@nextui-org/use-theme` in your project.

<PackageManagers
commands={{
npm: 'npm install use-dark-mode',
yarn: 'yarn add use-dark-mode',
pnpm: 'pnpm add use-dark-mode',
npm: 'npm install @nextui-org/use-theme',
yarn: 'yarn add @nextui-org/use-theme',
pnpm: 'pnpm add @nextui-org/use-theme',
}}
/>

Expand All @@ -217,13 +215,13 @@ Install `use-dark-mode` in your project.
```jsx
// App.tsx or App.jsx
import React from "react";
import useDarkMode from "use-dark-mode";
import {useTheme} from "@nextui-org/use-theme";

export default function App() {
const darkMode = useDarkMode(false);
const {theme} = useTheme();

return (
<main className={`${darkMode.value ? 'dark' : ''} text-foreground bg-background`}>
<main className={`${theme} text-foreground bg-background`}>
<App />
</main>
)
Expand All @@ -238,23 +236,22 @@ Add the theme switcher to your app.
// 'use client'; // uncomment this line if you're using Next.js App Directory Setup

// components/ThemeSwitcher.tsx
import useDarkMode from "use-dark-mode";
import {useTheme} from "@nextui-org/use-theme";

export const ThemeSwitcher = () => {
const darkMode = useDarkMode(false);
const { theme, setTheme } = useTheme()

return (
<div>
<button onClick={darkMode.disable}>Light Mode</button>
<button onClick={darkMode.enable}>Dark Mode</button>
The current theme is: {theme}
<button onClick={() => setTheme('light')}>Light Mode</button>
<button onClick={() => setTheme('dark')}>Dark Mode</button>
</div>
)
};
```



> **Note**: You can use any theme name you want, but make sure it exits in your
> **Note**: You can use any theme name you want, but make sure it exists in your
`tailwind.config.js` file. See [Create Theme](/docs/customization/create-theme) for more details.


Expand Down
55 changes: 55 additions & 0 deletions packages/hooks/use-theme/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# @nextui-org/use-theme

React hook to switch between light and dark themes

## Installation

```sh
yarn add @nextui-org/use-theme
# or
npm i @nextui-org/use-theme
```

## Usage

Import `useTheme`

```tsx
import {useTheme} from "@nextui-org/use-theme";
```

### theme

```tsx
// `theme` is the active theme name
// by default, it will use the one in localStorage.
// if it is no such value in localStorage, `light` theme will be used
const {theme} = useTheme();
```

### setTheme

You can use any theme name you want, but make sure it exists in your
`tailwind.config.js` file. See [Create Theme](https://nextui.org/docs/customization/create-theme) for more details.

```tsx
// set `theme` by using `setTheme`
const {setTheme} = useTheme();
// setting to light theme
setTheme('light')
// setting to dark theme
setTheme('dark')
// setting to purple-dark theme
setTheme('purple-dark')
```

## Contribution

Yes please! See the
[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md)
for details.

## License

This project is licensed under the terms of the
[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE).
147 changes: 147 additions & 0 deletions packages/hooks/use-theme/__tests__/use-theme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as React from "react";
import {render, act} from "@testing-library/react";

import {useTheme, ThemeProps, Theme} from "../src";

const TestComponent = ({defaultTheme}: {defaultTheme?: Theme}) => {
const {theme, setTheme} = useTheme(defaultTheme);

return (
<div>
<span data-testid="theme-display">{theme}</span>
<button type="button" onClick={() => setTheme(ThemeProps.DARK)}>
Set Dark
</button>
<button type="button" onClick={() => setTheme(ThemeProps.LIGHT)}>
Set Light
</button>
<button type="button" onClick={() => setTheme(ThemeProps.SYSTEM)}>
Set System
</button>
</div>
);
};

TestComponent.displayName = "TestComponent";

const localStorageMock = (() => {
let store: {[key: string]: string} = {};

return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
clear: () => {
store = {};
},
};
})();

Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});

describe("useTheme hook", () => {
beforeEach(() => {
jest.clearAllMocks();

localStorage.clear();

document.documentElement.className = "";
});

it("should initialize with default theme if no theme is stored in localStorage", () => {
const {getByTestId} = render(<TestComponent />);

expect(getByTestId("theme-display").textContent).toBe(ThemeProps.LIGHT);
expect(document.documentElement.classList.contains(ThemeProps.LIGHT)).toBe(true);
});

it("should initialize with the given theme if no theme is stored in localStorage", () => {
const customTheme = "purple-dark";
const {getByTestId} = render(<TestComponent defaultTheme={customTheme} />);

expect(getByTestId("theme-display").textContent).toBe(customTheme);
expect(document.documentElement.classList.contains(customTheme)).toBe(true);
});

it("should initialize with stored theme from localStorage", () => {
localStorage.setItem(ThemeProps.KEY, ThemeProps.DARK);

const {getByTestId} = render(<TestComponent />);

expect(localStorage.getItem(ThemeProps.KEY)).toBe(ThemeProps.DARK);

expect(getByTestId("theme-display").textContent).toBe(ThemeProps.DARK);
expect(document.documentElement.classList.contains(ThemeProps.DARK)).toBe(true);
});

it("should set new theme correctly and update localStorage and DOM (dark)", () => {
const {getByText, getByTestId} = render(<TestComponent />);

act(() => {
getByText("Set Dark").click();
});
expect(getByTestId("theme-display").textContent).toBe(ThemeProps.DARK);
expect(localStorage.getItem(ThemeProps.KEY)).toBe(ThemeProps.DARK);
expect(document.documentElement.classList.contains(ThemeProps.DARK)).toBe(true);
});

it("should set new theme correctly and update localStorage and DOM (light)", () => {
const {getByText, getByTestId} = render(<TestComponent />);

act(() => {
getByText("Set Light").click();
});
expect(getByTestId("theme-display").textContent).toBe(ThemeProps.LIGHT);
expect(localStorage.getItem(ThemeProps.KEY)).toBe(ThemeProps.LIGHT);
expect(document.documentElement.classList.contains(ThemeProps.LIGHT)).toBe(true);
});

it("should set new theme correctly and update localStorage and DOM (system - prefers-color-scheme: light)", () => {
const {getByText, getByTestId} = render(<TestComponent />);

Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

act(() => {
getByText("Set System").click();
});
expect(getByTestId("theme-display").textContent).toBe(ThemeProps.SYSTEM);
expect(localStorage.getItem(ThemeProps.KEY)).toBe(ThemeProps.SYSTEM);
expect(document.documentElement.classList.contains(ThemeProps.LIGHT)).toBe(true);
});

it("should set new theme correctly and update localStorage and DOM (system - prefers-color-scheme: dark)", () => {
const {getByText, getByTestId} = render(<TestComponent />);

Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

act(() => {
getByText("Set System").click();
});
expect(getByTestId("theme-display").textContent).toBe(ThemeProps.SYSTEM);
expect(localStorage.getItem(ThemeProps.KEY)).toBe(ThemeProps.SYSTEM);
expect(document.documentElement.classList.contains(ThemeProps.DARK)).toBe(true);
});
});
52 changes: 52 additions & 0 deletions packages/hooks/use-theme/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@nextui-org/use-theme",
"version": "2.0.0",
"description": "React hook to switch between light and dark themes",
"keywords": [
"use-theme"
],
"author": "WK Wong <[email protected]>",
"homepage": "https://nextui.org",
"license": "MIT",
"main": "src/index.ts",
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nextui-org/nextui.git",
"directory": "packages/hooks/use-theme"
},
"bugs": {
"url": "https://github.com/nextui-org/nextui/issues"
},
"scripts": {
"build": "tsup src --dts",
"build:fast": "tsup src",
"dev": "pnpm build:fast --watch",
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"prepack": "clean-package",
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"clean-package": "2.2.0",
"react": "^18.0.0"
},
"clean-package": "../../../clean-package.config.json",
"tsup": {
"clean": true,
"target": "es2019",
"format": [
"cjs",
"esm"
]
}
}
Loading

0 comments on commit ad7e261

Please sign in to comment.