Skip to content

Commit 9eb14ae

Browse files
committed
Preventing the banner from obscuring focus
When an element is focused under the banner, we're moving the banner out of the way, either by scrolling the page or by moving it. WCAG 2.4.12
1 parent 277e39a commit 9eb14ae

5 files changed

Lines changed: 126 additions & 8 deletions

File tree

e2e/orejime.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ 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>
4552
`
4653
);
4754
});
@@ -210,6 +217,24 @@ test.describe('Orejime', () => {
210217
await expect(orejimePage.contextualNotice).not.toBeVisible();
211218
await expect(orejimePage.contextualNoticePlaceholder).not.toBeAttached();
212219
});
220+
221+
test('should not obscure any focused element (WCAG 2.4.12)', async () => {
222+
const obscured = orejimePage.locator('#obscured');
223+
224+
// The button should be obscured by the banner at
225+
// this stage, thus not being able to receive click
226+
// events.
227+
await expect(
228+
obscured.click({
229+
timeout: 10
230+
})
231+
).rejects.toThrow();
232+
233+
// When the button takes focus, the banner should be
234+
// moved so it becomes visible/usable.
235+
await obscured.focus();
236+
await obscured.click();
237+
});
213238
});
214239

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

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/utils/dom.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,73 @@ 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+
// Resolves a visual collision between two elements, either
80+
// by scrolling the page or moving one of them.
81+
// We're only resolving collisions on the vertical axis, as
82+
// it is the main direction of web pages.
83+
export const resolveCollision = (fixed: HTMLElement, mobile: HTMLElement) => {
84+
if (mobile.contains(fixed)) {
85+
translateElementY(mobile, 0);
86+
return;
87+
}
88+
89+
const fixedRect = fixed.getBoundingClientRect();
90+
const mobileRect = mobile.getBoundingClientRect();
91+
const isColliding =
92+
mobileRect.left < fixedRect.right
93+
&& mobileRect.right > fixedRect.x
94+
&& mobileRect.top < fixedRect.bottom
95+
&& mobileRect.bottom > fixedRect.top;
96+
97+
const mobileCenterY = mobileRect.top + mobileRect.height / 2;
98+
const direction = mobileCenterY > window.innerHeight / 2 ? 1 : -1;
99+
const overlap =
100+
direction > 0
101+
? fixedRect.bottom - mobileRect.top
102+
: mobileRect.bottom - fixedRect.top;
103+
104+
if (!isColliding) {
105+
translateElementY(mobile, overlap, direction);
106+
return;
107+
}
108+
109+
const doc = document.documentElement;
110+
const leeway =
111+
direction > 0
112+
? Math.abs(doc.scrollHeight - doc.clientHeight - doc.scrollTop)
113+
: doc.scrollTop;
114+
115+
// We're scrolling as much possible first.
116+
window.scrollBy({
117+
top: overlap * direction
118+
});
119+
120+
// If scrolling isn't enough to get out of trouble,
121+
// we're moving the mobile element out of the way.
122+
if (overlap > leeway) {
123+
translateElementY(mobile, overlap - leeway, direction);
124+
}
125+
};

src/ui/utils/hooks.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import {useContext, useEffect, useState} from 'preact/hooks';
2-
import Context from '../components/Context';
1+
import {MutableRef, useContext, useEffect, useState} from 'preact/hooks';
32
import {Purpose} from '../../core/types';
43
import {
54
acceptedConsents,
65
areAllPurposesDisabled,
76
areAllPurposesEnabled,
87
declinedConsents
98
} from '../../core/utils/purposes';
9+
import Context from '../components/Context';
10+
import {resolveCollision} from './dom';
1011

1112
export const useConfig = () => {
1213
const {config} = useContext(Context);
@@ -107,3 +108,17 @@ export const useConsent = (
107108
const manager = useManager();
108109
return [manager.getConsent(id), manager.setConsent.bind(manager, id)];
109110
};
111+
112+
export const useNonObscuringElement = (ref: MutableRef<HTMLElement>): void => {
113+
useEffect(() => {
114+
const resolve = (event: FocusEvent) => {
115+
resolveCollision(event.target as HTMLElement, ref.current);
116+
};
117+
118+
document.addEventListener('focusin', resolve);
119+
120+
return () => {
121+
document.removeEventListener('focusin', resolve);
122+
};
123+
}, [ref]);
124+
};

0 commit comments

Comments
 (0)