Created
April 29, 2025 12:45
-
-
Save mizchi/109845fe953ed7c2b609d51cdba1a380 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* アクセシビリティ自動修正スクリプト | |
* | |
* 以下の問題を検出し修正します: | |
* 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