diff --git a/asset/css/search-base.less b/asset/css/search-base.less index 7dfa2a22..416ee310 100644 --- a/asset/css/search-base.less +++ b/asset/css/search-base.less @@ -40,6 +40,22 @@ color: var(--search-term-selected-color, @search-term-selected-color); font-style: italic; } + + [data-drag-initiator] { + cursor: grab; + } + + .sortable-drag > label { + border: 1px dashed var(--search-term-drag-border-color, @search-term-drag-border-color); + } + + .sortable-ghost { + opacity: .5; + } +} + +fieldset:disabled .term-input-area [data-drag-initiator] { + cursor: not-allowed; } .invalid-reason { @@ -198,24 +214,53 @@ display: flex; flex-direction: column-reverse; + @itemGap: 1px; + > .terms { - @gap: 1px; + margin-top: @itemGap; + + input { + text-overflow: ellipsis; + } + } + + > div.terms { @termsPerRow: 2; display: flex; flex-wrap: wrap; - gap: @gap; - margin-top: @gap; + gap: @itemGap; label { @termWidth: 100%/@termsPerRow; - @totalGapWidthPerRow: (@termsPerRow - 1) * @gap; + @totalGapWidthPerRow: (@termsPerRow - 1) * @itemGap; min-width: ~"calc(@{termWidth} - (@{totalGapWidthPerRow} / @{termsPerRow}))"; flex: 1 1 auto; + } + } - input { - text-overflow: ellipsis; + > ol.terms { + padding: 0; + margin-bottom: 0; + list-style-type: none; + + li:not(:first-child) { + margin-top: @itemGap; + } + + li { + display: flex; + align-items: center; + gap: .25em; + + > label { + flex: 1 1 auto; + } + + > [data-drag-initiator]::before { + font-size: 1.75em; + margin: 0; } } } diff --git a/asset/css/variables.less b/asset/css/variables.less index d1328fe6..322049cf 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -65,6 +65,7 @@ @search-term-selected-color: @base-gray-light; @search-term-highlighted-bg: @base-primary-bg; @search-term-highlighted-color: @default-text-color-inverted; +@search-term-drag-border-color: @base-gray; @search-condition-remove-bg: @state-critical; @search-condition-remove-color: @default-text-color-inverted; @@ -160,6 +161,7 @@ --search-term-selected-color: var(--base-gray); --search-term-highlighted-bg: var(--primary-button-bg); --search-term-highlighted-color: var(--default-text-color-inverted); + --search-term-drag-border-color: var(--base-gray); --search-condition-remove-bg: var(--base-remove-bg); --search-condition-remove-color: var(--default-text-color-inverted); diff --git a/asset/js/widget/BaseInput.js b/asset/js/widget/BaseInput.js index f94c3d5f..eca7371f 100644 --- a/asset/js/widget/BaseInput.js +++ b/asset/js/widget/BaseInput.js @@ -310,7 +310,8 @@ define(["../notjQuery", "Completer"], function ($, Completer) { } insertRenderedTerm(label) { - let next = this.termContainer.querySelector(`[data-index="${ label.dataset.index + 1 }"]`); + const termIndex = Number(label.dataset.index); + const next = this.termContainer.querySelector(`[data-index="${ termIndex + 1 }"]`); this.termContainer.insertBefore(label, next); return label; } @@ -464,10 +465,10 @@ define(["../notjQuery", "Completer"], function ($, Completer) { // Cut the term's data let [termData] = this.usedTerms.splice(termIndex, 1); - // Avoid saving the term, it's removed after all - label.firstChild.skipSaveOnBlur = true; - if (updateDOM) { + // Avoid saving the term, it's removed after all + label.firstChild.skipSaveOnBlur = true; + // Remove it from the DOM this.removeRenderedTerm(label); } diff --git a/asset/js/widget/TermInput.js b/asset/js/widget/TermInput.js index d405ae77..9e2d9de3 100644 --- a/asset/js/widget/TermInput.js +++ b/asset/js/widget/TermInput.js @@ -1,4 +1,4 @@ -define(["../notjQuery", "BaseInput"], function ($, BaseInput) { +define(["../notjQuery", "../vendor/Sortable", "BaseInput"], function ($, Sortable, BaseInput) { "use strict"; @@ -7,6 +7,7 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) { super(input); this.separator = this.input.dataset.termSeparator || ' '; + this.ordered = 'maintainTermOrder' in this.input.dataset; this.readOnly = 'readOnlyTerms' in this.input.dataset; this.ignoreSpaceUntil = null; } @@ -18,6 +19,16 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) { $(this.termContainer).on('click', '[data-index] > input', this.onTermClick, this); } + if (this.ordered) { + $(this.termContainer).on('end', this.onDrop, this); + + Sortable.create(this.termContainer, { + scroll: true, + direction: 'vertical', + handle: '[data-drag-initiator]' + }); + } + // 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); @@ -131,6 +142,41 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) { return quoted.join(this.separator).trim(); } + addRenderedTerm(label) { + if (! this.ordered) { + return super.addRenderedTerm(label); + } + + const listItem = document.createElement('li'); + listItem.appendChild(label); + listItem.appendChild($.render('')); + this.termContainer.appendChild(listItem); + } + + insertRenderedTerm(label) { + if (! this.ordered) { + return super.insertRenderedTerm(label); + } + + const termIndex = Number(label.dataset.index); + const nextListItemLabel = this.termContainer.querySelector(`[data-index="${ termIndex + 1 }"]`); + const nextListItem = nextListItemLabel?.parentNode || null; + const listItem = document.createElement('li'); + listItem.appendChild(label); + listItem.appendChild($.render('')); + this.termContainer.insertBefore(listItem, nextListItem); + + return label; + } + + removeRenderedTerm(label) { + if (! this.ordered) { + return super.removeRenderedTerm(label); + } + + label.parentNode.remove(); + } + complete(input, data) { data.exclude = this.usedTerms.map(termData => termData.search); @@ -159,6 +205,30 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) { this.moveFocusForward(termIndex - 1); } + onDrop(event) { + if (event.to === event.from && event.newIndex === event.oldIndex) { + // The user dropped the term at its previous position + return; + } + + // The item is the list item, not the term's label + const label = event.item.firstChild; + + // Remove the term from the internal map, but not the DOM, as it's been moved already + const termData = this.removeTerm(label, false); + delete label.dataset.index; // Which is why we have to take it out of the equation for now + + let newIndex = 0; // event.newIndex is intentionally not used, as we have our own indexing + if (event.item.previousSibling) { + newIndex = Number(event.item.previousSibling.firstChild.dataset.index) + 1; + } + + // This is essentially insertTerm() with custom DOM manipulation + this.reIndexTerms(newIndex, 1, true); // Free up the new index + this.registerTerm(termData, newIndex); // Re-register the term with the new index + label.dataset.index = `${ newIndex }`; // Update the DOM, we didn't do that during removal + } + onSubmit(event) { super.onSubmit(event); diff --git a/src/FormElement/TermInput.php b/src/FormElement/TermInput.php index 9310ffe6..f1485c76 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 term order is significant */ + protected $ordered = false; + /** @var bool Whether registered terms are read-only */ protected $readOnly = false; @@ -103,7 +106,31 @@ public function setVerticalTermDirection(bool $state = true): self */ public function getTermDirection(): ?string { - return $this->verticalTermDirection ? 'vertical' : null; + return $this->verticalTermDirection || $this->ordered ? 'vertical' : null; + } + + /** + * Set whether term order is significant + * + * @param bool $state + * + * @return $this + */ + public function setOrdered(bool $state = true): self + { + $this->ordered = $state; + + return $this; + } + + /** + * Get whether term order is significant + * + * @return bool + */ + public function getOrdered(): bool + { + return $this->ordered; } /** @@ -442,6 +469,7 @@ public function getValueAttribute() 'data-with-multi-completion' => true, 'data-no-auto-submit-on-remove' => true, 'data-term-direction' => $this->getTermDirection(), + 'data-maintain-term-order' => $this->getOrdered() && ! $this->getAttribute('disabled')->getValue(), 'data-read-only-terms' => $this->getReadOnly(), 'data-data-input' => '#' . $dataInputId, 'data-term-input' => '#' . $termInputId, diff --git a/src/FormElement/TermInput/TermContainer.php b/src/FormElement/TermInput/TermContainer.php index 6bcf4dbb..0e0bb0e4 100644 --- a/src/FormElement/TermInput/TermContainer.php +++ b/src/FormElement/TermInput/TermContainer.php @@ -25,6 +25,10 @@ class TermContainer extends BaseHtmlElement public function __construct(TermInput $input) { $this->input = $input; + + if ($input->getOrdered()) { + $this->tag = 'ol'; + } } protected function assemble() @@ -58,7 +62,16 @@ protected function assemble() ); } - $this->addHtml($label); + if ($this->tag === 'ol') { + $this->addHtml(new HtmlElement( + 'li', + null, + $label, + new Icon('bars', ['data-drag-initiator' => true]) + )); + } else { + $this->addHtml($label); + } } } }