Skip to content

Instantly share code, notes, and snippets.

@mizchi
Created April 29, 2025 12:45
Show Gist options
  • Save mizchi/109845fe953ed7c2b609d51cdba1a380 to your computer and use it in GitHub Desktop.
Save mizchi/109845fe953ed7c2b609d51cdba1a380 to your computer and use it in GitHub Desktop.
/**
* アクセシビリティ自動修正スクリプト
*
* 以下の問題を検出し修正します:
* 1. コントラスト比の問題
* 2. aria-hidden属性がない装飾的要素
* 3. アクセシブルな名前がないボタン
* 4. ビューポートのuser-scalable=noとmaximum-scale問題
* 5. タイトル要素の欠如
* 6. html要素のlang属性の問題
*
* 使用方法:
* 1. このスクリプトをページに含める
* 2. fixAccessibility()を呼び出す
*/
// 型定義
type RGB = {
r: number;
g: number;
b: number;
};
type FixerOptions = {
fixContrast?: boolean;
fixButtons?: boolean;
fixAriaHidden?: boolean;
fixViewport?: boolean;
fixTitle?: boolean;
fixLang?: boolean;
minContrastRatio?: number;
minLargeTextContrastRatio?: number;
debug?: boolean;
};
// コントラスト比計算のためのユーティリティ関数
/**
* HEX色コードをRGB値に変換
* @param hex - #FF0000 のような16進数カラーコード
* @returns RGB値のオブジェクト {r, g, b}
*/
export function hexToRgb(hex: string): RGB {
// #がある場合は削除
hex = hex.replace(/^#/, "");
// 3桁のhexの場合は6桁に拡張(例:#F00 → #FF0000)
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return { r, g, b };
}
/**
* RGB値をHEX色コードに変換
* @param r - 赤 (0-255)
* @param g - 緑 (0-255)
* @param b - 青 (0-255)
* @returns HEX色コード
*/
export function rgbToHex(r: number, g: number, b: number): string {
return (
"#" +
[r, g, b]
.map((x) => {
const hex = Math.max(0, Math.min(255, Math.round(x))).toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("")
);
}
/**
* 色の相対的な輝度を計算
* WCAG 2.0: https://www.w3.org/TR/WCAG20/#relativeluminancedef
* @param rgb - {r, g, b} 形式のオブジェクト (0-255)
* @returns 相対的な輝度 (0-1)
*/
export function getLuminance(rgb: RGB): number {
const sRGB = [rgb.r, rgb.g, rgb.b].map((val) => {
val = val / 255;
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * sRGB[0] + 0.7152 * sRGB[1] + 0.0722 * sRGB[2];
}
/**
* 2つの色のコントラスト比を計算
* WCAG 2.0: https://www.w3.org/TR/WCAG20/#contrast-ratiodef
* @param color1 - 最初の色 (HEX)
* @param color2 - 2番目の色 (HEX)
* @returns コントラスト比 (1-21)
*/
export function getContrastRatio(color1: string, color2: string): number {
const lum1 = getLuminance(hexToRgb(color1));
const lum2 = getLuminance(hexToRgb(color2));
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}
/**
* コントラスト比が基準を満たすように色を調整
* @param bgColor - 背景色 (HEX)
* @param textColor - テキスト色 (HEX)
* @param targetRatio - 目標コントラスト比 (通常 4.5 または 3)
* @returns 調整されたテキスト色 (HEX)
*/
export function adjustColorForContrast(
bgColor: string,
textColor: string,
targetRatio: number = 4.5
): string {
const bgRgb = hexToRgb(bgColor);
let textRgb = hexToRgb(textColor);
let ratio = getContrastRatio(bgColor, textColor);
// すでに目標を達成している場合は何もしない
if (ratio >= targetRatio) {
return textColor;
}
// 背景の輝度を取得
const bgLum = getLuminance(bgRgb);
// 背景が明るい場合はテキストを暗く、背景が暗い場合はテキストを明るくする
const makeDarker = bgLum > 0.5;
// 調整のステップサイズ (小さいほど精度が高いが処理に時間がかかる)
const step = 5;
if (makeDarker) {
// テキストを徐々に暗くする
while (
ratio < targetRatio &&
(textRgb.r > 0 || textRgb.g > 0 || textRgb.b > 0)
) {
textRgb.r = Math.max(0, textRgb.r - step);
textRgb.g = Math.max(0, textRgb.g - step);
textRgb.b = Math.max(0, textRgb.b - step);
ratio = getContrastRatio(
bgColor,
rgbToHex(textRgb.r, textRgb.g, textRgb.b)
);
}
} else {
// テキストを徐々に明るくする
while (
ratio < targetRatio &&
(textRgb.r < 255 || textRgb.g < 255 || textRgb.b < 255)
) {
textRgb.r = Math.min(255, textRgb.r + step);
textRgb.g = Math.min(255, textRgb.g + step);
textRgb.b = Math.min(255, textRgb.b + step);
ratio = getContrastRatio(
bgColor,
rgbToHex(textRgb.r, textRgb.g, textRgb.b)
);
}
}
return rgbToHex(textRgb.r, textRgb.g, textRgb.b);
}
/**
* CSSの色文字列(HEX、RGB、RGBAなど)を16進数形式に変換
* @param colorStr - 色文字列 (#FF0000, rgb(255,0,0), rgba(255,0,0,0.5)など)
* @returns HEX色コード (#FF0000など)
*/
export function parseColorToHex(colorStr: string): string {
// すでにHEX形式の場合
if (/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(colorStr)) {
return colorStr;
}
// RGB形式の場合
const rgbMatch = colorStr.match(
/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/
);
if (rgbMatch) {
return rgbToHex(
parseInt(rgbMatch[1], 10),
parseInt(rgbMatch[2], 10),
parseInt(rgbMatch[3], 10)
);
}
// RGBA形式の場合
const rgbaMatch = colorStr.match(
/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/
);
if (rgbaMatch) {
return rgbToHex(
parseInt(rgbaMatch[1], 10),
parseInt(rgbaMatch[2], 10),
parseInt(rgbaMatch[3], 10)
);
}
// 名前付き色(red, blue など)の場合は、一時的なDOM要素を使用して変換
const tempElem = document.createElement("div");
tempElem.style.color = colorStr;
document.body.appendChild(tempElem);
const computedColor = getComputedStyle(tempElem).color;
document.body.removeChild(tempElem);
// 計算されたRGB値を取得して16進数に変換
const match = computedColor.match(
/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/
);
if (match) {
return rgbToHex(
parseInt(match[1], 10),
parseInt(match[2], 10),
parseInt(match[3], 10)
);
}
// 変換できない場合はデフォルト値を返す
return "#000000";
}
// アクセシビリティ修正関数
/**
* HTML要素のlang属性を修正
*/
export function fixHtmlLang(): void {
const html = document.documentElement;
const currentLang = html.getAttribute("lang");
// lang属性がない、または空の場合
if (!currentLang || currentLang.trim() === "") {
// ページ内容から言語を推測する代わりに、
// ナビゲーター言語か日本語をデフォルトとして設定
const browserLang = navigator.language || "ja";
html.setAttribute("lang", browserLang.split("-")[0]);
console.log(`HTML lang属性を修正しました: ${html.getAttribute("lang")}`);
}
}
/**
* ビューポートメタタグを修正
*/
export function fixViewportMeta(): void {
let viewportMeta = document.querySelector('meta[name="viewport"]');
if (viewportMeta) {
let content = viewportMeta.getAttribute("content");
// user-scalable=noを削除し、maximum-scaleを5以上に設定
if (content) {
content = content
.replace(/user-scalable\s*=\s*no/gi, "user-scalable=yes")
.replace(/maximum-scale\s*=\s*([0-9.]*)/gi, (match, p1) => {
return parseFloat(p1) < 5 ? "maximum-scale=5.0" : match;
});
// maximum-scaleが存在しない場合は追加
if (!content.includes("maximum-scale")) {
content += ", maximum-scale=5.0";
}
viewportMeta.setAttribute("content", content);
console.log("ビューポートメタタグを修正しました:", content);
}
} else {
// ビューポートメタタグが存在しない場合は追加
viewportMeta = document.createElement("meta");
viewportMeta.setAttribute("name", "viewport");
viewportMeta.setAttribute(
"content",
"width=device-width, initial-scale=1.0, maximum-scale=5.0"
);
const head = document.head || document.getElementsByTagName("head")[0];
if (head) {
head.appendChild(viewportMeta);
console.log("ビューポートメタタグを追加しました");
}
}
}
/**
* ドキュメントのタイトルを修正
*/
export function fixDocumentTitle(): void {
if (!document.title || document.title.trim() === "") {
// h1またはページの最初の見出しからタイトルを生成
const h1 = document.querySelector("h1");
const firstHeading = h1 || document.querySelector("h2, h3, h4, h5, h6");
if (firstHeading) {
document.title = firstHeading.textContent!.trim();
} else {
// 適切な見出しがない場合はURLからタイトルを生成
const pathName = window.location.pathname;
const pageName = pathName?.split("/").pop()?.split(".")[0]!;
document.title =
pageName.charAt(0).toUpperCase() + pageName.slice(1) || "ホームページ";
}
console.log(`タイトルを追加しました: ${document.title}`);
}
}
/**
* 装飾的要素にaria-hidden="true"を追加
*/
export function fixAriaHidden(): void {
// 装飾的なアイコン(iタグやspanタグで中身がシンボルのみ)を探す
const iconElements = document.querySelectorAll("i, span:not(:empty)");
iconElements.forEach((element) => {
const text = element.textContent!.trim();
const hasAriaHidden = element.hasAttribute("aria-hidden");
const hasAriaLabel = element.hasAttribute("aria-label");
const childElements = element.children.length;
// テキストが1-2文字で、子要素がなく、aria-labelも設定されていない場合は装飾的とみなす
if (text.length <= 2 && !childElements && !hasAriaLabel && !hasAriaHidden) {
element.setAttribute("aria-hidden", "true");
console.log(`装飾的要素にaria-hidden="true"を追加しました:`, element);
}
});
// display:noneの要素にaria-hidden="true"を追加
const hiddenElements = document.querySelectorAll("*");
hiddenElements.forEach((element) => {
const computedStyle = window.getComputedStyle(element);
if (
computedStyle.display === "none" &&
!element.hasAttribute("aria-hidden")
) {
element.setAttribute("aria-hidden", "true");
console.log(`非表示要素にaria-hidden="true"を追加しました:`, element);
}
});
}
/**
* アクセシブルな名前がないボタンを修正
*/
export function fixButtonsWithoutAccessibleName(): void {
const buttons = document.querySelectorAll('button, [role="button"]');
buttons.forEach((button) => {
const hasText = button.textContent!.trim() !== "";
const hasAriaLabel = button.hasAttribute("aria-label");
const hasAriaLabelledby = button.hasAttribute("aria-labelledby");
const hasTitle = button.hasAttribute("title");
// アクセシブルな名前がない場合
if (!hasText && !hasAriaLabel && !hasAriaLabelledby && !hasTitle) {
// アイコンを含むかチェック
const iconElement = button.querySelector("i, span, img");
if (iconElement) {
// アイコンの種類を推測してラベルを生成
let label = "";
if (iconElement.textContent!.includes("⚙️")) {
label = "設定";
} else if (iconElement.textContent!.includes("🔍")) {
label = "検索";
} else if (iconElement.textContent!.includes("📊")) {
label = "グラフ";
} else {
// 一般的なアイコンの場合
label = "ボタン";
}
button.setAttribute("aria-label", label);
console.log(`ボタンにaria-label="${label}"を追加しました:`, button);
} else {
// アイコンがない場合はボタンの位置から用途を推測
button.setAttribute("aria-label", "ボタン");
console.log(`ボタンにaria-label="ボタン"を追加しました:`, button);
}
}
});
}
/**
* 要素の背景色を取得(透明な場合は親要素まで再帰的に探索)
* @param element - 対象要素
* @returns 背景色のHEX値
*/
export function getBackgroundColor(element: Element): string {
if (!element) return "#FFFFFF"; // デフォルトは白
const style = window.getComputedStyle(element);
const bgColor = style.backgroundColor;
// rgba(0,0,0,0)または'transparent'は透明を意味する
if (bgColor === "rgba(0, 0, 0, 0)" || bgColor === "transparent") {
// body要素に到達した場合はデフォルト色を返す
if (element.tagName === "BODY") {
return "#FFFFFF";
}
// 親要素の背景色を再帰的に取得
return getBackgroundColor(element.parentElement!);
}
return parseColorToHex(bgColor);
}
/**
* フォントサイズをピクセル単位に変換
* @param fontSize - CSSフォントサイズ値 (例: '16px', '1.2em', '14pt')
* @returns ピクセル単位のフォントサイズ
*/
export function getFontSizeInPixels(fontSize: string): number {
// すでにpx単位の場合
if (fontSize.endsWith("px")) {
return parseFloat(fontSize);
}
// ピクセル単位に変換するための一時的な要素を作成
const temp = document.createElement("div");
temp.style.visibility = "hidden";
temp.style.position = "absolute";
temp.style.fontSize = fontSize;
document.body.appendChild(temp);
// 計算されたスタイルでピクセル単位のサイズを取得
const pixelSize = parseFloat(window.getComputedStyle(temp).fontSize);
document.body.removeChild(temp);
return pixelSize;
}
/**
* コントラスト比の低い要素を修正
* @param minContrastRatio - 通常テキストの最小コントラスト比
* @param minLargeTextContrastRatio - 大きいテキストの最小コントラスト比
*/
export function fixLowContrast(
minContrastRatio: number = 4.5,
minLargeTextContrastRatio: number = 3
): void {
// テキストを持つ要素をすべて取得
const textElements = document.querySelectorAll(
"body *:not(script):not(style)"
);
textElements.forEach((element) => {
// 要素とその背景色を取得
const style = window.getComputedStyle(element);
const textColor = parseColorToHex(style.color);
// 背景色は親要素から継承される可能性があるため、再帰的に取得
const bgColor = getBackgroundColor(element);
// コントラスト比を計算
const ratio = getContrastRatio(textColor, bgColor);
// フォントサイズを取得(px単位に変換)
const fontSize = getFontSizeInPixels(style.fontSize);
// 太字かどうか
const isBold = parseInt(style.fontWeight, 10) >= 700;
// 大きいテキストかどうか (18pt/24px以上または14pt/18.67px以上で太字)
const isLargeText = fontSize >= 24 || (fontSize >= 18.67 && isBold);
// 適用する最小コントラスト比
const requiredRatio = isLargeText
? minLargeTextContrastRatio
: minContrastRatio;
// コントラスト比が不十分な場合
if (ratio < requiredRatio) {
// テキスト色を調整
const adjustedColor = adjustColorForContrast(
bgColor,
textColor,
requiredRatio
);
// 要素のインラインスタイルでテキスト色を修正
if (element instanceof HTMLElement) {
element.style.color = adjustedColor;
}
console.log(
`テキスト色を修正しました: ${textColor}${adjustedColor} (コントラスト比: ${ratio.toFixed(
2
)}${getContrastRatio(adjustedColor, bgColor).toFixed(2)})`
);
}
});
}
/**
* ページ全体のアクセシビリティ問題を修正する
* @param options - 設定オプション
*/
export function fixAccessibility(options: FixerOptions = {}): void {
const defaultOptions = {
fixContrast: true,
fixButtons: true,
fixAriaHidden: true,
fixViewport: true,
fixTitle: true,
fixLang: true,
minContrastRatio: 4.5,
minLargeTextContrastRatio: 3,
debug: false,
};
// オプションをマージ
const config = { ...defaultOptions, ...options };
// 修正を実行
if (config.fixLang) fixHtmlLang();
if (config.fixViewport) fixViewportMeta();
if (config.fixTitle) fixDocumentTitle();
if (config.fixAriaHidden) fixAriaHidden();
if (config.fixButtons) fixButtonsWithoutAccessibleName();
if (config.fixContrast)
fixLowContrast(config.minContrastRatio, config.minLargeTextContrastRatio);
if (config.debug) {
console.log("アクセシビリティ修正が完了しました。設定:", config);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment