import clsx from "clsx"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useDebounce } from "react-use"; import { CUSTOM_CSS_VAR_COLORS } from "~/features/user-page/user-page-constants"; import { SendouButton } from "./elements/Button"; import { InfoPopover } from "./InfoPopover"; import { AlertIcon } from "./icons/Alert"; import { CheckmarkIcon } from "./icons/Checkmark"; import { Label } from "./Label"; type CustomColorsRecord = Partial< Record<(typeof CUSTOM_CSS_VAR_COLORS)[number], string> >; type ContrastCombination = [ Exclude<(typeof CUSTOM_CSS_VAR_COLORS)[number], "bg-lightest">, Exclude<(typeof CUSTOM_CSS_VAR_COLORS)[number], "bg-lightest">, ]; type ContrastArray = { colors: ContrastCombination; contrast: { AA: { failed: boolean; ratio: string }; AAA: { failed: boolean; ratio: string }; }; }[]; export function CustomizedColorsInput({ initialColors, }: { initialColors?: Record | null; }) { const { t } = useTranslation(); const [colors, setColors] = React.useState( initialColors ?? {}, ); const [defaultColors, setDefaultColors] = React.useState< Record[] >([]); const [contrasts, setContrast] = React.useState([]); useDebounce( () => { for (const color in colors) { const value = colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? ""; document.documentElement.style.setProperty(`--preview-${color}`, value); } setContrast(handleContrast(defaultColors, colors)); }, 100, [colors], ); React.useEffect(() => { const colors = CUSTOM_CSS_VAR_COLORS.map((color) => { return { [color]: getComputedStyle(document.documentElement).getPropertyValue( `--${color}`, ), }; }); setDefaultColors(colors); return () => { document.documentElement.removeAttribute("style"); }; }, []); return (
{t("custom.colors.title")}
{CUSTOM_CSS_VAR_COLORS.filter( (cssVar) => cssVar !== "bg-lightest", ).map((cssVar) => { return (
{t(`custom.colors.${cssVar}`)}
{ const extras: Record = {}; if (cssVar === "bg-lighter") { extras["bg-lightest"] = `${e.target.value}80`; } setColors({ ...colors, ...extras, [cssVar]: e.target.value, }); }} data-testid={`color-input-${cssVar}`} /> { const newColors: Record = { ...colors, }; if (cssVar === "bg-lighter") { newColors["bg-lightest"] = defaultColors.find( (color) => color["bg-lightest"], )?.["bg-lightest"]; } setColors({ ...newColors, [cssVar]: defaultColors.find((color) => color[cssVar])?.[ cssVar ], }); }} > {t("actions.reset")}
); })}
{contrasts.map((contrast) => { return ( ); })}
{t("custom.colors.contrast.first-color")} {t("custom.colors.contrast.second-color")} AA AAA
{t(`custom.colors.${contrast.colors[0]}`)} {t(`custom.colors.${contrast.colors[1]}`)} {contrast.contrast.AA.failed ? ( ) : ( )} {contrast.contrast.AA.ratio} {contrast.contrast.AAA.failed ? ( ) : ( )} {contrast.contrast.AAA.ratio}
); } function colorsWithDefaultsFilteredOut( colors: CustomColorsRecord, defaultColors: Record[], ): CustomColorsRecord { const colorsWithoutDefaults: CustomColorsRecord = {}; for (const color in colors) { if ( colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]] !== defaultColors.find((c) => c[color])?.[color] ) { colorsWithoutDefaults[color as keyof CustomColorsRecord] = colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]]; } } return colorsWithoutDefaults; } function handleContrast( defaultColors: Record[], colors: CustomColorsRecord, ) { /* Excluded because bg-lightest is not visible to the user, tho these should be checked as well: ["bg-lightest", "text"], ["bg-lightest", "theme-secondary"], */ const combinations: ContrastCombination[] = [ ["bg", "text"], ["bg", "text-lighter"], ["bg-darker", "text"], ["bg-darker", "theme"], ["bg-lighter", "text-lighter"], ["bg-lighter", "theme"], ["bg-lighter", "theme-secondary"], ]; const results: ContrastArray = []; for (const [A, B] of combinations) { const valueA = colors[A as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? undefined; const valueB = colors[B as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? undefined; const colorA = valueA ?? defaultColors.find((color) => color[A])?.[A]; const colorB = valueB ?? defaultColors.find((color) => color[B])?.[B]; if (!colorA || !colorB) continue; const parsedA = colorA.includes("rgb") ? parseCSSVar(colorA) : colorA; const parsedB = colorB.includes("rgb") ? parseCSSVar(colorB) : colorB; results.push({ colors: [A, B], contrast: checkContrast(parsedA, parsedB), }); } return results; } function parseCSSVar(cssVar: string) { const regex = /rgb\((\d+)\s+(\d+)\s+(\d+)(?:\s+\/\s+(\d+%?))?\)/; const match = cssVar.match(regex); if (!match) { return "#000000"; } const r = Number.parseInt(match[1], 10); const g = Number.parseInt(match[2], 10); const b = Number.parseInt(match[3], 10); let alpha = 255; if (match[4]) { const percentage = Number.parseInt(match[4], 10); alpha = Math.round((percentage / 100) * 255); } const toHex = (value: number) => { const hex = value.toString(16); return hex.length === 1 ? `0${hex}` : hex; }; if (match[4]) { return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alpha)}`; } return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } function checkContrast(colorA: string, colorB: string) { const rgb1 = hexToRgb(colorA); const rgb2 = hexToRgb(colorB); const luminanceA = calculateLuminance(rgb1); const luminanceB = calculateLuminance(rgb2); const light = Math.max(luminanceA, luminanceB); const dark = Math.min(luminanceA, luminanceB); const ratio = (light + 0.05) / (dark + 0.05); return { AA: { failed: ratio < 4.5, ratio: ratio.toFixed(1), }, AAA: { failed: ratio < 7, ratio: ratio.toFixed(1), }, }; } function hexToRgb(hex: string) { const noHash = hex.replace(/^#/, ""); const r = Number.parseInt(noHash.substring(0, 2), 16); const g = Number.parseInt(noHash.substring(2, 4), 16); const b = Number.parseInt(noHash.substring(4, 6), 16); if (noHash.length === 8) { const alpha = Number.parseInt(noHash.substring(6, 8), 16) / 255; return [ Math.round(r * alpha), Math.round(g * alpha), Math.round(b * alpha), ]; } return [r, g, b]; } function calculateLuminance(rgb: number[]) { const [r, g, b] = rgb.map((value) => { const normalized = value / 255; return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }