Skip to content

Commit

Permalink
Add helper for detecting color-contrast
Browse files Browse the repository at this point in the history
  • Loading branch information
futa-ikeda committed Jul 31, 2023
1 parent dc0c3da commit afcb56b
Show file tree
Hide file tree
Showing 2 changed files with 140 additions and 0 deletions.
74 changes: 74 additions & 0 deletions app/helpers/sufficient-contrast.ts
Original file line number Diff line number Diff line change
@@ -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);
66 changes: 66 additions & 0 deletions tests/integration/helpers/sufficient-contrast-test.ts
Original file line number Diff line number Diff line change
@@ -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');
});

});

0 comments on commit afcb56b

Please sign in to comment.