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