Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/cookie-banner/.npmignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
.DS_Store
*.log
src
__tests__
example
coverage
Expand Down
48 changes: 23 additions & 25 deletions packages/cookie-banner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,27 @@ GDPR compliant cookie banner and consent form.

Renders a cookie banner and a consent form based on configuration settings, and conditionally invokes cookie-reliant functionality based on user consent.

A WCAG and GDPR compliant example is available for reference at https://storm-ui-patterns.netlify.app/patterns/cookie-banner/.

---

## Usage
Cookie consent is based on categorising cookies and the functions that initialise them, describing them in a configuration object passed into the module at initialisition.

The cookie banner renders itself if no consent preferences are recorded in the browser.

The consent form renders into a DOMElement with a particular className configurable options (classNames.formContainer).

A page containing a cookie consent form should include a visually hidden live region (role=alert) with a particular className (classNames.formAnnouncement), default: 'privacy-banner__form-announcement'.

Optionally the banner also supports basic Google EU consent mode [https://developers.google.com/tag-platform/security/guides/consent?consentmode=basic], and can push user consent preferences to the dataLayer for Google libraries to use. All that is necessary to suport Google consent mode is to map Google consent categories to the cookie categories in the configuration.
Cookie consent management works by categorising cookies and the functions that initialise them, describing them in a configuration object passed into the module at initialisition.

For example, to map the ad_storage, ad_user_data, and ad_personalisation to an 'ads' consent category defined in the banner config, add a `euConsentTypes` object to the configuration like this:

```
euConsentTypes: {
ad_storage: 'test',
ad_user_data: 'test',
ad_personalization: 'test',
analytics_storage: 'test'
}
```
The cookie banner renders itself if no consent preferences are recorded in the browser, or if recorded consent categories do not match.


Install the package
```
npm i -S @stormid/cookie-banner
```

Create a container element for the consent form.
Create a container element for the consent form. The consent form renders into a DOMElement with a className matching the classNames.formContainer option.
```
<div class="privacy-banner__form-container"></div>
```

Create a visually hidden live region for the screen reader announcement.
A page containing a cookie consent form should also include a visually hidden live region (role=alert) with a className matching the classNames.formAnnouncement option.
```
<div class="visually-hidden privacy-banner__form-announcement" role="alert"></div>
```
Expand All @@ -51,7 +36,6 @@ import banner from '@stormid/cookie-banner';
const cookieBanner = banner({
types: {
'performance': {
suggested: true, //set as pre-checked on consent form as a suggested response
title: 'Performance preferences',
description: 'Performance cookies are used to measure the performance of our website and make improvements. Your personal data is not identified.',
labels: {
Expand Down Expand Up @@ -84,7 +68,21 @@ const cookieBanner = banner({
});
```

## Google EU consent mode
Optionally the banner also supports Google EU consent mode v2 https://developers.google.com/tag-platform/security/guides/consent, and can push user consent preferences to the dataLayer for Google libraries to use. All that is necessary to support Google consent mode is to map Google consent categories to the cookie categories in the configuration.

For example, to map the ad_storage, ad_user_data, and ad_personalisation to an 'ads' consent category defined in the banner config, add a `euConsentTypes` object to the configuration like this:
```
euConsentTypes: {
ad_storage: 'Marketing',
ad_user_data: 'Marketing',
ad_personalization: 'Marketing',
analytics_storage: 'Performance'
}
```

## Options
Full options that can be passed during initialisation:
```
{
name: '.CookiePreferences', //name of the cookie set to record user consent
Expand Down Expand Up @@ -116,7 +114,7 @@ const cookieBanner = banner({
savedMessage: 'Your settings have been saved.', //displayed after consent form update,
trapTab: false, //trap the user's keyboard tab within the banner when open
bannerTemplate(model){
return `<section role="dialog" aria-live="polite" aria-label="Your privacy" class="${model.classNames.banner}">
return `<div role="region" aria-live="polite" aria-label="Your privacy" class="${model.classNames.banner}">
<div class="privacy-content">
<div class="wrap">
<!--googleoff: all-->
Expand All @@ -128,7 +126,7 @@ const cookieBanner = banner({
<!--googleon: all-->
</div>
</div>
</section>`;
</div>`;
},
messageTemplate(model){
return `<div class="${model.settings.classNames.formMessage}" aria-role="alert">${model.settings.savedMessage}</div>`
Expand Down Expand Up @@ -176,7 +174,7 @@ const cookieBanner = banner({
```

## Utility functions
There are two utility functions provided by the library designed to be invoked following user consent.
There are two utility functions provided by the library that can be invoked following user consent.

### Render iframe
`state.utils.renderIframe`
Expand Down
4 changes: 2 additions & 2 deletions packages/cookie-banner/__tests__/banner/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ describe(`Cookie banner > DOM > not render`, () => {

describe(`Cookie banner > DOM > accessibility`, () => {
beforeAll(init);
it('The banner should be a dialog', async () => {
expect(document.querySelector(`.${defaults.classNames.banner}`).getAttribute('role')).toEqual('dialog');
it('The banner should be a region', async () => {
expect(document.querySelector(`.${defaults.classNames.banner}`).getAttribute('role')).toEqual('region');
});

it('The banner should have be polite aria live region', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking here! But seems to be a small typo, might be clearer to say "The banner should be a polite aria live region"?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in regards to the test starting on line 78, just for clarification.

Expand Down
18 changes: 0 additions & 18 deletions packages/cookie-banner/__tests__/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,6 @@ const init = () => {

};

describe(`Cookie banner > state > init`, () => {
beforeAll(init);

it('Should return the Store.getState method on initialisation', async () => {
const Store = cookieBanner({ types: {} });
expect(Store.getState).not.toBeUndefined();
});

it('Should return the state Object from Store.getState', async () => {
const Store = cookieBanner({ types: {} });

expect(Store.getState()).toBeDefined();
expect(Store.getState().consent).toEqual({});
expect(Store.getState().settings).toBeDefined();
});

});

describe(`Cookie banner > state > update/reducers`, () => {
beforeAll(init);

Expand Down
40 changes: 40 additions & 0 deletions packages/cookie-banner/__tests__/store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createStore } from '../src/lib/store';

describe(`Cookie banner > Store`, () => {

const Store = createStore();
let effect = false;
const sideEffect = state => {
effect = !effect;
};

it('createStore should return an Object with an API', async () => {
expect(Store).not.toBeNull();
expect(Store.getState).not.toBeNull();
expect(Store.update).not.toBeNull();
});

it('should have a getState function that returns a private state Object', async () => {
expect(Store.state).toBeUndefined();
expect(Store.getState()).toEqual({});
});

it('should have an update function that updates state', async () => {
const nextState = { isOpen: true };
Store.update(nextState);
expect(Store.getState()).toEqual(nextState);
});

it('should have an update function that does not update state if nextState is not passed', async () => {
const Store = createStore();
Store.update();
expect(Store.getState()).toEqual({});
});

it('should have an update function that invokes any side effect functions passed after the state change, with new null or undefined state', async () => {
Store.update(null, [sideEffect]);
expect(effect).toEqual(true);
});


});
20 changes: 15 additions & 5 deletions packages/cookie-banner/__tests__/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,20 @@ describe('Cookie > Utils > extractFromCookie > malformed JSON cookie', () => {

});

describe('Cookie > Utils > extractFromCookie > category mismatch', () => {
it('should return default hasCookie and content properties if categroies do not match', () => {
document.cookie = `${defaults.name}=${btoa(JSON.stringify({ consent: { performance: 1, thirdParty: 0 } }))}`;
const [hasCookie, consent ] = extractFromCookie({ ...defaults, types: { performance: {}, thirdParty: {}, ads: {} } });

expect(hasCookie).toEqual(false);
expect(consent).toEqual({ });
});
});

describe('Cookie > Utils > extractFromCookie > well-formed JSON cookie', () => {
it('should return hasCookie and content properties from well-formed JSON cookie', () => {
document.cookie = `${defaults.name}=${btoa(JSON.stringify({ consent: { performance: 1, thirdParty: 0 } }))}`;
const [hasCookie, consent ] = extractFromCookie(defaults);
const [hasCookie, consent ] = extractFromCookie({ ...defaults, types: { performance: {}, thirdParty: {} } });

expect(hasCookie).toEqual(true);
expect(consent).toEqual({ performance: 1, thirdParty: 0 });
Expand Down Expand Up @@ -135,14 +145,14 @@ describe(`Cookie banner > Utils > broadcast`, () => {
bannerOpen: true,
settings: defaults
};
Store.update(_ => _, state);
Store.update(state);
const listener = jest.fn();
document.addEventListener(EVENTS.OPEN, listener);
document.addEventListener(EVENTS.OPEN, e => {
document.addEventListener(EVENTS.SHOW, listener);
document.addEventListener(EVENTS.SHOW, e => {
expect(e.detail).toEqual({ getState: Store.getState });
});

broadcast(EVENTS.OPEN, Store)(state);
broadcast(EVENTS.SHOW, Store)(state);
expect(listener).toHaveBeenCalled();
});

Expand Down
104 changes: 84 additions & 20 deletions packages/cookie-banner/example/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,54 @@
}
h1 {
font: 1.25rem/1.2 sans-serif;
margin-bottom:1.5rem;
margin-top: 1.5rem;
}
h2 {
font: 1.1rem/1.2 sans-serif;
margin-bottom:12px;
margin-top:12px;
padding-top:12px;
}
h3 {
font: 1rem/1.2 sans-serif;
margin-bottom:12px;
margin-top:12px;
padding-top:12px;
}
p {
margin-bottom:1.5rem;
margin-top:1.5rem;
}
header {
width:100%;
padding:20px;
margin-bottom:40px;
fieldset {
padding: 0;
margin: 0;
bordeR: 0 none;
}
legend {
display: block;
}
.container {
margin: 24px auto 48px;
width: calc(100% - 48px);
}
.embed iframe,
.embed__placeholder {
width: 600px;
aspect-ratio: 16 / 9;
}
.embed__placeholder {
background-color: hsl(0, 0%, 90%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
}
.privacy-banner__btn-text,
.embed__placeholder-btn,
.privacy-banner__link {
background-color: transparent;
color: blue;
text-decoration: underline;
font-size: 1rem;
padding: 0;
}
.privacy-banner__title {
font-size:1.2rem;
Expand All @@ -54,22 +83,54 @@
right:0;
padding:24px 25px;
/* border-top:1px solid #191919; */
box-shadow: 0 1px 3px 1px rgba(60,64,67,.2), 0 2px 8px 4px rgba(60,64,67,.1)
z-index: 1000;
box-shadow: 0 1px 3px 1px rgba(60,64,67,.2), 0 2px 8px 4px rgba(60,64,67,.1);
background-color: #fff;
}
.privacy-banner__list {
list-style: none;
margin-bottom: 24px;
}
button,
.privacy-banner__btn {
background-color:#191919;
padding:8px 16px;
color: #fff;
border:0 none;
cursor: pointer;
display: inline-block;
}
button + button {
margin-left: 12px;
}
.privacy-banner__submit:hover,
.privacy-banner__btn:hover {
background-color: blue
}
.privacy-banner__set-accept {
display: inline-block;
}
.privacy-banner__actions {
padding-top: 24px;
}
.privacy-banner__row {
margin-top: 12px;
}
.privacy-banner__legend {
padding-top: 12px;
}
.privacy-banner__form-title {
font-size: 1.2rem;
}
.privacy-banner__form-description {
display: block;
padding-top: 12px;
}
.privacy-banner__label-description {
display: block;
padding-left: 18px;
font-size: .85rem;
}
.privacy-banner__form-announcement {
position: absolute;
opacity: 0;
Expand All @@ -84,6 +145,9 @@
.privacy-banner__panel {
display:none;
}
.privacy-banner__submit {
margin-top: 12px;
}
.on--preferences .privacy-banner__panel {
display:block;
}
Expand All @@ -94,19 +158,19 @@
</style>
</head>
<body>
<div class="embed"><div class="embed__placeholder" data-iframe-src="https://www.youtube.com/embed/qpLKTUQev30" data-iframe-title="test video"><p>Update your cookie preferences to view this content.</p><button type="button" class="embed__placeholder-btn js-preferences-update">Update preferences</button></div></div>
<button class="js-preferences-update">Open banner</button>
<div class="container">
<div class="privacy-banner__update"></div>
<div class="">
<h1>Cookies</h1>
<p>Cookies are a common feature used on almost all websites. A cookie is a small text file that is downloaded onto a device to allow a website to recognise it and to store some information about your preferences.</p>
<p>We use cookies for different reasons, including for managing your shopping cart, measuring your visits on our sites, recognising and remembering your preferences and showing you personalised ads. Learn more about cookies and how we use them.</p>
<div class="privacy-banner__form-container"></div>
<div class="privacy-banner__form-announcement" style="position: absolute;width:1px;height:1px;opacity:0;" role="alert"></div>


<div class="embed">
<div class="embed__placeholder" data-iframe-src="https://www.youtube.com/embed/qpLKTUQev30" data-iframe-title="test video">
<p>Update your cookie preferences to view this content.</p>
<button type="button" class="embed__placeholder-btn js-preferences-update">Update preferences</button>
</div>
</div>
<div class="privacy-banner__update"></div>
<h1>Cookies</h1>
<p>Cookies are a common feature used on almost all websites. A cookie is a small text file that is downloaded onto a device to allow a website to recognise it and to store some information about your preferences.</p>
<p>We use cookies for different reasons, including for managing your shopping cart, measuring your visits on our sites, recognising and remembering your preferences and showing you personalised ads. Learn more about cookies and how we use them.</p>
<div class="privacy-banner__form-container "></div>
<div class="privacy-banner__form-announcement" style="position: absolute;width:1px;height:1px;opacity:0;" role="alert"></div>
</div>
</body>
</html>
Loading