diff --git a/asset/css/search-base.less b/asset/css/search-base.less
index e4af764b..7dfa2a22 100644
--- a/asset/css/search-base.less
+++ b/asset/css/search-base.less
@@ -25,7 +25,8 @@
.search-bar,
.term-input-area {
- [data-index] input:invalid {
+ [data-index] input:invalid,
+ [data-index] input.invalid {
background-color: var(--search-term-invalid-bg, @search-term-invalid-bg);
color: var(--search-term-invalid-color, @search-term-invalid-color);
}
@@ -41,6 +42,23 @@
}
}
+.invalid-reason {
+ padding: .25em;
+ .rounded-corners(.25em);
+ border: 1px solid black;
+ font-weight: bold;
+ background: var(--search-term-invalid-reason-bg, @search-term-invalid-reason-bg);
+
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 2s, visibility 2s;
+ &.visible {
+ opacity: 1;
+ visibility: visible;
+ transition: none;
+ }
+}
+
.search-suggestions {
background: var(--suggestions-bg, @suggestions-bg);
color: var(--suggestions-color, @suggestions-color);
@@ -209,6 +227,41 @@
outline-width: 3px;
outline-offset: ~"calc(-@{labelPad} + 3px)";
}
+
+ &.read-only {
+ [data-index] {
+ position: relative;
+
+ input {
+ padding-left: 1.5em;
+ text-align: center;
+ cursor: pointer;
+
+ &:disabled {
+ cursor: default;
+ }
+
+ + i {
+ position: absolute;
+ display: none;
+ top: .5em;
+ left: .5em;
+ }
+
+ &:not(:disabled):hover + i,
+ &:not(:disabled):focus + i {
+ display: revert;
+ }
+ }
+
+ .invalid-reason {
+ position: absolute;
+ z-index: 1;
+ top: 85%;
+ left: .5em;
+ }
+ }
+ }
}
.search-suggestions {
diff --git a/asset/css/variables.less b/asset/css/variables.less
index a500c5e9..d1328fe6 100644
--- a/asset/css/variables.less
+++ b/asset/css/variables.less
@@ -60,6 +60,7 @@
@search-term-selected-bg: @base-disabled;
@search-term-invalid-bg: @state-critical;
@search-term-invalid-color: @default-text-color-inverted;
+@search-term-invalid-reason-bg: @base-gray-lighter;
@search-term-disabled-bg: @base-disabled;
@search-term-selected-color: @base-gray-light;
@search-term-highlighted-bg: @base-primary-bg;
@@ -154,6 +155,7 @@
--search-term-selected-bg: var(--base-disabled);
--search-term-invalid-bg: var(--base-remove-bg);
--search-term-invalid-color: var(--default-text-color-inverted);
+ --search-term-invalid-reason-bg: var(--base-gray-lighter);
--search-term-disabled-bg: var(--base-gray-light);
--search-term-selected-color: var(--base-gray);
--search-term-highlighted-bg: var(--primary-button-bg);
diff --git a/asset/js/widget/BaseInput.js b/asset/js/widget/BaseInput.js
index 269d6f7d..f94c3d5f 100644
--- a/asset/js/widget/BaseInput.js
+++ b/asset/js/widget/BaseInput.js
@@ -5,6 +5,7 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
class BaseInput {
constructor(input) {
this.input = input;
+ this.readOnly = false;
this.disabled = false;
this.separator = '';
this.usedTerms = [];
@@ -55,9 +56,12 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
$(this.termContainer).on('input', '[data-label]', this.onInput, this);
$(this.termContainer).on('keydown', '[data-label]', this.onKeyDown, this);
$(this.termContainer).on('keyup', '[data-label]', this.onKeyUp, this);
- $(this.termContainer).on('focusout', '[data-index]', this.onTermFocusOut, this);
$(this.termContainer).on('focusin', '[data-index]', this.onTermFocus, this);
+ if (! this.readOnly) {
+ $(this.termContainer).on('focusout', '[data-index]', this.onTermFocusOut, this);
+ }
+
// Copy/Paste
$(this.input).on('paste', this.onPaste, this);
$(this.input).on('copy', this.onCopyAndCut, this);
@@ -664,7 +668,10 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
toFocus = this.input;
}
- toFocus.selectionStart = toFocus.selectionEnd = 0;
+ if (! this.readOnly) {
+ toFocus.selectionStart = toFocus.selectionEnd = 0;
+ }
+
$(toFocus).focus();
return toFocus;
@@ -687,7 +694,10 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
toFocus = this.input;
}
- toFocus.selectionStart = toFocus.selectionEnd = toFocus.value.length;
+ if (! this.readOnly) {
+ toFocus.selectionStart = toFocus.selectionEnd = toFocus.value.length;
+ }
+
$(toFocus).focus();
return toFocus;
@@ -801,7 +811,12 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
case 'Backspace':
removedTerms = this.clearSelectedTerms();
- if (this.isTermDirectionVertical()) {
+ if (this.readOnly) {
+ if (termIndex >= 0) {
+ removedTerms[termIndex] = this.removeTerm(input.parentNode);
+ this.moveFocusForward(termIndex - 1);
+ }
+ } else if (this.isTermDirectionVertical()) {
// pass
} else if (termIndex >= 0 && ! input.value) {
let removedTerm = this.removeTerm(input.parentNode);
@@ -835,7 +850,12 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
case 'Delete':
removedTerms = this.clearSelectedTerms();
- if (! this.isTermDirectionVertical() && termIndex >= 0 && ! input.value) {
+ if (this.readOnly) {
+ if (termIndex >= 0) {
+ removedTerms[termIndex] = this.removeTerm(input.parentNode);
+ this.moveFocusForward(termIndex - 1);
+ }
+ } else if (! this.isTermDirectionVertical() && termIndex >= 0 && ! input.value) {
let removedTerm = this.removeTerm(input.parentNode);
if (removedTerm !== false) {
input = this.moveFocusForward(termIndex - 1);
@@ -863,20 +883,20 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
}
break;
case 'ArrowLeft':
- if (input.selectionStart === 0 && this.hasTerms()) {
+ if (this.hasTerms() && (this.readOnly || input.selectionStart === 0)) {
event.preventDefault();
this.moveFocusBackward();
}
break;
case 'ArrowRight':
- if (input.selectionStart === input.value.length && this.hasTerms()) {
+ if (this.hasTerms() && (this.readOnly || input.selectionStart === input.value.length)) {
event.preventDefault();
this.moveFocusForward();
}
break;
case 'ArrowUp':
if (this.isTermDirectionVertical()
- && input.selectionStart === 0
+ && (this.readOnly || input.selectionStart === 0)
&& this.hasTerms()
&& (this.completer === null || ! this.completer.isBeingCompleted(input))
) {
@@ -886,7 +906,7 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
break;
case 'ArrowDown':
if (this.isTermDirectionVertical()
- && input.selectionStart === input.value.length
+ && (this.readOnly || input.selectionStart === input.value.length)
&& this.hasTerms()
&& (this.completer === null || ! this.completer.isBeingCompleted(input))
) {
@@ -974,6 +994,10 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
this.deselectTerms();
+ if (this.readOnly) {
+ return;
+ }
+
if (! this.hasSyntaxError(input) && (
this.completer === null || ! this.completer.isBeingCompleted(input, false)
)) {
diff --git a/asset/js/widget/TermInput.js b/asset/js/widget/TermInput.js
index 537f4c48..d405ae77 100644
--- a/asset/js/widget/TermInput.js
+++ b/asset/js/widget/TermInput.js
@@ -7,12 +7,17 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
super(input);
this.separator = this.input.dataset.termSeparator || ' ';
+ this.readOnly = 'readOnlyTerms' in this.input.dataset;
this.ignoreSpaceUntil = null;
}
bind() {
super.bind();
+ if (this.readOnly) {
+ $(this.termContainer).on('click', '[data-index] > input', this.onTermClick, this);
+ }
+
// TODO: Compatibility only. Remove as soon as possible once Web 2.12 (?) is out.
// Or upon any other update which lets Web trigger a real submit upon auto submit.
$(this.input.form).on('change', 'select.autosubmit', this.onSubmit, this);
@@ -27,6 +32,21 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
this.ignoreSpaceUntil = null;
}
+ registerTerm(termData, termIndex = null) {
+ termIndex = super.registerTerm(termData, termIndex);
+
+ if (this.readOnly) {
+ const label = this.termContainer.querySelector(`[data-index="${ termIndex }"]`);
+ if (label) {
+ // The label only exists in DOM at this time if it was transmitted
+ // by the server. So it's safe to assume that it needs validation
+ this.validate(label.firstChild);
+ }
+ }
+
+ return termIndex;
+ }
+
readPartialTerm(input) {
let value = super.readPartialTerm(input);
if (value && this.ignoreSpaceUntil && value[0] === this.ignoreSpaceUntil) {
@@ -70,6 +90,33 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
return super.hasSyntaxError(input);
}
+ checkValidity(input) {
+ if (! this.readOnly) {
+ return super.checkValidity(input);
+ }
+
+ // Readonly terms don't participate in constraint validation, so we have to do it ourselves
+ return ! (input.pattern && ! input.value.match(input.pattern));
+ }
+
+ reportValidity(element) {
+ if (! this.readOnly) {
+ return super.reportValidity(element);
+ }
+
+ // Once invalid, it stays invalid since it's readonly
+ element.classList.add('invalid');
+ if (element.dataset.invalidMsg) {
+ const reason = element.parentNode.querySelector(':scope > .invalid-reason');
+ if (! reason.matches('.visible')) {
+ element.title = element.dataset.invalidMsg;
+ reason.textContent = element.dataset.invalidMsg;
+ reason.classList.add('visible');
+ setTimeout(() => reason.classList.remove('visible'), 5000);
+ }
+ }
+ }
+
termsToQueryString(terms) {
let quoted = [];
for (const termData of terms) {
@@ -90,10 +137,28 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
super.complete(input, data);
}
+ renderTerm(termData, termIndex) {
+ const label = super.renderTerm(termData, termIndex);
+
+ if (this.readOnly) {
+ label.firstChild.readOnly = true;
+ label.appendChild($.render(''));
+ label.appendChild($.render(''));
+ }
+
+ return label;
+ }
+
/**
* Event listeners
*/
+ onTermClick(event) {
+ let termIndex = Number(event.target.parentNode.dataset.index);
+ this.removeTerm(event.target.parentNode);
+ this.moveFocusForward(termIndex - 1);
+ }
+
onSubmit(event) {
super.onSubmit(event);
diff --git a/src/FormElement/TermInput.php b/src/FormElement/TermInput.php
index 184be1da..9310ffe6 100644
--- a/src/FormElement/TermInput.php
+++ b/src/FormElement/TermInput.php
@@ -40,6 +40,9 @@ class TermInput extends FieldsetElement
/** @var bool Whether term direction is vertical */
protected $verticalTermDirection = false;
+ /** @var bool Whether registered terms are read-only */
+ protected $readOnly = false;
+
/** @var array Changes to transmit to the client */
protected $changes = [];
@@ -103,6 +106,30 @@ public function getTermDirection(): ?string
return $this->verticalTermDirection ? 'vertical' : null;
}
+ /**
+ * Set whether registered terms are read-only
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setReadOnly(bool $state = true): self
+ {
+ $this->readOnly = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether registered terms are read-only
+ *
+ * @return bool
+ */
+ public function getReadOnly(): bool
+ {
+ return $this->readOnly;
+ }
+
/**
* Set terms
*
@@ -415,6 +442,7 @@ public function getValueAttribute()
'data-with-multi-completion' => true,
'data-no-auto-submit-on-remove' => true,
'data-term-direction' => $this->getTermDirection(),
+ 'data-read-only-terms' => $this->getReadOnly(),
'data-data-input' => '#' . $dataInputId,
'data-term-input' => '#' . $termInputId,
'data-term-container' => '#' . $termContainer->getAttribute('id')->getValue(),
@@ -436,7 +464,11 @@ public function getValueAttribute()
$mainInput->prependWrapper((new HtmlElement(
'div',
- Attributes::create(['class' => ['term-input-area', $this->getTermDirection()]]),
+ Attributes::create(['class' => [
+ 'term-input-area',
+ $this->getTermDirection(),
+ $this->getReadOnly() ? 'read-only' : null
+ ]]),
$termContainer,
new HtmlElement('label', null, $mainInput)
)));
diff --git a/src/FormElement/TermInput/TermContainer.php b/src/FormElement/TermInput/TermContainer.php
index c5a614cd..6bcf4dbb 100644
--- a/src/FormElement/TermInput/TermContainer.php
+++ b/src/FormElement/TermInput/TermContainer.php
@@ -6,6 +6,7 @@
use ipl\Html\BaseHtmlElement;
use ipl\Html\HtmlElement;
use ipl\Web\FormElement\TermInput;
+use ipl\Web\Widget\Icon;
class TermContainer extends BaseHtmlElement
{
@@ -29,26 +30,35 @@ public function __construct(TermInput $input)
protected function assemble()
{
foreach ($this->input->getTerms() as $i => $term) {
- $label = $term->getLabel() ?: $term->getSearchValue();
+ $value = $term->getLabel() ?: $term->getSearchValue();
- $this->addHtml(new HtmlElement(
+ $label = new HtmlElement(
'label',
Attributes::create([
'class' => $term->getClass(),
'data-search' => $term->getSearchValue(),
- 'data-label' => $label,
+ 'data-label' => $value,
'data-index' => $i
]),
new HtmlElement(
'input',
Attributes::create([
'type' => 'text',
- 'value' => $label,
+ 'value' => $value,
'pattern' => $term->getPattern(),
- 'data-invalid-msg' => $term->getMessage()
+ 'data-invalid-msg' => $term->getMessage(),
+ 'readonly' => $this->input->getReadOnly()
])
)
- ));
+ );
+ if ($this->input->getReadOnly()) {
+ $label->addHtml(
+ new Icon('trash'),
+ new HtmlElement('span', Attributes::create(['class' => 'invalid-reason']))
+ );
+ }
+
+ $this->addHtml($label);
}
}
}