From afcb56bb3b4e26db9f6eab012ac9f5986b807830 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Mon, 31 Jul 2023 10:08:38 -0400 Subject: [PATCH] Add helper for detecting color-contrast --- app/helpers/sufficient-contrast.ts | 74 +++++++++++++++++++ .../helpers/sufficient-contrast-test.ts | 66 +++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 app/helpers/sufficient-contrast.ts create mode 100644 tests/integration/helpers/sufficient-contrast-test.ts diff --git a/app/helpers/sufficient-contrast.ts b/app/helpers/sufficient-contrast.ts new file mode 100644 index 0000000000..643650df48 --- /dev/null +++ b/app/helpers/sufficient-contrast.ts @@ -0,0 +1,74 @@ +import { helper } from '@ember/component/helper'; + +/** + * Criteria is based on WCAG 2.0 Guidelines. + * https://www.w3.org/WAI/WCAG21/quickref/?versions=2.0#qr-visual-audio-contrast-contrast + * + * Relative Luminance is calculated using the formula from WCAG 2.0 Guidelines. + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + * + * @param {Array} backgroundColor The background color in hex format + * @param {Array} foregroundColor The foreground color in hex format + * @param {Object} options {largeText: true if text is at least 18 point if not bold and at least 14 point if bold} + * @return {Boolean} Whether the contrast between the two colors is sufficient + */ + +const wcagAA = { + normalText: 4.5, + largeText: 3, +}; +const wcagAAA = { + normalText: 7, + largeText: 4.5, +}; + +function threeDigitHexToSixDigit(hex: string): string { + if (hex.length === 3) { + return hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + return hex; +} + +export function sufficientContrast( + [backgroundColor, foregroundColor]: [string, string], { largeText = false, useAAA = false }, +): boolean { + const standard = useAAA ? wcagAAA : wcagAA; + const threshold = largeText ? standard.largeText : standard.normalText; + let bg = backgroundColor.replace('#', ''); + let fg = foregroundColor.replace('#', ''); + bg = bg.length === 3 ? threeDigitHexToSixDigit(bg) : bg; + fg = fg.length === 3 ? threeDigitHexToSixDigit(fg) : fg; + + // convert background and foreground color hex to sRGB + const bgSRGB = { + r: parseInt('0x' + bg.substring(0, 2), 16) / 255, + g: parseInt('0x' + bg.substring(2, 4), 16) / 255, + b: parseInt('0x' + bg.substring(4, 6), 16) / 255, + }; + const fgSRGB = { + r: parseInt('0x' + fg.substring(0, 2), 16) / 255, + g: parseInt('0x' + fg.substring(2, 4), 16) / 255, + b: parseInt('0x' + fg.substring(4, 6), 16) / 255, + }; + + const bgRGBLuminance = { + r: bgSRGB.r <= 0.03928 ? bgSRGB.r / 12.92 : Math.pow((bgSRGB.r + 0.055) / 1.055, 2.4), + g: bgSRGB.g <= 0.03928 ? bgSRGB.g / 12.92 : Math.pow((bgSRGB.g + 0.055) / 1.055, 2.4), + b: bgSRGB.b <= 0.03928 ? bgSRGB.b / 12.92 : Math.pow((bgSRGB.b + 0.055) / 1.055, 2.4), + }; + const fgRGBLuminance = { + r: fgSRGB.r <= 0.03928 ? fgSRGB.r / 12.92 : Math.pow((fgSRGB.r + 0.055) / 1.055, 2.4), + g: fgSRGB.g <= 0.03928 ? fgSRGB.g / 12.92 : Math.pow((fgSRGB.g + 0.055) / 1.055, 2.4), + b: fgSRGB.b <= 0.03928 ? fgSRGB.b / 12.92 : Math.pow((fgSRGB.b + 0.055) / 1.055, 2.4), + }; + + // calculate relative luminance + const bgLuminance = 0.2126 * bgRGBLuminance.r + 0.7152 * bgRGBLuminance.g + 0.0722 * bgRGBLuminance.b; + const fgLuminance = 0.2126 * fgRGBLuminance.r + 0.7152 * fgRGBLuminance.g + 0.0722 * fgRGBLuminance.b; + + // calculate contrast ratio + const contrastRatio = (Math.max(bgLuminance, fgLuminance) + 0.05) / (Math.min(bgLuminance, fgLuminance) + 0.05); + return contrastRatio >= threshold; +} + +export default helper(sufficientContrast); diff --git a/tests/integration/helpers/sufficient-contrast-test.ts b/tests/integration/helpers/sufficient-contrast-test.ts new file mode 100644 index 0000000000..8f66b86f7c --- /dev/null +++ b/tests/integration/helpers/sufficient-contrast-test.ts @@ -0,0 +1,66 @@ +/* eslint-disable max-len */ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Helper | sufficient-contrast', function(hooks) { + setupRenderingTest(hooks); + + test('it calculates normal text for AA', async function(assert) { + // 21:1 ratio + await render(hbs`{{if (sufficient-contrast '#000' '#fff') 'good contrast' 'poor contrast'}}`); + assert.equal(this.element.textContent!.trim(), 'good contrast', '21:1 passes AA using three digit hex colors'); + // 4.6:1 ratio + await render(hbs`{{if (sufficient-contrast '#757575' '#fff') 'good contrast' 'poor contrast'}}`); + assert.equal(this.element.textContent!.trim(), 'good contrast', '4.6:1 passes AA'); + // 4.47:1 ratio + await render(hbs`{{if (sufficient-contrast '#fff' '#777') 'good contrast' 'poor contrast'}}`); + assert.equal(this.element.textContent!.trim(), 'poor contrast', '4.47:1 fails AA'); + }); + + test('it calculates large text AA', async function(assert) { + // 3.26:1 ratio + await render( + hbs`{{if (sufficient-contrast '#0090FF' '#fff' largeText=true) 'good contrast' 'poor contrast'}}`, + ); + assert.equal(this.element.textContent!.trim(), 'good contrast', '3.26:1 passes AA Large Text'); + // 2.81:1 ratio + await render( + hbs`{{if (sufficient-contrast '#fff' '#00A0FF' largeText=true) 'good contrast' 'poor contrast'}}`, + ); + assert.equal(this.element.textContent!.trim(), 'poor contrast', '2.81:1 fails AA Large Text'); + }); + + test('it calculates normal text AAA', async function(assert) { + // 7.2:1 ratio + await render( + hbs`{{if (sufficient-contrast '#50AA50' '#000' useAAA=true) 'good contrast' 'poor contrast'}}`, + ); + assert.equal(this.element.textContent!.trim(), 'good contrast', '7.2:1 passes AAA'); + // 6.48:1 ratio + await render( + hbs`{{if (sufficient-contrast '#000' '#50A050' useAAA=true) 'good contrast' 'poor contrast'}}`, + ); + assert.equal(this.element.textContent!.trim(), 'poor contrast', '6.48:1 fails AAA'); + }); + + test('it calculates large text AAA', async function(assert) { + // 6.48:1 ratio + await render( + hbs`{{if (sufficient-contrast '#000' '#50AA50' largeText=true useAAA=true) 'good contrast' 'poor contrast'}}`, + ); + assert.equal(this.element.textContent!.trim(), 'good contrast', '6.48:1 passes AAA Large Text'); + // 4.49:1 ratio + await render( + hbs`{{if (sufficient-contrast '#333' '#00A0FF' largeText=true useAAA=true) 'good contrast' 'poor contrast'}}`, + ); + assert.equal(this.element.textContent!.trim(), 'poor contrast', '4.49:1 fails AAA Large Text'); + // 4.53:1 ratio + await render( + hbs`{{if (sufficient-contrast '#00A0FF' '#303333' largeText=true useAAA=true) 'good contrast' 'poor contrast'}}`, + ); + assert.equal(this.element.textContent!.trim(), 'good contrast', '4.53:1 passes AAA Large Text'); + }); + +});