Skip to content

Commit

Permalink
Merge pull request #1429 from City-of-Helsinki/hds-2521-focus-on-close
Browse files Browse the repository at this point in the history
(release-4.0.0) hds-2521: set focus on banner close
  • Loading branch information
NikoHelle authored Nov 20, 2024
2 parents 461d873 + 4616a52 commit f774ce5
Show file tree
Hide file tree
Showing 8 changed files with 68 additions and 17 deletions.
26 changes: 23 additions & 3 deletions e2e/tests/react/components/react-cookie-consent-spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { test, expect, Page, Locator } from '@playwright/test';
import { isLocatorSelectedOrChecked } from '../../../utils/element.util';
import { getFocusedElement, isLocatorSelectedOrChecked } from '../../../utils/element.util';
import {
gotoStorybookUrlByName,
createScreenshotFileName,
takeScreenshotWithSpacing,
waitFor,
getLocatorElement,
} from '../../../utils/playwright.util';
const componentName = 'cookieconsent';
const storybook = 'react';
Expand Down Expand Up @@ -80,7 +81,7 @@ test.describe(`Banner`, () => {

const storeConsentsAndWaitForBannerClose = async (
page: Page,
approveType: 'all' | 'required',
approveType: 'all' | 'required' | 'selected',
bannerLocator: Locator,
) => {
const banner = bannerLocator || getBannerOrPageLocator(page);
Expand Down Expand Up @@ -179,7 +180,7 @@ test.describe(`Banner`, () => {
const screenshotNameSV = createScreenshotFileName(testInfo, isMobile, 'sv language');
await takeScreenshotWithSpacing(page, banner, screenshotNameSV);
});
test('Banner is closed after approval and not shown again.', async ({ page, isMobile }, testInfo) => {
test('Banner is closed after approval and not shown again. Focus is moved.', async ({ page, isMobile }, testInfo) => {
if (isMobile) {
// viewport is too small to test with mobile view. Banner blocks the ui.
return;
Expand All @@ -188,6 +189,9 @@ test.describe(`Banner`, () => {
await changeTab(page, 'banner');
const banner = getBannerOrPageLocator(page);
await storeConsentsAndWaitForBannerClose(page, 'all', banner);
const focusTarget = await getLocatorElement(page.locator('#actionbar > a'));
const focusedElement = await getFocusedElement(page.locator('body'));
expect(focusTarget === focusedElement).toBeTruthy();
const screenshotName = createScreenshotFileName(testInfo, isMobile);
await takeScreenshotWithSpacing(page, page.locator('body'), screenshotName);

Expand All @@ -207,6 +211,22 @@ test.describe(`Banner`, () => {
const consents = await approveRequiredAndCheckStoredConsents(page);
expect(consents).toEqual(['essential', 'test_essential']);
});
test('Element given in openBanner is focused on banner close', async ({ page, isMobile }, testInfo) => {
if (isMobile) {
// viewport is too small to test with mobile view. Banner blocks the ui.
return;
}
const openerSelector = '#banner-opener';
await gotoStorybookUrlByName(page, 'Example', componentName, storybook);
await changeTab(page, 'actions');
const bannerOpener = page.locator(openerSelector);
const banner = getBannerOrPageLocator(page);
await bannerOpener.click();
await storeConsentsAndWaitForBannerClose(page, 'selected', banner);
const focusTarget = await getLocatorElement(page.locator(openerSelector));
const focusedElement = await getFocusedElement(page.locator('body'));
expect(focusTarget === focusedElement).toBeTruthy();
});
test('Stored consents are changed via settings page', async ({ page, isMobile }, testInfo) => {
if (isMobile) {
// viewport is too small to test with mobile view. Banner blocks the ui.
Expand Down
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions e2e/utils/playwright.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,8 @@ export const gotoStorybookUrlByName = async (page: Page, name: string, component
await page.goto(targetUrl);
return targetUrl;
};

export const getLocatorElement = async (locator: Locator): Promise<HTMLElement | SVGElement | null> => {
const first = locator.first();
return first.evaluate((el) => el);
};
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const Actions = () => {
};
const openBanner = async () => {
// eslint-disable-next-line no-console
console.log('Spawning banner', await window.hds.cookieConsent.openBanner(['statistics', 'chat']));
console.log('Spawning banner', await window.hds.cookieConsent.openBanner(['statistics', 'chat'], '#banner-opener'));
};
return (
<div>
Expand All @@ -71,7 +71,7 @@ const Actions = () => {
<Button data-testid="remove-cookie-button" onClick={removeConsentCookie}>
Remove all consents
</Button>
<Button data-testid="open-banner-button" onClick={openBanner}>
<Button id="banner-opener" data-testid="open-banner-button" onClick={openBanner}>
Open banner with highlighted groups &quot;chat&quot; and &quot;statistics&quot;
</Button>
</div>
Expand Down Expand Up @@ -120,7 +120,8 @@ export const Example = ({ currentTabIndex }: { currentTabIndex?: number } = {})
return (
<CookieConsentContextProvider
onChange={onChange}
options={{ language, theme }}
// focusing the logo link, because the tab component loses focus on re-render.
options={{ language, focusTargetSelector: '#actionbar > a', theme }}
siteSettings={{ ...siteSettings, remove: false, monitorInterval: 0 }}
>
<Header languages={languages} onDidChangeLanguage={onLangChange} defaultLanguage={language}>
Expand All @@ -131,6 +132,7 @@ export const Example = ({ currentTabIndex }: { currentTabIndex?: number } = {})
titleHref="https://hel.fi"
logo={<Logo src={logoFi} alt="City of Helsinki" />}
logoAriaLabel="Service logo"
id="actionbar"
>
<Header.LanguageSelector aria-label="aria" languageHeading="other" />
</Header.ActionBar>
Expand All @@ -157,13 +159,13 @@ export const Example = ({ currentTabIndex }: { currentTabIndex?: number } = {})
<span data-testid="page-tab" />
</Tabs.TabPanel>
<Tabs.TabPanel>
<h1>Banner ( {language} )</h1>
<h1 tabIndex={-1}>Banner ( {language} )</h1>
<p>Banner is shown if required consents are not consented.</p>
<CookieBanner />
<span data-testid="banner-tab" />
</Tabs.TabPanel>
<Tabs.TabPanel>
<h1>Consents ( {language} )</h1>
<h1 tabIndex={-1}>Consents ( {language} )</h1>
<p>Banner is also shown here when needed.</p>
<ConsentOutput />
<CookieBanner />
Expand Down Expand Up @@ -205,9 +207,10 @@ export const Banner = () => {
<CookieConsentContextProvider
onChange={onChange}
siteSettings={{ ...siteSettings, remove: false, monitorInterval: 0 }}
options={{ focusTargetSelector: 'main h1' }}
>
<main>
<h1>Cookie consent banner</h1>
<h1 tabIndex={-1}>Cookie consent banner</h1>
<p>The banner is shown only if necessary.</p>
<CookieBanner />
</main>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const Actions = () => {
};
const openBanner = async () => {
// eslint-disable-next-line no-console
console.log('Spawning banner', await window.hds.cookieConsent.openBanner(['statistics', 'chat']));
console.log('Spawning banner', await window.hds.cookieConsent.openBanner(['statistics', 'chat'], '#banner-opener'));
};
return (
<>
Expand All @@ -45,7 +45,9 @@ const Actions = () => {
<Button onClick={addChatCookie}>Add chat group</Button>
<Button onClick={addUnallowedCookie}>Add unallowed group</Button>
<Button onClick={removeConsentCookie}>Remove consent cookie</Button>
<Button onClick={openBanner}>Open banner</Button>
<Button id="banner-opener" onClick={openBanner}>
Open banner
</Button>
</div>
</>
);
Expand All @@ -71,20 +73,22 @@ const DummyContent = () => (
);

export const Banner = (options: Options = {}) => {
const focusTargetSelector = 'main h1';
const combinedOptions: Options = { ...options, focusTargetSelector, submitEvent: true };
return (
<main>
<Info />
<h1>Cookie consent banner</h1>
<h1 tabIndex={-1}>Cookie consent banner</h1>
<p>The banner is shown only if necessary.</p>
<Actions />
<DummyContent />
<BannerComponent siteSettings={siteSettingsJsonUrl} options={options} />
<BannerComponent siteSettings={siteSettingsJsonUrl} options={combinedOptions} />
</main>
);
};

export const SettingsPage = (options: Options = {}) => {
const combinedOptions = { ...options, submitEvent: true };
const combinedOptions: Options = { ...options, submitEvent: true };
return (
<main>
<Info />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class CookieConsentCore {
#pageContentSelector;
#submitEvent = false;
#settingsPageSelector;
#focusTargetSelector;
#disableAutoRender;
#monitor;
#cookieHandler;
Expand Down Expand Up @@ -74,6 +75,7 @@ export class CookieConsentCore {
* @param {string} [options.pageContentSelector='body'] - The selector for where to add scroll-margin-bottom.
* @param {boolean} [options.submitEvent=false] - If set to true, do not reload the page, but submit the string as an event after consent.
* @param {string} [options.settingsPageSelector=null] - If this string is set and a matching element is found on the page, show cookie settings in a page replacing the matched element.
* @param {string} [options.focusTargetSelector=null] - Selector for the element that will receive focus once the banner is closed.
* @param {boolean} [options.disableAutoRender=false] - If true, neither banner or page are rendered automatically
* @param {boolean} [calledFromCreate=false] - Indicates if the constructor was called from the create method.
* @throws {Error} Throws an error if called from outside the create method.
Expand All @@ -89,6 +91,7 @@ export class CookieConsentCore {
pageContentSelector = 'body', // Where to add scroll-margin-bottom
submitEvent = false, // if set, do not reload page, but submit 'hds-cookie-consent-changed' as event after consent
settingsPageSelector = null, // If this string is set and a matching element is found on the page, show cookie settings in a page replacing the matched element.
focusTargetSelector = null,
disableAutoRender = false,
},
calledFromCreate = false,
Expand All @@ -106,6 +109,7 @@ export class CookieConsentCore {
this.#pageContentSelector = pageContentSelector;
this.#submitEvent = submitEvent;
this.#settingsPageSelector = settingsPageSelector;
this.#focusTargetSelector = focusTargetSelector;
this.#disableAutoRender = disableAutoRender;

CookieConsentCore.addToHdsScope('cookieConsent', this);
Expand Down Expand Up @@ -161,6 +165,7 @@ export class CookieConsentCore {
* @param {string} [options.pageContentSelector='body'] - The selector for where to add scroll-margin-bottom.
* @param {boolean} [options.submitEvent=false] - If set, do not reload the page, but submit 'hds-cookie-consent-changed' event after consent.
* @param {string} [options.settingsPageSelector=null] - If this string is set and a matching element is found on the page, show cookie settings in a page replacing the matched element.
* @param {string} [options.focusTargetSelector=null] - Selector for the element that will receive focus once the banner is closed.
* @param {boolean} [options.disableAutoRender=false] - If...
* @return {Promise<CookieConsentCore>} A promise that resolves to a new instance of the CookieConsent class.
* @throws {Error} Throws an error if the siteSettingsParam is not a string or an object.
Expand Down Expand Up @@ -246,13 +251,19 @@ export class CookieConsentCore {

/**
* Opens banner when not on cookie settings page.
* * @param {Array} highlightedGroups - Groups to highlight when opened
* * @param {string} focusTargetSelector - Selector for the element that will receive focus once the banner is closed. Overrides the options.focusTargetSelector
*/
openBanner(highlightedGroups = []) {
openBanner(highlightedGroups = [], focusTargetSelector = '') {
if (this.#settingsPageSelector && document.querySelector(this.#settingsPageSelector)) {
// eslint-disable-next-line no-console
console.error(`Cookie consent: The user is already on settings page`);
return;
}

if (focusTargetSelector) {
this.#focusTargetSelector = focusTargetSelector;
}
this.removeBanner();
this.#render(this.#language, this.#siteSettings, true, null, highlightedGroups);
}
Expand Down Expand Up @@ -342,7 +353,7 @@ export class CookieConsentCore {
* Removes the banner and related elements.
* @returns {void}
*/
removeBanner() {
removeBanner(setFocus = false) {
this.killTimeout();
// Remove banner size observer
if (this.#resizeReference.resizeObserver && this.#resizeReference.bannerHeightElement) {
Expand All @@ -360,6 +371,13 @@ export class CookieConsentCore {

// Remove scroll-margin-bottom variable from all elements inside the contentSelector
document.documentElement.style.removeProperty('--hds-cookie-consent-height');

if (setFocus && this.#focusTargetSelector) {
const element = document.querySelector(this.#focusTargetSelector);
if (element) {
element.focus();
}
}
}

// MARK: Private methods
Expand Down Expand Up @@ -417,7 +435,7 @@ export class CookieConsentCore {
} else {
window.dispatchEvent(new CustomEvent(cookieEventType.CHANGE, { detail: { acceptedGroups } }));
if (!this.#settingsPageElement) {
this.removeBanner();
this.removeBanner(true);
// removeBanner() removes the setTimeout that shows notification
// announceSettingsSaved() must be called after the removeBanner()
this.#announceSettingsSaved();
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/components/cookieConsentCore/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type Options = {
pageContentSelector?: string | undefined;
submitEvent?: boolean | undefined;
settingsPageSelector?: string | undefined;
focusTargetSelector?: string | undefined;
disableAutoRender?: boolean | undefined;
};

Expand Down

0 comments on commit f774ce5

Please sign in to comment.