Skip to content

Commit 5ee6bc8

Browse files
committed
consolidate theme logic, allow the user to select a highlighter style from the new dropdown
1 parent c64fed2 commit 5ee6bc8

File tree

9 files changed

+289
-59
lines changed

9 files changed

+289
-59
lines changed

src/components/CodePreview.tsx

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
import { useEffect, useState } from "react";
21
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
3-
import {
4-
oneDark,
5-
oneLight,
6-
} from "react-syntax-highlighter/dist/esm/styles/prism";
2+
3+
import { useAppContext } from "@contexts/AppContext";
74

85
import CopyToClipboard from "./CopyToClipboard";
96

@@ -13,34 +10,22 @@ type Props = {
1310
};
1411

1512
const CodePreview = ({ language = "markdown", code }: Props) => {
16-
const [theme, setTheme] = useState<"dark" | "light">("dark");
17-
18-
useEffect(() => {
19-
const handleThemeChange = () => {
20-
const newTheme = document.documentElement.getAttribute("data-theme") as
21-
| "dark"
22-
| "light";
23-
setTheme(newTheme || "dark");
24-
};
25-
26-
handleThemeChange();
27-
const observer = new MutationObserver(handleThemeChange);
28-
observer.observe(document.documentElement, {
29-
attributes: true,
30-
attributeFilter: ["data-theme"],
31-
});
32-
33-
return () => observer.disconnect();
34-
}, []);
13+
const { highlighterStyle } = useAppContext();
3514

3615
return (
3716
<div className="code-preview">
3817
<CopyToClipboard text={code.join("\n")} className="modal__copy" />
3918
<SyntaxHighlighter
4019
language={language}
41-
style={theme === "dark" ? oneDark : oneLight}
20+
style={highlighterStyle.style}
4221
wrapLines={true}
43-
customStyle={{ margin: "0", maxHeight: "20rem" }}
22+
customStyle={{
23+
margin: "0",
24+
maxHeight: "20rem",
25+
fontSize: "0.875rem",
26+
lineHeight: "1.5",
27+
padding: "1rem",
28+
}}
4429
>
4530
{code.join("\n")}
4631
</SyntaxHighlighter>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { useRef, useEffect, useState } from "react";
2+
3+
import { highlighterStyles } from "@consts/highlighter-styles";
4+
import { useAppContext } from "@contexts/AppContext";
5+
import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation";
6+
import { HighlighterStyleType } from "@types";
7+
8+
const HighlighterStyleSelector = () => {
9+
const { highlighterStyle, setHighlighterStyle } = useAppContext();
10+
11+
const dropdownRef = useRef<HTMLDivElement>(null);
12+
const [isOpen, setIsOpen] = useState(false);
13+
14+
const handleSelect = (selected: HighlighterStyleType) => {
15+
setHighlighterStyle(selected);
16+
setIsOpen(false);
17+
};
18+
19+
const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
20+
useKeyboardNavigation({
21+
items: highlighterStyles,
22+
isOpen,
23+
onSelect: handleSelect,
24+
onClose: () => setIsOpen(false),
25+
});
26+
27+
const handleBlur = () => {
28+
setTimeout(() => {
29+
if (
30+
dropdownRef.current &&
31+
!dropdownRef.current.contains(document.activeElement)
32+
) {
33+
setIsOpen(false);
34+
}
35+
}, 0);
36+
};
37+
38+
const toggleDropdown = () => {
39+
setIsOpen((prev) => {
40+
if (!prev) setTimeout(focusFirst, 0);
41+
return !prev;
42+
});
43+
};
44+
45+
useEffect(() => {
46+
if (!isOpen) {
47+
resetFocus();
48+
}
49+
// eslint-disable-next-line react-hooks/exhaustive-deps
50+
}, [isOpen]);
51+
52+
useEffect(() => {
53+
if (isOpen && focusedIndex >= 0) {
54+
const element = document.querySelector(
55+
`.selector__item:nth-child(${focusedIndex + 1})`
56+
) as HTMLElement;
57+
element?.focus();
58+
}
59+
}, [isOpen, focusedIndex]);
60+
61+
return (
62+
<div
63+
className={`selector ${isOpen ? "selector--open" : ""}`}
64+
ref={dropdownRef}
65+
onBlur={handleBlur}
66+
>
67+
<button
68+
className="selector__button"
69+
aria-label="select button"
70+
aria-haspopup="listbox"
71+
aria-expanded={isOpen}
72+
onClick={toggleDropdown}
73+
>
74+
<div className="selector__value">
75+
<span>{highlighterStyle.name}</span>
76+
</div>
77+
<span className="selector__arrow" />
78+
</button>
79+
{isOpen && (
80+
<ul
81+
className="selector__dropdown"
82+
role="listbox"
83+
onKeyDown={handleKeyDown}
84+
tabIndex={-1}
85+
>
86+
{highlighterStyles.map((hs, index) => (
87+
<li
88+
key={hs.name}
89+
role="option"
90+
tabIndex={-1}
91+
onClick={() => handleSelect(hs)}
92+
className={`selector__item ${
93+
highlighterStyle.name === hs.name ? "selected" : ""
94+
} ${focusedIndex === index ? "focused" : ""}`}
95+
aria-selected={highlighterStyle.name === hs.name}
96+
>
97+
<label>
98+
<span>{hs.name}</span>
99+
</label>
100+
</li>
101+
))}
102+
</ul>
103+
)}
104+
</div>
105+
);
106+
};
107+
108+
export default HighlighterStyleSelector;

src/components/ThemeToggle.tsx

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,7 @@
1-
import { useState, useEffect } from "react";
1+
import { useAppContext } from "@contexts/AppContext";
22

33
const ThemeToggle = () => {
4-
const [theme, setTheme] = useState("dark");
5-
6-
useEffect(() => {
7-
// if the theme isn't set, use the user's system preference
8-
const savedTheme = localStorage.getItem("theme");
9-
if (savedTheme) {
10-
setTheme(savedTheme);
11-
document.documentElement.setAttribute("data-theme", savedTheme);
12-
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
13-
setTheme("dark");
14-
document.documentElement.setAttribute("data-theme", "dark");
15-
} else {
16-
setTheme("light");
17-
document.documentElement.setAttribute("data-theme", "light");
18-
}
19-
}, []);
20-
21-
const toggleTheme = () => {
22-
const newTheme = theme === "dark" ? "light" : "dark";
23-
setTheme(newTheme);
24-
localStorage.setItem("theme", newTheme);
25-
document.documentElement.setAttribute("data-theme", newTheme);
26-
};
4+
const { theme, toggleTheme } = useAppContext();
275

286
return (
297
<button onClick={toggleTheme} className="button" aria-label="Toggle theme">

src/consts/highlighter-styles.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
a11yDark,
3+
atomDark,
4+
base16AteliersulphurpoolLight,
5+
cb,
6+
coldarkCold,
7+
coldarkDark,
8+
coyWithoutShadows,
9+
coy,
10+
darcula,
11+
dark,
12+
dracula,
13+
duotoneDark,
14+
duotoneEarth,
15+
duotoneForest,
16+
duotoneLight,
17+
duotoneSea,
18+
duotoneSpace,
19+
funky,
20+
ghcolors,
21+
gruvboxDark,
22+
gruvboxLight,
23+
holiTheme,
24+
hopscotch,
25+
lucario,
26+
materialDark,
27+
materialLight,
28+
materialOceanic,
29+
nightOwl,
30+
nord,
31+
okaidia,
32+
oneDark,
33+
oneLight,
34+
pojoaque,
35+
prism,
36+
shadesOfPurple,
37+
solarizedDarkAtom,
38+
solarizedlight,
39+
synthwave84,
40+
tomorrow,
41+
twilight,
42+
vs,
43+
vscDarkPlus,
44+
xonokai,
45+
zTouch,
46+
} from "react-syntax-highlighter/dist/esm/styles/prism";
47+
48+
import { HighlighterStyleType } from "@types";
49+
50+
export const highlighterStyles: HighlighterStyleType[] = [
51+
{ name: "a11yDark", style: a11yDark },
52+
{ name: "atomDark", style: atomDark },
53+
{
54+
name: "base16AteliersulphurpoolLight",
55+
style: base16AteliersulphurpoolLight,
56+
},
57+
{ name: "cb", style: cb },
58+
{ name: "coldarkCold", style: coldarkCold },
59+
{ name: "coldarkDark", style: coldarkDark },
60+
{ name: "coyWithoutShadows", style: coyWithoutShadows },
61+
{ name: "coy", style: coy },
62+
{ name: "darcula", style: darcula },
63+
{ name: "dark", style: dark },
64+
{ name: "dracula", style: dracula },
65+
{ name: "duotoneDark", style: duotoneDark },
66+
{ name: "duotoneEarth", style: duotoneEarth },
67+
{ name: "duotoneForest", style: duotoneForest },
68+
{ name: "duotoneLight", style: duotoneLight },
69+
{ name: "duotoneSea", style: duotoneSea },
70+
{ name: "duotoneSpace", style: duotoneSpace },
71+
{ name: "funky", style: funky },
72+
{ name: "ghcolors", style: ghcolors },
73+
{ name: "gruvboxDark", style: gruvboxDark },
74+
{ name: "gruvboxLight", style: gruvboxLight },
75+
{ name: "holiTheme", style: holiTheme },
76+
{ name: "hopscotch", style: hopscotch },
77+
{ name: "lucario", style: lucario },
78+
{ name: "materialDark", style: materialDark },
79+
{ name: "materialLight", style: materialLight },
80+
{ name: "materialOceanic", style: materialOceanic },
81+
{ name: "nightOwl", style: nightOwl },
82+
{ name: "nord", style: nord },
83+
{ name: "okaidia", style: okaidia },
84+
{ name: "oneDark", style: oneDark },
85+
{ name: "oneLight", style: oneLight },
86+
{ name: "pojoaque", style: pojoaque },
87+
{ name: "prism", style: prism },
88+
{ name: "shadesOfPurple", style: shadesOfPurple },
89+
{ name: "solarizedDarkAtom", style: solarizedDarkAtom },
90+
{ name: "solarizedlight", style: solarizedlight },
91+
{ name: "synthwave84", style: synthwave84 },
92+
{ name: "tomorrow", style: tomorrow },
93+
{ name: "twilight", style: twilight },
94+
{ name: "vs", style: vs },
95+
{ name: "vscDarkPlus", style: vscDarkPlus },
96+
{ name: "xonokai", style: xonokai },
97+
{ name: "zTouch", style: zTouch },
98+
];

src/contexts/AppContext.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { createContext, FC, useContext, useState } from "react";
1+
import {
2+
createContext,
3+
FC,
4+
useCallback,
5+
useContext,
6+
useEffect,
7+
useState,
8+
} from "react";
9+
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
210

311
import { AppState, LanguageType, SnippetType } from "@types";
412

@@ -8,6 +16,8 @@ const defaultLanguage: LanguageType = {
816
icon: "/icons/javascript.svg",
917
};
1018

19+
const defaultHighlighterStyle = { name: "oneDark", style: oneDark };
20+
1121
// TODO: add custom loading and error handling
1222
const defaultState: AppState = {
1323
language: defaultLanguage,
@@ -16,6 +26,10 @@ const defaultState: AppState = {
1626
setCategory: () => {},
1727
snippet: null,
1828
setSnippet: () => {},
29+
theme: "dark",
30+
toggleTheme: () => {},
31+
highlighterStyle: defaultHighlighterStyle,
32+
setHighlighterStyle: () => {},
1933
};
2034

2135
const AppContext = createContext<AppState>(defaultState);
@@ -26,6 +40,30 @@ export const AppProvider: FC<{ children: React.ReactNode }> = ({
2640
const [language, setLanguage] = useState<LanguageType>(defaultLanguage);
2741
const [category, setCategory] = useState<string>("");
2842
const [snippet, setSnippet] = useState<SnippetType | null>(null);
43+
const [theme, setTheme] = useState<"dark" | "light">(
44+
(localStorage.getItem("theme") as "dark" | "light" | null) ||
45+
(window.matchMedia("(prefers-color-scheme: dark)").matches
46+
? "dark"
47+
: "light")
48+
);
49+
const [highlighterStyle, setHighlighterStyle] = useState<
50+
AppState["highlighterStyle"]
51+
>(defaultHighlighterStyle);
52+
53+
const toggleTheme = useCallback(() => {
54+
const newTheme = theme === "dark" ? "light" : "dark";
55+
setTheme(newTheme);
56+
localStorage.setItem("theme", newTheme);
57+
document.documentElement.setAttribute("data-theme", newTheme);
58+
}, [theme]);
59+
60+
/**
61+
* set the theme on initial load
62+
*/
63+
useEffect(() => {
64+
document.documentElement.setAttribute("data-theme", theme);
65+
// eslint-disable-next-line react-hooks/exhaustive-deps
66+
}, []);
2967

3068
return (
3169
<AppContext.Provider
@@ -36,6 +74,10 @@ export const AppProvider: FC<{ children: React.ReactNode }> = ({
3674
setCategory,
3775
snippet,
3876
setSnippet,
77+
theme,
78+
toggleTheme,
79+
highlighterStyle,
80+
setHighlighterStyle,
3981
}}
4082
>
4183
{children}

src/hooks/useKeyboardNavigation.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
import { useState } from "react";
22

3-
import { LanguageType } from "@types";
4-
5-
interface UseKeyboardNavigationProps {
6-
items: LanguageType[];
3+
interface UseKeyboardNavigationProps<T> {
4+
// items: LanguageType[];
5+
items: Array<T>;
76
isOpen: boolean;
8-
onSelect: (item: LanguageType) => void;
7+
onSelect: (item: T) => void;
98
onClose: () => void;
109
}
1110

12-
export const useKeyboardNavigation = ({
11+
export const useKeyboardNavigation = <T>({
1312
items,
1413
isOpen,
1514
onSelect,
1615
onClose,
17-
}: UseKeyboardNavigationProps) => {
16+
}: UseKeyboardNavigationProps<T>) => {
1817
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
1918

2019
const handleKeyDown = (event: React.KeyboardEvent) => {

0 commit comments

Comments
 (0)