mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 07:32:19 -05:00
A11y colors (#2261)
* Fix value parsing to avoid errors and display reset correctly * Add color preview * Add color contrast checker * Run formatter * Satisfy checks * Small fixes * Update wording * Re-order combinations * Add translation keys * Use clsx for pass/fail * Use table elements * Remove wrapper element * Use single decimal for contrast * Move description into popover * Update css vars to accept preview colors * Update colors input * Remove reloadDocument * Wrap in details summary * Update popover text
This commit is contained in:
parent
6ba4b9d6ff
commit
5cc0be347f
|
|
@ -1,13 +1,31 @@
|
|||
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 "~/constants";
|
||||
import { Button } from "./Button";
|
||||
import { InfoPopover } from "./InfoPopover";
|
||||
import { Label } from "./Label";
|
||||
import { AlertIcon } from "./icons/Alert";
|
||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
||||
|
||||
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,
|
||||
}: {
|
||||
|
|
@ -18,20 +36,61 @@ export function CustomizedColorsInput({
|
|||
initialColors ?? {},
|
||||
);
|
||||
|
||||
const [defaultColors, setDefaultColors] = React.useState<
|
||||
Record<string, string>[]
|
||||
>([]);
|
||||
const [contrasts, setContrast] = React.useState<ContrastArray>([]);
|
||||
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<Label>{t("custom.colors.title")}</Label>
|
||||
<input type="hidden" name="css" value={JSON.stringify(colors)} />
|
||||
<div className="colors__grid">
|
||||
{CUSTOM_CSS_VAR_COLORS.filter((cssVar) => cssVar !== "bg-lightest").map(
|
||||
(cssVar) => {
|
||||
<details className="w-full">
|
||||
<summary className="colors__summary">
|
||||
<div>
|
||||
<span>{t("custom.colors.title")}</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div>
|
||||
<Label>{t("custom.colors.title")}</Label>
|
||||
<input type="hidden" name="css" value={JSON.stringify(colors)} />
|
||||
<div className="colors__container colors__grid">
|
||||
{CUSTOM_CSS_VAR_COLORS.filter(
|
||||
(cssVar) => cssVar !== "bg-lightest",
|
||||
).map((cssVar) => {
|
||||
return (
|
||||
<React.Fragment key={cssVar}>
|
||||
<div>{t(`custom.colors.${cssVar}`)}</div>
|
||||
<input
|
||||
type="color"
|
||||
className="plain"
|
||||
value={colors[cssVar]}
|
||||
value={colors[cssVar] ?? "#000000"}
|
||||
onChange={(e) => {
|
||||
const extras: Record<string, string> = {};
|
||||
if (cssVar === "bg-lighter") {
|
||||
|
|
@ -55,16 +114,204 @@ export function CustomizedColorsInput({
|
|||
if (cssVar === "bg-lighter") {
|
||||
newColors["bg-lightest"] = undefined;
|
||||
}
|
||||
setColors({ ...newColors, [cssVar]: undefined });
|
||||
setColors({
|
||||
...newColors,
|
||||
[cssVar]: defaultColors.find((color) => color[cssVar])?.[
|
||||
cssVar
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("actions.reset")}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
);
|
||||
},
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
<Label labelClassName="stack horizontal sm items-center">
|
||||
{t("custom.colors.contrast.title")}
|
||||
<InfoPopover tiny>
|
||||
<div className="colors__description">
|
||||
{t("custom.colors.contrast.description")}
|
||||
</div>
|
||||
</InfoPopover>
|
||||
</Label>
|
||||
<table className="colors__container colors__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("custom.colors.contrast.first-color")}</th>
|
||||
<th>{t("custom.colors.contrast.second-color")}</th>
|
||||
<th>AA</th>
|
||||
<th>AAA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contrasts.map((contrast) => {
|
||||
return (
|
||||
<tr key={contrast.colors.join("-")}>
|
||||
<td>{t(`custom.colors.${contrast.colors[0]}`)}</td>
|
||||
<td>{t(`custom.colors.${contrast.colors[1]}`)}</td>
|
||||
<td
|
||||
className={clsx(
|
||||
"colors__contrast",
|
||||
contrast.contrast.AA.failed ? "fail" : "success",
|
||||
)}
|
||||
>
|
||||
{contrast.contrast.AA.failed ? (
|
||||
<AlertIcon />
|
||||
) : (
|
||||
<CheckmarkIcon />
|
||||
)}
|
||||
{contrast.contrast.AA.ratio}
|
||||
</td>
|
||||
<td
|
||||
className={clsx(
|
||||
"colors__contrast",
|
||||
contrast.contrast.AAA.failed ? "fail" : "success",
|
||||
)}
|
||||
>
|
||||
{contrast.contrast.AAA.failed ? (
|
||||
<AlertIcon />
|
||||
) : (
|
||||
<CheckmarkIcon />
|
||||
)}
|
||||
{contrast.contrast.AAA.ratio}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function handleContrast(
|
||||
defaultColors: Record<string, string>[],
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,7 +291,10 @@ function useCustomizedCSSVars() {
|
|||
string,
|
||||
string
|
||||
>,
|
||||
).map(([key, value]) => [`--${key}`, value]),
|
||||
).map(([key, value]) => [
|
||||
`--${key}`,
|
||||
`var(--preview-${key}, ${value})`,
|
||||
]),
|
||||
) as React.CSSProperties;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1216,14 +1216,85 @@ abbr[title] {
|
|||
box-shadow: 0 0 100px inset rgb(255 255 255 / 25%);
|
||||
}
|
||||
|
||||
.colors__grid {
|
||||
display: grid;
|
||||
.colors__summary {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded-sm);
|
||||
background-color: var(--bg-input);
|
||||
font-weight: var(--bold);
|
||||
font-size: var(--fonts-xs);
|
||||
|
||||
& div {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 24px;
|
||||
color: var(--theme);
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
& + div {
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
}
|
||||
|
||||
.colors__container {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--bold);
|
||||
grid-template-columns: 3fr 2fr 1fr;
|
||||
row-gap: var(--s-3);
|
||||
padding: var(--s-3);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded-sm);
|
||||
background-color: var(--bg-input);
|
||||
margin-bottom: var(--s-3);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.colors__grid {
|
||||
display: grid;
|
||||
justify-content: space-between;
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.colors__table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
text-indent: 0;
|
||||
text-align: left;
|
||||
font-size: var(--fonts-xs);
|
||||
|
||||
& svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline;
|
||||
vertical-align: sub;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
& td {
|
||||
padding-block: var(--s-2);
|
||||
}
|
||||
|
||||
& tr:last-child td {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.colors__contrast {
|
||||
text-wrap-mode: nowrap;
|
||||
|
||||
&.fail {
|
||||
color: var(--theme-error);
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--theme-success);
|
||||
}
|
||||
}
|
||||
|
||||
.playwire__img {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
html {
|
||||
--bg: #ebebf0;
|
||||
--bg-darker: #f8f8f8;
|
||||
--bg-lighter: rgb(250 250 250);
|
||||
--bg: var(--preview-bg, #ebebf0);
|
||||
--bg-darker: var(--preview-bg-darker, #f8f8f8);
|
||||
--bg-lighter: var(--preview-bg-lighter, rgb(250 250 250));
|
||||
--bg-lighter-solid: rgb(250 250 250);
|
||||
--bg-lightest: #fff;
|
||||
--bg-lightest: var(--preview-bg-lightest, #fff);
|
||||
--bg-lightest-solid: #fff;
|
||||
--bg-light-variation: #fff;
|
||||
--bg-lighter-transparent: hsla(225deg 100% 88% / 50%);
|
||||
|
|
@ -18,9 +18,9 @@ html {
|
|||
--border-style: 1.5px solid var(--border);
|
||||
--button-text: rgb(0 0 0 / 85%);
|
||||
--button-text-transparent: rgb(0 0 0 / 65%);
|
||||
--text: rgb(0 0 0 / 95%);
|
||||
--text: var(--preview-text, rgb(0 0 0 / 95%));
|
||||
--black-text: rgb(0 0 0 / 95%);
|
||||
--text-lighter: rgb(75 75 75 / 95%);
|
||||
--text-lighter: var(--preview-text-lighter, rgb(75 75 75 / 95%));
|
||||
--divider: #f5a2c8;
|
||||
--theme-error: rgb(199 13 6);
|
||||
--theme-error-transparent: rgba(199 13 6 / 55%);
|
||||
|
|
@ -35,12 +35,12 @@ html {
|
|||
--theme-informative-red: #9d0404;
|
||||
--theme-informative-blue: #007f9c;
|
||||
--theme-informative-green: #017a0f;
|
||||
--theme: #f73e8b;
|
||||
--theme: var(--preview-theme, #f73e8b);
|
||||
--theme-transparent: #f3a0c386;
|
||||
--theme-very-transparent: #f3a0c341;
|
||||
--theme-vibrant: #f73e8b;
|
||||
--theme-semi-transparent: #ff99c477;
|
||||
--theme-secondary: rgb(63 58 255);
|
||||
--theme-secondary: var(--preview-theme-secondary, rgb(63 58 255));
|
||||
--theme-secondary-transparent: rgb(63 58 255 / 55%);
|
||||
--input-icon: #6a6a6c;
|
||||
--backdrop-filter: blur(10px) brightness(95%);
|
||||
|
|
@ -102,13 +102,13 @@ html {
|
|||
}
|
||||
|
||||
html.dark {
|
||||
--bg: #02011e;
|
||||
--bg-darker: #0a092d;
|
||||
--bg-lighter: rgb(169 138 255 / 10%);
|
||||
--bg: var(--preview-bg, #02011e);
|
||||
--bg-darker: var(--preview-bg-darker, #0a092d);
|
||||
--bg-lighter: var(--preview-bg-lighter, rgb(169 138 255 / 10%));
|
||||
--bg-lighter-solid: #140f34;
|
||||
--bg-lighter-transparent: rgb(64 67 108 / 50%);
|
||||
--bg-light-variation: #a98aff30;
|
||||
--bg-lightest: rgb(169 138 255 / 30%);
|
||||
--bg-lightest: var(--preview-bg-lightest, rgb(169 138 255 / 30%));
|
||||
--bg-lightest-solid: #342b62;
|
||||
--bg-darker-very-transparent: hsla(237.3deg 42.3% 26.6% / 50%);
|
||||
--bg-darker-transparent: #0a092dce;
|
||||
|
|
@ -120,10 +120,10 @@ html.dark {
|
|||
--border: rgb(255 255 255 / 10%);
|
||||
--button-text: rgb(0 0 0 / 85%);
|
||||
--button-text-transparent: rgb(0 0 0 / 65%);
|
||||
--text: #e1dede;
|
||||
--text: var(--preview-text, #e1dede);
|
||||
--badge-text: var(--text);
|
||||
--black-text: rgb(0 0 0 / 95%);
|
||||
--text-lighter: rgb(215 214 255 / 80%);
|
||||
--text-lighter: var(--preview-text-lighter, rgb(215 214 255 / 80%));
|
||||
--divider: #ffbedb2f;
|
||||
--theme-error: rgb(219 70 65);
|
||||
--theme-error-transparent: rgba(219 70 65 / 55%);
|
||||
|
|
@ -137,12 +137,12 @@ html.dark {
|
|||
--theme-informative-red: #ff9494;
|
||||
--theme-informative-blue: #a7efff;
|
||||
--theme-informative-green: #a2ffad;
|
||||
--theme: #ffc6de;
|
||||
--theme: var(--preview-theme, #ffc6de);
|
||||
--theme-very-transparent: #ffc6de36;
|
||||
--theme-vibrant: #f391ba;
|
||||
--theme-semi-transparent: #ff99c477;
|
||||
--theme-transparent: #ffc6de52;
|
||||
--theme-secondary: rgb(239 229 83);
|
||||
--theme-secondary: var(--preview-theme-secondary, rgb(239 229 83));
|
||||
--theme-secondary-transparent: rgb(239 229 83 / 55%);
|
||||
--input-icon: #9898a4;
|
||||
--backdrop-filter: blur(10px) brightness(75%);
|
||||
|
|
|
|||
|
|
@ -265,6 +265,10 @@
|
|||
"custom.colors.theme": "Theme",
|
||||
"custom.colors.theme-secondary": "Theme (secondary)",
|
||||
"custom.colors.chat": "Chat name",
|
||||
"custom.colors.contrast.title": "Color contrast table",
|
||||
"custom.colors.contrast.first-color": "First color",
|
||||
"custom.colors.contrast.second-color": "Second color",
|
||||
"custom.colors.contrast.description": "This table shows the contrast ratio between two colors listed in the first two columns. \n\nTo make your custom colors accessible to as many people as possible, you should meet a contrast ratio of at least 4.5 (AA) for all listed color combinations. \n\nThough not required, a contrast ratio of at least 7 (AAA) is recommended. \nThank you for making the web a more accessible place!",
|
||||
|
||||
"divisions.WEST": "Tentatek",
|
||||
"divisions.JPN": "Takoroka",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user