Skip to content
This repository was archived by the owner on Feb 6, 2024. It is now read-only.

Commit b4a25d3

Browse files
Merge pull request #842 from deckgo/contrast-inspector
feat: display an a11y warning when slide's colors do not meet contrast ratio
2 parents f782016 + 8206cfd commit b4a25d3

File tree

16 files changed

+452
-16
lines changed

16 files changed

+452
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
### Others
1919

2020
- deck-utils: v2.4.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/deck/CHANGELOG.md))
21-
- utils: v1.2.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/utils/CHANGELOG.md))
21+
- utils: v1.3.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/utils/CHANGELOG.md))
2222
- starter kit: v2.6.4 ([CHANGELOG](https://github.com/deckgo/deckdeckgo-starter/blob/master/CHANGELOG.md))
2323

2424
<a name="2.1.0"></a>

studio/package-lock.json

Lines changed: 10 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

studio/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"@deckdeckgo/slide-title": "^1.1.3",
4141
"@deckdeckgo/slide-youtube": "^1.1.2",
4242
"@deckdeckgo/social": "^2.0.0",
43-
"@deckdeckgo/utils": "^1.2.0",
43+
"@deckdeckgo/utils": "^1.3.0",
4444
"@deckdeckgo/youtube": "^1.1.2",
4545
"@ionic/core": "^5.3.1",
4646
"firebase": "^7.17.2",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
app-slide-contrast {
2+
position: absolute;
3+
top: 16px;
4+
left: 16px;
5+
6+
transition: opacity 0.5s;
7+
8+
visibility: initial;
9+
opacity: 1;
10+
11+
&:not(.warning) {
12+
visibility: hidden;
13+
opacity: 0;
14+
}
15+
16+
button {
17+
display: flex;
18+
justify-content: center;
19+
align-items: center;
20+
21+
background: var(--ion-color-warning);
22+
color: var(--ion-color-warning-contrast);
23+
24+
padding: 6px 12px;
25+
border-radius: 64px;
26+
27+
position: relative;
28+
overflow: hidden;
29+
30+
outline: 0;
31+
32+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
33+
34+
font-size: var(--font-size-small);
35+
36+
ion-label {
37+
margin-right: 4px;
38+
}
39+
}
40+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {Component, h, Host, Listen, State} from '@stencil/core';
2+
3+
import {popoverController} from '@ionic/core';
4+
5+
import {ContrastUtils} from '../../../utils/editor/contrast.utils';
6+
import {NodeUtils} from '../../../utils/editor/node.utils';
7+
8+
@Component({
9+
tag: 'app-slide-contrast',
10+
styleUrl: 'app-slide-contrast.scss',
11+
})
12+
export class AppSlideContrast {
13+
private readonly lowestAACompliantLevel: number = 3;
14+
15+
@State()
16+
private warning: boolean = false;
17+
18+
@Listen('slidesDidLoad', {target: 'document'})
19+
async onSlidesDidLoad() {
20+
await this.analyzeContrast();
21+
}
22+
23+
@Listen('slideDidUpdate', {target: 'document'})
24+
async onSlideDidUpdate() {
25+
await this.analyzeContrast();
26+
}
27+
28+
@Listen('deckDidChange', {target: 'document'})
29+
async onDeckDidChange() {
30+
await this.analyzeContrast();
31+
}
32+
33+
@Listen('slideNextDidChange', {target: 'document'})
34+
@Listen('slidePrevDidChange', {target: 'document'})
35+
@Listen('slideToChange', {target: 'document'})
36+
async onSlideNavigate() {
37+
await this.analyzeContrast();
38+
}
39+
40+
private async analyzeContrast() {
41+
this.warning = await this.hasLowContrast();
42+
}
43+
44+
private async hasLowContrast(): Promise<boolean> {
45+
const deck: HTMLElement = document.querySelector('main > deckgo-deck');
46+
47+
if (!deck) {
48+
return false;
49+
}
50+
51+
const index = await (deck as any).getActiveIndex();
52+
53+
const slide: HTMLElement = deck.querySelector('.deckgo-slide-container:nth-child(' + (index + 1) + ')');
54+
55+
if (!slide) {
56+
return false;
57+
}
58+
59+
const slots: NodeListOf<HTMLElement> = slide.querySelectorAll(
60+
'[slot="title"]:not(:empty),[slot="content"]:not(:empty),[slot="start"]:not(:empty),[slot="end"]:not(:empty),[slot="header"]:not(:empty),[slot="footer"]:not(:empty),[slot="author"]:not(:empty),deckgo-drr > section:not(:empty)'
61+
);
62+
63+
if (!slots || slots.length <= 0) {
64+
return false;
65+
}
66+
67+
// Slots with direct text children
68+
const slotsWithText: HTMLElement[] = await NodeUtils.childrenTextNode(slots);
69+
70+
// All children (<span/>) of the slots
71+
const children: HTMLElement[] = await NodeUtils.children(slots);
72+
73+
const elements: HTMLElement[] =
74+
children && children.length > 0
75+
? slotsWithText && slotsWithText.length > 0
76+
? [...Array.from(slotsWithText), ...children]
77+
: [...children]
78+
: slotsWithText && slotsWithText.length > 0
79+
? [...slotsWithText]
80+
: null;
81+
82+
if (!elements) {
83+
return false;
84+
}
85+
86+
const promises: Promise<number>[] = Array.from(elements).map((element: HTMLElement) => this.calculateRatio(element, deck, slide));
87+
88+
const contrasts: number[] = await Promise.all(promises);
89+
90+
if (!contrasts || contrasts.length <= 0) {
91+
return false;
92+
}
93+
94+
const lowContrast: number | undefined = contrasts.find((contrast: number) => contrast < this.lowestAACompliantLevel);
95+
96+
return lowContrast !== undefined;
97+
}
98+
99+
private async calculateRatio(element: HTMLElement, deck: HTMLElement, slide: HTMLElement) {
100+
const bgColor = await NodeUtils.findColors(element, 'background', deck, slide);
101+
const color = await NodeUtils.findColors(element, 'color', deck, slide);
102+
103+
console.log('yo', bgColor, color);
104+
105+
return ContrastUtils.calculateContrastRatio(bgColor, color);
106+
}
107+
108+
private async openInformation($event: UIEvent) {
109+
const popover: HTMLIonPopoverElement = await popoverController.create({
110+
component: 'app-contrast-info',
111+
event: $event,
112+
mode: 'ios',
113+
cssClass: 'info',
114+
});
115+
116+
await popover.present();
117+
}
118+
119+
render() {
120+
return (
121+
<Host
122+
class={{
123+
warning: this.warning,
124+
}}>
125+
<button class="ion-activatable" onClick={($event: UIEvent) => this.openInformation($event)}>
126+
<ion-ripple-effect></ion-ripple-effect>
127+
<ion-label>Low contrast</ion-label>
128+
<ion-icon name="warning-outline"></ion-icon>
129+
</button>
130+
</Host>
131+
);
132+
}
133+
}

studio/src/app/pages/editor/app-editor/app-editor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,7 @@ export class AppEditor {
652652
{this.footer}
653653
</deckgo-deck>
654654
<deckgo-remote autoConnect={false}></deckgo-remote>
655+
<app-slide-contrast></app-slide-contrast>
655656
</main>
656657
</ion-content>,
657658
<ion-footer class={this.presenting ? 'idle' : undefined}>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {Component, Element, h} from '@stencil/core';
2+
3+
@Component({
4+
tag: 'app-contrast-info',
5+
})
6+
export class AppContrastInfo {
7+
@Element() el: HTMLElement;
8+
9+
private async closePopover() {
10+
await (this.el.closest('ion-popover') as HTMLIonPopoverElement).dismiss();
11+
}
12+
13+
render() {
14+
return (
15+
<div class="ion-padding">
16+
<h2>Low contrast</h2>
17+
<p>We noticed that (a part of) the text color of this slide does not meet contrast ratio standards.</p>
18+
<p>
19+
Elements are compared according{' '}
20+
<a href="https://www.w3.org/TR/WCAG/#contrast-minimum" target="_blank" rel="noopener noreferrer">
21+
WCAG
22+
</a>{' '}
23+
Level AA.
24+
</p>
25+
26+
<p>Note that if you are using semi-transparent background, the contrast ratio cannot be precise.</p>
27+
<div class="ion-text-center">
28+
<ion-button size="small" shape="round" color="primary" onClick={() => this.closePopover()}>
29+
Got it
30+
</ion-button>
31+
</div>
32+
</div>
33+
);
34+
}
35+
}

studio/src/app/popovers/editor/app-get-help/app-get-help.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export class AppGetHelp {
3030
</a>
3131
.
3232
</p>
33-
<div class="ion-text-center ion-padding-top">
33+
<div class="ion-text-center">
3434
<ion-button size="small" shape="round" color="primary" onClick={() => this.closePopover()}>
3535
Got it
3636
</ion-button>
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {extractRgb, extractRgba} from '@deckdeckgo/utils';
2+
3+
export class ContrastUtils {
4+
static async calculateContrastRatio(bgColor: string | undefined, color: string | undefined): Promise<number> {
5+
const bgColorWithDefault: string = bgColor === undefined || bgColor === '' ? `rgb(255, 255, 255)` : bgColor;
6+
const colorWithDefault: string = color === undefined || color === '' ? `rgb(0, 0, 0)` : color;
7+
8+
// The text color may or may not be semi-transparent, but that doesn't matter
9+
const bgRgba: number[] | undefined = extractRgba(bgColorWithDefault);
10+
11+
if (!bgRgba || bgRgba.length < 4 || bgRgba[3] >= 1) {
12+
return this.calculateContrastRatioOpaque(bgColorWithDefault, colorWithDefault);
13+
}
14+
15+
return this.calculateContrastRatioAlpha(bgColorWithDefault, colorWithDefault);
16+
}
17+
18+
private static calculateLuminance(rgb: number[]): number {
19+
const a = rgb.map((v) => {
20+
v /= 255;
21+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
22+
});
23+
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
24+
}
25+
26+
private static calculateColorContrastRatio(firstColorLum: number, secondColorLum: number): number {
27+
// return firstColorLum > secondColorLum ? (secondColorLum + 0.05) / (firstColorLum + 0.05) : (firstColorLum + 0.05) / (secondColorLum + 0.05);
28+
29+
const l1 = firstColorLum + 0.05;
30+
const l2 = secondColorLum + 0.05;
31+
32+
let ratio = l1 / l2;
33+
34+
if (l2 > l1) {
35+
ratio = 1 / ratio;
36+
}
37+
38+
return ratio;
39+
}
40+
41+
// Source: https://github.com/LeaVerou/contrast-ratio/blob/eb7fe8f16206869f8d36d517d7eb0962830d0e81/color.js#L86
42+
private static async convertAlphaRgba(color: string, base: number[]): Promise<string> {
43+
const rgba: number[] | undefined = extractRgba(color);
44+
45+
if (!rgba || rgba.length < 4) {
46+
return color;
47+
}
48+
49+
const alpha: number = rgba[3];
50+
51+
const rgb: number[] = [];
52+
53+
for (let i = 0; i < 3; i++) {
54+
rgb.push(rgba[i] * alpha + base[i] * base[3] * (1 - alpha));
55+
}
56+
57+
// Not used here
58+
// rgb[3] = alpha + base[3] * (1 - alpha);
59+
60+
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
61+
}
62+
63+
private static async calculateColorContrastRatioWithBase(
64+
bgColor: string,
65+
lumColor: number,
66+
base: number[]
67+
): Promise<{luminanceOverlay: number; ratio: number}> {
68+
const overlay = extractRgb(await this.convertAlphaRgba(bgColor, base));
69+
70+
const lumOverlay: number = this.calculateLuminance(overlay);
71+
72+
return {
73+
luminanceOverlay: lumOverlay,
74+
ratio: this.calculateColorContrastRatio(lumOverlay, lumColor),
75+
};
76+
}
77+
78+
private static async calculateContrastRatioAlpha(bgColor: string, color: string): Promise<number> {
79+
const lumColor: number = this.calculateLuminance(extractRgb(color));
80+
81+
const onBlack: {luminanceOverlay: number; ratio: number} = await this.calculateColorContrastRatioWithBase(bgColor, lumColor, [0, 0, 0, 1]);
82+
const onWhite: {luminanceOverlay: number; ratio: number} = await this.calculateColorContrastRatioWithBase(bgColor, lumColor, [255, 255, 255, 1]);
83+
84+
const max = Math.max(onBlack.ratio, onWhite.ratio);
85+
86+
let min = 1;
87+
if (onBlack.luminanceOverlay > lumColor) {
88+
min = onBlack.ratio;
89+
} else if (onWhite.luminanceOverlay < lumColor) {
90+
min = onWhite.ratio;
91+
}
92+
93+
return (min + max) / 2;
94+
}
95+
96+
private static async calculateContrastRatioOpaque(bgColor: string, color: string): Promise<number> {
97+
const bgRgb: number[] | undefined = extractRgb(bgColor);
98+
const colorRgb: number[] | undefined = extractRgb(color);
99+
100+
if (bgColor === undefined || colorRgb === undefined) {
101+
// 0 being AA and AAA level friendly. We assume that if for some reason we can't extract color, we better not display a warning about it.
102+
return 0;
103+
}
104+
105+
const lumBg: number = this.calculateLuminance(bgRgb);
106+
const lumColor: number = this.calculateLuminance(colorRgb);
107+
108+
return this.calculateColorContrastRatio(lumBg, lumColor);
109+
}
110+
}

0 commit comments

Comments
 (0)