Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Theme toggle #229

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
4 changes: 2 additions & 2 deletions libs/juno-ui-components/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ export const parameters = {

export const decorators = [
(Story) => {
const themeClass = useDarkMode() ? "theme-dark" : "theme-light"
const theme = useDarkMode() ? "dark" : "light"

return (
<StyleProvider stylesWrapper="head" theme={themeClass}>
<StyleProvider stylesWrapper="head" theme={theme}>
<ContentArea>
<Container px py>
<Story />
Expand Down
28 changes: 28 additions & 0 deletions libs/juno-ui-components/src/components/Icon/Icon.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -87,6 +89,7 @@ export const knownIcons = [
"contentCopy",
"danger",
"dangerous",
"darkMode",
"default",
"deleteForever",
"description",
Expand All @@ -102,6 +105,7 @@ export const knownIcons = [
"home",
"info",
"insertComment",
"lightMode",
"manageAccounts",
"moreVert",
"openInBrowser",
Expand Down Expand Up @@ -283,6 +287,18 @@ const getColoredSizedIcon = ({
{...iconProps}
/>
)
case "darkMode":
return (
<DarkMode
width={size}
height={size}
className={iconClass}
alt="dark mode"
title={title ? title : "Dark Mode"}
role="img"
{...iconProps}
/>
)
case "deleteForever":
return (
<DeleteForever
Expand Down Expand Up @@ -451,6 +467,18 @@ const getColoredSizedIcon = ({
{...iconProps}
/>
)
case "lightMode":
return (
<LightMode
width={size}
height={size}
className={iconClass}
alt="light mode"
title={title ? title : "Light Mode"}
role="img"
{...iconProps}
/>
)
case "manageAccounts":
return (
<ManageAccounts
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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("")

Expand Down Expand Up @@ -89,13 +109,13 @@ export const StyleProvider = ({
<ShadowRoot
mode={shadowRootMode}
styles={styles}
themeClass={themeClass}
themeClass={"theme-" + currentTheme}
customCssClasses={customCssClasses}
>
{children}
</ShadowRoot>
) : (
<div className={`${themeClass} ${customCssClasses || ""}`}>
<div className={`theme-${currentTheme} ${customCssClasses || ""}`}>
{stylesWrapper === "inline" && (
<style data-style-provider="inline">{styles}</style>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ AddStylesToShadowRoot.args = {
export const WithTheme = Template.bind({})
WithTheme.args = {
stylesWrapper: "shadowRoot",
theme: "theme-light",
theme: "light",
children: "Light Theme",
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe("StyleProvider", () => {

test("renders a StyleProvider wrapper div with theme class as passed", async () => {
const { container } = render(<StyleProvider theme="my-theme"></StyleProvider>)
expect(container.firstChild).toHaveClass('my-theme')
expect(container.firstChild).toHaveClass('theme-my-theme')
})


Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Icon icon={`${currentTheme === "dark" ? "lightMode" : "darkMode" }`} onClick={handleThemeToggleClick} title={titleText()} className={`juno-theme-toggle ${togglestyles} ${className}`}/>
)
}

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: "",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import { ThemeToggle } from './index.js';

export default {
title: 'WiP/ThemeToggle',
component: ThemeToggle,
argTypes: {},
};

const Template = (args) => <ThemeToggle {...args} />;

export const Default = Template.bind({});
Default.args = {}
Original file line number Diff line number Diff line change
@@ -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(<ThemeToggle />);
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveClass("juno-theme-toggle");
});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ThemeToggle } from "./ThemeToggle.component"
31 changes: 31 additions & 0 deletions libs/juno-ui-components/src/hooks/useThemeDetector.js
Original file line number Diff line number Diff line change
@@ -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 (
* <p>{ isDarkTheme ? "Dark" : "Light" } Theme</p>
* )
* }
*/


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

}
1 change: 1 addition & 0 deletions libs/juno-ui-components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down