mirror of
https://github.com/nextui-org/nextui.git
synced 2025-12-08 19:26:11 +00:00
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:
parent
3f0d81b560
commit
ad7e2615d3
5
.changeset/light-needles-behave.md
Normal file
5
.changeset/light-needles-behave.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@nextui-org/use-theme": minor
|
||||
---
|
||||
|
||||
introduce `use-theme` hook
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
|
||||
55
packages/hooks/use-theme/README.md
Normal file
55
packages/hooks/use-theme/README.md
Normal 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).
|
||||
147
packages/hooks/use-theme/__tests__/use-theme.test.tsx
Normal file
147
packages/hooks/use-theme/__tests__/use-theme.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
52
packages/hooks/use-theme/package.json
Normal file
52
packages/hooks/use-theme/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
85
packages/hooks/use-theme/src/index.ts
Normal file
85
packages/hooks/use-theme/src/index.ts
Normal 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};
|
||||
}
|
||||
4
packages/hooks/use-theme/tsconfig.json
Normal file
4
packages/hooks/use-theme/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["src", "index.ts"]
|
||||
}
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user