diff --git a/e2e/tests/react/components/react-cookie-consent-spec.ts b/e2e/tests/react/components/react-cookie-consent-spec.ts index fc35bc1df1..f6f55d288a 100644 --- a/e2e/tests/react/components/react-cookie-consent-spec.ts +++ b/e2e/tests/react/components/react-cookie-consent-spec.ts @@ -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'; @@ -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); @@ -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; @@ -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); @@ -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. diff --git a/e2e/tests/react/components/react-cookie-consent-spec.ts-snapshots/banner-is-closed-after-approval-and-not-shown-again--desktop-png.png b/e2e/tests/react/components/react-cookie-consent-spec.ts-snapshots/banner-is-closed-after-approval-and-not-shown-again--desktop-png.png deleted file mode 100644 index e81ee9041b..0000000000 Binary files a/e2e/tests/react/components/react-cookie-consent-spec.ts-snapshots/banner-is-closed-after-approval-and-not-shown-again--desktop-png.png and /dev/null differ diff --git a/e2e/tests/react/components/react-cookie-consent-spec.ts-snapshots/banner-is-closed-after-approval-and-not-shown-again-focus-is-moved--desktop-png.png b/e2e/tests/react/components/react-cookie-consent-spec.ts-snapshots/banner-is-closed-after-approval-and-not-shown-again-focus-is-moved--desktop-png.png new file mode 100644 index 0000000000..6599225b45 Binary files /dev/null and b/e2e/tests/react/components/react-cookie-consent-spec.ts-snapshots/banner-is-closed-after-approval-and-not-shown-again-focus-is-moved--desktop-png.png differ diff --git a/e2e/utils/playwright.util.ts b/e2e/utils/playwright.util.ts index f64c5954e0..03beb8a2c4 100644 --- a/e2e/utils/playwright.util.ts +++ b/e2e/utils/playwright.util.ts @@ -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 => { + const first = locator.first(); + return first.evaluate((el) => el); +}; diff --git a/packages/react/src/components/cookieConsent/CookieConsent.stories.tsx b/packages/react/src/components/cookieConsent/CookieConsent.stories.tsx index ae9e5a1ff4..8156deff53 100644 --- a/packages/react/src/components/cookieConsent/CookieConsent.stories.tsx +++ b/packages/react/src/components/cookieConsent/CookieConsent.stories.tsx @@ -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 (
@@ -71,7 +71,7 @@ const Actions = () => { -
@@ -120,7 +120,8 @@ export const Example = ({ currentTabIndex }: { currentTabIndex?: number } = {}) return ( a', theme }} siteSettings={{ ...siteSettings, remove: false, monitorInterval: 0 }} >
@@ -131,6 +132,7 @@ export const Example = ({ currentTabIndex }: { currentTabIndex?: number } = {}) titleHref="https://hel.fi" logo={} logoAriaLabel="Service logo" + id="actionbar" > @@ -157,13 +159,13 @@ export const Example = ({ currentTabIndex }: { currentTabIndex?: number } = {}) -

Banner ( {language} )

+

Banner ( {language} )

Banner is shown if required consents are not consented.

-

Consents ( {language} )

+

Consents ( {language} )

Banner is also shown here when needed.

@@ -205,9 +207,10 @@ export const Banner = () => {
-

Cookie consent banner

+

Cookie consent banner

The banner is shown only if necessary.

diff --git a/packages/react/src/components/cookieConsentCore/CookieConsentCore.stories.tsx b/packages/react/src/components/cookieConsentCore/CookieConsentCore.stories.tsx index 15dbab4f0b..6f17216320 100644 --- a/packages/react/src/components/cookieConsentCore/CookieConsentCore.stories.tsx +++ b/packages/react/src/components/cookieConsentCore/CookieConsentCore.stories.tsx @@ -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 ( <> @@ -45,7 +45,9 @@ const Actions = () => { - + ); @@ -71,20 +73,22 @@ const DummyContent = () => ( ); export const Banner = (options: Options = {}) => { + const focusTargetSelector = 'main h1'; + const combinedOptions: Options = { ...options, focusTargetSelector, submitEvent: true }; return (
-

Cookie consent banner

+

Cookie consent banner

The banner is shown only if necessary.

- +
); }; export const SettingsPage = (options: Options = {}) => { - const combinedOptions = { ...options, submitEvent: true }; + const combinedOptions: Options = { ...options, submitEvent: true }; return (
diff --git a/packages/react/src/components/cookieConsentCore/cookieConsentCore.js b/packages/react/src/components/cookieConsentCore/cookieConsentCore.js index 6b8d96e417..5a3ad80791 100644 --- a/packages/react/src/components/cookieConsentCore/cookieConsentCore.js +++ b/packages/react/src/components/cookieConsentCore/cookieConsentCore.js @@ -40,6 +40,7 @@ export class CookieConsentCore { #pageContentSelector; #submitEvent = false; #settingsPageSelector; + #focusTargetSelector; #disableAutoRender; #monitor; #cookieHandler; @@ -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. @@ -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, @@ -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); @@ -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} 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. @@ -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); } @@ -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) { @@ -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 @@ -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(); diff --git a/packages/react/src/components/cookieConsentCore/types.ts b/packages/react/src/components/cookieConsentCore/types.ts index 94d3ef1816..6eaacba93b 100644 --- a/packages/react/src/components/cookieConsentCore/types.ts +++ b/packages/react/src/components/cookieConsentCore/types.ts @@ -15,6 +15,7 @@ export type Options = { pageContentSelector?: string | undefined; submitEvent?: boolean | undefined; settingsPageSelector?: string | undefined; + focusTargetSelector?: string | undefined; disableAutoRender?: boolean | undefined; };