Add light/dark theme switching

This commit is contained in:
Brandon Dean 2021-07-10 17:57:08 -04:00
parent cd22b904ee
commit 5f41608e92
13 changed files with 133 additions and 65 deletions

View file

@ -1,4 +1,4 @@
import { createGlobalStyle } from "styled-components"; import { createGlobalStyle, ThemeProvider } from "styled-components";
import SearchBar from "./components/searchBar"; import SearchBar from "./components/searchBar";
import Greeter from "./components/greeter"; import Greeter from "./components/greeter";
@ -7,12 +7,13 @@ import BookmarkList from "./components/bookmarks";
import Settings from "./components/settings"; import Settings from "./components/settings";
import Imprint from "./components/imprint"; import Imprint from "./components/imprint";
import selectedTheme from "./lib/theme"; import { IThemeProps, getTheme, setScheme } from "./lib/theme";
import useFetcher from "./lib/fetcher"; import useFetcher from "./lib/fetcher";
import useMediaQuery from "./lib/useMediaQuery";
export const GlobalStyle = createGlobalStyle` export const GlobalStyle = createGlobalStyle<{ theme: IThemeProps }>`
body { body {
background-color: ${selectedTheme.backgroundColor}; background-color: ${(props) => props.theme.backgroundColor};
font-family: Roboto, sans-serif; font-family: Roboto, sans-serif;
margin: auto; margin: auto;
@ -38,8 +39,16 @@ const App = () => {
greeterData, greeterData,
} = useFetcher(); } = useFetcher();
const theme = getTheme();
let isDark = useMediaQuery("(prefers-color-scheme: dark");
if (isDark) {
setScheme("dark-theme");
} else {
setScheme("light-theme");
}
return ( return (
<> <ThemeProvider theme={theme}>
<GlobalStyle /> <GlobalStyle />
<div> <div>
<SearchBar search={searchProviderData?.search} /> <SearchBar search={searchProviderData?.search} />
@ -56,7 +65,7 @@ const App = () => {
{!bookmarkData.error && <BookmarkList groups={bookmarkData.groups} />} {!bookmarkData.error && <BookmarkList groups={bookmarkData.groups} />}
{!imprintData.error && <Imprint imprint={imprintData.imprint} />} {!imprintData.error && <Imprint imprint={imprintData.imprint} />}
</div> </div>
</> </ThemeProvider>
); );
}; };

View file

@ -1,12 +1,11 @@
import Icon from "./icon"; import Icon from "./icon";
import styled from "styled-components"; import styled from "styled-components";
import selectedTheme from "../lib/theme";
const AppContainer = styled.a` const AppContainer = styled.a`
display: flex; display: flex;
flex: 1 0 auto; flex: 1 0 auto;
padding: 1rem; padding: 1rem;
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
margin: 0; margin: 0;
@ -37,7 +36,7 @@ const AppDescription = styled.p`
margin: 0; margin: 0;
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 400; font-weight: 400;
color: ${selectedTheme.accentColor}; color: ${(props) => props.theme.accentColor};
`; `;
export interface IAppProps { export interface IAppProps {

View file

@ -6,7 +6,6 @@ import {
ListContainer, ListContainer,
SubHeadline, SubHeadline,
} from "./elements"; } from "./elements";
import selectedTheme from "../lib/theme";
const GroupContainer = styled.div` const GroupContainer = styled.div`
display: flex; display: flex;
@ -18,7 +17,7 @@ const GroupContainer = styled.div`
const Bookmark = styled.a` const Bookmark = styled.a`
font-weight: 400; font-weight: 400;
text-decoration: none; text-decoration: none;
color: ${selectedTheme.accentColor}; color: ${(props) => props.theme.accentColor};
padding-top: 0.75rem; padding-top: 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;

View file

@ -1,5 +1,4 @@
import styled from "styled-components"; import styled from "styled-components";
import selectedTheme from "../lib/theme";
export const ListContainer = styled.div` export const ListContainer = styled.div`
padding: 2rem 0; padding: 2rem 0;
@ -11,7 +10,7 @@ export const Headline = styled.h2`
text-transform: uppercase; text-transform: uppercase;
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
`; `;
export const SubHeadline = styled.h3` export const SubHeadline = styled.h3`
@ -19,7 +18,7 @@ export const SubHeadline = styled.h3`
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
margin: 0; margin: 0;
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
`; `;
export const ItemList = styled.ul` export const ItemList = styled.ul`
@ -44,8 +43,8 @@ export const Button = styled.button`
text-transform: uppercase; text-transform: uppercase;
font-family: Roboto, sans-serif; font-family: Roboto, sans-serif;
font-weight: 400; font-weight: 400;
border: 1px solid ${selectedTheme.mainColor}; border: 1px solid ${(props) => props.theme.mainColor};
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
background: none; background: none;
min-height: 2rem; min-height: 2rem;

View file

@ -1,5 +1,4 @@
import styled from "styled-components"; import styled from "styled-components";
import selectedTheme from "../lib/theme";
const GreeterContainer = styled.div` const GreeterContainer = styled.div`
padding: 2rem 0; padding: 2rem 0;
@ -9,7 +8,7 @@ const GreetText = styled.h1`
font-weight: 900; font-weight: 900;
font-size: 3rem; font-size: 3rem;
margin: 0.5rem 0; margin: 0.5rem 0;
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
`; `;
const DateText = styled.h3` const DateText = styled.h3`
@ -17,7 +16,7 @@ const DateText = styled.h3`
font-size: 1rem; font-size: 1rem;
text-transform: uppercase; text-transform: uppercase;
margin: 0; margin: 0;
color: ${selectedTheme.accentColor}; color: ${(props) => props.theme.accentColor};
`; `;
export interface IGreeterComponentProps { export interface IGreeterComponentProps {

View file

@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import styled from "styled-components"; import styled from "styled-components";
import selectedTheme from "../lib/theme";
interface IIconProps { interface IIconProps {
name: string; name: string;
@ -40,7 +39,7 @@ const IconContainer = styled.i`
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
font-feature-settings: "liga"; font-feature-settings: "liga";
font-size: ${(props) => props.about}; font-size: ${(props) => props.about};
color: ${(props) => props.color}; color: ${(props) => props.theme.mainColor};
`; `;
/** /**
@ -49,7 +48,7 @@ const IconContainer = styled.i`
* @returns {React.ReactNode} the icon node * @returns {React.ReactNode} the icon node
*/ */
export const Icon = ({ name, size }: IIconProps) => ( export const Icon = ({ name, size }: IIconProps) => (
<IconContainer color={selectedTheme.mainColor} about={size}> <IconContainer about={size}>
{name} {name}
</IconContainer> </IconContainer>
); );

View file

@ -1,6 +1,5 @@
import Modal from "./modal"; import Modal from "./modal";
import styled from "styled-components"; import styled from "styled-components";
import selectedTheme from "../lib/theme";
import { ListContainer, ItemList, Headline, SubHeadline } from "./elements"; import { ListContainer, ItemList, Headline, SubHeadline } from "./elements";
const ModalSubHeadline = styled(SubHeadline)` const ModalSubHeadline = styled(SubHeadline)`
@ -12,14 +11,14 @@ const Text = styled.p`
padding: 0; padding: 0;
margin: 0; margin: 0;
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
`; `;
const Link = styled.a` const Link = styled.a`
display: block; display: block;
padding: 0; padding: 0;
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
text-decoration: none; text-decoration: none;
&:hover { &:hover {

View file

@ -1,6 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import styled from "styled-components"; import styled from "styled-components";
import selectedTheme from "../lib/theme";
import { Headline } from "./elements"; import { Headline } from "./elements";
import { IconButton } from "./icon"; import { IconButton } from "./icon";
@ -12,8 +11,8 @@ const ModalContainer = styled.div`
padding: 1rem; padding: 1rem;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
z-index: 10; z-index: 10;
border: 1px solid ${selectedTheme.mainColor}; border: 1px solid ${(props) => props.theme.mainColor};
background-color: ${selectedTheme.backgroundColor}; background-color: ${(props) => props.theme.backgroundColor};
`; `;
const Text = styled.p` const Text = styled.p`
@ -22,7 +21,7 @@ const Text = styled.p`
font-weight: 400; font-weight: 400;
text-decoration: none; text-decoration: none;
color: ${selectedTheme.accentColor}; color: ${(props) => props.theme.accentColor};
padding-top: 0.75rem; padding-top: 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;

View file

@ -1,8 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import styled from "styled-components"; import styled from "styled-components";
import selectedTheme from "../lib/theme";
import { Button } from "./elements"; import { Button } from "./elements";
const Search = styled.form` const Search = styled.form`
@ -21,11 +19,11 @@ const SearchInput = styled.input`
font-size: 1rem; font-size: 1rem;
border: none; border: none;
border-bottom: 1px solid ${selectedTheme.accentColor}; border-bottom: 1px solid ${(props) => props.theme.accentColor};
border-radius: 0; border-radius: 0;
background: none; background: none;
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
:focus { :focus {
outline: none; outline: none;

View file

@ -8,6 +8,7 @@ export interface IItemProps {
export interface ISelectProps { export interface ISelectProps {
items: Array<IItemProps>; items: Array<IItemProps>;
onChange: (item: any) => void; onChange: (item: any) => void;
current: string;
className?: string; className?: string;
} }
@ -19,7 +20,7 @@ const update = (
onChange(items.find((item) => item.value.toString() === e.target.value)); onChange(items.find((item) => item.value.toString() === e.target.value));
}; };
const Select = ({ items, onChange, className }: ISelectProps) => ( const Select = ({ items, onChange, current, className }: ISelectProps) => (
<select <select
data-testid="select" data-testid="select"
onChange={(e) => update(items, onChange, e)} onChange={(e) => update(items, onChange, e)}
@ -30,6 +31,7 @@ const Select = ({ items, onChange, className }: ISelectProps) => (
data-testid={"option-" + index} data-testid={"option-" + index}
key={[label, index].join("")} key={[label, index].join("")}
value={value.toString()} value={value.toString()}
selected={current === label}
> >
{label} {label}
</option> </option>

View file

@ -4,25 +4,24 @@ import styled from "styled-components";
import Select from "./select"; import Select from "./select";
import { ISearchProps } from "./searchBar"; import { ISearchProps } from "./searchBar";
import selectedTheme, { setTheme, IThemeProps } from "../lib/theme"; import { setTheme, IThemeProps, getTheme } from "../lib/theme";
import { Button, SubHeadline } from "./elements"; import { Button, SubHeadline } from "./elements";
import Modal from "./modal"; import Modal from "./modal";
export const FormContainer = styled.div` export const FormContainer = styled.div`
display: grid; margin-bottom: 1em;
grid-template-columns: auto auto auto;
`; `;
export const Table = styled.table` export const Table = styled.table`
font-weight: 400; font-weight: 400;
background: none; background: none;
width: 100%; width: 100%;
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
`; `;
export const TableRow = styled.tr` export const TableRow = styled.tr`
border-bottom: 1px solid ${selectedTheme.mainColor}; border-bottom: 1px solid ${(props) => props.theme.mainColor};
`; `;
export const TableCell = styled.td` export const TableCell = styled.td`
@ -41,18 +40,23 @@ export const Section = styled.div`
export const SectionHeadline = styled(SubHeadline)` export const SectionHeadline = styled(SubHeadline)`
width: 100%; width: 100%;
border-bottom: 1px solid ${selectedTheme.accentColor}; border-bottom: 1px solid ${(props) => props.theme.accentColor};
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
`; `;
const Text = styled.p` const Text = styled.p`
font-weight: 700; font-weight: 700;
color: ${selectedTheme.accentColor}; color: ${(props) => props.theme.accentColor};
`; `;
const Code = styled.p` const Code = styled.p`
font-family: monospace; font-family: monospace;
color: ${selectedTheme.accentColor}; color: ${(props) => props.theme.accentColor};
`;
const ThemeHeader = styled.p`
grid-column: 1 / 4;
color: ${(props) => props.theme.accentColor};
`; `;
const ThemeSelect = styled(Select)` const ThemeSelect = styled(Select)`
@ -62,12 +66,12 @@ const ThemeSelect = styled(Select)`
text-transform: uppercase; text-transform: uppercase;
font-family: Roboto, sans-serif; font-family: Roboto, sans-serif;
font-weight: 400; font-weight: 400;
border: 1px solid ${selectedTheme.mainColor}; border: 1px solid ${(props) => props.theme.mainColor};
color: ${selectedTheme.mainColor}; color: ${(props) => props.theme.mainColor};
background: none; background: none;
& > option { & > option {
background-color: ${selectedTheme.backgroundColor}; background-color: ${(props) => props.theme.backgroundColor};
} }
`; `;
@ -82,7 +86,12 @@ interface ISettingsProps {
* @param {ISearchProps} search - the list of search providers * @param {ISearchProps} search - the list of search providers
*/ */
const Settings = ({ themes, search }: ISettingsProps) => { const Settings = ({ themes, search }: ISettingsProps) => {
const [newTheme, setNewTheme] = useState<IThemeProps>(); const [newLightTheme, setNewLightTheme] = useState<IThemeProps>();
const [newDarkTheme, setNewDarkTheme] = useState<IThemeProps>();
const currentLightTheme = getTheme("light").label;
const currentDarkTheme = getTheme("dark").label;
console.log(currentLightTheme, currentDarkTheme);
if (themes || search) { if (themes || search) {
return ( return (
@ -91,25 +100,34 @@ const Settings = ({ themes, search }: ISettingsProps) => {
<Section> <Section>
<SectionHeadline>Theme:</SectionHeadline> <SectionHeadline>Theme:</SectionHeadline>
<FormContainer> <FormContainer>
<ThemeHeader>Light</ThemeHeader>
<ThemeSelect <ThemeSelect
items={themes} items={themes}
onChange={(theme: IThemeProps) => setNewTheme(theme)} onChange={(theme: IThemeProps) => setNewLightTheme(theme)}
current={currentLightTheme}
></ThemeSelect>
<ThemeHeader>Dark</ThemeHeader>
<ThemeSelect
items={themes}
onChange={(theme: IThemeProps) => setNewDarkTheme(theme)}
current={currentDarkTheme}
></ThemeSelect> ></ThemeSelect>
<Button
data-testid="button-submit"
onClick={() => {
if (newTheme) setTheme(newTheme);
}}
>
Apply
</Button>
<Button
data-testid="button-refresh"
onClick={() => window.location.reload()}
>
Refresh
</Button>
</FormContainer> </FormContainer>
<Button
data-testid="button-submit"
onClick={() => {
if (newLightTheme) setTheme("light", newLightTheme);
if (newDarkTheme) setTheme("dark", newDarkTheme);
}}
>
Apply
</Button>
<Button
data-testid="button-refresh"
onClick={() => window.location.reload()}
>
Refresh
</Button>
</Section> </Section>
)} )}
{search && ( {search && (

View file

@ -14,23 +14,47 @@ export const defaultTheme: IThemeProps = {
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
}; };
/**
* Writes the color scheme into localStorage
* @param {string} scheme
*/
export const setScheme = (scheme: string) => {
localStorage.setItem("theme", scheme);
};
/** /**
* Writes a given theme into localStorage * Writes a given theme into localStorage
* @param {string} scheme - the color scheme (light or dark) to save the theme to
* @param {string} theme - the theme that shall be saved (in stringified JSON) * @param {string} theme - the theme that shall be saved (in stringified JSON)
*/ */
export const setTheme = (theme: IThemeProps) => { export const setTheme = (scheme: string, theme: IThemeProps) => {
localStorage.setItem("theme", JSON.stringify(theme)); if (scheme === "light") {
localStorage.setItem("light-theme", JSON.stringify(theme));
localStorage.setItem("theme", "light-theme");
}
if (scheme === "dark") {
localStorage.setItem("dark-theme", JSON.stringify(theme));
localStorage.setItem("theme", "dark-theme");
}
window.location.reload(); window.location.reload();
}; };
/** /**
* Function that gets the saved theme from localStorage or returns the default * Function that gets the saved theme from localStorage or returns the default
* @param {string} [scheme] the color scheme to retrieve the theme for
* @returns {IThemeProps} the saved theme or the default theme * @returns {IThemeProps} the saved theme or the default theme
*/ */
export const getTheme = (): IThemeProps => { export const getTheme = (scheme?: string): IThemeProps => {
let currentScheme = localStorage.getItem("theme");
let selectedTheme = defaultTheme; let selectedTheme = defaultTheme;
let theme = localStorage.getItem("theme"); if (scheme === "light") {
currentScheme = "light-theme";
} else if (scheme === "dark") {
currentScheme = "dark-theme";
}
let theme = currentScheme === "dark-theme" ? localStorage.getItem("dark-theme") : localStorage.getItem("light-theme");
if (theme !== null) selectedTheme = JSON.parse(theme || "{}"); if (theme !== null) selectedTheme = JSON.parse(theme || "{}");
return selectedTheme; return selectedTheme;

24
src/lib/useMediaQuery.tsx Normal file
View file

@ -0,0 +1,24 @@
// Credit: https://www.netlify.com/blog/2020/12/05/building-a-custom-react-media-query-hook-for-more-responsive-apps/
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => {
setMatches(media.matches);
};
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [matches, query]);
return matches;
}
export const IsDark = () => useMediaQuery("(prefers-color-scheme: dark");
export default useMediaQuery;