From 3b7ef7dd3f1c7649cd60a0c4741f239438c8ee42 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 6 May 2024 15:12:16 +0200 Subject: [PATCH] TermInput: Support reorder by drag and drop --- asset/css/search-base.less | 53 +++++++++++++-- asset/css/variables.less | 2 + asset/js/widget/TermInput.js | 72 ++++++++++++++++++++- src/FormElement/TermInput.php | 30 ++++++++- src/FormElement/TermInput/TermContainer.php | 20 +++++- 5 files changed, 167 insertions(+), 10 deletions(-) diff --git a/asset/css/search-base.less b/asset/css/search-base.less index 1dc67056..13292b91 100644 --- a/asset/css/search-base.less +++ b/asset/css/search-base.less @@ -39,6 +39,18 @@ 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; + } } .search-suggestions { @@ -180,24 +192,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; + } + } + + > 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; + } - input { - text-overflow: ellipsis; + > [data-drag-initiator]::before { + font-size: 1.75em; + margin: 0; } } } diff --git a/asset/css/variables.less b/asset/css/variables.less index a500c5e9..c02e3621 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -64,6 +64,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; @@ -158,6 +159,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/TermInput.js b/asset/js/widget/TermInput.js index 8f33a0cd..0a7134c5 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); @@ -89,6 +100,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); @@ -116,6 +162,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..9ddc568c 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(), '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 961309c6..50a21bc6 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 { @@ -24,6 +25,10 @@ class TermContainer extends BaseHtmlElement public function __construct(TermInput $input) { $this->input = $input; + + if ($input->getOrdered()) { + $this->tag = 'ol'; + } } protected function assemble() @@ -32,7 +37,7 @@ protected function assemble() foreach ($this->input->getTerms() as $i => $term) { $label = $term->getLabel() ?: $term->getSearchValue(); - $this->addHtml(new HtmlElement( + $label = new HtmlElement( 'label', Attributes::create([ 'class' => $term->getClass(), @@ -49,7 +54,18 @@ protected function assemble() 'data-invalid-msg' => $term->getMessage() ]) ) - )); + ); + + if ($this->tag === 'ol') { + $this->addHtml(new HtmlElement( + 'li', + null, + $label, + new Icon('bars', ['data-drag-initiator' => true]) + )); + } else { + $this->addHtml($label); + } } } }