Skip to content

Commit

Permalink
dev: Provide the input-texts component
Browse files Browse the repository at this point in the history
  • Loading branch information
marien-probesys committed Jun 19, 2024
1 parent b8ffd2b commit 7dfedcc
Show file tree
Hide file tree
Showing 6 changed files with 312 additions and 1 deletion.
2 changes: 2 additions & 0 deletions assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
195 changes: 195 additions & 0 deletions assets/javascripts/controllers/input_texts_controller.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
49 changes: 48 additions & 1 deletion assets/stylesheets/components/forms.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ label {
}

input,
textarea {
textarea,
.input-texts {
width: 100%;
padding: var(--form-padding);

Expand Down Expand Up @@ -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);
}
65 changes: 65 additions & 0 deletions templates/form/_input_texts.html.twig
Original file line number Diff line number Diff line change
@@ -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 %}

<div
class="input-texts"
data-controller="input-texts"
data-action="click->input-texts#focusInput"
data-input-texts-index-value="{{ field|length > 0 ? field|last.vars.name + 1 : 0 }}"
data-input-texts-name-value="{{ field_name(field) }}"
data-input-texts-name-template-value="{{ field_name(field.vars.prototype) }}"
>
<span class="input-texts__list" data-input-texts-target="list">
</span>

<input
class="input-texts__input"
type="text"
id="{{ field_id(field) }}"
value=""
data-input-texts-target="input"
data-action="keydown->input-texts#handleInput blur->input-texts#addCurrentValue"
{% if field_has_errors(field) %}
aria-invalid="true"
aria-errormessage="{{ field_id(field, 'error') }}"
{% endif %}
{% for attr, value in inputAttrs %}
{{ attr }}="{{ value }}"
{% endfor %}
/>

<template data-input-texts-target="template">
<button
type="button"
class="button--discreet-alt"
data-action="input-texts#remove keydown->input-texts#removeCurrent"
data-value=""
aria-label={{ 'forms.input_texts.remove' | trans }}
>
<span data-target="value">
</span>

{{ icon('close') }}
</button>
</template>

{% for childField in field %}
<input
type="hidden"
name="{{ field_name(childField) }}"
value="{{ field_value(childField) }}"
data-input-texts-target="data"
{% if field_has_errors(childField) %}
aria-invalid="true"
aria-errormessage="{{ field_id(childField, 'error') }}"
{% endif %}
/>
{% endfor %}
</div>
1 change: 1 addition & 0 deletions translations/messages+intl-icu.en_GB.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand Down
1 change: 1 addition & 0 deletions translations/messages+intl-icu.fr_FR.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand Down

0 comments on commit 7dfedcc

Please sign in to comment.