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); } } }