-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
dev: Provide the input-texts component
- Loading branch information
1 parent
b8ffd2b
commit 7dfedcc
Showing
6 changed files
with
312 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
195 changes: 195 additions & 0 deletions
195
assets/javascripts/controllers/input_texts_controller.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters