From 2c9639b272f9ce59afd875ab74ac623bc22d4553 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 May 2024 07:41:37 +0200 Subject: [PATCH] TermInput: Mark invalid terms in read only mode as such The browser's validation API doesn't run for readonly inputs, so we have to always check the constraint. --- asset/css/search-base.less | 26 ++++++++++++- asset/css/variables.less | 2 + asset/js/widget/TermInput.js | 43 +++++++++++++++++++++ src/FormElement/TermInput/TermContainer.php | 5 ++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/asset/css/search-base.less b/asset/css/search-base.less index 60253f49..d6f0e3b6 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); @@ -236,6 +254,12 @@ display: revert; } } + + .invalid-reason { + position: absolute; + top: 85%; + left: .5em; + } } } } 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/TermInput.js b/asset/js/widget/TermInput.js index 7e8e891e..d405ae77 100644 --- a/asset/js/widget/TermInput.js +++ b/asset/js/widget/TermInput.js @@ -32,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) { @@ -75,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) { @@ -101,6 +143,7 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) { if (this.readOnly) { label.firstChild.readOnly = true; label.appendChild($.render('')); + label.appendChild($.render('')); } return label; diff --git a/src/FormElement/TermInput/TermContainer.php b/src/FormElement/TermInput/TermContainer.php index 5d3643ab..6bcf4dbb 100644 --- a/src/FormElement/TermInput/TermContainer.php +++ b/src/FormElement/TermInput/TermContainer.php @@ -52,7 +52,10 @@ protected function assemble() ) ); if ($this->input->getReadOnly()) { - $label->addHtml(new Icon('trash')); + $label->addHtml( + new Icon('trash'), + new HtmlElement('span', Attributes::create(['class' => 'invalid-reason'])) + ); } $this->addHtml($label);