Skip to content

Commit 60ce7c2

Browse files
authored
Merge pull request #125 from boscop-fr/fix-wcag
WCAG compliance
2 parents 5788993 + d8b2f8e commit 60ce7c2

10 files changed

Lines changed: 272 additions & 25 deletions

File tree

e2e/orejime.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ test.describe('Orejime', () => {
4242
<template data-purpose="mandatory">
4343
<script id="mandatory"></script>
4444
</template>
45+
46+
<button
47+
id="obscured"
48+
style="position: absolute; bottom: 3rem; right: 3rem;"
49+
>
50+
Obscured
51+
</button>
52+
53+
<button
54+
id="non-obscured"
55+
style="position: absolute; bottom: 3rem; left: 3rem;"
56+
>
57+
Non obscured
58+
</button>
4559
`
4660
);
4761
});
@@ -210,6 +224,38 @@ test.describe('Orejime', () => {
210224
await expect(orejimePage.contextualNotice).not.toBeVisible();
211225
await expect(orejimePage.contextualNoticePlaceholder).not.toBeAttached();
212226
});
227+
228+
test('should not obscure any focused element (WCAG 2.4.12)', async () => {
229+
const initalPosition = await orejimePage.banner.boundingBox();
230+
const obscured = orejimePage.locator('#obscured');
231+
232+
// The button should be obscured by the banner at
233+
// this stage, thus not being able to receive click
234+
// events.
235+
await expect(
236+
obscured.click({
237+
timeout: 10
238+
})
239+
).rejects.toThrow();
240+
241+
// When the obscured button takes focus, the banner
242+
// should be moved so it becomes visible/usable.
243+
await obscured.focus();
244+
await obscured.click();
245+
246+
const position = await orejimePage.banner.boundingBox();
247+
console.log(position, initalPosition);
248+
await expect(position).not.toEqual(initalPosition);
249+
250+
// When a non obscured button takes focus, the banner
251+
// shouldn't be displaced.
252+
const nonObscured = orejimePage.locator('#non-obscured');
253+
await nonObscured.focus();
254+
await nonObscured.click();
255+
256+
const position2 = await orejimePage.banner.boundingBox();
257+
await expect(position2).toEqual(initalPosition);
258+
});
213259
});
214260

215261
test.describe('Orejime with forced banner', () => {

rspack.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ module.exports = {
128128
chunks: [],
129129
lang: 'fr'
130130
}),
131+
featureTemplatePlugin({
132+
title: 'WCAG compliance',
133+
feature: 'wcag',
134+
template: 'wcag'
135+
}),
131136
assetsPlugin(),
132137
matomoPlugin()
133138
]

site/assets/style.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ iframe {
1313
min-height: 25lh;
1414
}
1515

16+
.Iframe--narrow {
17+
min-height: 12lh;
18+
}
19+
1620
.is-hidden {
1721
display: none;
1822
}
@@ -68,6 +72,11 @@ label:has(.Form-error):has(+ :user-invalid) .Form-error {
6872
padding: var(--spacing-block--l) var(--spacing-inline--xl);
6973
}
7074

75+
.ExampleGrid {
76+
grid-template-columns: repeat(var(--grid-algorithm), minmax(20ch, 1fr));
77+
gap: var(--spacing-block--l) var(--spacing-inline--xl);
78+
}
79+
7180
.ExampleReset {
7281
--orejime-color-interactive: royalblue;
7382
--orejime-color-on-interactive: white;

site/features/wcag.html

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<!doctype html>
2+
3+
<html class="ExamplePage" lang="en">
4+
<head>
5+
<title>Orejime</title>
6+
7+
<link
8+
rel="stylesheet"
9+
href="https://boscop.fr/wp-content/themes/boscop/dist/app.css"
10+
/>
11+
12+
<link rel="stylesheet" href="../assets/style.css" />
13+
<link rel="stylesheet" href="../orejime-standard.css" />
14+
</head>
15+
16+
<body>
17+
<main class="ExampleMain" role="main">
18+
<div class="ExampleGrid Grid">
19+
<a href="#">Example link 1</a>
20+
<a href="#">Example link 2</a>
21+
<a href="#">Example link 3</a>
22+
<a href="#">Example link 4</a>
23+
<a href="#">Example link 5</a>
24+
<a href="#">Example link 6</a>
25+
<a href="#">Example link 7</a>
26+
<a href="#">Example link 8</a>
27+
<a href="#">Example link 9</a>
28+
</div>
29+
30+
<div class="orejime-Env">
31+
<button class="ExampleReset Button">Reset consent</button>
32+
</div>
33+
</main>
34+
35+
<script>
36+
window.orejimeConfig = {
37+
purposes: [
38+
{
39+
id: 'example',
40+
title: 'Example purpose'
41+
}
42+
],
43+
privacyPolicyUrl: '#'
44+
};
45+
</script>
46+
47+
<script src="../orejime-standard-en.js"></script>
48+
49+
<script>
50+
document
51+
.querySelector('.ExampleReset')
52+
.addEventListener('click', function () {
53+
window.orejime.manager.clearConsents();
54+
});
55+
</script>
56+
</body>
57+
</html>

site/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,31 @@ <h3>Accessibility</h3>
247247
complies to the WCAG.
248248
</p>
249249

250+
<details>
251+
<summary>Example</summary>
252+
253+
<p>
254+
Try navigating below with the keyboard, a screen reader or
255+
any assistive technology.
256+
<br />
257+
Orejime even handles complex criteria from the latest WCAG,
258+
like
259+
<a
260+
href="https://www.w3.org/TR/WCAG22/#focus-not-obscured-enhanced"
261+
>Criterion 2.4.12 Focus Not Obscured</a
262+
>: when navigating with the keyboard, the banner will
263+
always stay out of the way as to avoid obscuring the
264+
focused element.
265+
</p>
266+
267+
<iframe
268+
class="Iframe--narrow"
269+
data-src="./features/wcag.html"
270+
title="WCAG compliance"
271+
loading="lazy"
272+
></iframe>
273+
</details>
274+
250275
<h3>Ecodesign & performance</h3>
251276

252277
<p>

src/ui/themes/dsfr/Banner.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import {useTranslations} from '../../utils/hooks';
2-
import {template} from '../../utils/template';
1+
import {useRef} from 'preact/hooks';
32
import type {BannerComponent} from '../../components/types/Banner';
3+
import {useNonObscuringElement, useTranslations} from '../../utils/hooks';
4+
import {template} from '../../utils/template';
45

56
const Banner: BannerComponent = ({
67
needsUpdate,
@@ -11,10 +12,13 @@ const Banner: BannerComponent = ({
1112
onDecline,
1213
onConfigure
1314
}) => {
15+
const ref = useRef<HTMLDivElement>();
1416
const t = useTranslations();
1517

18+
useNonObscuringElement(ref);
19+
1620
return (
17-
<div className="fr-consent-banner" aria-hidden={isHidden}>
21+
<div className="fr-consent-banner" aria-hidden={isHidden} ref={ref}>
1822
{t.banner.title ? <h2 className="fr-h6">{t.banner.title}</h2> : null}
1923

2024
<div className="fr-consent-banner__content">

src/ui/themes/standard/Banner.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import {useRef} from 'preact/hooks';
2+
import {BannerComponent} from '../../components/types/Banner';
13
import {imageAttributes} from '../../utils/config';
2-
import {useTranslations} from '../../utils/hooks';
4+
import {useNonObscuringElement, useTranslations} from '../../utils/hooks';
35
import {template} from '../../utils/template';
4-
import {BannerComponent} from '../../components/types/Banner';
56

67
const Banner: BannerComponent = ({
78
isHidden,
@@ -13,10 +14,13 @@ const Banner: BannerComponent = ({
1314
onDecline: onDeclineRequest,
1415
onConfigure: onConfigRequest
1516
}) => {
17+
const ref = useRef<HTMLDivElement>();
1618
const t = useTranslations();
1719

20+
useNonObscuringElement(ref);
21+
1822
return (
19-
<div aria-hidden={isHidden} className="orejime-Banner">
23+
<div aria-hidden={isHidden} className="orejime-Banner" ref={ref}>
2024
<div className="orejime-Banner-body">
2125
{logo && (
2226
<div className="orejime-Banner-logoContainer">

src/ui/themes/standard/index.css

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
0.2em 0.6em 1.5em rgba(var(--orejime-color-shadow), 0.2);
3636
--orejime-banner-max-width: 45ch;
3737
--orejime-modal-max-width: 65ch;
38+
--orejime-collision-padding: 0;
3839

3940
all: unset;
4041

@@ -70,12 +71,13 @@
7071
:hover,
7172
:focus
7273
) {
73-
outline: 1px solid var(--orejime-color-interactive);
74+
outline: 2px solid var(--orejime-color-interactive);
7475
outline-offset: 1px;
7576
}
7677

7778
.orejime-Button:not(:is([disabled], [aria-disabled='true'])):active {
78-
outline-width: 2px;
79+
outline-width: 1px;
80+
outline-offset: 2px;
7981
}
8082

8183
.orejime-ButtonList {
@@ -89,15 +91,8 @@
8991
z-index: 1000;
9092
right: 0;
9193
bottom: 0;
92-
left: 0;
93-
display: flex;
94-
justify-content: flex-end;
9594
padding: var(--orejime-space-m);
96-
pointer-events: none;
97-
}
98-
99-
.orejime-Banner--forced {
100-
padding: 0;
95+
max-width: 100%;
10196
}
10297

10398
.orejime-Banner-body {
@@ -107,13 +102,6 @@
107102
max-width: var(--orejime-banner-max-width);
108103
background: var(--orejime-color-background);
109104
color: var(--orejime-color-text);
110-
pointer-events: auto;
111-
}
112-
113-
.orejime-Banner--forced .orejime-Banner-body {
114-
border: 0;
115-
border-radius: 0;
116-
max-width: 100%;
117105
}
118106

119107
.orejime-Banner-logo {

src/ui/utils/dom.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,97 @@ export const findFirstFocusableChild = (element: HTMLElement) =>
5353
element.querySelector<HTMLElement>(
5454
'a[href], button:not([disabled]):not([aria-hidden]), [tabindex]:not([tabindex^="-"])'
5555
);
56+
57+
// Translates an element vertically by a given offset,
58+
// relatively to its current translation.
59+
const translateElementY = (
60+
element: HTMLElement,
61+
offset: number,
62+
direction?: 1 | -1
63+
) => {
64+
const base = Number(element.dataset.translation) || 0;
65+
const translation = base + offset * direction;
66+
67+
// We're only moving the element if the translation has
68+
// the given direction, as we don't want it to move the
69+
// other way.
70+
if (Math.sign(translation) === Math.sign(direction)) {
71+
element.dataset.translation = translation.toString();
72+
element.style.transform = `translateY(${translation}px)`;
73+
} else {
74+
delete element.dataset.translation;
75+
element.style.transform = '';
76+
}
77+
};
78+
79+
const getCollisionPadding = (element: HTMLElement) => {
80+
const styles = window.getComputedStyle(element);
81+
const padding = styles.getPropertyValue('--orejime-collision-padding');
82+
return padding.length ? parseInt(padding, 10) : 16;
83+
};
84+
85+
const getPaddedBoundingBox = (element: DOMRect, padding: number) => ({
86+
top: element.top + padding,
87+
right: element.right + padding,
88+
bottom: element.bottom + padding,
89+
left: element.left + padding
90+
});
91+
92+
// Resolves a visual collision between two elements, either
93+
// by scrolling the page or moving one of them.
94+
// We're only resolving collisions on the vertical axis, as
95+
// it is the main direction of web pages.
96+
export const resolveCollision = (fixed: HTMLElement, mobile: HTMLElement) => {
97+
if (mobile.contains(fixed)) {
98+
translateElementY(mobile, 0);
99+
return;
100+
}
101+
102+
// We're padding the fixed element's bounding box to
103+
// avoid snapping the mobile one right on its border.
104+
const fixedRect = getPaddedBoundingBox(
105+
fixed.getBoundingClientRect(),
106+
getCollisionPadding(mobile)
107+
);
108+
109+
const mobileRect = mobile.getBoundingClientRect();
110+
const isCollidingX =
111+
mobileRect.left < fixedRect.right && mobileRect.right > fixedRect.left;
112+
113+
if (!isCollidingX) {
114+
translateElementY(mobile, 0);
115+
return;
116+
}
117+
118+
const mobileCenterY = mobileRect.top + mobileRect.height / 2;
119+
const direction = mobileCenterY > window.innerHeight / 2 ? 1 : -1;
120+
const overlap =
121+
direction > 0
122+
? fixedRect.bottom - mobileRect.top
123+
: mobileRect.bottom - fixedRect.top;
124+
125+
const isCollidingY =
126+
mobileRect.top < fixedRect.bottom && mobileRect.bottom > fixedRect.top;
127+
128+
if (!isCollidingY) {
129+
translateElementY(mobile, overlap, direction);
130+
return;
131+
}
132+
133+
const doc = document.documentElement;
134+
const leeway =
135+
direction > 0
136+
? Math.abs(doc.scrollHeight - doc.clientHeight - doc.scrollTop)
137+
: doc.scrollTop;
138+
139+
// We're scrolling as much possible first.
140+
window.scrollBy({
141+
top: overlap * direction
142+
});
143+
144+
// If scrolling isn't enough to get out of trouble,
145+
// we're moving the mobile element out of the way.
146+
if (overlap > leeway) {
147+
translateElementY(mobile, overlap - leeway, direction);
148+
}
149+
};

0 commit comments

Comments
 (0)