diff --git a/libs/juno-ui-components/.storybook/preview.js b/libs/juno-ui-components/.storybook/preview.js index 51562a442..8c518f36f 100644 --- a/libs/juno-ui-components/.storybook/preview.js +++ b/libs/juno-ui-components/.storybook/preview.js @@ -77,10 +77,10 @@ export const parameters = { export const decorators = [ (Story) => { - const themeClass = useDarkMode() ? "theme-dark" : "theme-light" + const theme = useDarkMode() ? "dark" : "light" return ( - + diff --git a/libs/juno-ui-components/src/components/Icon/Icon.component.js b/libs/juno-ui-components/src/components/Icon/Icon.component.js index a540a19c5..0bbc0675f 100644 --- a/libs/juno-ui-components/src/components/Icon/Icon.component.js +++ b/libs/juno-ui-components/src/components/Icon/Icon.component.js @@ -16,6 +16,7 @@ import Close from "./icons/close.svg" import ContentCopy from "./icons/content_copy.svg" import Danger from "./icons/juno-danger.svg" import Dangerous from "./icons/dangerous.svg" +import DarkMode from "./icons/dark_mode.svg" import DeleteForever from "./icons/delete_forever.svg" import Description from "./icons/description.svg" import DNS from "./icons/dns.svg" @@ -30,6 +31,7 @@ import Help from "./icons/help.svg" import Home from "./icons/home_sharp.svg" import Info from "./icons/info.svg" import InsertComment from "./icons/insert_comment.svg" +import LightMode from "./icons/light_mode.svg" import ManageAccounts from "./icons/manage_accounts.svg" import MoreVert from "./icons/more_vert.svg" import OpenInBrowser from "./icons/open_in_browser.svg" @@ -87,6 +89,7 @@ export const knownIcons = [ "contentCopy", "danger", "dangerous", + "darkMode", "default", "deleteForever", "description", @@ -102,6 +105,7 @@ export const knownIcons = [ "home", "info", "insertComment", + "lightMode", "manageAccounts", "moreVert", "openInBrowser", @@ -283,6 +287,18 @@ const getColoredSizedIcon = ({ {...iconProps} /> ) + case "darkMode": + return ( + + ) case "deleteForever": return ( ) + case "lightMode": + return ( + + ) case "manageAccounts": return ( \ No newline at end of file diff --git a/libs/juno-ui-components/src/components/Icon/icons/light_mode.svg b/libs/juno-ui-components/src/components/Icon/icons/light_mode.svg new file mode 100644 index 000000000..7fae634e8 --- /dev/null +++ b/libs/juno-ui-components/src/components/Icon/icons/light_mode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.component.js b/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.component.js index aacc85784..678f8d679 100644 --- a/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.component.js +++ b/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.component.js @@ -4,7 +4,7 @@ * to place the ui-components styles. * @module StyleProvider */ -import React from "react" +import React, { useEffect, useState } from "react" import PropTypes from "prop-types" import { ShadowRoot } from "../ShadowRoot" @@ -25,12 +25,32 @@ const StylesContext = React.createContext() */ export const StyleProvider = ({ stylesWrapper, - theme: themeClassName, + theme, children, shadowRootMode, }) => { - // theme class default to theme-dark - const themeClass = themeClassName || "theme-dark" + const DEFAULT_THEME = "dark" + // Use theme prop if passed, otherwise use localStorage.currentTheme if present, otherwise default to "dark": + const theTheme = theme ? theme : ( localStorage.getItem("currentTheme") ? localStorage.getItem("currentTheme") : DEFAULT_THEME ) + // init theme state: + const [currentTheme, setCurrentTheme] = React.useState(theTheme); + + // update currentTheme in state when local storage changes: + useEffect(() => { + + const handleLocalStorageChange = () => { + const themeFromStorage = localStorage.getItem('currentTheme') + if (currentTheme != themeFromStorage) { + setCurrentTheme(themeFromStorage) + } + } + + window.addEventListener('storage', handleLocalStorageChange ) + + return () => window.removeEventListener('storage', handleLocalStorageChange ); + + }, []); + // manage custom css classes (useStyles) const [customCssClasses, setCustomCssClasses] = React.useState("") @@ -89,13 +109,13 @@ export const StyleProvider = ({ {children} ) : ( -
+
{stylesWrapper === "inline" && ( )} diff --git a/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.stories.js b/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.stories.js index 6e64b8226..4fdac807d 100644 --- a/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.stories.js +++ b/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.stories.js @@ -38,6 +38,6 @@ AddStylesToShadowRoot.args = { export const WithTheme = Template.bind({}) WithTheme.args = { stylesWrapper: "shadowRoot", - theme: "theme-light", + theme: "light", children: "Light Theme", } diff --git a/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.test.js b/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.test.js index 80696612b..7694d0a95 100644 --- a/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.test.js +++ b/libs/juno-ui-components/src/components/StyleProvider/StyleProvider.test.js @@ -12,7 +12,7 @@ describe("StyleProvider", () => { test("renders a StyleProvider wrapper div with theme class as passed", async () => { const { container } = render() - expect(container.firstChild).toHaveClass('my-theme') + expect(container.firstChild).toHaveClass('theme-my-theme') }) diff --git a/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.component.js b/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.component.js new file mode 100644 index 000000000..be5d0bc42 --- /dev/null +++ b/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.component.js @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { Icon } from "../Icon/index.js"; +import { useThemeDetector } from '../../hooks/useThemeDetector' + +const togglestyles = ` +` + +/** A Toggle to switch between Light and Dark UI themes. +* Pass `theme="auto"` to auto-detect and apply current system/user agent theme. The current theme will be written to `localStorage.currentTheme`. +*/ +export const ThemeToggle = ({ + theme, + className, + ...props +}) => { + const [currentTheme, setCurrentTheme] = useState(undefined) + + let theTheme = "" + + switch (theme) { + case "auto": + theTheme = useThemeDetector() ? "dark" : "light" + case "light": + theTheme = "light" + case "dark": + theTheme = "dark" + default: + const storedTheme = localStorage.getItem("currentTheme") + storedTheme ? theTheme = storedTheme : "dark" + } + + React.useEffect(() => { + setCurrentTheme(theTheme) + }, [theTheme]) + + const handleThemeToggleClick = () => { + if (currentTheme === "dark") { + setCurrentTheme("light") + localStorage.setItem("currentTheme", "light") + } else { + setCurrentTheme("dark") + localStorage.setItem("currentTheme", "dark") + } + window.dispatchEvent(new Event('storage')) + } + + const titleText = () => { + if (currentTheme === "dark") { + return "Change theme to Light mode" + } else { + return "Change theme to Dark mode" + } + } + + return ( + + ) +} + +ThemeToggle.propTypes = { + /** The theme to show, either 'dark' or 'light'. */ + theme: PropTypes.oneOf(["auto", "dark", "light"]), + /** Pass custom className */ + className: PropTypes.string, +} + +ThemeToggle.defaultProps = { + theme: "dark", + className: "", +} \ No newline at end of file diff --git a/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.stories.js b/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.stories.js new file mode 100644 index 000000000..8d0e88a0e --- /dev/null +++ b/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.stories.js @@ -0,0 +1,13 @@ +import React from 'react'; +import { ThemeToggle } from './index.js'; + +export default { + title: 'WiP/ThemeToggle', + component: ThemeToggle, + argTypes: {}, +}; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = {} \ No newline at end of file diff --git a/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.test.js b/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.test.js new file mode 100644 index 000000000..d2c1aed5a --- /dev/null +++ b/libs/juno-ui-components/src/components/ThemeToggle/ThemeToggle.test.js @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeToggle } from './index'; + +describe('ThemeToggle', () => { + test('render a theme toggle', async () => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveClass("juno-theme-toggle"); + }); + +}); diff --git a/libs/juno-ui-components/src/components/ThemeToggle/index.js b/libs/juno-ui-components/src/components/ThemeToggle/index.js new file mode 100644 index 000000000..468d2eb6c --- /dev/null +++ b/libs/juno-ui-components/src/components/ThemeToggle/index.js @@ -0,0 +1 @@ +export { ThemeToggle } from "./ThemeToggle.component" \ No newline at end of file diff --git a/libs/juno-ui-components/src/hooks/useThemeDetector.js b/libs/juno-ui-components/src/hooks/useThemeDetector.js new file mode 100644 index 000000000..2f47150ee --- /dev/null +++ b/libs/juno-ui-components/src/hooks/useThemeDetector.js @@ -0,0 +1,31 @@ +import React, { useState, useEffect } from 'react' + +/* Hook to detect user UI theme setting and to detect when it changes. + * From Kacper Kula: https://medium.com/hypersphere-codes/detecting-system-theme-in-javascript-css-react-f6b961916d48 + * Using this hook, any component can test wether the user is currently using dark theme: + * + * const MyComponent = () => { + * const isDarkTheme = useThemeDetector() + * return ( + *

{ isDarkTheme ? "Dark" : "Light" } Theme

+ * ) + * } +*/ + + +export const useThemeDetector = () => { + const getUserTheme = () => window.matchMedia("(prefers-color-scheme: dark)").matches + const [ isDarkTheme, setIsDarkTheme ] = useState(getUserTheme()) + const mqListener = (e) => { + setIsDarkTheme(e.matches) + } + + useEffect(() => { + const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)") + darkThemeMq.addListener(mqListener) + return () => darkThemeMq.removeListener(mqListener) + }, []) + + return isDarkTheme + +} diff --git a/libs/juno-ui-components/src/index.js b/libs/juno-ui-components/src/index.js index 5b6cfe8cf..2127c9838 100644 --- a/libs/juno-ui-components/src/index.js +++ b/libs/juno-ui-components/src/index.js @@ -73,6 +73,7 @@ export { Tabs } from "./components/Tabs/index.js" export { TextareaRow } from "./components/TextareaRow/index.js" export { TextInput } from "./components/TextInput/index.js" export { TextInputRow } from "./components/TextInputRow/index.js" +export { ThemeToggle } from "./components/ThemeToggle/index" export { Toast } from "./components/Toast/index.js" export { Tooltip } from "./components/Tooltip/index.js" export { TopNavigation } from "./components/TopNavigation/index.js"