mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-08-30 18:10:28 +08:00
feat: Implement contrast checker and color picker refactor
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ColorPickerProps = {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
onlyShowPicker?: boolean;
|
||||
};
|
||||
|
||||
export type SliderInputProps = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user