From ac543306b71b5fca9fd014f69e378451064ad13a Mon Sep 17 00:00:00 2001 From: Tim Kolberger Date: Sat, 26 Feb 2022 17:03:24 +0100 Subject: [PATCH] feat(semantic-tokens): add support for DarkMode/LightMode components --- .changeset/brown-moose-relate.md | 6 ++ .changeset/mighty-monkeys-wash.md | 6 ++ .changeset/twenty-cooks-visit.md | 6 +- .changeset/wild-shirts-smell.md | 26 +++++ .storybook/preview.tsx | 15 ++- .../color-mode/src/color-mode-provider.tsx | 44 +------- .../test/color-mode-provider.test.tsx | 2 +- .../test/color-mode-provider__server.test.tsx | 2 +- packages/color-mode/test/dark-mode.test.tsx | 77 -------------- .../test/use-color-mode-value.test.tsx | 6 +- packages/color-mode/test/utils.tsx | 28 ++--- packages/styled-system/src/pseudos.ts | 10 +- packages/styled-system/tests/css-var.test.ts | 2 +- packages/system/src/color-mode-components.tsx | 100 ++++++++++++++++++ packages/system/src/index.ts | 1 + packages/system/src/providers.tsx | 17 ++- packages/system/stories/system.stories.tsx | 21 ++++ .../tests/color-mode-component.utils.tsx | 30 ++++++ packages/system/tests/dark-mode.test.tsx | 84 +++++++++++++++ .../test => system/tests}/light-mode.test.tsx | 19 ++-- 20 files changed, 333 insertions(+), 169 deletions(-) create mode 100644 .changeset/brown-moose-relate.md create mode 100644 .changeset/mighty-monkeys-wash.md create mode 100644 .changeset/wild-shirts-smell.md delete mode 100644 packages/color-mode/test/dark-mode.test.tsx create mode 100644 packages/system/src/color-mode-components.tsx create mode 100644 packages/system/tests/color-mode-component.utils.tsx create mode 100644 packages/system/tests/dark-mode.test.tsx rename packages/{color-mode/test => system/tests}/light-mode.test.tsx (72%) diff --git a/.changeset/brown-moose-relate.md b/.changeset/brown-moose-relate.md new file mode 100644 index 00000000000..3fed02f8048 --- /dev/null +++ b/.changeset/brown-moose-relate.md @@ -0,0 +1,6 @@ +--- +"@chakra-ui/color-mode": major +--- + +Moved components `LightMode` and `DarkMode` to package `@chakra-ui/system` to +prevent circular dependencies. diff --git a/.changeset/mighty-monkeys-wash.md b/.changeset/mighty-monkeys-wash.md new file mode 100644 index 00000000000..8b98e6cae05 --- /dev/null +++ b/.changeset/mighty-monkeys-wash.md @@ -0,0 +1,6 @@ +--- +"@chakra-ui/styled-system": minor +--- + +Updated `_dark` and `_light` pseudo selectors to allow semantic tokens to change +with the `DarkMode` and `LightMode` component. diff --git a/.changeset/twenty-cooks-visit.md b/.changeset/twenty-cooks-visit.md index c8095ae0da3..3280fa5fddb 100644 --- a/.changeset/twenty-cooks-visit.md +++ b/.changeset/twenty-cooks-visit.md @@ -2,6 +2,6 @@ "@chakra-ui/system": minor --- -Added `[data-css-vars-root=true]` to the CSS variables root selector. This -allows to layer the CSS variable definitions and allow the semantic tokens to -react to `data-theme="dark"` and `data-theme="light"`. +Added `[data-theme]` to the CSS variables root selector. This allows the +semantic tokens to change according to `data-theme="dark"` and +`data-theme="light"` DOM element attributes. diff --git a/.changeset/wild-shirts-smell.md b/.changeset/wild-shirts-smell.md new file mode 100644 index 00000000000..54170e16200 --- /dev/null +++ b/.changeset/wild-shirts-smell.md @@ -0,0 +1,26 @@ +--- +"@chakra-ui/color-mode": major +--- + +The `LightMode` and `DarkMode` components are now able to toggle semantic tokens +as well. + +For backward compatibility reasons this needs to be enabled by passing the +boolean prop `withSemanticTokens`. + +```tsx live=false + + + This uses always the _dark value of your semantic token + + +``` + +Please note that by adding the prop `withSemanticTokens` the ColorMode +components will render a DOM element which accepts style props as usual. + +```tsx + + ... + +``` diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 404d424a85a..9ac9cfabcb0 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -56,7 +56,20 @@ const withChakra = (StoryFn: Function, context: StoryContext) => { }, [dir]) return ( - +
diff --git a/packages/color-mode/src/color-mode-provider.tsx b/packages/color-mode/src/color-mode-provider.tsx index ab7d7e7e04d..e091f158eb5 100644 --- a/packages/color-mode/src/color-mode-provider.tsx +++ b/packages/color-mode/src/color-mode-provider.tsx @@ -1,5 +1,5 @@ import { useEnvironment } from "@chakra-ui/react-env" -import { isBrowser, noop, __DEV__ } from "@chakra-ui/utils" +import { __DEV__, isBrowser, noop } from "@chakra-ui/utils" import * as React from "react" import { addListener, @@ -18,7 +18,7 @@ export interface ColorModeOptions { useSystemColorMode?: boolean } -interface ColorModeContextType { +export interface ColorModeContextType { colorMode: ColorMode toggleColorMode: () => void setColorMode: (value: any) => void @@ -173,46 +173,6 @@ if (__DEV__) { ColorModeProvider.displayName = "ColorModeProvider" } -/** - * Locks the color mode to `dark`, without any way to change it. - */ -export const DarkMode: React.FC = (props) => { - const context = React.useMemo( - () => ({ - colorMode: "dark", - toggleColorMode: noop, - setColorMode: noop, - }), - [], - ) - - return -} - -if (__DEV__) { - DarkMode.displayName = "DarkMode" -} - -/** - * Locks the color mode to `light` without any way to change it. - */ -export const LightMode: React.FC = (props) => { - const context = React.useMemo( - () => ({ - colorMode: "light", - toggleColorMode: noop, - setColorMode: noop, - }), - [], - ) - - return -} - -if (__DEV__) { - LightMode.displayName = "LightMode" -} - /** * Change value based on color mode. * diff --git a/packages/color-mode/test/color-mode-provider.test.tsx b/packages/color-mode/test/color-mode-provider.test.tsx index 55e5950b465..b28b67356f0 100644 --- a/packages/color-mode/test/color-mode-provider.test.tsx +++ b/packages/color-mode/test/color-mode-provider.test.tsx @@ -1,7 +1,7 @@ import { render } from "@testing-library/react" import userEvent from "@testing-library/user-event" import React from "react" -import { ColorModeProvider } from "../src/color-mode-provider" +import { ColorModeProvider } from "../src" import * as colorModeUtils from "../src/color-mode.utils" import { defaultThemeOptions, diff --git a/packages/color-mode/test/color-mode-provider__server.test.tsx b/packages/color-mode/test/color-mode-provider__server.test.tsx index 983ce910571..0a45071af13 100644 --- a/packages/color-mode/test/color-mode-provider__server.test.tsx +++ b/packages/color-mode/test/color-mode-provider__server.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable global-require */ -import React from "react" +import * as React from "react" import { render } from "@testing-library/react" import { createMockStorageManager, diff --git a/packages/color-mode/test/dark-mode.test.tsx b/packages/color-mode/test/dark-mode.test.tsx deleted file mode 100644 index c99aa3d048e..00000000000 --- a/packages/color-mode/test/dark-mode.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { render } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import React from "react" -import { DarkMode } from "../src/color-mode-provider" -import { - DummyComponent, - getColorModeButton, - MemoizedComponent, - RegularComponent, - resetCounter, -} from "./utils" - -const MemoTest = () => { - const [_, setRenderCount] = React.useState(0) - - return ( - <> - - - - - - ) -} - -const NoMemoTest = () => { - const [_, setRenderCount] = React.useState(0) - - return ( - <> - - - - - - ) -} - -describe("", () => { - beforeEach(() => { - resetCounter() - }) - - test("is always dark", () => { - render( - - - , - ) - - const button = getColorModeButton() - - expect(button).toHaveTextContent("dark") - - userEvent.click(button) - - expect(getColorModeButton()).toHaveTextContent("dark") - }) - - test("memoized component renders once", () => { - const { getByText, getByTestId } = render() - - userEvent.click(getByText("Rerender")) - userEvent.click(getByText("Rerender")) - - expect(getByTestId("rendered")).toHaveTextContent("1") - }) - - test("non memoized component renders multiple", () => { - const { getByText, getByTestId } = render() - - userEvent.click(getByText("Rerender")) - userEvent.click(getByText("Rerender")) - - expect(getByTestId("rendered")).toHaveTextContent("3") - }) -}) diff --git a/packages/color-mode/test/use-color-mode-value.test.tsx b/packages/color-mode/test/use-color-mode-value.test.tsx index 20afdfc2553..740c6a108a3 100644 --- a/packages/color-mode/test/use-color-mode-value.test.tsx +++ b/packages/color-mode/test/use-color-mode-value.test.tsx @@ -1,11 +1,7 @@ import React from "react" import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import { - ColorModeProvider, - useColorModeValue, - useColorMode, -} from "../src/color-mode-provider" +import { ColorModeProvider, useColorModeValue, useColorMode } from "../src" import { defaultThemeOptions } from "./utils" const lightValue = "light-value" diff --git a/packages/color-mode/test/utils.tsx b/packages/color-mode/test/utils.tsx index 3b97684bfe6..8b93af273cb 100644 --- a/packages/color-mode/test/utils.tsx +++ b/packages/color-mode/test/utils.tsx @@ -1,12 +1,14 @@ +import * as React from "react" import theme from "@chakra-ui/theme" import { screen } from "@testing-library/react" -import React from "react" -import { ColorModeOptions } from "../src/color-mode-provider" -import type { ColorMode } from "../src/color-mode.utils" -import type { StorageManager } from "../src/storage-manager" +import { + useColorMode, + ColorModeOptions, + ColorMode, + StorageManager, +} from "../src" export const DummyComponent = () => { - const { useColorMode } = require("../src/color-mode-provider") const { colorMode, toggleColorMode } = useColorMode() return ( @@ -16,22 +18,6 @@ export const DummyComponent = () => { ) } -var renderCount = 0 - -export const resetCounter = () => { - renderCount = 0 -} - -export const MemoizedComponent = React.memo(() => { - renderCount = renderCount + 1 - return
{renderCount}
-}) - -export const RegularComponent = () => { - renderCount = renderCount + 1 - return
{renderCount}
-} - export const getColorModeButton = () => screen.getByRole("button") export const defaultThemeOptions = theme.config as Required diff --git a/packages/styled-system/src/pseudos.ts b/packages/styled-system/src/pseudos.ts index 15277d23432..688f35bab15 100644 --- a/packages/styled-system/src/pseudos.ts +++ b/packages/styled-system/src/pseudos.ts @@ -298,12 +298,18 @@ export const pseudoSelectors = { * Styles for when `data-theme` is applied to any parent of * this component or element. */ - _dark: ".chakra-ui-dark &, [data-theme=dark] &, &[data-theme=dark]", + _dark: + ".chakra-ui-dark &:not([data-theme])," + + "[data-theme=dark] &:not([data-theme])," + + "&[data-theme=dark]", /** * Styles for when `data-theme` is applied to any parent of * this component or element. */ - _light: ".chakra-ui-light &, [data-theme=light] &, &[data-theme=light]", + _light: + ".chakra-ui-light &:not([data-theme])," + + "[data-theme=light] &:not([data-theme])," + + "&[data-theme=light]", } export type Pseudos = typeof pseudoSelectors diff --git a/packages/styled-system/tests/css-var.test.ts b/packages/styled-system/tests/css-var.test.ts index 15162868967..7a16f0f5271 100644 --- a/packages/styled-system/tests/css-var.test.ts +++ b/packages/styled-system/tests/css-var.test.ts @@ -482,7 +482,7 @@ test("should convert semantic tokens", () => { "--colors-red-800": "#ff0080", "--colors-secondary": "var(--colors-red-800)", "--colors-success": "var(--colors-red-100)", - ".chakra-ui-dark &, [data-theme=dark] &, &[data-theme=dark]": Object { + ".chakra-ui-dark &:not([data-theme]),[data-theme=dark] &:not([data-theme]),&[data-theme=dark]": Object { "--colors-primary": "var(--colors-red-400)", "--colors-secondary": "var(--colors-red-700)", }, diff --git a/packages/system/src/color-mode-components.tsx b/packages/system/src/color-mode-components.tsx new file mode 100644 index 00000000000..0a4026dcbd3 --- /dev/null +++ b/packages/system/src/color-mode-components.tsx @@ -0,0 +1,100 @@ +import * as React from "react" +import { __DEV__, noop } from "@chakra-ui/utils" +import { ColorModeContext, ColorModeContextType } from "@chakra-ui/color-mode" +import { HTMLChakraProps } from "./system" +import { chakra } from "./factory" + +type WithDomElement = + | { + withSemanticTokens?: false + children?: React.ReactNode | undefined + } + | ({ + /** + * The DarkMode and LightMode components render a DOM element + * when `withSemanticTokens` is set to `true`. + * This forces the semantic tokens to use the desired color mode as well. + * + * This is an optional prop for backwards compatibility reasons and + * will be the default in an upcoming major release. + */ + withSemanticTokens: true + } & HTMLChakraProps<"div">) + +export type DarkModeProps = WithDomElement + +/** + * Locks the color mode to `dark`, without any way to change it. + */ +export const DarkMode = ({ + withSemanticTokens, + children, + ...restProps +}: DarkModeProps) => { + const colorMode = "dark" + const context = React.useMemo( + () => ({ + colorMode, + toggleColorMode: noop, + setColorMode: noop, + }), + [], + ) + + const maybeDomElement = withSemanticTokens ? ( + + {children} + + ) : ( + children + ) + + return ( + + {maybeDomElement} + + ) +} + +if (__DEV__) { + DarkMode.displayName = "DarkMode" +} + +export type LightModeProps = WithDomElement + +/** + * Locks the color mode to `light` without any way to change it. + */ +export const LightMode: React.FC = ({ + withSemanticTokens, + children, + ...restProps +}) => { + const colorMode = "light" + const context = React.useMemo( + () => ({ + colorMode, + toggleColorMode: noop, + setColorMode: noop, + }), + [], + ) + + const maybeDomElement = withSemanticTokens ? ( + + {children} + + ) : ( + children + ) + + return ( + + {maybeDomElement} + + ) +} + +if (__DEV__) { + LightMode.displayName = "LightMode" +} diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index 73ef44fc50f..9c17ab9153a 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -1,4 +1,5 @@ export * from "@chakra-ui/color-mode" +export * from "./color-mode-components" export * from "@chakra-ui/styled-system" export { keyframes } from "@emotion/react" export type { Interpolation } from "@emotion/react" diff --git a/packages/system/src/providers.tsx b/packages/system/src/providers.tsx index 5fe06fd9815..47c229eeb8f 100644 --- a/packages/system/src/providers.tsx +++ b/packages/system/src/providers.tsx @@ -34,19 +34,18 @@ export const ThemeProvider = (props: ThemeProviderProps) => { export interface CSSVarsProps { /** * The element to attach the CSS custom properties to. - * Re-hoist CSS vars by attaching `data-css-vars-root={true}` to a DOM element. - * @example
- * - * @default ":host, :root, [data-css-vars-root=true]" + * @default ":host, :root" */ root?: string } -export const CSSVars = ({ - root = ":host, :root, [data-css-vars-root=true]", -}: CSSVarsProps) => ( - ({ [root]: theme.__cssVars })} /> -) +export const CSSVars = ({ root = ":host, :root" }: CSSVarsProps) => { + /** + * Append color mode selector to allow semantic tokens to change according to the color mode + */ + const selector = [root, `[data-theme]`].join(",") + return ({ [selector]: theme.__cssVars })} /> +} export function useTheme() { const theme = React.useContext( diff --git a/packages/system/stories/system.stories.tsx b/packages/system/stories/system.stories.tsx index fa1dd181634..c304ba483c6 100644 --- a/packages/system/stories/system.stories.tsx +++ b/packages/system/stories/system.stories.tsx @@ -4,6 +4,8 @@ import { Text } from "@chakra-ui/layout" import { motion } from "framer-motion" import { chakra, + LightMode, + DarkMode, PropsOf, ThemeProvider, ThemingProps, @@ -195,3 +197,22 @@ export const WithCSSVarToken = () => { ) } + +export const WithSemanticToken = () => { + return ( +
+ I am in the default color mode + + I am forced to light mode + + + I am forced to dark mode + + + I am nested and forced to light mode + + + +
+ ) +} diff --git a/packages/system/tests/color-mode-component.utils.tsx b/packages/system/tests/color-mode-component.utils.tsx new file mode 100644 index 00000000000..9d69629d515 --- /dev/null +++ b/packages/system/tests/color-mode-component.utils.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import { useColorMode } from "@chakra-ui/color-mode" +import { screen } from "@testing-library/react" + +let renderCount = 0 +export const resetCounter = () => { + renderCount = 0 +} + +export const MemoizedComponent = React.memo(() => { + renderCount = renderCount + 1 + return
{renderCount}
+}) + +export const RegularComponent = () => { + renderCount = renderCount + 1 + return
{renderCount}
+} + +export const DummyComponent = () => { + const { colorMode, toggleColorMode } = useColorMode() + + return ( + + ) +} + +export const getColorModeButton = () => screen.getByRole("button") diff --git a/packages/system/tests/dark-mode.test.tsx b/packages/system/tests/dark-mode.test.tsx new file mode 100644 index 00000000000..48b1a603807 --- /dev/null +++ b/packages/system/tests/dark-mode.test.tsx @@ -0,0 +1,84 @@ +import * as React from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { DarkMode } from "../src" +import { + DummyComponent, + getColorModeButton, + MemoizedComponent, + RegularComponent, + resetCounter, +} from "./color-mode-component.utils" + +describe("", () => { + const MemoTest = () => { + const [, setRenderCount] = React.useState(0) + + return ( + <> + + + + + + ) + } + + const NoMemoTest = () => { + const [, setRenderCount] = React.useState(0) + + return ( + <> + + + + + + ) + } + + beforeEach(() => { + resetCounter() + }) + + test("is always dark", () => { + render( + + + , + ) + + const button = screen.getByRole("button") + + expect(button).toHaveTextContent("dark") + + userEvent.click(button) + + expect(getColorModeButton()).toHaveTextContent("dark") + }) + + test("renders a DOM element with data-theme attribute when withSemanticTokens prop is set", async () => { + const label = "Test Component" + render({label}) + const element = await screen.findByText(label) + expect(element).toHaveAttribute("data-theme", "dark") + }) + + test("memoized component renders once", () => { + const { getByText, getByTestId } = render() + + userEvent.click(getByText("Rerender")) + userEvent.click(getByText("Rerender")) + + expect(getByTestId("rendered")).toHaveTextContent("1") + }) + + test("non memoized component renders multiple", () => { + const { getByText, getByTestId } = render() + + userEvent.click(getByText("Rerender")) + userEvent.click(getByText("Rerender")) + + expect(getByTestId("rendered")).toHaveTextContent("3") + }) +}) diff --git a/packages/color-mode/test/light-mode.test.tsx b/packages/system/tests/light-mode.test.tsx similarity index 72% rename from packages/color-mode/test/light-mode.test.tsx rename to packages/system/tests/light-mode.test.tsx index c98c240ceee..54910cc1024 100644 --- a/packages/color-mode/test/light-mode.test.tsx +++ b/packages/system/tests/light-mode.test.tsx @@ -1,17 +1,17 @@ -import { render } from "@testing-library/react" +import * as React from "react" +import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import React from "react" -import { LightMode } from "../src/color-mode-provider" +import { LightMode } from "../src" import { DummyComponent, getColorModeButton, MemoizedComponent, RegularComponent, resetCounter, -} from "./utils" +} from "./color-mode-component.utils" const MemoTest = () => { - const [_, setRenderCount] = React.useState(0) + const [, setRenderCount] = React.useState(0) return ( <> @@ -24,7 +24,7 @@ const MemoTest = () => { } const NoMemoTest = () => { - const [_, setRenderCount] = React.useState(0) + const [, setRenderCount] = React.useState(0) return ( <> @@ -57,6 +57,13 @@ describe("", () => { expect(getColorModeButton()).toHaveTextContent("light") }) + test("renders a DOM element with data-theme attribute when withSemanticTokens prop is set", async () => { + const label = "Test Component" + render({label}) + const element = await screen.findByText(label) + expect(element).toHaveAttribute("data-theme", "light") + }) + test("memoized component renders once", () => { const { getByText, getByTestId } = render()