This site makes use of third-party cookies. Read more in our - privacy policy.
`, - // Button labels based on state and preferences. - button: { - // The default button label. - default: 'Save preferences', - // Shown when `acceptAllButton` is set, and no option is selected. - acceptAll: 'Accept all', - }, - // ARIA labels to improve accessibility. - aria: { - button: 'Confirm cookie settings', - tabList: 'List with cookie types', - tabToggle: 'Toggle cookie tab', +export const DEFAULTS = { + prefix: "cookie-consent", + append: true, + appendDelay: 500, + acceptAllButton: false, + labels: { + title: "Cookies & Privacy", + description: + 'This site makes use of third-party cookies. Read more in our privacy policy.
', + button: { + default: "Save preferences", + acceptAll: "Accept all", + }, + aria: { + button: "Confirm cookie settings", + tabList: "List with cookie types", + tabToggle: "Toggle cookie tab", + }, }, - }, -} +}; ``` ## API -- [CookieConsent()](#cookieconsentoptions-object) -- [getDialog()](#getdialog) -- [showDialog()](#showdialog) -- [hideDialog()](#hidedialog) -- [isAccepted()](#isacceptedid-string) -- [getPreferences()](#getpreferences) -- [on()](#on) -- [updatePreference()](#updatePreferencecookies-array) - -### CookieConsent(options: object) - -Will create a new instance. - -```js -const cookieConsent = CookieConsent({ - cookies: [ - // ... - ] -}); -``` - -To make the instance globally available (for instance to add event listeners elsewhere), add it as a global after the instance has been created: +- [show()](#show) +- [hide()](#hide) +- [getPreferences()](#getpreferences) +- [updatePreference()](#updatePreferencecookies-array) +- [on()](#on) -```js -const cookieConsent = CookieConsent(); - -window.CookieConsent = cookieConsent; -``` - -### getDialog() - -Will fetch the dialog element, for example to append it at a custom DOM position. - -```js -document.body.insertBefore(cookieConsent.getDialog(), document.body.firstElementChild); -``` - -### showDialog() +### show() Will show the dialog element, for example to show it when triggered to change settings. ```js -el.addEventListener('click', e => { - e.preventDefault(); - cookieConsent.showDialog(); +button.addEventListener("click", (e) => { + e.preventDefault(); + cookieConsent.show(); }); ``` -### hideDialog() +### hide() Will hide the dialog element. ```js -el.addEventListener('click', e => { - e.preventDefault(); - cookieConsent.hideDialog(); +button.addEventListener("click", (e) => { + e.preventDefault(); + cookieConsent.hide(); }); ``` -### isAccepted(id: string) - -Check if a certain cookie type has been accepted. Will return `true` when accepted, `false` when denied, and `undefined` when no action has been taken. - -```js -const acceptedMarketing = cookieConsent.isAccepted('marketing'); // => true, false, undefined -``` - ### getPreferences() Will return an array with preferences per cookie type. @@ -223,16 +159,6 @@ const preferences = cookieConsent.getPreferences(); // ] ``` -### on(event: string) - -Add listeners for events. Will fire when the event is dispatched from the CookieConsent module. -See available [events](#events). - -```js -cookieConsent.on('event', eventHandler); -``` - - ### updatePreference(cookies: array) Update cookies programmatically. @@ -242,29 +168,36 @@ By updating cookies programmatically, the event handler will receive an update m ```js const cookies = [ { - id: 'marketing', - label: 'Marketing', - description: '...', - required: false, - checked: true, + id: "marketing", + label: "Marketing", + description: "...", + required: false, + checked: true, }, { - id: 'simple', - label: 'Simple', - description: '...', - required: false, - checked: false, + id: "simple", + label: "Simple", + description: "...", + required: false, + checked: false, }, ]; +``` + +### on(event: string) -cookieConsent.updatePreference(cookies); +Add listeners for events. Will fire when the event is dispatched from the CookieConsent module. +See available [events](#events). + +```js +cookieConsent.on("event", eventHandler); ``` ## Events Events are bound by the [on](#onevent-string) method. -- [update](#update) +- [update](#update) ### update @@ -275,13 +208,15 @@ This event can be used to fire tag triggers for each cookie type, for example vi Example: ```js -cookieConsent.on('update', cookies => { - const accepted = cookies.filter(cookie => cookie.accepted); - const dataLayer = window.dataLayer || []; - accepted.forEach(cookie => dataLayer.push({ - event: 'cookieConsent', - cookieType: cookie.id, - })); +cookieConsent.on("update", (cookies) => { + const accepted = cookies.filter((cookie) => cookie.accepted); + const dataLayer = window.dataLayer || []; + accepted.forEach((cookie) => + dataLayer.push({ + event: "cookieConsent", + cookieType: cookie.id, + }) + ); }); ``` @@ -289,14 +224,80 @@ cookieConsent.on('update', cookies => { No styling is being applied by the JavaScript module. However, there is a default stylesheet in the form of a [Sass](https://sass-lang.com/) module which can easily be added and customized to your project and its needs. +You have to use the `::parts` pseudo-element to style the dialog and its elements due to the Shadow DOM encapsulation. You can style the dialog and its elements by using the following parts: + +```scss +cookie-consent::part(cookie-consent) { + // Styles for the cookie consent dialog +} + +/** + * Header + */ +cookie-consent::part(cookie-consent__header) { + // Styles for the cookie consent header +} +cookie-consent::part(cookie-consent__title) { + // Styles for the cookie consent title +} + +/** + * Tabs + */ +cookie-consent::part(cookie-consent__tab-list) { + // Styles for the cookie consent tab list +} +cookie-consent::part(cookie-consent__tab-list-item) { + // Styles for the cookie consent tab list item +} +cookie-consent::part(cookie-consent__tab) { + // Styles for the cookie consent tabs +} + +/** + * Tab option (label with input in it) & tab toggle + */ +cookie-consent::part(cookie-consent__option) { + // Styles for the tab option label +} +cookie-consent::part(cookie-consent__input) { + // Styles for the tab option input +} +cookie-consent::part(cookie-consent__tab-toggle) { + // Styles for the tab toggle +} +cookie-consent::part(cookie-consent__tab-toggle-icon) { + // Styles for the tab toggle icon +} + +/** + * Tab panel (with description) + */ +cookie-consent::part(cookie-consent__tab-panel) { + // Styles for the tab panel +} + +cookie-consent::part(cookie-consent__tab-description) { + // Styles for the tab description +} + +/** + * Button + */ +cookie-consent::part(cookie-consent__button) { + // styles for the consent button +} +cookie-consent::part(cookie-consent__button-text) { + // Styles for the consent button text +} +``` + ### Stylesheet View the [base stylesheet](https://github.com/grrr-amsterdam/cookie-consent/tree/master/styles/cookie-consent.scss). -Note: no vendor prefixes are applied. We recommend using something like [Autoprefixer](https://github.com/postcss/autoprefixer) to do that automatically. - ### Interface With the styling from the base module applied, the interface will look roughly like this (fonts, sizes and margins might differ): - + diff --git a/index.mjs b/index.mjs index 02e1bbb..673cf5f 100644 --- a/index.mjs +++ b/index.mjs @@ -1,3 +1,3 @@ -import CookieConsent from './src/cookie-consent.mjs'; +import CookieConsent from "./src/cookie-consent.mjs"; export default CookieConsent; diff --git a/src/config-defaults.mjs b/src/config-defaults.mjs index ed7673c..79a6a09 100644 --- a/src/config-defaults.mjs +++ b/src/config-defaults.mjs @@ -1,20 +1,20 @@ export const DEFAULTS = { - type: 'checkbox', - prefix: 'cookie-consent', + prefix: "cookie-consent", append: true, appendDelay: 500, acceptAllButton: false, labels: { - title: 'Cookies & Privacy', - description: 'This site makes use of third-party cookies. Read more in our privacy policy.
', + title: "Cookies & Privacy", + description: + 'This site makes use of third-party cookies. Read more in our privacy policy.
', button: { - default: 'Save preferences', - acceptAll: 'Accept all', + default: "Save preferences", + acceptAll: "Accept all", }, aria: { - button: 'Confirm cookie settings', - tabList: 'List with cookie types', - tabToggle: 'Toggle cookie tab', + button: "Confirm cookie settings", + tabList: "List with cookie types", + tabToggle: "Toggle cookie tab", }, }, }; diff --git a/src/config.mjs b/src/config.mjs index eb77611..a417ce6 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -1,10 +1,10 @@ -import { DEFAULTS } from './config-defaults.mjs'; -import { getEntryByDotString } from './utils.mjs'; +import { DEFAULTS } from "./config-defaults.mjs"; +import { getEntryByDotString } from "./utils.mjs"; /** * Config getter with defaults fallback and warning when required values are missing. */ -const Config = settings => { +const Config = (settings) => { return { get: (entryString, required = false) => { const value = getEntryByDotString(settings, entryString); @@ -12,7 +12,9 @@ const Config = settings => { console.warn(`Required setting '${entryString}' is missing.`); return undefined; } - return value === undefined ? getEntryByDotString(DEFAULTS, entryString) : value; + return value === undefined + ? getEntryByDotString(DEFAULTS, entryString) + : value; }, }; }; diff --git a/src/cookie-consent.mjs b/src/cookie-consent.mjs index 2959b73..41c9fcd 100644 --- a/src/cookie-consent.mjs +++ b/src/cookie-consent.mjs @@ -1,65 +1,264 @@ -import Config from './config.mjs'; -import Dialog from './dialog.mjs'; -import DomToggler from './dom-toggler.mjs'; -import EventDispatcher from './event-dispatcher.mjs'; -import Preferences from './preferences.mjs'; +/* eslint class-methods-use-this:[ + "error", + { + "exceptMethods": + [ + "getConfig", + "initEventDispatcher", + "getPreferences", + "initDomToggler" + ] + } +] */ + +import { htmlToElement, preventingDefault } from "@grrr/utils"; +import EventDispatcher from "./event-dispatcher.mjs"; +import DialogTabList from "./dialog-tablist.mjs"; +import DomToggler from "./dom-toggler.mjs"; + +import Config from "./config.mjs"; +import Preferences from "./preferences.mjs"; /** - * Main constructor, which provides the API to the outside. + * Dialog which is shown to update cookie preferences. */ -const CookieConsent = settings => { - - // Show warning when settings are missing. - if (typeof settings !== 'object' || !Object.keys(settings).length) { - console.warn(`No settings specified.`); - } - - // Construct 'classes'. - const config = Config(settings); - const preferences = Preferences(config.get('prefix')); - const dialog = Dialog({ config, preferences }); - const domToggler = DomToggler(config); - const events = EventDispatcher(); - - // Update initial content. - domToggler.toggle(preferences); - - const updatePreference = (cookies) => { - preferences.store(cookies); - events.dispatch('update', preferences.getAll()); - domToggler.toggle(preferences); - }; - - // Initialize dialog and bind `submit` event. - dialog.init(); - dialog.on('submit', updatePreference); - - // Append dialog to the DOM, if this is not explicitly prevented. - if (config.get('append') !== false) { - const appendEl = document.querySelector('main') || document.body.firstElementChild; - const container = appendEl ? appendEl.parentNode : document.body; - container.insertBefore(dialog.element, appendEl); - } - - // Show the dialog when no preferences are found. If found, fire the `update` event. - if (preferences.hasPreferences()) { - events.dispatch('update', preferences.getAll()); - } else { - // Show the dialog. Invoked via a timeout, to ensure it's added in the next cycle - // to cater for possible transitions. - window.setTimeout(() => dialog.show(), config.get('appendDelay')); - } - - return { - getDialog: () => dialog.element, - hideDialog: dialog.hide, - showDialog: dialog.show, - isAccepted: preferences.getState, - getPreferences: preferences.getAll, - on: events.add, - updatePreference, - }; - -}; - -export default CookieConsent; +export default class Dialog extends HTMLElement { + constructor() { + // Always call super first in constructor + super(); + // sets and returns 'this.shadowRoot' + this.attachShadow({ mode: "open" }); + // get data + this.data = this.getData(); + // get config + this.config = this.getConfig(); + // initialize event dispatcher + this.events = this.initEventDispatcher(); + // initialize teblist + this.tabList = this.initTabList(); + // get cookies + this.cookies = this.data.cookies; + // generate dialog element + this.dialogElement = this.generateDialogElement(); + // append dialog to shadowRoot + this.shadowRoot.append(this.dialogElement); + // get preferences + this.preferences = this.getPreferences(); + // initialize domtoggler + this.domToggler = this.initDomToggler(); + // initialize show and hide + this.show = this.show(); + this.hide = this.hide(); + // get all preferences + this.preferences.getAll(); + + this.domToggler.toggle(this.preferences); + + // if cookie prefs already selected dispatch update event and hide + if (this.preferences.hasPreferences()) { + this.events.dispatch("update", this.preferences.getAll()); + } + + // add submit event + this.events.add("submit", this.updatePreference.bind(this)); + } + + getData() { + // fallback content from config + const fallbackContent = { + title: Config().get("labels.title"), + description: Config().get("labels.description"), + saveButtonText: Config().get("labels.aria.button"), + defaultButtonLabel: Config().get("labels.button.default"), + acceptAllButton: + Config().get("acceptAllButton") + && !Preferences().hasPreferences(), + }; + // custom content from data-attributes + const customContent = { + title: this.getAttribute("data-title"), + description: this.getAttribute("data-description"), + saveButtonText: this.getAttribute("data-saveButtonText"), + }; + // parse cookies to json + const cookies = JSON.parse(this.getAttribute("data-cookies")); + + return { + title: + customContent.title === null + ? fallbackContent.title + : customContent.title, + description: + customContent.description === null + ? fallbackContent.description + : customContent.description, + saveButtonText: + customContent.saveButtonText === null + ? fallbackContent.defaultButtonLabel + : customContent.saveButtonText, + acceptAllButton: fallbackContent.acceptAllButton, + cookies, + }; + } + + getConfig() { + return { + type: Config().get("type"), + prefix: Config().get("prefix"), + dialogTemplate: Config().get("dialogTemplate"), + }; + } + + initEventDispatcher() { + return EventDispatcher(); + } + + initTabList() { + return DialogTabList(this.data.cookies); + } + + generateDialogElement() { + // Initialize tab list and append it to the form. + this.tabList.init(); + + const template = ` + `; + + const dialogElement = htmlToElement(template); + + dialogElement.insertAdjacentHTML( + "afterbegin", + ` + ` + ); + + const formElement = dialogElement.lastElementChild; + + formElement.addEventListener( + "submit", + preventingDefault(this.submitHandler.bind(this)) + ); + + dialogElement.insertBefore(this.tabList.element, formElement); + + return dialogElement; + } + + submitHandler(e) { + e.preventDefault(); + // Get values based on the rules defined in `composeValues`. + const values = this.composeValues(this.tabList.getValues()); + + if (!values) { + return; + } + + // Dispatch values and hide the dialog. + this.events.dispatch("submit", values); + // toggleDialogVisibility(this.firstElementChild).hide(); + this.hide(); + } + + composeValues(values) { + // Checkbox with `acceptAllButton` and no user-choosable option is checked. + // We compare amount of required options against checked options. + + const requiredCount = this.data.cookies.filter((c) => c.required).length; + const checkedCount = values.filter((v) => v.accepted).length; + const userOptionsChecked = checkedCount >= requiredCount; + if ( + this.data.acceptAllButton + && this.config.type === "checkbox" + && !userOptionsChecked + ) { + return values.map((value) => ({ + ...value, + accepted: true, + })); + } + + // Return the values untouched. Happens for: + // - Checkbox with or without checked option, except the `acceptAllButton` case above. + return values; + } + + getPreferences() { + const preferences = Preferences(Config().get("prefix")); + + return preferences; + } + + updatePreference(selectedCookies) { + this.preferences.store(selectedCookies); + this.events.dispatch("update", this.preferences.getAll()); + this.domToggler.toggle(this.preferences); + } + + initDomToggler() { + return DomToggler(Config()); + } + + show() { + return () => + this.shadowRoot + .querySelector(".cookie-consent") + .setAttribute("aria-hidden", "false"); + } + + hide() { + return () => + this.shadowRoot + .querySelector(".cookie-consent") + .setAttribute("aria-hidden", "true"); + } + + on(type, payload) { + return this.events.add(type, payload); + } + + static get observedAttributes() { + return ["data-cookies"]; + } + + attributeChangedCallback(attrName, oldValue, newValue) { + // Set this.cookies to the updated value + this.cookies = JSON.parse(newValue); + // Transform NodeList to array + const arrayfiedTabList = Array.from(this.tabList.element.children); + // Filter out all li elements + const tabListChildren = arrayfiedTabList.filter( + (item) => item.nodeName === "LI" + ); + // Loop through arrayfiedTabListChildren + tabListChildren.forEach((input) => { + // Find all input elements + const inputElement = input.firstElementChild.firstElementChild.firstElementChild; + // Loop through updated cookies + this.cookies.forEach((cookie) => { + // set the checked state to the updated cookie state + if (inputElement.value === cookie.id && cookie.checked) { + inputElement.checked = true; + } + }); + }); + } +} diff --git a/src/dialog-tablist.mjs b/src/dialog-tablist.mjs index 41e1355..c84a178 100644 --- a/src/dialog-tablist.mjs +++ b/src/dialog-tablist.mjs @@ -1,21 +1,24 @@ -import { htmlToElement } from '@grrr/utils'; -import EventDispatcher from './event-dispatcher.mjs'; +import { htmlToElement } from "@grrr/utils"; +import EventDispatcher from "./event-dispatcher.mjs"; + +import Config from "./config.mjs"; +import Preferences from "./preferences.mjs"; /** * Dialog tab list with cookie tabs. */ -const DialogTabList = ({ config, preferences }) => { - +const DialogTabList = (cookieInformation) => { const events = EventDispatcher(); - const TYPE = config.get('type'); - const PREFIX = config.get('prefix'); + const PREFIX = Config().get("prefix"); /** * Render cookie tabs. */ - const renderTab = ({ id, label, description, required, checked, accepted }, index) => { - + const renderTab = ( + { id, label, description, required, checked, accepted }, + index + ) => { /** * Check if the checkbox should be checked: * @@ -24,37 +27,54 @@ const DialogTabList = ({ config, preferences }) => { * `required: false`, because of #3) * 3. Use the `checked` setting. */ - const shouldBeChecked = typeof accepted !== 'undefined' + const shouldBeChecked = typeof accepted !== "undefined" ? accepted : required === true ? required : checked; return ` -