feat(hooks): use-theme hook (#3169)

* 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 <jrgarciadev@gmail.com>
This commit is contained in:
աӄա 2024-11-05 04:53:04 +08:00 committed by GitHub
parent 3f0d81b560
commit ad7e2615d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 379 additions and 26 deletions

View File

@ -0,0 +1,5 @@
---
"@nextui-org/use-theme": minor
---
introduce `use-theme` hook

View File

@ -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",

View File

@ -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 @nextui-org/use-theme
### Install use-dark-mode
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',
}}
/>
@ -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>
)
@ -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.

View File

@ -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).

View File

@ -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);
});
});

View File

@ -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 <wingkwong.code@gmail.com>",
"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"
]
}
}

View File

@ -0,0 +1,85 @@
import {useCallback, useEffect, useState} from "react";
// constant properties for Theme
export const ThemeProps = {
// localStorage key for storing the current theme
KEY: "nextui-theme",
// light theme
LIGHT: "light",
// dark theme
DARK: "dark",
// system theme
SYSTEM: "system",
} as const;
// type definition for Theme using system theme names or custom theme names
export type customTheme = string;
export type Theme =
| typeof ThemeProps.LIGHT
| typeof ThemeProps.DARK
| typeof ThemeProps.SYSTEM
| customTheme;
/**
* React hook to switch between themes
*
* @param defaultTheme the default theme name (e.g. light, dark, purple-dark and etc)
* @returns An object containing the current theme and theme manipulation functions
*/
export function useTheme(defaultTheme: Theme = ThemeProps.SYSTEM) {
const MEDIA = "(prefers-color-scheme: dark)";
const [theme, setThemeState] = useState<Theme>(() => {
const storedTheme = localStorage.getItem(ThemeProps.KEY) as Theme | null;
// return stored theme if it is selected previously
if (storedTheme) return storedTheme;
// if it is using system theme, check `prefers-color-scheme` value
// return light theme if not specified
if (defaultTheme === ThemeProps.SYSTEM) {
return window.matchMedia?.(MEDIA).matches ? ThemeProps.DARK : ThemeProps.LIGHT;
}
return defaultTheme;
});
const setTheme = useCallback(
(newTheme: Theme) => {
const targetTheme =
newTheme === ThemeProps.SYSTEM
? window.matchMedia?.(MEDIA).matches
? ThemeProps.DARK
: ThemeProps.LIGHT
: newTheme;
localStorage.setItem(ThemeProps.KEY, newTheme);
document.documentElement.classList.remove(theme);
document.documentElement.classList.add(targetTheme);
setThemeState(newTheme);
},
[theme],
);
const handleMediaQuery = useCallback(
(e: MediaQueryListEvent | MediaQueryList) => {
if (defaultTheme === ThemeProps.SYSTEM) {
setTheme(e.matches ? ThemeProps.DARK : ThemeProps.LIGHT);
}
},
[setTheme],
);
useEffect(() => setTheme(theme), [theme, setTheme]);
useEffect(() => {
const media = window.matchMedia(MEDIA);
media.addEventListener("change", handleMediaQuery);
return () => media.removeEventListener("change", handleMediaQuery);
}, [handleMediaQuery]);
return {theme, setTheme};
}

View File

@ -0,0 +1,4 @@
{
"extends": "../../../tsconfig.json",
"include": ["src", "index.ts"]
}

17
pnpm-lock.yaml generated
View File

@ -3663,6 +3663,15 @@ importers:
specifier: ^18.2.0
version: 18.3.1
packages/hooks/use-theme:
devDependencies:
clean-package:
specifier: 2.2.0
version: 2.2.0
react:
specifier: ^18.2.0
version: 18.3.1
packages/hooks/use-update-effect:
devDependencies:
clean-package:
@ -18199,15 +18208,13 @@ snapshots:
- '@parcel/core'
- '@swc/helpers'
'@parcel/cache@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))(@swc/helpers@0.5.13)':
'@parcel/cache@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))':
dependencies:
'@parcel/core': 2.12.0(@swc/helpers@0.5.13)
'@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))(@swc/helpers@0.5.13)
'@parcel/logger': 2.12.0
'@parcel/utils': 2.12.0
lmdb: 2.8.5
transitivePeerDependencies:
- '@swc/helpers'
'@parcel/codeframe@2.12.0':
dependencies:
@ -18268,7 +18275,7 @@ snapshots:
'@parcel/core@2.12.0(@swc/helpers@0.5.13)':
dependencies:
'@mischnic/json-sourcemap': 0.1.1
'@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))(@swc/helpers@0.5.13)
'@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))
'@parcel/diagnostic': 2.12.0
'@parcel/events': 2.12.0
'@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))(@swc/helpers@0.5.13)
@ -18715,7 +18722,7 @@ snapshots:
'@parcel/types@2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))(@swc/helpers@0.5.13)':
dependencies:
'@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))(@swc/helpers@0.5.13)
'@parcel/cache': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))
'@parcel/diagnostic': 2.12.0
'@parcel/fs': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))(@swc/helpers@0.5.13)
'@parcel/package-manager': 2.12.0(@parcel/core@2.12.0(@swc/helpers@0.5.13))(@swc/helpers@0.5.13)