diff --git a/.changeset/light-needles-behave.md b/.changeset/light-needles-behave.md new file mode 100644 index 0000000000..9ebdcb81f4 --- /dev/null +++ b/.changeset/light-needles-behave.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/use-theme": minor +--- + +introduce `use-theme` hook diff --git a/apps/docs/config/routes.json b/apps/docs/config/routes.json index d62238ce3f..1d5500f469 100644 --- a/apps/docs/config/routes.json +++ b/apps/docs/config/routes.json @@ -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", diff --git a/apps/docs/content/docs/customization/dark-mode.mdx b/apps/docs/content/docs/customization/dark-mode.mdx index 1feeac9806..0601d1f1bb 100644 --- a/apps/docs/content/docs/customization/dark-mode.mdx +++ b/apps/docs/content/docs/customization/dark-mode.mdx @@ -191,24 +191,22 @@ export const ThemeSwitcher = () => { -## 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. + - - -### Install use-dark-mode +### Install @nextui-org/use-theme -Install `use-dark-mode` in your project. +Install `@nextui-org/use-theme` in your project. @@ -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 ( -
+
) @@ -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 (
- - + The current theme is: {theme} + +
) }; ``` - - -> **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. diff --git a/packages/hooks/use-theme/README.md b/packages/hooks/use-theme/README.md new file mode 100644 index 0000000000..4f6fdc709a --- /dev/null +++ b/packages/hooks/use-theme/README.md @@ -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). diff --git a/packages/hooks/use-theme/__tests__/use-theme.test.tsx b/packages/hooks/use-theme/__tests__/use-theme.test.tsx new file mode 100644 index 0000000000..67f0155d0c --- /dev/null +++ b/packages/hooks/use-theme/__tests__/use-theme.test.tsx @@ -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 ( +
+ {theme} + + + +
+ ); +}; + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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); + }); +}); diff --git a/packages/hooks/use-theme/package.json b/packages/hooks/use-theme/package.json new file mode 100644 index 0000000000..1294c4ee8e --- /dev/null +++ b/packages/hooks/use-theme/package.json @@ -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 ", + "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" + ] + } +} diff --git a/packages/hooks/use-theme/src/index.ts b/packages/hooks/use-theme/src/index.ts new file mode 100644 index 0000000000..00dcd1a4ca --- /dev/null +++ b/packages/hooks/use-theme/src/index.ts @@ -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(() => { + 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}; +} diff --git a/packages/hooks/use-theme/tsconfig.json b/packages/hooks/use-theme/tsconfig.json new file mode 100644 index 0000000000..46e3b466c2 --- /dev/null +++ b/packages/hooks/use-theme/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "index.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13e189b807..1a57149cc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)