feat: Implement contrast checker and color picker refactor

This commit is contained in:
lorenzopalaia
2025-04-17 17:16:29 +02:00
parent a28de24e7c
commit 8499dc6ec4
8 changed files with 383 additions and 30 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ npm install
npm run dev
```
4. Open [http://localhost:8080](http://localhost:8080) in your browser.
4. Open [http://localhost:3000](http://localhost:3000) in your browser.
## Contributors
+24 -1
View File
@@ -3,7 +3,12 @@ import { Label } from "@/components/ui/label";
import { ColorPickerProps } from "@/types";
import { debounce } from "@/utils/debounce";
const ColorPicker = ({ color, onChange, label }: ColorPickerProps) => {
const ColorPicker = ({
color,
onChange,
label,
onlyShowPicker,
}: ColorPickerProps) => {
const [isOpen, setIsOpen] = useState(false);
const [localColor, setLocalColor] = useState(color);
@@ -31,6 +36,24 @@ const ColorPicker = ({ color, onChange, label }: ColorPickerProps) => {
};
}, [debouncedOnChange]);
if (onlyShowPicker) {
return (
<div
className="h-8 w-8 rounded border cursor-pointer overflow-hidden relative flex items-center justify-center"
style={{ backgroundColor: localColor }}
onClick={() => setIsOpen(!isOpen)}
>
<input
type="color"
id={`color-${label.replace(/\s+/g, "-").toLowerCase()}`}
value={localColor}
onChange={handleColorChange}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
/>
</div>
);
}
return (
<div className="mb-3">
<div className="flex items-center justify-between mb-1.5">
+217
View File
@@ -0,0 +1,217 @@
import React from "react";
import { useContrastChecker } from "../../hooks/use-contrast-checker";
import { ThemeStyleProps } from "@/types/theme";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "../ui/dialog";
import ColorPicker from "./color-picker";
import { useEditorStore } from "@/store/editor-store";
import { Contrast, Check, AlertTriangle } from "lucide-react";
type ContrastCheckerProps = {
currentStyles: ThemeStyleProps | Partial<ThemeStyleProps>;
};
const MIN_CONTRAST_RATIO = 4.5;
type ColorPair = {
id: string;
foregroundId: keyof ThemeStyleProps;
backgroundId: keyof ThemeStyleProps;
foreground: string | undefined;
background: string | undefined;
label: string;
};
const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
const colorPairsToCheck: ColorPair[] = [
{
id: "primary",
foregroundId: "primary-foreground",
backgroundId: "primary",
foreground: currentStyles?.["primary-foreground"],
background: currentStyles?.["primary"],
label: "Primary",
},
{
id: "secondary",
foregroundId: "secondary-foreground",
backgroundId: "secondary",
foreground: currentStyles?.["secondary-foreground"],
background: currentStyles?.["secondary"],
label: "Secondary",
},
{
id: "accent",
foregroundId: "accent-foreground",
backgroundId: "accent",
foreground: currentStyles?.["accent-foreground"],
background: currentStyles?.["accent"],
label: "Accent",
},
{
id: "base",
foregroundId: "foreground",
backgroundId: "background",
foreground: currentStyles?.["foreground"],
background: currentStyles?.["background"],
label: "Base",
},
{
id: "card",
foregroundId: "card-foreground",
backgroundId: "card",
foreground: currentStyles?.["card-foreground"],
background: currentStyles?.["card"],
label: "Card",
},
{
id: "popover",
foregroundId: "popover-foreground",
backgroundId: "popover",
foreground: currentStyles?.["popover-foreground"],
background: currentStyles?.["popover"],
label: "Popover",
},
{
id: "muted",
foregroundId: "muted-foreground",
backgroundId: "muted",
foreground: currentStyles?.["muted-foreground"],
background: currentStyles?.["muted"],
label: "Muted",
},
{
id: "destructive",
foregroundId: "destructive-foreground",
backgroundId: "destructive",
foreground: currentStyles?.["destructive-foreground"],
background: currentStyles?.["destructive"],
label: "Destructive",
},
{
id: "sidebar",
foregroundId: "sidebar-foreground",
backgroundId: "sidebar",
foreground: currentStyles?.["sidebar-foreground"],
background: currentStyles?.["sidebar"],
label: "Sidebar Base",
},
{
id: "sidebar-primary",
foregroundId: "sidebar-primary-foreground",
backgroundId: "sidebar-primary",
foreground: currentStyles?.["sidebar-primary-foreground"],
background: currentStyles?.["sidebar-primary"],
label: "Sidebar Primary",
},
{
id: "sidebar-accent",
foregroundId: "sidebar-accent-foreground",
backgroundId: "sidebar-accent",
foreground: currentStyles?.["sidebar-accent-foreground"],
background: currentStyles?.["sidebar-accent"],
label: "Sidebar Accent",
},
];
const { updateStyle } = useEditorStore();
const validColorPairsToCheck = colorPairsToCheck.filter(
(pair): pair is ColorPair & { foreground: string; background: string } =>
!!pair.foreground && !!pair.background
);
const contrastResults = useContrastChecker(validColorPairsToCheck);
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Contrast />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Contrast Checker</DialogTitle>
<DialogDescription>
Check the contrast ratios of your theme colors to ensure they meet
accessibility standards. You can also adjust the colors using the
color pickers below. The recommended minimum contrast ratio is{" "}
<span className="font-bold">{MIN_CONTRAST_RATIO}</span>.
</DialogDescription>
<div className="space-y-4 pt-4">
{colorPairsToCheck.map((pair) => {
const result = contrastResults?.find((res) => res.id === pair.id);
return (
<div key={pair.id} className="flex flex-col gap-2 w-full">
<p className="text-sm text-center font-medium">
{pair.label}
</p>
<div className="flex h-8 w-full gap-2 items-center">
<ColorPicker
color={pair.background ?? ""}
onChange={(color) =>
updateStyle(pair.backgroundId, color)
}
label={pair.label}
onlyShowPicker
/>
<div
style={{
backgroundColor: pair.background ?? "transparent",
}}
className="h-8 flex-1 rounded border flex items-center justify-center"
>
{pair.foreground && pair.background && (
<p
style={{ color: pair.foreground }}
className="text-lg"
>
Aa
</p>
)}
{(!pair.foreground || !pair.background) && (
<p className="text-xs text-muted-foreground">N/A</p>
)}
</div>
<ColorPicker
color={pair.foreground ?? ""}
onChange={(color) =>
updateStyle(pair.foregroundId, color)
}
label={pair.label}
onlyShowPicker
/>
</div>
<p className="text-end text-sm">
{result ? (
<>
Contrast Ratio: {result.contrastRatio.toFixed(2)}{" "}
{result.contrastRatio >= MIN_CONTRAST_RATIO ? (
<Check className="inline h-4 w-4 text-green-600" />
) : (
<AlertTriangle className="inline h-4 w-4 text-yellow-600" />
)}
</>
) : (
"N/A"
)}
</p>
</div>
);
})}
</div>
</DialogHeader>
</DialogContent>
</Dialog>
);
};
export default ContrastChecker;
+3 -27
View File
@@ -22,7 +22,6 @@ import {
DEFAULT_FONT_MONO,
DEFAULT_FONT_SANS,
DEFAULT_FONT_SERIF,
COMMON_STYLES,
defaultThemeState,
} from "../../config/theme";
import { Separator } from "../ui/separator";
@@ -32,6 +31,7 @@ import { toast } from "../ui/use-toast";
import { parseCssInput } from "../../utils/parse-css-input";
import ShadowControl from "./shadow-control";
import ThemeControlActions from "./theme-control-actions";
import ContrastChecker from "./contrast-checker";
const ThemeControlPanel = ({
styles,
@@ -45,6 +45,7 @@ const ThemeControlPanel = ({
resetToDefault,
hasDefaultThemeChanged,
hasCurrentPresetChanged,
updateStyle,
} = useEditorStore();
const [cssImportOpen, setCssImportOpen] = useState(false);
@@ -56,32 +57,6 @@ const ThemeControlPanel = ({
[currentMode, styles]
);
const updateStyle = React.useCallback(
<K extends keyof typeof currentStyles>(
key: K,
value: (typeof currentStyles)[K]
) => {
// apply common styles to both light and dark modes
if (COMMON_STYLES.includes(key)) {
onChange({
...styles,
light: { ...styles.light, [key]: value },
dark: { ...styles.dark, [key]: value },
});
return;
}
onChange({
...styles,
[currentMode]: {
...currentStyles,
[key]: value,
},
});
},
[onChange, styles, currentMode, currentStyles]
);
const handleCssImport = (css: string) => {
// This just shows a success toast for now
const { lightColors, darkColors } = parseCssInput(css);
@@ -110,6 +85,7 @@ const ThemeControlPanel = ({
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold">Theme Editor</h2>
<ContrastChecker currentStyles={currentStyles} />
</div>
<ThemeControlActions
hasChanges={hasDefaultThemeChanged()}
+56
View File
@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback } from "react";
import { getContrastRatio } from "../utils/contrast-checker";
import { debounce } from "../utils/debounce";
type ColorPair = {
id: string;
foreground: string;
background: string;
};
type ContrastResult = {
id: string;
contrastRatio: number;
};
/**
* Hook that calculates the contrast ratio between foreground and background colors for a list of pairs.
* @param colorPairs - An array of color pairs, each with an id, foreground color, and background color.
* @returns An array of objects, each containing the id and calculated contrast ratio for a pair.
*/
export function useContrastChecker(colorPairs: ColorPair[]) {
const [contrastResults, setContrastResults] = useState<ContrastResult[]>([]);
const debouncedCalculation = useCallback(
debounce((pairs: ColorPair[]) => {
if (!pairs.length) {
setContrastResults([]);
return;
}
try {
const results = pairs.map((pair) => {
const ratio = parseFloat(
getContrastRatio(pair.foreground, pair.background)
);
return {
id: pair.id,
contrastRatio: ratio,
};
});
setContrastResults(results);
} catch (error) {
console.error("Error checking contrast:", error);
setContrastResults([]);
}
}, 750),
[]
);
useEffect(() => {
debouncedCalculation(colorPairs);
}, [colorPairs, debouncedCalculation]);
return contrastResults;
}
+36 -1
View File
@@ -1,9 +1,10 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { ThemeEditorState } from "@/types/editor";
import { ThemeStyleProps } from "@/types/theme";
// @ts-expect-error: owned by ngard
import { isEqual } from "@ngard/tiny-isequal";
import { defaultThemeState } from "@/config/theme";
import { defaultThemeState, COMMON_STYLES } from "@/config/theme";
import { getPresetThemeStyles } from "@/utils/theme-presets";
interface EditorStore {
@@ -15,6 +16,10 @@ interface EditorStore {
hasDefaultThemeChanged: () => boolean;
hasCurrentPresetChanged: () => boolean;
hasChangedThemeFromDefault: boolean;
updateStyle: <K extends keyof ThemeStyleProps>(
key: K,
value: ThemeStyleProps[K]
) => void;
}
export const useEditorStore = create<EditorStore>()(
@@ -63,6 +68,36 @@ export const useEditorStore = create<EditorStore>()(
);
return !isEqual(state.themeState.styles, presetStyles);
},
updateStyle: (key, value) =>
set((state) => {
const currentMode = state.themeState.currentMode;
const currentStyles = state.themeState.styles;
let newStyles = { ...currentStyles };
if (COMMON_STYLES.includes(key as string)) {
newStyles = {
...newStyles,
light: { ...newStyles.light, [key]: value },
dark: { ...newStyles.dark, [key]: value },
};
} else {
newStyles = {
...newStyles,
[currentMode]: {
...(newStyles[currentMode] || {}),
[key]: value,
},
};
}
return {
themeState: {
...state.themeState,
styles: newStyles,
},
};
}),
}),
{
name: "editor-storage", // unique name for localStorage
+1
View File
@@ -10,6 +10,7 @@ export type ColorPickerProps = {
color: string;
onChange: (color: string) => void;
label: string;
onlyShowPicker?: boolean;
};
export type SliderInputProps = {
+45
View File
@@ -0,0 +1,45 @@
import * as culori from "culori";
/**
* Calculates the luminance of a color according to WCAG standard
* @param colorValue - The color in any supported format
* @returns The relative luminance of the color (0-1)
*/
function getLuminance(colorValue: string): number {
try {
const color = culori.parse(colorValue);
if (!color) {
console.warn(`Invalid color: ${colorValue}`);
return 0;
}
// Culori directly provides the luminance according to WCAG standard
return culori.wcagLuminance(color);
} catch (error) {
console.error(`Error calculating luminance: ${colorValue}`, error);
return 0;
}
}
/**
* Calculates the contrast ratio between two colors according to WCAG guidelines
* @param color1 - First color (in any format)
* @param color2 - Second color (in any format)
* @returns The contrast ratio as a string (e.g. "4.50")
*/
export function getContrastRatio(color1: string, color2: string): string {
try {
const lum1 = getLuminance(color1);
const lum2 = getLuminance(color2);
// WCAG contrast ratio formula
const ratio = (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05);
return ratio.toFixed(2);
} catch (error) {
console.error(
`Error calculating contrast between ${color1} and ${color2}:`,
error
);
return "1.00"; // Fallback value indicating low contrast
}
}