Skip to content

Feature - optimise build size and performance with PrismLight syntax highlighter and add a new highlighter style selector #97

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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"ignorePaths": [
"node_modules",
"dist",
"public"
"public",
"src/consts/highlighter-styles.ts"
]
}
37 changes: 11 additions & 26 deletions src/components/CodePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { useEffect, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";

import { useAppContext } from "@contexts/AppContext";

import CopyToClipboard from "./CopyToClipboard";

Expand All @@ -13,34 +10,22 @@ type Props = {
};

const CodePreview = ({ language = "markdown", code }: Props) => {
const [theme, setTheme] = useState<"dark" | "light">("dark");

useEffect(() => {
const handleThemeChange = () => {
const newTheme = document.documentElement.getAttribute("data-theme") as
| "dark"
| "light";
setTheme(newTheme || "dark");
};

handleThemeChange();
const observer = new MutationObserver(handleThemeChange);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});

return () => observer.disconnect();
}, []);
const { highlighterStyle } = useAppContext();

return (
<div className="code-preview">
<CopyToClipboard text={code} className="modal__copy" />
<SyntaxHighlighter
language={language}
style={theme === "dark" ? oneDark : oneLight}
style={highlighterStyle.style}
wrapLines={true}
customStyle={{ margin: "0", maxHeight: "20rem" }}
customStyle={{
margin: "0",
maxHeight: "20rem",
fontSize: "0.875rem",
lineHeight: "1.5",
padding: "1rem",
}}
>
{code}
</SyntaxHighlighter>
Expand Down
33 changes: 33 additions & 0 deletions src/components/HighlighterStyleSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { highlighterStyles } from "@consts/highlighter-styles";
import { useAppContext } from "@contexts/AppContext";
import { SelectorOption } from "@types";

import Selector from "./Selector";

const HighlighterStyleSelector = () => {
const { highlighterStyle, toggleHighlighterStyle } = useAppContext();

const options = highlighterStyles.map((style) => ({
name: style.name,
}));

const handleSelect = (option: SelectorOption) => {
const selected = highlighterStyles.find(
(style) => style.name === option.name
);
if (!selected) {
return;
}
toggleHighlighterStyle(selected);
};

return (
<Selector
options={options}
selectedOption={{ name: highlighterStyle.name }}
handleSelect={handleSelect}
/>
);
};

export default HighlighterStyleSelector;
127 changes: 29 additions & 98 deletions src/components/LanguageSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,115 +1,46 @@
import { useRef, useEffect, useState } from "react";
import { useMemo } from "react";

import { useAppContext } from "@contexts/AppContext";
import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation";
import { useLanguages } from "@hooks/useLanguages";
import { LanguageType } from "@types";
import { SelectorOption } from "@types";

// Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
import Selector from "./Selector";

const LanguageSelector = () => {
const { language, setLanguage } = useAppContext();
const { language, toggleLanguage } = useAppContext();
const { fetchedLanguages, loading, error } = useLanguages();

const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);

const handleSelect = (selected: LanguageType) => {
setLanguage(selected);
setIsOpen(false);
};

const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
useKeyboardNavigation({
items: fetchedLanguages,
isOpen,
onSelect: handleSelect,
onClose: () => setIsOpen(false),
});

const handleBlur = () => {
setTimeout(() => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(document.activeElement)
) {
setIsOpen(false);
}
}, 0);
};

const toggleDropdown = () => {
setIsOpen((prev) => {
if (!prev) setTimeout(focusFirst, 0);
return !prev;
});
};
const options = useMemo(
() =>
fetchedLanguages.map((item) => ({
name: item.lang,
icon: item.icon,
})),
[fetchedLanguages]
);

useEffect(() => {
if (!isOpen) {
resetFocus();
const handleSelect = (option: SelectorOption) => {
const selected = fetchedLanguages.find((lang) => lang.lang === option.name);
if (!selected) {
return;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
toggleLanguage(selected);
};

useEffect(() => {
if (isOpen && focusedIndex >= 0) {
const element = document.querySelector(
`.selector__item:nth-child(${focusedIndex + 1})`
) as HTMLElement;
element?.focus();
}
}, [isOpen, focusedIndex]);
if (loading) {
return <p>Loading languages...</p>;
}

if (loading) return <p>Loading languages...</p>;
if (error) return <p>Error fetching languages: {error}</p>;
if (error) {
return <p>Error fetching languages: {error}</p>;
}

return (
<div
className={`selector ${isOpen ? "selector--open" : ""}`}
ref={dropdownRef}
onBlur={handleBlur}
>
<button
className="selector__button"
aria-label="select button"
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={toggleDropdown}
>
<div className="selector__value">
<img src={language.icon} alt="" />
<span>{language.lang || "Select a language"}</span>
</div>
<span className="selector__arrow" />
</button>
{isOpen && (
<ul
className="selector__dropdown"
role="listbox"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{fetchedLanguages.map((lang, index) => (
<li
key={lang.lang}
role="option"
tabIndex={-1}
onClick={() => handleSelect(lang)}
className={`selector__item ${
language.lang === lang.lang ? "selected" : ""
} ${focusedIndex === index ? "focused" : ""}`}
aria-selected={language.lang === lang.lang}
>
<label>
<img src={lang.icon} alt="" />
<span>{lang.lang}</span>
</label>
</li>
))}
</ul>
)}
</div>
<Selector
options={options}
selectedOption={{ name: language.lang, icon: language.icon }}
handleSelect={handleSelect}
/>
);
};

Expand Down
116 changes: 116 additions & 0 deletions src/components/Selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
*/

import { FC, useEffect, useRef, useState } from "react";

import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation";
import { SelectorOption } from "@types";

interface SelectorProps {
options: Array<SelectorOption>;
selectedOption: SelectorOption;
handleSelect: (option: SelectorOption) => void;
}

const Selector: FC<SelectorProps> = (props) => {
const { options, selectedOption, handleSelect } = props;

const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);

const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
useKeyboardNavigation({
options,
isOpen,
onSelect: handleSelect,
onClose: () => setIsOpen(false),
});

const handleBlur = () => {
setTimeout(() => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(document.activeElement)
) {
setIsOpen(false);
}
}, 0);
};

const toggleDropdown = () => {
setIsOpen((prev) => {
if (!prev) setTimeout(focusFirst, 0);
return !prev;
});
};

useEffect(() => {
if (!isOpen) {
resetFocus();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);

useEffect(() => {
if (isOpen && focusedIndex >= 0) {
const element = document.querySelector(
`.selector__item:nth-child(${focusedIndex + 1})`
) as HTMLElement;
element?.focus();
}
}, [isOpen, focusedIndex]);

return (
<div
className={`selector ${isOpen ? "selector--open" : ""}`}
ref={dropdownRef}
onBlur={handleBlur}
>
<button
className="selector__button"
aria-label="select button"
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={toggleDropdown}
>
<div className="selector__value">
{selectedOption.icon && <img src={selectedOption.icon} alt="" />}
<span>{selectedOption.name}</span>
</div>
<span className="selector__arrow" />
</button>
{isOpen && (
<ul
className="selector__dropdown"
role="listbox"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{options.map((item, index) => (
<li
key={item.name}
role="option"
tabIndex={-1}
onClick={() => {
handleSelect(item);
setIsOpen(false);
}}
className={`selector__item ${
selectedOption.name === item.name ? "selected" : ""
} ${focusedIndex === index ? "focused" : ""}`}
aria-selected={selectedOption.name === item.name}
>
<label>
{item.icon && <img src={item.icon} alt="" />}
<span>{item.name as string}</span>
</label>
</li>
))}
</ul>
)}
</div>
);
};

export default Selector;
26 changes: 2 additions & 24 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,7 @@
import { useState, useEffect } from "react";
import { useAppContext } from "@contexts/AppContext";

const ThemeToggle = () => {
const [theme, setTheme] = useState("dark");

useEffect(() => {
// if the theme isn't set, use the user's system preference
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme);
document.documentElement.setAttribute("data-theme", savedTheme);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setTheme("dark");
document.documentElement.setAttribute("data-theme", "dark");
} else {
setTheme("light");
document.documentElement.setAttribute("data-theme", "light");
}
}, []);

const toggleTheme = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
document.documentElement.setAttribute("data-theme", newTheme);
};
const { theme, toggleTheme } = useAppContext();

return (
<button onClick={toggleTheme} className="button" aria-label="Toggle theme">
Expand Down
Loading
Loading