diff --git a/package.json b/package.json index 0e7a4afc5ff..c911fa462fe 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "src/playground/**", "src/playground/**/!(build)/", "src/resources/*", + "src/components/**", "src/specs/**", "src/themes/*", "src/presets/*", diff --git a/src/components/accordion/.npmignore b/src/components/accordion/.npmignore new file mode 100644 index 00000000000..c1ac67be31a --- /dev/null +++ b/src/components/accordion/.npmignore @@ -0,0 +1,4 @@ +__snapshots__ +*story.js +*test.js +demo diff --git a/src/components/accordion/README.md b/src/components/accordion/README.md new file mode 100644 index 00000000000..737113b4c6f --- /dev/null +++ b/src/components/accordion/README.md @@ -0,0 +1 @@ +# Accordion diff --git a/src/components/accordion/__snapshots__/accordion.test.js.snap b/src/components/accordion/__snapshots__/accordion.test.js.snap new file mode 100644 index 00000000000..6fc4e9c0754 --- /dev/null +++ b/src/components/accordion/__snapshots__/accordion.test.js.snap @@ -0,0 +1,898 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Accordion renders correctly 1`] = ` + +
+
+

+ +

+ +
+
+

+ +

+ +
+
+

+ +

+ +
+
+
+`; + +exports[`Accordion renders correctly with a single icon 1`] = ` + +
+
+

+ +

+ +
+
+

+ +

+ +
+
+

+ +

+ +
+
+
+`; + +exports[`Accordion renders correctly with extra attributes 1`] = ` + +
+
+

+ +

+ +
+
+

+ +

+ +
+
+

+ +

+ +
+
+
+`; + +exports[`Accordion renders correctly with extra class names 1`] = ` + +
+
+

+ +

+ +
+
+

+ +

+ +
+
+

+ +

+ +
+
+
+`; + +exports[`Accordion renders correctly with old data 1`] = ` + +
+
+

+ +

+ +
+
+

+ +

+ +
+
+

+ +

+ +
+
+
+`; diff --git a/src/components/accordion/accordion-print.scss b/src/components/accordion/accordion-print.scss new file mode 100644 index 00000000000..8ef4f4a399f --- /dev/null +++ b/src/components/accordion/accordion-print.scss @@ -0,0 +1,50 @@ +/** + * Accordion print + * @define accordion + */ + +@use 'sass:map'; + +// Exposed variables +$theme: null !default; +$accordion-print: null !default; + +.ecl-accordion__item { + margin-top: map.get($theme, 'spacing-print', 'l'); + page-break-inside: avoid; + + &:first-of-type { + margin-top: 0; + } +} + +.ecl-accordion__toggle-icon { + display: none; +} + +.ecl-accordion__title { + font: map.get($accordion-print, 'toggle-font'); + margin: 0; + max-width: var(--max-w); + page-break-after: avoid; +} + +.ecl-accordion__toggle { + background-color: map.get($accordion-print, 'toggle-background'); + border-width: 0; + color: map.get($accordion-print, 'toggle-color'); + display: block; + padding: 0; + text-align: start; + width: 100%; +} + +.ecl-accordion__content, +.ecl-accordion__content[hidden] { + color: map.get($accordion-print, 'content-color'); + display: block; + font: map.get($accordion-print, 'content-font'); + margin-top: map.get($theme, 'spacing-print', 'm'); + max-width: var(--max-w); + page-break-inside: avoid; +} diff --git a/src/components/accordion/accordion.html.twig b/src/components/accordion/accordion.html.twig new file mode 100644 index 00000000000..e97d31fa0af --- /dev/null +++ b/src/components/accordion/accordion.html.twig @@ -0,0 +1,106 @@ +{% apply spaceless %} + +{# + Parameters: + - "items" (array) (default: []): format: [ + { + "id": (string), + level: (string), + toggle: (predefined structure): see Button component, format: { + label: (string), + }, + content: (block), + }, + ... + ] + - "icon" (array) OR (object) (default: []) Two icons in an array that will be toggled. + - "extra_classes" (string) (default: '') + - "extra_attributes" (array) (default: []): format: [ + { + "name" (string) (default: ''), + "value" (optional) (string) + ... + ], +#} + +{# Internal properties #} + +{% set _css_class = 'ecl-accordion' %} +{% set _extra_attributes = 'data-ecl-auto-init="Accordion"' %} +{% set _items = items|default([]) %} +{% set _icons = icon is iterable and icon|keys is not empty ? icon : [icon] %} + +{% if _icons|length == 1 and _icons[0].name == 'plus' %} + {% set _icons = _icons|merge([{'name': 'minus', 'path': _icons[0].path}]) %} +{% endif %} + +{# Internal logic - Process properties #} + +{% if extra_classes is defined and extra_classes is not empty %} + {% set _css_class = _css_class ~ ' ' ~ extra_classes %} +{% endif %} + +{% if extra_attributes is defined and extra_attributes is not empty and extra_attributes is iterable %} + {% for attr in extra_attributes %} + {% if attr.value is defined %} + {% set _extra_attributes = _extra_attributes ~ ' ' ~ attr.name|e('html_attr') ~ '="' ~ attr.value|e('html_attr') ~ '"' %} + {% else %} + {% set _extra_attributes = _extra_attributes ~ ' ' ~ attr.name|e('html_attr') %} + {% endif %} + {% endfor %} +{% endif %} + +{# Print the result #} + +
+ {% if _items is not empty %} + {% for _item in _items %} +
+ + + + +
+ {% endfor %} + {% endif %} +
+ +{% endapply %} diff --git a/src/components/accordion/accordion.js b/src/components/accordion/accordion.js new file mode 100644 index 00000000000..c0ba822f32c --- /dev/null +++ b/src/components/accordion/accordion.js @@ -0,0 +1,179 @@ +import { queryAll, queryOne } from '@ecl/dom-utils'; +import EventManager from '@ecl/event-manager'; + +/** + * @param {HTMLElement} element DOM element for component instantiation and scope + * @param {Object} options + * @param {String} options.toggleSelector Selector for toggling element + * @param {String} options.iconSelector Selector for icon element + * @param {Boolean} options.attachClickListener Whether or not to bind click events on toggle + */ +export class Accordion { + /** + * @static + * Shorthand for instance creation and initialisation. + * + * @param {HTMLElement} root DOM element for component instantiation and scope + * + * @return {Accordion} An instance of Accordion. + */ + static autoInit(root, { ACCORDION: defaultOptions = {} } = {}) { + const accordion = new Accordion(root, defaultOptions); + accordion.init(); + root.ECLAccordion = accordion; + return accordion; + } + + /** + * An array of supported events for this component. + * + * @type {Array} + * @event Accordion#onToggle + * @memberof Accordion + */ + supportedEvents = ['onToggle']; + + constructor( + element, + { + toggleSelector = '[data-ecl-accordion-toggle]', + iconSelector = '[data-ecl-accordion-icon]', + attachClickListener = true, + } = {}, + ) { + // Check element + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + throw new TypeError( + 'DOM element should be given to initialize this widget.', + ); + } + + this.element = element; + this.eventManager = new EventManager(); + + // Options + this.toggleSelector = toggleSelector; + this.iconSelector = iconSelector; + this.attachClickListener = attachClickListener; + + // Private variables + this.toggles = null; + this.forceClose = false; + this.target = null; + + // Bind `this` for use in callbacks + this.handleClickOnToggle = this.handleClickOnToggle.bind(this); + } + + /** + * Initialise component. + */ + init() { + if (!ECL) { + throw new TypeError('Called init but ECL is not present'); + } + ECL.components = ECL.components || new Map(); + + this.toggles = queryAll(this.toggleSelector, this.element); + + // Bind click event on toggles + if (this.attachClickListener && this.toggles) { + this.toggles.forEach((toggle) => { + toggle.addEventListener('click', () => + this.handleClickOnToggle(toggle), + ); + }); + } + + // Set ecl initialized attribute + this.element.setAttribute('data-ecl-auto-initialized', 'true'); + ECL.components.set(this.element, this); + } + + /** + * Register a callback function for a specific event. + * + * @param {string} eventName - The name of the event to listen for. + * @param {Function} callback - The callback function to be invoked when the event occurs. + * @returns {void} + * @memberof Accordion + * @instance + * + * @example + * // Registering a callback for the 'click' event + * accordion.on('onToggle', (event) => { + * console.log('Toggle event occurred!', event); + * }); + */ + on(eventName, callback) { + this.eventManager.on(eventName, callback); + } + + /** + * Trigger a component event. + * + * @param {string} eventName - The name of the event to trigger. + * @param {any} eventData - Data associated with the event. + * + * @memberof Accordion + */ + trigger(eventName, eventData) { + this.eventManager.trigger(eventName, eventData); + } + + /** + * Destroy component. + */ + destroy() { + if (this.attachClickListener && this.toggles) { + this.toggles.forEach((toggle) => { + toggle.replaceWith(toggle.cloneNode(true)); + }); + } + if (this.element) { + this.element.removeAttribute('data-ecl-auto-initialized'); + ECL.components.delete(this.element); + } + } + + /** + * @param {HTMLElement} toggle Target element to toggle. + * + * @fires Accordion#onToggle + */ + handleClickOnToggle(toggle) { + let isOpening = false; + // Get target element + const target = queryOne( + `#${toggle.getAttribute('aria-controls')}`, + this.element, + ); + + // Exit if no target found + if (!target) { + throw new TypeError( + 'Target has to be provided for accordion (aria-controls)', + ); + } + + // Get current status + const isExpanded = + this.forceClose === true || + toggle.getAttribute('aria-expanded') === 'true'; + + // Toggle the expandable/collapsible + toggle.setAttribute('aria-expanded', isExpanded ? 'false' : 'true'); + + if (isExpanded) { + target.hidden = true; + } else { + target.hidden = false; + isOpening = true; + } + + const eventData = { item: target, isOpening }; + this.trigger('onToggle', eventData); + } +} + +export default Accordion; diff --git a/src/components/accordion/accordion.scss b/src/components/accordion/accordion.scss new file mode 100644 index 00000000000..ab78778f4c5 --- /dev/null +++ b/src/components/accordion/accordion.scss @@ -0,0 +1,149 @@ +/** + * Accordion + * @define accordion + */ + +@use 'sass:map'; + +// Exposed variables +$theme: null !default; +$accordion: null !default; + +.ecl-accordion { + border-radius: map.get($accordion, 'radius'); + box-shadow: map.get($accordion, 'shadow'); + margin: 0; +} + +.ecl-accordion__toggle { + background-color: transparent; + border: none; + color: map.get($accordion, 'title-text-color'); + cursor: pointer; + display: block; + font: var(--f-m); + padding: map.get($accordion, 'item-padding'); + position: relative; + text-align: start; + width: 100%; + + &:hover { + background-color: map.get($accordion, 'item-background-hover'); + } + + &[aria-expanded='true'] { + padding: map.get($accordion, 'item-padding-expanded'); + } +} + +.ecl-accordion__toggle:focus-visible { + outline: 2px solid var(--c-p); + outline-offset: -2px; +} + +.ecl-accordion__content { + color: map.get($accordion, 'text-color'); + font: var(--f-m); + margin-inline-start: 0; + max-width: var(--max-w); + padding-inline-end: map.get($accordion, 'item-padding'); + padding-inline-start: map.get($accordion, 'item-padding'); + padding-bottom: var(--s-xl); +} + +.no-js .ecl-accordion__content { + display: block; +} + +.ecl-accordion__item { + border-bottom: map.get($accordion, 'separator'); + position: relative; + + &:first-child { + @if map.has-key($accordion, 'yellow-bar') { + &::before { + background-color: var(--c-s); + border-end-start-radius: 2px; + border-end-end-radius: 2px; + content: ''; + height: 4px; + left: var(--s-l); + position: absolute; + top: 0; + width: 32px; + z-index: 1; + } + } + + .ecl-accordion__toggle { + border-start-start-radius: map.get($accordion, 'radius'); + border-start-end-radius: map.get($accordion, 'radius'); + } + } + + &:last-child { + border-bottom-width: 0; + + .ecl-accordion__toggle { + border-end-start-radius: map.get($accordion, 'radius'); + border-end-end-radius: map.get($accordion, 'radius'); + + &[aria-expanded='true'] { + border-end-start-radius: 0; + border-end-end-radius: 0; + } + } + + .ecl-accordion__content { + border-end-start-radius: map.get($accordion, 'radius'); + border-end-end-radius: map.get($accordion, 'radius'); + overflow: hidden; + } + } +} + +.ecl-accordion__title { + margin: 0; + padding: 0; +} + +.ecl-accordion__toggle-flex { + align-items: flex-start; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.ecl-accordion__toggle-title { + max-width: var(--max-w); +} + +.ecl-accordion__toggle-indicator { + display: flex; + flex-grow: 0; + flex-shrink: 0; + margin-inline-end: 0; + margin-inline-start: map.get($accordion, 'icon-margin'); + + .ecl-accordion__toggle-icon { + fill: map.get($accordion, 'icon-color'); + + &:last-child { + display: none; + } + + &:first-child, + &:only-child { + display: flex; + } + + [aria-expanded='true'] &:first-child { + display: none; + } + + [aria-expanded='true'] &:last-child, + [aria-expanded='true'] &:only-child { + display: block; + } + } +} diff --git a/src/implementations/twig/components/accordion/accordion.story.js b/src/components/accordion/accordion.story.js similarity index 100% rename from src/implementations/twig/components/accordion/accordion.story.js rename to src/components/accordion/accordion.story.js diff --git a/src/components/accordion/accordion.test.js b/src/components/accordion/accordion.test.js new file mode 100644 index 00000000000..62ad1c8ce38 --- /dev/null +++ b/src/components/accordion/accordion.test.js @@ -0,0 +1,67 @@ +import { + merge, + renderTwigFileAsNode, + renderTwigFileAsHtml, +} from '@ecl/test-utils'; +import { axe, toHaveNoViolations } from 'jest-axe'; + +import demoData from '@ecl/specs-component-accordion/demo/data'; + +expect.extend(toHaveNoViolations); + +const oldData = JSON.parse(JSON.stringify(demoData)); +oldData.icon.splice(1, 1); + +describe('Accordion', () => { + const template = '@ecl/accordion/accordion.html.twig'; + const render = (params) => renderTwigFileAsNode(template, params); + + test('renders correctly', () => { + expect.assertions(1); + + return expect(render(demoData)).resolves.toMatchSnapshot(); + }); + + test('renders correctly with old data', () => { + expect.assertions(1); + + return expect(render(oldData)).resolves.toMatchSnapshot(); + }); + + test('renders correctly with a single icon', () => { + expect.assertions(1); + + oldData.icon[0].name = 'corner-arrow'; + + return expect(render(oldData)).resolves.toMatchSnapshot(); + }); + + test('renders correctly with extra class names', () => { + expect.assertions(1); + + const optionsWithExtraClasses = merge(demoData, { + extra_classes: 'custom-class custom-class--test', + }); + + return expect(render(optionsWithExtraClasses)).resolves.toMatchSnapshot(); + }); + + test('renders correctly with extra attributes', () => { + expect.assertions(1); + + const optionsWithExtraAttrs = merge(demoData, { + extra_attributes: [ + { name: 'data-test', value: 'data-test-value' }, + { name: 'data-test-1', value: 'data-test-value-1' }, + ], + }); + + return expect(render(optionsWithExtraAttrs)).resolves.toMatchSnapshot(); + }); + + test(`passes the accessibility tests`, async () => { + expect( + await axe(await renderTwigFileAsHtml(template, demoData)), + ).toHaveNoViolations(); + }); +}); diff --git a/src/components/accordion/demo/data.js b/src/components/accordion/demo/data.js new file mode 100644 index 00000000000..835daf413a8 --- /dev/null +++ b/src/components/accordion/demo/data.js @@ -0,0 +1,45 @@ +// Simple content for demo +module.exports = { + items: [ + { + id: 'accordion-example', + level: 3, + toggle: { + label: + 'Delivery of last pending proposals, a common Destiny of unity, the hour of European Democracy', + }, + content: + 'The College of Commissioners held today the first weekly meeting of 2019 which was devoted to discussing the challenges of this new year. Commissioners used the opportunity to take stock and discuss the year ahead, including the European elections in May and other important milestones ahead.', + }, + { + id: 'accordion-example2', + level: 3, + toggle: { + label: + 'Spring 2019 Economic Forecast: Growth continues at a more moderate pace', + }, + content: + 'The European economy is forecast to continue expanding for the seventh year in a row in 2019, with real GDP expected to grow in all EU Member States. As global uncertainties continue to weigh, domestic dynamics are set to support the European economy. Growth is expected to gather pace again next year.', + }, + { + id: 'accordion-example3', + level: 3, + toggle: { + label: + 'Delivery of last pending proposals, a common Destiny of unity, the hour of European Democracy', + }, + content: + 'In the modern global economy trade is essential for growth, jobs and competiveness, and the EU is committed to maintaining an open and rules-based trading system. With the rising threat of protectionism and weakened commitment of large players to global trade governance, the EU must take the lead.', + }, + ], + icon: [ + { + path: '/icons.svg', + name: 'plus', + }, + { + path: '/icons.svg', + name: 'minus', + }, + ], +}; diff --git a/src/components/accordion/package.json b/src/components/accordion/package.json new file mode 100644 index 00000000000..53d7cbd3709 --- /dev/null +++ b/src/components/accordion/package.json @@ -0,0 +1,31 @@ +{ + "name": "@ecl/accordion", + "author": "European Commission", + "license": "EUPL-1.2", + "version": "5.0.0-alpha.0", + "description": "ECL Accordion", + "style": "accordion.scss", + "sass": "accordion.scss", + "main": "accordion.js", + "module": "accordion.js", + "dependencies": { + "@ecl/dom-utils": "4.8.1", + "@ecl/vanilla-layout-grid": "4.8.1" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ec-europa/europa-component-library.git" + }, + "bugs": { + "url": "https://github.com/ec-europa/europa-component-library/issues" + }, + "homepage": "https://github.com/ec-europa/europa-component-library", + "keywords": [ + "ecl", + "europa-component-library", + "design-system" + ] +} diff --git a/src/playground/ec/.storybook/main.js b/src/playground/ec/.storybook/main.js index dfbc1a8c98a..5bd547fee88 100644 --- a/src/playground/ec/.storybook/main.js +++ b/src/playground/ec/.storybook/main.js @@ -4,7 +4,10 @@ const webpack = require('webpack'); const isProd = process.env.NODE_ENV === 'production'; const outputFolder = isProd ? 'dist' : 'build'; const publicUrl = process.env.PUBLIC_URL || ''; -const stories = ['../../../implementations/twig/**/!(eu*).story.js']; +const stories = [ + '../../../components/*/*.story.js', + '../../../implementations/twig/**/!(eu*).story.js', +]; const addons = [ '@storybook/addon-docs', diff --git a/src/playground/eu/.storybook/main.js b/src/playground/eu/.storybook/main.js index 134585c5277..78cafc2127a 100644 --- a/src/playground/eu/.storybook/main.js +++ b/src/playground/eu/.storybook/main.js @@ -4,7 +4,10 @@ const webpack = require('webpack'); const isProd = process.env.NODE_ENV === 'production'; const outputFolder = isProd ? 'dist' : 'build'; const publicUrl = process.env.PUBLIC_URL || ''; -const stories = ['../../../implementations/twig/**/!(ec*).story.js']; +const stories = [ + '../../../components/*/*.story.js', + '../../../implementations/twig/**/!(ec*).story.js', +]; const addons = [ '@storybook/addon-docs', diff --git a/src/presets/ec/src/ec-print.scss b/src/presets/ec/src/ec-print.scss index 9317630cd2b..ee82d4126aa 100644 --- a/src/presets/ec/src/ec-print.scss +++ b/src/presets/ec/src/ec-print.scss @@ -141,7 +141,7 @@ ); // Organisms -@use '@ecl/vanilla-component-accordion/accordion-print' with ( +@use '@ecl/accordion/accordion-print' with ( $theme: theme.$theme, $accordion-print: var.$accordion-print ); diff --git a/src/presets/ec/src/ec.js b/src/presets/ec/src/ec.js index 6e71cb6332b..4aaad748f4e 100644 --- a/src/presets/ec/src/ec.js +++ b/src/presets/ec/src/ec.js @@ -1,7 +1,7 @@ import '@ecl/dom-utils/polyfills'; export * from '@ecl/dom-utils/autoinit'; -export * from '@ecl/vanilla-component-accordion'; +export * from '@ecl/accordion'; export * from '@ecl/vanilla-component-banner'; export * from '@ecl/vanilla-component-carousel'; export * from '@ecl/vanilla-component-category-filter'; diff --git a/src/presets/ec/src/ec.scss b/src/presets/ec/src/ec.scss index 1e9c2b40f6e..7b21801488a 100644 --- a/src/presets/ec/src/ec.scss +++ b/src/presets/ec/src/ec.scss @@ -203,7 +203,7 @@ ); // Organisms -@use '@ecl/vanilla-component-accordion/accordion' with ( +@use '@ecl/accordion/accordion' with ( $theme: theme.$theme, $accordion: var.$accordion ); diff --git a/src/presets/eu/src/eu-print.scss b/src/presets/eu/src/eu-print.scss index 5692e189979..6553f3be704 100644 --- a/src/presets/eu/src/eu-print.scss +++ b/src/presets/eu/src/eu-print.scss @@ -141,7 +141,7 @@ ); // Organisms -@use '@ecl/vanilla-component-accordion/accordion-print' with ( +@use '@ecl/accordion/accordion-print' with ( $theme: theme.$theme, $accordion-print: var.$accordion-print ); diff --git a/src/presets/eu/src/eu.js b/src/presets/eu/src/eu.js index b51e46ae9d4..54f35c82f82 100644 --- a/src/presets/eu/src/eu.js +++ b/src/presets/eu/src/eu.js @@ -1,7 +1,7 @@ import '@ecl/dom-utils/polyfills'; export * from '@ecl/dom-utils/autoinit'; -export * from '@ecl/vanilla-component-accordion'; +export * from '@ecl/accordion'; export * from '@ecl/vanilla-component-banner'; export * from '@ecl/vanilla-component-carousel'; export * from '@ecl/vanilla-component-category-filter'; diff --git a/src/presets/eu/src/eu.scss b/src/presets/eu/src/eu.scss index 83dd640bcad..242d4002897 100644 --- a/src/presets/eu/src/eu.scss +++ b/src/presets/eu/src/eu.scss @@ -203,7 +203,7 @@ ); // Organisms -@use '@ecl/vanilla-component-accordion/accordion' with ( +@use '@ecl/accordion/accordion' with ( $theme: theme.$theme, $accordion: var.$accordion ); diff --git a/src/website/src/pages/ec/components/accordion/demo/index.js b/src/website/src/pages/ec/components/accordion/demo/index.js index feaf3078b34..6f4cc923261 100644 --- a/src/website/src/pages/ec/components/accordion/demo/index.js +++ b/src/website/src/pages/ec/components/accordion/demo/index.js @@ -1,5 +1,5 @@ -import demoContent from '@ecl/specs-component-accordion/demo/data'; -import template from '@ecl/twig-component-accordion/accordion.html.twig'; +import demoContent from '@ecl/accordion/demo/data'; +import template from '@ecl/accordion/accordion.html.twig'; const accordion = template(demoContent); export default accordion; diff --git a/src/website/src/pages/eu/components/accordion/demo/index.js b/src/website/src/pages/eu/components/accordion/demo/index.js index e8ef29948cd..80ea06d220b 100644 --- a/src/website/src/pages/eu/components/accordion/demo/index.js +++ b/src/website/src/pages/eu/components/accordion/demo/index.js @@ -1,5 +1,5 @@ -import demoContent from '@ecl/specs-component-accordion/demo/data'; -import template from '@ecl/twig-component-accordion/accordion.html.twig'; +import demoContent from '@ecl/accordion/demo/data'; +import template from '@ecl/accordion/accordion.html.twig'; demoContent.icon.size = 'm'; const accordion = template(demoContent);