diff --git a/assets/javascripts/application.js b/assets/javascripts/application.js index 34dc9d03..2614da7a 100644 --- a/assets/javascripts/application.js +++ b/assets/javascripts/application.js @@ -13,6 +13,7 @@ import FormNewAuthorizationController from '@/controllers/form_new_authorization import FormContractController from '@/controllers/form_contract_controller.js'; import FormPriorityController from '@/controllers/form_priority_controller.js'; import FormTicketActorsController from '@/controllers/form_ticket_actors_controller.js'; +import InputTextsController from '@/controllers/input_texts_controller.js'; import MessageDocumentsController from '@/controllers/message_documents_controller.js'; import ModalController from '@/controllers/modal_controller.js'; import ModalOpenerController from '@/controllers/modal_opener_controller.js'; @@ -37,6 +38,7 @@ application.register('form-new-authorization', FormNewAuthorizationController); application.register('form-contract', FormContractController); application.register('form-priority', FormPriorityController); application.register('form-ticket-actors', FormTicketActorsController); +application.register('input-texts', InputTextsController); application.register('message-documents', MessageDocumentsController); application.register('modal', ModalController); application.register('modal-opener', ModalOpenerController); diff --git a/assets/javascripts/controllers/input_texts_controller.js b/assets/javascripts/controllers/input_texts_controller.js new file mode 100644 index 00000000..7a4d8529 --- /dev/null +++ b/assets/javascripts/controllers/input_texts_controller.js @@ -0,0 +1,195 @@ +// This file is part of Bileto. +// Copyright 2022-2024 Probesys +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static get targets () { + return ['list', 'input', 'template', 'data']; + } + + static get values () { + return { + index: Number, + nameTemplate: String, + }; + } + + connect () { + this.refresh(); + } + + /** + * Reset the list of buttons and recreate them based on the source of truth + * (i.e. dataTargets). + * + * This method is called each time that the data change (added or removed). + */ + refresh () { + this.listTarget.innerHTML = ''; + + this.dataTargets.forEach((dataNode) => { + const buttonNode = this.buttonNode(dataNode); + this.listTarget.appendChild(buttonNode); + }); + } + + /** + * Handle special keystroke in the input. + * + * Typing a `,`, a space, enter or tab validate the current typed element + * and add it to the list. + * + * Typing backspace in an empty input remove the last element from the list. + */ + handleInput (event) { + const value = this.inputTarget.value; + + if (event.key === ',' || event.key === ' ' || event.key === 'Enter' || event.key === 'Tab') { + this.addCurrentValue(); + } else if (event.key === 'Backspace' && !value) { + this.removeLastData(); + } + + if (event.key === ',' || event.key === ' ') { + event.preventDefault(); + } + } + + /** + * Add the value actually typed in the input. + * + * A new node (hidden input) is added to the data if the input is not empty. + * Then, the input is empty, and the buttons list is refreshed. + */ + addCurrentValue () { + const value = this.inputTarget.value; + + if (!value) { + return; + } + + const name = this.nameTemplateValue.replace(/__name__/g, this.indexValue); + + const dataNode = document.createElement('input'); + dataNode.setAttribute('type', 'hidden'); + dataNode.setAttribute('name', name); + dataNode.setAttribute('value', value); + dataNode.setAttribute('data-input-texts-target', 'data'); + this.element.appendChild(dataNode); + + this.inputTarget.value = ''; + this.indexValue += 1; + + this.refresh(); + } + + /** + * Remove the clicked element. + */ + remove (event) { + const currentButton = event.currentTarget; + this.removeDataByButton(currentButton); + } + + /** + * Remove the element that has the focus when typing backspace or delete. + */ + removeCurrent (event) { + if (event.key !== 'Backspace' && event.key !== 'Delete') { + return; + } + + const currentButton = document.activeElement; + + if (!currentButton) { + return; + } + + this.removeDataByButton(currentButton); + } + + /** + * Remove the data corresponding to the given button node. + */ + removeDataByButton (buttonNode) { + // The sibling will be used to give the focus at the end of the function. + const sibling = buttonNode.nextElementSibling; + + // Find the data node that corresponds to the given button. They must + // have the same value/data-value attributes. + const value = buttonNode.getAttribute('data-value'); + const dataNode = this.dataTargets.find((node) => node.value === value); + if (dataNode) { + // We simply remove the node from the DOM to remove the data. + dataNode.remove(); + } + + this.refresh(); + + if (sibling) { + // As the list of buttons is reset (i.e. innerHTML is set to empty + // string), the initial "sibling" node doesn't exist anymore in the + // DOM. So we need to find the button that has the same value as + // the old one. + const siblingValue = sibling.getAttribute('data-value'); + const actualSibling = this.listTarget.querySelector(`button[data-value="${siblingValue}"]`); + if (actualSibling) { + actualSibling.focus(); + } + } else { + // If there are no sibling, then give the focus to the input. + this.inputTarget.focus(); + } + } + + /** + * Remove the last data node. This is called when typing backspace in an + * empty input. + */ + removeLastData () { + if (this.dataTargets.length === 0) { + return; + } + + this.dataTargets.at(-1).remove(); + + this.refresh(); + this.inputTarget.focus(); + } + + /** + * Return a button node used to display the actual data. + */ + buttonNode (dataNode) { + const buttonNode = this.templateTarget.content.firstElementChild.cloneNode(true); + + buttonNode.setAttribute('data-value', dataNode.value); + buttonNode.querySelector('[data-target="value"]').textContent = dataNode.value; + + const ariaInvalid = dataNode.getAttribute('aria-invalid'); + if (ariaInvalid) { + buttonNode.setAttribute('aria-invalid', ariaInvalid); + } + + const ariaErrorMessage = dataNode.getAttribute('aria-errormessage'); + if (ariaErrorMessage) { + buttonNode.setAttribute('aria-errormessage', ariaErrorMessage); + } + + return buttonNode; + } + + /** + * Give the focus on the input when we click on the root element. + * + * It avoids to give the focus to the input if we click on one of the + * button (as the focus must be given to the sibling button). + */ + focusInput (event) { + if (event.target === this.element) { + this.inputTarget.focus(); + } + } +} diff --git a/assets/stylesheets/components/forms.css b/assets/stylesheets/components/forms.css index a3d84de8..abfcea70 100644 --- a/assets/stylesheets/components/forms.css +++ b/assets/stylesheets/components/forms.css @@ -51,7 +51,8 @@ label { } input, -textarea { +textarea, +.input-texts { width: 100%; padding: var(--form-padding); @@ -390,3 +391,49 @@ legend { .input-container button[aria-pressed="true"] .icon--eye { display: none; } + +.input-texts { + --input-texts-elements-padding-y: var(--space-smaller); + --input-texts-elements-margin-bottom: var(--space-smaller); + --input-texts-elements-border-width: 1px; + + /* Make sure to remove the paddings, margins and borders of internal + * elements of the container from the initial padding. */ + padding-top: calc(var(--form-padding-y) - var(--input-texts-elements-padding-y) - var(--input-texts-elements-border-width)); + padding-bottom: calc(var(--form-padding-y) - var(--input-texts-elements-padding-y) - var(--input-texts-elements-margin-bottom) - var(--input-texts-elements-border-width)); + + cursor: text; +} + +.input-texts:focus-within { + outline: var(--outline-width) solid var(--outline-color); + outline-offset: var(--outline-offset); +} + +.input-texts__input { + display: inline-block; + width: 175px; + margin-bottom: var(--input-texts-elements-margin-bottom); + padding: var(--input-texts-elements-padding-y) 0; + + border-color: transparent; +} + +.input-texts__input:focus { + outline: none; +} + +.input-texts__list > * { + margin-right: var(--space-smaller); + margin-bottom: var(--input-texts-elements-margin-bottom); +} + +.input-texts__list > *[aria-invalid] { + --button-color: var(--color-error11); +} + +.input-texts__list > *[aria-invalid]:hover, +.input-texts__list > *[aria-invalid]:focus, +.input-texts__list > *[aria-invalid]:active { + --button-background-color: var(--color-error3); +} diff --git a/templates/form/_input_texts.html.twig b/templates/form/_input_texts.html.twig new file mode 100644 index 00000000..b8502f2c --- /dev/null +++ b/templates/form/_input_texts.html.twig @@ -0,0 +1,65 @@ +{# + # This file is part of Bileto. + # Copyright 2022-2024 Probesys + # SPDX-License-Identifier: AGPL-3.0-or-later + #} + +{% if inputAttrs is not defined %} + {% set inputAttrs = {} %} +{% endif %} + +
+ + + + + + + + {% for childField in field %} + + {% endfor %} +
diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index d1eef9e7..4fcb2395 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -106,6 +106,7 @@ errors.generic.description: 'Please tell your administrator what you were doing errors.generic.sorry: 'Sorry for any inconvenience caused.' errors.generic.title: 'An error has occured!' forms.error: Error +forms.input_texts.remove: Remove forms.max_chars: '(max. {number} characters)' forms.optional: (optional) forms.optional_max_chars: '(optional, max. {number} characters)' diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index 30b21561..a4ef24ab 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -106,6 +106,7 @@ errors.generic.description: 'Veuillez indiquer à votre administrateur ce que vo errors.generic.sorry: 'Désolé pour la gêne occasionnée.' errors.generic.title: "Une erreur est survenue\_!" forms.error: Erreur +forms.input_texts.remove: Retirer forms.max_chars: '(max. {number} caractères)' forms.optional: (optionnel) forms.optional_max_chars: '(optionnel, max. {number} caractères)'