diff --git a/.size-limit.json b/.size-limit.json index dd4cb739c68..90f5adf1dcd 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -13,13 +13,13 @@ }, { "path": "dist/packages/ec/scripts/ecl-ec.js", - "limit": "190 KB", + "limit": "197 KB", "webpack": false, "gzip": false }, { "path": "dist/packages/eu/scripts/ecl-eu.js", - "limit": "190 KB", + "limit": "197 KB", "webpack": false, "gzip": false }, diff --git a/src/implementations/twig/components/modal/.npmignore b/src/implementations/twig/components/modal/.npmignore new file mode 100644 index 00000000000..38460544773 --- /dev/null +++ b/src/implementations/twig/components/modal/.npmignore @@ -0,0 +1,2 @@ +__snapshots__ +*.js diff --git a/src/implementations/twig/components/modal/README.md b/src/implementations/twig/components/modal/README.md new file mode 100644 index 00000000000..5f562548089 --- /dev/null +++ b/src/implementations/twig/components/modal/README.md @@ -0,0 +1,59 @@ +# ECL Modal component + +npm package: `@ecl/twig-component-modal` + +```shell +npm install --save @ecl/twig-component-modal +``` + +### Parameters: + +- **"id"** (string) (default: '') id of the modal +- **"toggle_id"** (string) (default: '') id of the element to toggle the modal +- **"icon_path"** (string) (default: '') Path to the icon sprite +- **"close_label"** (string) (default: '') Label of the close button (for screen reader only) +- **"header_icon"** (associative array) (default: {}): Optional icon in the header, following ECL Icon structure +- **"buttons"** (array) (default: {}) Array of ECL Button, displayed in the modal footer +- **"extra_classes"** (optional) (string) (default: '') Extra classes (space separated) +- **"extra_attributes"** (optional) (array) (default: []) Extra attributes + - "name" (string) Attribute name, eg. 'data-test' + - "value" (string) Attribute value, eg: 'data-test-1' + +### Blocks: + +- **"header"**: free block to put any content in the modal header +- **"body"**: free block to put any content in the modal body +- **"footer"**: free block to put any content in the modal footer + +### Example: + + +```twig +{% include '@ecl/modal/modal.html.twig' with { + id: 'modal-example', + toggle_id: 'modal-toggle', + icon_path: '/icons.svg', + close_label: 'Close', + header_icon: { + icon: { + name: 'warning', + }, + extra_classes: 'ecl-u-type-color-warning', + }, + header: 'Lorem ipsum dolor sit amet', + body: 'Sed quam augue, volutpat sed dapibus in, accumsan a arcu. Nulla quam enim, porttitor at neque a, egestas porttitor tortor. Nam tortor sem, elementum id augue quis, posuere vestibulum dui. Donec id posuere libero, sit amet egestas lorem. Aliquam finibus ipsum mauris, a molestie tortor laoreet.', + buttons: [ + { + label: 'Secondary action', + type: 'button', + variant: 'secondary', + extra_attributes: [{name: 'data-ecl-modal-close'}], + }, + { + label: 'Primary action', + type: 'submit', + variant: 'primary', + }, + ], +} %} +``` diff --git a/src/implementations/twig/components/modal/__snapshots__/modal.test.js.snap b/src/implementations/twig/components/modal/__snapshots__/modal.test.js.snap new file mode 100644 index 00000000000..9b915b56da5 --- /dev/null +++ b/src/implementations/twig/components/modal/__snapshots__/modal.test.js.snap @@ -0,0 +1,247 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal Collapsed renders correctly 1`] = ` + + +
+
+
+
+ Lorem ipsum dolor sit amet +
+ +
+
+ Sed quam augue, volutpat sed dapibus in, accumsan a arcu. Nulla quam enim, porttitor at neque a, egestas porttitor tortor. Nam tortor sem, elementum id augue quis, posuere vestibulum dui. Donec id posuere libero, sit amet egestas lorem. Aliquam finibus ipsum mauris, a molestie tortor laoreet. +
+ +
+
+
+
+`; + +exports[`Modal Collapsed renders correctly with extra attributes 1`] = ` + + +
+
+
+
+ Lorem ipsum dolor sit amet +
+ +
+
+ Sed quam augue, volutpat sed dapibus in, accumsan a arcu. Nulla quam enim, porttitor at neque a, egestas porttitor tortor. Nam tortor sem, elementum id augue quis, posuere vestibulum dui. Donec id posuere libero, sit amet egestas lorem. Aliquam finibus ipsum mauris, a molestie tortor laoreet. +
+ +
+
+
+
+`; + +exports[`Modal Collapsed renders correctly with extra class names 1`] = ` + + +
+
+
+
+ Lorem ipsum dolor sit amet +
+ +
+
+ Sed quam augue, volutpat sed dapibus in, accumsan a arcu. Nulla quam enim, porttitor at neque a, egestas porttitor tortor. Nam tortor sem, elementum id augue quis, posuere vestibulum dui. Donec id posuere libero, sit amet egestas lorem. Aliquam finibus ipsum mauris, a molestie tortor laoreet. +
+ +
+
+
+
+`; diff --git a/src/implementations/twig/components/modal/modal.html.twig b/src/implementations/twig/components/modal/modal.html.twig new file mode 100644 index 00000000000..7da61f437ed --- /dev/null +++ b/src/implementations/twig/components/modal/modal.html.twig @@ -0,0 +1,133 @@ +{% apply spaceless %} + +{# + Parameters: + - "id" (string) (default: ''): id of the modal + - "toggle_id" (string) (default: ''): id of the element to toggle the modal + - "variant" (string) (default: ''): could be empty, 'information, 'success', 'warning' or 'error' + - "icon_path" (string) (default: '') Path to the icon sprite + - "close_label" (string) (default: '') Label of the close button (for screen reader only) + - "buttons" (array) (default: {}) Array of ECL Button, displayed in the modal footer + - "extra_classes" (string) (default: '') + - "extra_attributes" (array) (default: []): format: [ + { + "name" (string) (default: ''), + "value" (optional) (string) + }, + ... + ], + Blocks: + - "header": free block to put any content in the modal header + - "body": free block to put any content in the modal body + - "footer": free block to put any content in the modal footer +#} + +{# Internal properties #} + +{% set _id = id|default('') %} +{% set _toggle_id = toggle_id|default('') %} +{% set _variant = variant|default('') %} +{% set _css_class = 'ecl-modal' %} +{% set _icon_path = icon_path|default('') %} +{% set _close_label = close_label|default('') %} +{% set _header = header|default('') %} +{% set _header_icon = '' %} +{% set _body = body|default('') %} +{% set _footer = footer|default('') %} +{% set _buttons = buttons|default({}) %} + +{# Internal logic - Process properties #} + +{% if _variant is not empty %} + {% set _css_class = _css_class ~ ' ecl-modal--' ~ _variant %} + {% set _header_icon = _variant %} +{% endif %} + +{% 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 _header_icon is not empty and _icon_path is not empty %} + {% include '@ecl/icon/icon.html.twig' with { + icon: { + path: _icon_path, + name: _header_icon, + size: 'm', + }, + extra_classes: 'ecl-modal__icon', + } only %} + {% endif %} + + {% if _header is not empty or block('header') is not empty %} +
+ {%- block header %}{{ _header }}{% endblock -%} +
+ {% endif %} + + {%- if _icon_path is not empty %} + {% include '@ecl/button/button.html.twig' with { + type: 'button', + variant: 'ghost', + label: _close_label, + icon: { + path: _icon_path, + name: 'close-filled', + size: 's', + }, + hide_label: true, + extra_classes: 'ecl-modal__close', + extra_attributes: [{ + name: 'data-ecl-modal-close', + }], + } only %} + {% endif -%} +
+ + {% if _body is not empty or block('body') is not empty %} +
+ {%- block body %}{{ _body }}{% endblock -%} +
+ {% endif %} + + {% if _buttons is not empty or _footer is not empty or block('footer') is not empty %} + + {% endif %} +
+
+
+ +{% endapply %} diff --git a/src/implementations/twig/components/modal/modal.story.js b/src/implementations/twig/components/modal/modal.story.js new file mode 100644 index 00000000000..fff127db28d --- /dev/null +++ b/src/implementations/twig/components/modal/modal.story.js @@ -0,0 +1,97 @@ +import { withNotes } from '@ecl/storybook-addon-notes'; +import withCode from '@ecl/storybook-addon-code'; +import { correctPaths } from '@ecl/story-utils'; + +import dataDefault from '@ecl/specs-component-modal/demo/data'; +import modal from './modal.html.twig'; +import notes from './README.md'; + +const getArgs = (data) => ({ + header: data.header, + variant: '', + body: data.body, + footer: 2, +}); + +const getArgTypes = () => ({ + variant: { + name: 'variant', + type: { name: 'select' }, + description: 'Variant of the modal', + options: { + default: '', + information: 'information', + success: 'success', + warning: 'warning', + error: 'error', + }, + table: { + category: 'Display', + }, + }, + header: { + name: 'header', + type: { name: 'string', required: true }, + description: 'Header of the modal', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '' }, + category: 'Content', + }, + }, + body: { + name: 'body', + type: { name: 'string' }, + description: 'Body of the modal', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '' }, + category: 'Content', + }, + }, + footer: { + name: 'footer buttons', + control: { type: 'range', min: 0, max: 2, step: 1 }, + description: 'Button examples in the footer', + table: { + defaultValue: { summary: 2 }, + category: 'Content', + }, + }, +}); + +const prepareData = (data, args) => { + const dataClone = structuredClone(data); + + dataClone.variant = args.variant; + dataClone.header = args.header; + dataClone.body = args.body; + + if (args.footer === 0) { + delete dataClone.buttons; + } else { + dataClone.buttons = dataClone.buttons.slice(-args.footer); + } + + correctPaths(dataClone); + + return dataClone; +}; + +export default { + title: 'Components/Modal', + decorators: [withNotes, withCode], +}; + +export const Default = (args) => { + const demo = ` + + ${modal(prepareData(dataDefault, args))} + `; + return demo; +}; + +Default.storyName = 'default'; +Default.args = getArgs(dataDefault); +Default.argTypes = getArgTypes(); +Default.parameters = { notes: { markdown: notes, json: dataDefault } }; diff --git a/src/implementations/twig/components/modal/modal.test.js b/src/implementations/twig/components/modal/modal.test.js new file mode 100644 index 00000000000..28d5d21e2d0 --- /dev/null +++ b/src/implementations/twig/components/modal/modal.test.js @@ -0,0 +1,38 @@ +import { merge, renderTwigFileAsNode } from '@ecl/test-utils'; + +import demoData from '@ecl/specs-component-modal/demo/data'; + +describe('Modal', () => { + const template = '@ecl/modal/modal.html.twig'; + const render = (params) => renderTwigFileAsNode(template, params); + + describe('Collapsed', () => { + test('renders correctly', () => { + expect.assertions(1); + return expect(render(demoData)).resolves.toMatchSnapshot(); + }); + + test('renders correctly with extra class names', () => { + expect.assertions(1); + + const withExtraClasses = merge(demoData, { + extra_classes: 'custom-class custom-class--test', + }); + + return expect(render(withExtraClasses)).resolves.toMatchSnapshot(); + }); + + test('renders correctly with extra attributes', () => { + expect.assertions(1); + + const withExtraAttributes = merge(demoData, { + extra_attributes: [ + { name: 'data-test', value: 'data-test-value' }, + { name: 'data-test-1', value: 'data-test-value-1' }, + ], + }); + + return expect(render(withExtraAttributes)).resolves.toMatchSnapshot(); + }); + }); +}); diff --git a/src/implementations/twig/components/modal/package.json b/src/implementations/twig/components/modal/package.json new file mode 100644 index 00000000000..3a072359a4a --- /dev/null +++ b/src/implementations/twig/components/modal/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ecl/twig-component-modal", + "author": "European Commission", + "license": "EUPL-1.2", + "version": "3.7.1", + "description": "ECL Modal", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@ecl/twig-component-button": "3.7.1", + "@ecl/twig-component-icon": "3.7.1" + }, + "devDependencies": { + "@ecl/specs-component-modal": "3.7.1", + "@ecl/vanilla-component-modal": "3.7.1" + }, + "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", + "twig" + ] +} diff --git a/src/implementations/vanilla/components/message/message-eu.scss b/src/implementations/vanilla/components/message/message-eu.scss index 92943c626f3..dc1b038dd04 100644 --- a/src/implementations/vanilla/components/message/message-eu.scss +++ b/src/implementations/vanilla/components/message/message-eu.scss @@ -15,10 +15,10 @@ ), $_close-font-weight: map.get(theme.$font-weight, 'normal'), $_description-color: map.get(theme.$color, 'grey-80'), - $_icon-error-fill: map.get(theme.$color, 'red-120'), - $_icon-info-fill: map.get(theme.$color, 'blue-100'), - $_icon-success-fill: map.get(theme.$color, 'green-130'), - $_icon-warning-fill: map.get(theme.$color, 'orange-120'), + $_icon-error-fill: map.get(theme.$color, 'error'), + $_icon-info-fill: map.get(theme.$color, 'info'), + $_icon-success-fill: map.get(theme.$color, 'success'), + $_icon-warning-fill: map.get(theme.$color, 'warning'), $_icon-spacing: map.get(theme.$spacing, 'xs'), $_title-color: map.get(theme.$color, 'grey-140'), $_title-font: map.get(theme.$font-prolonged, 'm'), diff --git a/src/implementations/vanilla/components/modal/README.md b/src/implementations/vanilla/components/modal/README.md new file mode 100644 index 00000000000..d8b4b4cdf77 --- /dev/null +++ b/src/implementations/vanilla/components/modal/README.md @@ -0,0 +1 @@ +# Modal diff --git a/src/implementations/vanilla/components/modal/_modal.scss b/src/implementations/vanilla/components/modal/_modal.scss new file mode 100644 index 00000000000..6d50900c270 --- /dev/null +++ b/src/implementations/vanilla/components/modal/_modal.scss @@ -0,0 +1,144 @@ +/** + * Modal + * @define modal + */ + +@use 'sass:map'; +@use '@ecl/theme-dev/theme'; +@use '@ecl/vanilla-layout-grid/mixins/breakpoints'; + +$_overlay: null !default; +$_border-radius: null !default; +$_separator-color: null !default; + +.ecl-modal { + background-color: $_overlay; + border: none; + color: map.get(theme.$color, 'grey-100'); + height: 100%; + left: 0; + margin: 0; + max-height: 100%; + max-width: 100%; + padding: 0; + position: fixed; + top: 0; + width: 100%; +} + +.ecl-modal[open] { + display: block; +} + +.ecl-modal__container { + position: relative; + top: 50%; + transform: translateY(-50%); +} + +.ecl-modal__content { + background-color: map.get(theme.$color, 'white-100'); + border-radius: $_border-radius; + box-shadow: map.get(theme.$shadow, '4'); + font: map.get(theme.$font-prolonged, 'm'); + padding: 0; +} + +.ecl-modal__header { + align-items: flex-start; + border-bottom: 1px solid $_separator-color; + display: flex; + font: map.get(theme.$font-prolonged, 'l'); + padding: map.get(theme.$spacing, 'm'); +} + +.ecl-modal__icon { + flex-shrink: 0; + margin-inline-end: map.get(theme.$spacing, 's'); + margin-top: 2px; // Fix alignment with text + + .ecl-modal--information & { + color: map.get(theme.$color, 'info'); + } + + .ecl-modal--success & { + color: map.get(theme.$color, 'success'); + } + + .ecl-modal--warning & { + color: map.get(theme.$color, 'warning'); + } + + .ecl-modal--error & { + color: map.get(theme.$color, 'error'); + } +} + +.ecl-modal__header-content { + flex-grow: 1; +} + +.ecl-modal__body { + padding: map.get(theme.$spacing, 'm'); +} + +.ecl-modal__footer { + border-top: 1px solid $_separator-color; + padding: map.get(theme.$spacing, 'm'); +} + +.ecl-modal__header + .ecl-modal__footer { + border-top: none; +} + +.ecl-modal__footer-content { + display: flex; + justify-content: space-between; +} + +.ecl-modal__button { + flex-basis: 50%; + margin-inline-end: map.get(theme.$spacing, 'm'); + + &:last-of-type { + margin-inline-end: 0; + } +} + +.ecl-modal__close { + padding: map.get(theme.$spacing, 's'); + margin-bottom: -#{map.get(theme.$spacing, 'xs')}; + margin-inline-end: -#{map.get(theme.$spacing, 's')}; + margin-top: -#{map.get(theme.$spacing, 'xs')}; + + &:hover { + box-shadow: none; + } + + .ecl-button__icon { + margin: 0; + } +} + +/* stylelint-disable-next-line order/order */ +@include breakpoints.up('m') { + .ecl-modal__header { + padding: map.get(theme.$spacing, 'm') map.get(theme.$spacing, 'l'); + } + + .ecl-modal__body { + padding: map.get(theme.$spacing, 'l'); + } + + .ecl-modal__footer { + padding: map.get(theme.$spacing, 'm') map.get(theme.$spacing, 'l'); + } + + .ecl-modal__footer-content { + justify-content: flex-end; + } + + .ecl-modal__button { + flex-basis: auto; + } +} diff --git a/src/implementations/vanilla/components/modal/modal-ec.scss b/src/implementations/vanilla/components/modal/modal-ec.scss new file mode 100644 index 00000000000..759f58405a8 --- /dev/null +++ b/src/implementations/vanilla/components/modal/modal-ec.scss @@ -0,0 +1,7 @@ +@use 'sass:map'; +@use '@ecl/theme-dev/theme'; +@use 'modal' with ( + $_overlay: rgba(map.get(theme.$color, 'black-100'), 0.4), + $_border-radius: 0, + $_separator-color: map.get(theme.$color, 'grey-25') +); diff --git a/src/implementations/vanilla/components/modal/modal-eu.scss b/src/implementations/vanilla/components/modal/modal-eu.scss new file mode 100644 index 00000000000..337429e90f1 --- /dev/null +++ b/src/implementations/vanilla/components/modal/modal-eu.scss @@ -0,0 +1,7 @@ +@use 'sass:map'; +@use '@ecl/theme-dev/theme'; +@use 'modal' with ( + $_overlay: rgba(map.get(theme.$color, 'blue-140'), 0.4), + $_border-radius: map.get(theme.$border-radius, 'm'), + $_separator-color: map.get(theme.$color, 'blue-20') +); diff --git a/src/implementations/vanilla/components/modal/modal-print-ec.scss b/src/implementations/vanilla/components/modal/modal-print-ec.scss new file mode 100644 index 00000000000..b0ea89c46db --- /dev/null +++ b/src/implementations/vanilla/components/modal/modal-print-ec.scss @@ -0,0 +1,7 @@ +@use 'sass:map'; +@use '@ecl/theme-dev/theme'; +@use 'modal-print' with ( + $_overlay: rgba(map.get(theme.$color, 'black-100'), 0.4), + $_border-radius: 0, + $_separator-color: map.get(theme.$color, 'grey-25') +); diff --git a/src/implementations/vanilla/components/modal/modal-print-eu.scss b/src/implementations/vanilla/components/modal/modal-print-eu.scss new file mode 100644 index 00000000000..a740fe87bd4 --- /dev/null +++ b/src/implementations/vanilla/components/modal/modal-print-eu.scss @@ -0,0 +1,7 @@ +@use 'sass:map'; +@use '@ecl/theme-dev/theme'; +@use 'modal-print' with ( + $_overlay: rgba(map.get(theme.$color, 'blue-140'), 0.4), + $_border-radius: map.get(theme.$border-radius, 'm'), + $_separator-color: map.get(theme.$color, 'blue-20') +); diff --git a/src/implementations/vanilla/components/modal/modal-print.scss b/src/implementations/vanilla/components/modal/modal-print.scss new file mode 100644 index 00000000000..a95c97590d4 --- /dev/null +++ b/src/implementations/vanilla/components/modal/modal-print.scss @@ -0,0 +1,109 @@ +/* + * Modal + * @define modal + */ + +@use 'sass:map'; +@use '@ecl/theme-dev/theme'; + +$_overlay: null !default; +$_border-radius: null !default; +$_separator-color: null !default; + +.ecl-modal { + background-color: $_overlay; + border: none; + color: map.get(theme.$color, 'grey-100'); + height: 100%; + margin: 0; + max-height: 100%; + max-width: 100%; +} + +.ecl-modal[open] { + display: block; +} + +.ecl-modal__container { + position: relative; + top: 50%; + transform: translateY(-50%); +} + +.ecl-modal__content { + background-color: map.get(theme.$color, 'white-100'); + border-radius: $_border-radius; + box-shadow: map.get(theme.$shadow, '4'); + font: map.get(theme.$font-prolonged-print, 'm'); +} + +.ecl-modal__header { + align-items: flex-start; + border-bottom: 1px solid $_separator-color; + display: flex; + font: map.get(theme.$font-prolonged-print, 'l'); + padding: map.get(theme.$spacing-print, 'm') map.get(theme.$spacing-print, 'l'); +} + +.ecl-modal__icon { + flex-shrink: 0; + margin-inline-end: map.get(theme.$spacing-print, 's'); + margin-top: 2px; // Fix alignment with text + + .ecl-modal--information & { + color: map.get(theme.$color, 'info'); + } + + .ecl-modal--success & { + color: map.get(theme.$color, 'success'); + } + + .ecl-modal--warning & { + color: map.get(theme.$color, 'warning'); + } + + .ecl-modal--error & { + color: map.get(theme.$color, 'error'); + } +} + +.ecl-modal__header-content { + flex-grow: 1; +} + +.ecl-modal__body { + padding: map.get(theme.$spacing-print, 'l'); +} + +.ecl-modal__footer { + border-top: 1px solid $_separator-color; + padding: map.get(theme.$spacing-print, 'm') map.get(theme.$spacing-print, 'l'); +} + +.ecl-modal__header + .ecl-modal__footer { + border-top: none; +} + +.ecl-modal__footer-content { + display: flex; + justify-content: flex-end; +} + +.ecl-modal__button { + margin-inline-end: map.get(theme.$spacing-print, 'm'); + + &:last-of-type { + margin-inline-end: 0; + } +} + +.ecl-modal__close.ecl-button { + padding: map.get(theme.$spacing-print, 's'); + margin-bottom: -#{map.get(theme.$spacing-print, 'xs')}; + margin-inline-end: -#{map.get(theme.$spacing-print, 's')}; + margin-top: -#{map.get(theme.$spacing-print, 'xs')}; + + .ecl-button__icon { + margin: 0; + } +} diff --git a/src/implementations/vanilla/components/modal/modal.js b/src/implementations/vanilla/components/modal/modal.js new file mode 100644 index 00000000000..de63cbc28fb --- /dev/null +++ b/src/implementations/vanilla/components/modal/modal.js @@ -0,0 +1,199 @@ +import { queryAll } from '@ecl/dom-utils'; +import { createFocusTrap } from 'focus-trap'; + +/** + * @param {HTMLElement} element DOM element for component instantiation and scope + * @param {Object} options + * @param {String} options.toggleSelector Selector for the modal toggle + * @param {String} options.closeSelector Selector for closing the modal + * @param {Boolean} options.attachClickListener Whether or not to bind click events on toggle + * @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events + */ +export class Modal { + /** + * @static + * Shorthand for instance creation and initialisation. + * + * @param {HTMLElement} root DOM element for component instantiation and scope + * + * @return {Modal} An instance of Modal. + */ + static autoInit(root, { MODAL: defaultOptions = {} } = {}) { + const modal = new Modal(root, defaultOptions); + modal.init(); + root.ECLModal = modal; + return modal; + } + + constructor( + element, + { + toggleSelector = '', + closeSelector = '[data-ecl-modal-close]', + attachClickListener = true, + attachKeyListener = 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; + + // Options + this.toggleSelector = toggleSelector; + this.closeSelector = closeSelector; + this.attachClickListener = attachClickListener; + this.attachKeyListener = attachKeyListener; + + // Private variables + this.toggle = null; + this.close = null; + this.focusTrap = null; + + // Bind `this` for use in callbacks + this.openModal = this.openModal.bind(this); + this.closeModal = this.closeModal.bind(this); + this.handleClickOnToggle = this.handleClickOnToggle.bind(this); + this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this); + } + + /** + * Initialise component. + */ + init() { + // Bind global events + if (this.attachKeyListener) { + document.addEventListener('keyup', this.handleKeyboardGlobal); + } + + // Get toggle element + if (this.toggleSelector === '') { + this.toggleSelector = `#${this.element.getAttribute( + 'data-ecl-modal-toggle' + )}`; + } + this.toggle = document.querySelector(this.toggleSelector); + + // Apply aria to toggle + if (this.toggle) { + this.toggle.setAttribute('aria-controls', this.element.id); + if (!this.toggle.getAttribute('aria-haspopup')) { + this.toggle.setAttribute('aria-haspopup', 'dialog'); + } + } + + // Get other elements + this.close = queryAll(this.closeSelector, this.element); + + // Create focus trap + this.focusTrap = createFocusTrap(this.element); + + // Polyfill to support + this.isDialogSupported = true; + if (!window.HTMLDialogElement) { + this.isDialogSupported = false; + } + + // Bind click event on toggle + if (this.toggle && this.attachClickListener) { + this.toggle.addEventListener('click', this.handleClickOnToggle); + } + + // Bind click event on close buttons + if (this.close && this.attachClickListener) { + this.close.forEach((close) => { + close.addEventListener('click', this.closeModal); + }); + } + + // Set ecl initialized attribute + this.element.setAttribute('data-ecl-auto-initialized', 'true'); + } + + /** + * Destroy component. + */ + destroy() { + if (this.toggle && this.attachClickListener) { + this.toggle.removeEventListener('click', this.handleClickOnToggle); + } + + if (this.attachKeyListener) { + document.removeEventListener('keyup', this.handleKeyboardGlobal); + } + + if (this.close && this.attachClickListener) { + this.close.forEach((close) => { + close.removeEventListener('click', this.closeModal); + }); + } + + this.element.removeAttribute('data-ecl-auto-initialized'); + } + + /** + * Toggles between collapsed/expanded states. + * + * @param {Event} e + */ + handleClickOnToggle(e) { + e.preventDefault(); + + // Get current status + const isExpanded = this.toggle.getAttribute('aria-expanded') === 'true'; + + // Toggle the modal + if (isExpanded) { + this.closeModal(); + return; + } + + this.openModal(); + } + + /** + * Open the modal. + */ + openModal() { + if (this.isDialogSupported) { + this.element.showModal(); + } else { + this.element.setAttribute('open', ''); + } + + // Trap focus + this.focusTrap.activate(); + } + + /** + * Close the modal. + */ + closeModal() { + if (this.isDialogSupported) { + this.element.close(); + } else { + this.element.removeAttribute('open'); + } + + // Untrap focus + this.focusTrap.deactivate(); + } + + /** + * Handles global keyboard events, triggered outside of the modal. + * + * @param {Event} e + */ + handleKeyboardGlobal(e) { + // Detect press on Escape + if (e.key === 'Escape' || e.key === 'Esc') { + this.closeModal(); + } + } +} + +export default Modal; diff --git a/src/implementations/vanilla/components/modal/package.json b/src/implementations/vanilla/components/modal/package.json new file mode 100644 index 00000000000..9d756a77f4b --- /dev/null +++ b/src/implementations/vanilla/components/modal/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ecl/vanilla-component-modal", + "author": "European Commission", + "license": "EUPL-1.2", + "version": "3.7.1", + "description": "ECL Modal", + "main": "modal.js", + "module": "modal.js", + "style": "modal.scss", + "sass": "modal.scss", + "dependencies": { + "@ecl/dom-utils": "3.7.1", + "@ecl/theme-dev": "3.7.1", + "focus-trap": "7.2.0" + }, + "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/presets/dev/package.json b/src/presets/dev/package.json index 0b906d16a8b..1713591259b 100644 --- a/src/presets/dev/package.json +++ b/src/presets/dev/package.json @@ -44,6 +44,7 @@ "@ecl/vanilla-component-list-illustration": "3.7.1", "@ecl/vanilla-component-menu": "3.7.1", "@ecl/vanilla-component-message": "3.7.1", + "@ecl/vanilla-component-modal": "3.7.1", "@ecl/vanilla-component-news-ticker": "3.7.1", "@ecl/vanilla-component-ordered-list": "3.7.1", "@ecl/vanilla-component-page-banner": "3.7.1", diff --git a/src/presets/dev/src/dev.js b/src/presets/dev/src/dev.js index 3af8087510e..eff9afdf1d6 100644 --- a/src/presets/dev/src/dev.js +++ b/src/presets/dev/src/dev.js @@ -17,6 +17,7 @@ export * from '@ecl/vanilla-component-inpage-navigation'; export * from '@ecl/vanilla-component-media-container'; export * from '@ecl/vanilla-component-message'; export * from '@ecl/vanilla-component-menu'; +export * from '@ecl/vanilla-component-modal'; export * from '@ecl/vanilla-component-news-ticker'; export * from '@ecl/vanilla-component-popover'; export * from '@ecl/vanilla-component-range'; diff --git a/src/presets/dev/src/dev.scss b/src/presets/dev/src/dev.scss index 713dd658f3a..3df3d3a414c 100644 --- a/src/presets/dev/src/dev.scss +++ b/src/presets/dev/src/dev.scss @@ -34,6 +34,7 @@ @use '@ecl/vanilla-component-rating-field/rating-field'; @use '@ecl/vanilla-component-list-illustration/list-illustration'; @use '@ecl/vanilla-component-media-container/media-container'; +@use '@ecl/vanilla-component-modal/modal'; @use '@ecl/vanilla-component-navigation-list/navigation-list'; @use '@ecl/vanilla-component-ordered-list/ordered-list'; @use '@ecl/vanilla-component-unordered-list/unordered-list'; diff --git a/src/presets/ec/src/ec-print.scss b/src/presets/ec/src/ec-print.scss index 2f0c3083196..fbff19509de 100644 --- a/src/presets/ec/src/ec-print.scss +++ b/src/presets/ec/src/ec-print.scss @@ -48,10 +48,12 @@ @use '@ecl/vanilla-component-file/file-print'; @use '@ecl/vanilla-component-rating-field/rating-field-print'; @use '@ecl/vanilla-component-media-container/media-container-print'; +@use '@ecl/vanilla-component-modal/modal-print-ec'; @use '@ecl/vanilla-component-navigation-list/navigation-list-print'; @use '@ecl/vanilla-component-ordered-list/ordered-list-print'; @use '@ecl/vanilla-component-unordered-list/unordered-list-print'; @use '@ecl/vanilla-component-pagination/pagination-print'; +@use '@ecl/vanilla-component-popover/popover-print'; @use '@ecl/vanilla-component-search-form/search-form-print'; @use '@ecl/vanilla-component-social-media-follow/social-media-follow-print'; @use '@ecl/vanilla-component-social-media-share/social-media-share-print'; diff --git a/src/presets/ec/src/ec.scss b/src/presets/ec/src/ec.scss index 7ee842c8b39..2b7a85d38a1 100644 --- a/src/presets/ec/src/ec.scss +++ b/src/presets/ec/src/ec.scss @@ -49,6 +49,7 @@ @use '@ecl/vanilla-component-fact-figures/fact-figures-ec'; @use '@ecl/vanilla-component-file/file-ec'; @use '@ecl/vanilla-component-rating-field/rating-field'; +@use '@ecl/vanilla-component-modal/modal-ec'; @use '@ecl/vanilla-component-media-container/media-container-ec'; @use '@ecl/vanilla-component-navigation-list/navigation-list-ec'; @use '@ecl/vanilla-component-ordered-list/ordered-list-ec'; diff --git a/src/presets/eu/src/eu-print.scss b/src/presets/eu/src/eu-print.scss index 5c3678c0226..093f49edbb5 100644 --- a/src/presets/eu/src/eu-print.scss +++ b/src/presets/eu/src/eu-print.scss @@ -48,10 +48,12 @@ @use '@ecl/vanilla-component-file/file-print'; @use '@ecl/vanilla-component-rating-field/rating-field-print'; @use '@ecl/vanilla-component-media-container/media-container-print'; +@use '@ecl/vanilla-component-modal/modal-print-eu'; @use '@ecl/vanilla-component-navigation-list/navigation-list-print'; @use '@ecl/vanilla-component-ordered-list/ordered-list-print'; @use '@ecl/vanilla-component-unordered-list/unordered-list-print'; @use '@ecl/vanilla-component-pagination/pagination-print'; +@use '@ecl/vanilla-component-popover/popover-print'; @use '@ecl/vanilla-component-search-form/search-form-print'; @use '@ecl/vanilla-component-social-media-follow/social-media-follow-print'; @use '@ecl/vanilla-component-social-media-share/social-media-share-print'; diff --git a/src/presets/eu/src/eu.scss b/src/presets/eu/src/eu.scss index b25de6577ca..bc538feef71 100644 --- a/src/presets/eu/src/eu.scss +++ b/src/presets/eu/src/eu.scss @@ -50,6 +50,7 @@ @use '@ecl/vanilla-component-file/file-eu'; @use '@ecl/vanilla-component-rating-field/rating-field'; @use '@ecl/vanilla-component-media-container/media-container-eu'; +@use '@ecl/vanilla-component-modal/modal-eu'; @use '@ecl/vanilla-component-navigation-list/navigation-list-eu'; @use '@ecl/vanilla-component-ordered-list/ordered-list-eu'; @use '@ecl/vanilla-component-unordered-list/unordered-list-eu'; diff --git a/src/specs/components/modal/demo/data.js b/src/specs/components/modal/demo/data.js new file mode 100644 index 00000000000..c995fe944e8 --- /dev/null +++ b/src/specs/components/modal/demo/data.js @@ -0,0 +1,22 @@ +// Simple content for demo +module.exports = { + id: 'modal-example', + toggle_id: 'modal-toggle', + icon_path: '/icons.svg', + close_label: 'Close', + header: 'Lorem ipsum dolor sit amet', + body: 'Sed quam augue, volutpat sed dapibus in, accumsan a arcu. Nulla quam enim, porttitor at neque a, egestas porttitor tortor. Nam tortor sem, elementum id augue quis, posuere vestibulum dui. Donec id posuere libero, sit amet egestas lorem. Aliquam finibus ipsum mauris, a molestie tortor laoreet.', + buttons: [ + { + label: 'Secondary action', + type: 'button', + variant: 'secondary', + extra_attributes: [{ name: 'data-ecl-modal-close' }], + }, + { + label: 'Primary action', + type: 'submit', + variant: 'primary', + }, + ], +}; diff --git a/src/specs/components/modal/package.json b/src/specs/components/modal/package.json new file mode 100644 index 00000000000..44fe0964da2 --- /dev/null +++ b/src/specs/components/modal/package.json @@ -0,0 +1,23 @@ +{ + "name": "@ecl/specs-component-modal", + "author": "European Commission", + "license": "EUPL-1.2", + "version": "3.7.1", + "description": "ECL Modal Specs", + "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/themes/dev/maps/color.scss b/src/themes/dev/maps/color.scss index f5ad85240ae..0c6ca0277d2 100644 --- a/src/themes/dev/maps/color.scss +++ b/src/themes/dev/maps/color.scss @@ -6,9 +6,13 @@ $color: ( 'secondary': #ffd617, 'tertiary': #e3e3e3, 'error': #da2131, + // red-100 'info': #006fb4, + // blue-n 'success': #467a39, + // green-100 'warning': #f29527, + // orange-100 'text': #404040, // main colours diff --git a/src/themes/dev/maps/shape.scss b/src/themes/dev/maps/shape.scss index 9739efc4c7d..7038465af54 100644 --- a/src/themes/dev/maps/shape.scss +++ b/src/themes/dev/maps/shape.scss @@ -20,6 +20,10 @@ $shadow: ( 0 0 22px rgba($shadow-color, 0.04), 0 12px 17px rgba($shadow-color, 0.04), 0 -4px 4px rgba($shadow-color, 0.04)}, + '4': #{0 11px 15px rgba($shadow-color, 0.08), + 0 9px 46px rgba($shadow-color, 0.04), + 0 24px 38px rgba($shadow-color, 0.04), + 0 -4px 4px rgba($shadow-color, 0.04)}, ) !default; $shadow-inner: ( '1': #{0 2px 4px rgba($shadow-color, 0.08) inset, @@ -45,6 +49,10 @@ $shadow-negative: ( 0 0 22px rgba($shadow-negative-color, 0.04), 0 12px 17px rgba($shadow-negative-color, 0.04), 0 -4px 4px rgba($shadow-negative-color, 0.04)}, + '4': #{0 11px 15px rgba($shadow-color, 0.08), + 0 9px 46px rgba($shadow-color, 0.04), + 0 24px 38px rgba($shadow-color, 0.04), + 0 -4px 4px rgba($shadow-color, 0.04)}, ) !default; $shadow-negative-inner: ( '1': #{0 2px 4px rgba($shadow-negative-color, 0.08) inset, diff --git a/src/themes/eu/_index.scss b/src/themes/eu/_index.scss index 3594691b3e5..ebd101ffb42 100644 --- a/src/themes/eu/_index.scss +++ b/src/themes/eu/_index.scss @@ -4,10 +4,14 @@ 'primary': #0e47cb, 'secondary': #fc0, 'tertiary': #262b38, - 'error': #ef0044, + 'error': #bf0036, + // red-120 'info': #0e47cb, - 'success': #00c991, - 'warning': #ff6200, + // blue-100 + 'success': #008d66, + // green-130 + 'warning': #cc4e00, + // orange-120 'text': #262b38, // main colours diff --git a/src/website/src/pages/ec/components/index.mdx b/src/website/src/pages/ec/components/index.mdx index 4f16949c98f..2b6ea06def6 100644 --- a/src/website/src/pages/ec/components/index.mdx +++ b/src/website/src/pages/ec/components/index.mdx @@ -22,6 +22,7 @@ import LabelThumbnail from './label/ec_comp_label.svg'; import ListThumbnail from './list/ec_comp_lists.svg'; import ListIllustrationThumbnail from './list-illustration/ec_comp_list_illustration.svg'; import MessageThumbnail from './message/ec_comp_messages.svg'; +import ModalThumbnail from './modal/ec_comp_modal.svg'; import NewsTickerThumbnail from './news-ticker/ec_comp_news_ticker.svg'; import PopoverThumbnail from './popover/ec_comp_popover.svg'; import SMFThumbnail from './social-media-follow/ec_comp_social_media_follow.svg'; @@ -127,6 +128,13 @@ Every component is coded to serve a function and designed to solve a specific us title="Messages" /> + + + + +## Setup + +There are 2 ways to initialise the component. + +### Automatic + +Add `data-ecl-auto-init="Modal"` attribute to component's markup: + +```html +
...
+``` + +Use the `ECL` library's `autoInit()` (`ECL.autoInit()`) when your page is ready or other custom event you want to hook onto. + +### Manual + +Get target element, create an instance and invoke `init()`. + +Given you have 1 element with an attribute `data-ecl-modal` on the page: + +```js +var elt = document.querySelector('[data-ecl-modal]'); +var modal = new ECL.Modal(elt); +modal.init(); +``` diff --git a/src/website/src/pages/ec/components/modal/docs/code.mdx b/src/website/src/pages/ec/components/modal/docs/code.mdx new file mode 100644 index 00000000000..76eafa43ef6 --- /dev/null +++ b/src/website/src/pages/ec/components/modal/docs/code.mdx @@ -0,0 +1,19 @@ +--- +title: Showcase +order: 2 +--- + +import { Playground, Html } from '@ecl/website-components'; +import modal from '../demo'; + + + + + +Note: you can have any button in the modal act as a close button. To do so, simply add `data-ecl-modal-close` to it. diff --git a/src/website/src/pages/ec/components/modal/docs/usage.md b/src/website/src/pages/ec/components/modal/docs/usage.md new file mode 100644 index 00000000000..b81363c33fc --- /dev/null +++ b/src/website/src/pages/ec/components/modal/docs/usage.md @@ -0,0 +1,4 @@ +--- +title: Usage +order: 1 +--- diff --git a/src/website/src/pages/ec/components/modal/ec_comp_modal.svg b/src/website/src/pages/ec/components/modal/ec_comp_modal.svg new file mode 100644 index 00000000000..41e0ab70063 --- /dev/null +++ b/src/website/src/pages/ec/components/modal/ec_comp_modal.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + diff --git a/src/website/src/pages/ec/components/modal/index.md b/src/website/src/pages/ec/components/modal/index.md new file mode 100644 index 00000000000..55fbad6d1fc --- /dev/null +++ b/src/website/src/pages/ec/components/modal/index.md @@ -0,0 +1,8 @@ +--- +title: Modal +defaultTab: usage +status: ready +playground: + system: ec + path: /story/components-modal--default +--- diff --git a/src/website/src/pages/eu/components/index.mdx b/src/website/src/pages/eu/components/index.mdx index 533c8adf195..f01d21a1da8 100644 --- a/src/website/src/pages/eu/components/index.mdx +++ b/src/website/src/pages/eu/components/index.mdx @@ -22,6 +22,7 @@ import LabelThumbnail from './label/eu_comp_label.svg'; import ListThumbnail from './list/eu_comp_lists.svg'; import ListIllustrationThumbnail from './list-illustration/eu_comp_list_illustration.svg'; import MessageThumbnail from './message/eu_comp_messages.svg'; +import ModalThumbnail from './modal/eu_comp_modal.svg'; import NewsTickerThumbnail from './news-ticker/eu_comp_news_ticker.svg'; import PopoverThumbnail from './popover/eu_comp_popover.svg'; import SMFThumbnail from './social-media-follow/eu_comp_social_media_follow.svg'; @@ -147,6 +148,9 @@ Every component is coded to serve a function and designed to solve a specific us title="Messages" /> + + + + +## Setup + +There are 2 ways to initialise the component. + +### Automatic + +Add `data-ecl-auto-init="Modal"` attribute to component's markup: + +```html +
...
+``` + +Use the `ECL` library's `autoInit()` (`ECL.autoInit()`) when your page is ready or other custom event you want to hook onto. + +### Manual + +Get target element, create an instance and invoke `init()`. + +Given you have 1 element with an attribute `data-ecl-modal` on the page: + +```js +var elt = document.querySelector('[data-ecl-modal]'); +var modal = new ECL.Modal(elt); +modal.init(); +``` diff --git a/src/website/src/pages/eu/components/modal/docs/code.mdx b/src/website/src/pages/eu/components/modal/docs/code.mdx new file mode 100644 index 00000000000..fc8dd29ed46 --- /dev/null +++ b/src/website/src/pages/eu/components/modal/docs/code.mdx @@ -0,0 +1,19 @@ +--- +title: Showcase +order: 2 +--- + +import { Playground, Html } from '@ecl/website-components'; +import modal from '../demo'; + + + + + +Note: you can have any button in the modal act as a close button. To do so, simply add `data-ecl-modal-close` to it. diff --git a/src/website/src/pages/eu/components/modal/docs/usage.md b/src/website/src/pages/eu/components/modal/docs/usage.md new file mode 100644 index 00000000000..b81363c33fc --- /dev/null +++ b/src/website/src/pages/eu/components/modal/docs/usage.md @@ -0,0 +1,4 @@ +--- +title: Usage +order: 1 +--- diff --git a/src/website/src/pages/eu/components/modal/eu_comp_modal.svg b/src/website/src/pages/eu/components/modal/eu_comp_modal.svg new file mode 100644 index 00000000000..498c1965989 --- /dev/null +++ b/src/website/src/pages/eu/components/modal/eu_comp_modal.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + diff --git a/src/website/src/pages/eu/components/modal/index.md b/src/website/src/pages/eu/components/modal/index.md new file mode 100644 index 00000000000..ab6c5ede379 --- /dev/null +++ b/src/website/src/pages/eu/components/modal/index.md @@ -0,0 +1,8 @@ +--- +title: Modal +defaultTab: usage +status: ready +playground: + system: eu + path: /story/components-modal--default +--- diff --git a/yarn.lock b/yarn.lock index 60bc8e59b9c..50a75fbb57e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10006,6 +10006,13 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== +focus-trap@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-7.2.0.tgz#25af61b5635d3c18cd2fd176087db7b60be72c6b" + integrity sha512-v4wY6HDDYvzkBy4735kW5BUEuw6Yz9ABqMYLuTNbzAFPcBOGiGHwwcNVMvUz4G0kgSYh13wa/7TG3XwTeT4O/A== + dependencies: + tabbable "^6.0.1" + focus-trap@7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-7.3.1.tgz#417c98e5f1ab94e717d31f1bafa2da45dabcd65f" @@ -19335,7 +19342,7 @@ synchronous-promise@^2.0.15: resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.15.tgz#07ca1822b9de0001f5ff73595f3d08c4f720eb8e" integrity sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg== -tabbable@^6.1.1: +tabbable@^6.0.1, tabbable@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.1.1.tgz#40cfead5ed11be49043f04436ef924c8890186a0" integrity sha512-4kl5w+nCB44EVRdO0g/UGoOp3vlwgycUVtkk/7DPyeLZUCuNFFKCFG6/t/DgHLrUPHjrZg6s5tNm+56Q2B0xyg==