Skip to content

Commit a37ed9a

Browse files
authored
TermInput: Support reorder by drag and drop (#223)
2 parents 64b335f + 7cba398 commit a37ed9a

File tree

6 files changed

+172
-13
lines changed

6 files changed

+172
-13
lines changed

asset/css/search-base.less

+51-6
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@
4040
color: var(--search-term-selected-color, @search-term-selected-color);
4141
font-style: italic;
4242
}
43+
44+
[data-drag-initiator] {
45+
cursor: grab;
46+
}
47+
48+
.sortable-drag > label {
49+
border: 1px dashed var(--search-term-drag-border-color, @search-term-drag-border-color);
50+
}
51+
52+
.sortable-ghost {
53+
opacity: .5;
54+
}
55+
}
56+
57+
fieldset:disabled .term-input-area [data-drag-initiator] {
58+
cursor: not-allowed;
4359
}
4460

4561
.invalid-reason {
@@ -198,24 +214,53 @@
198214
display: flex;
199215
flex-direction: column-reverse;
200216

217+
@itemGap: 1px;
218+
201219
> .terms {
202-
@gap: 1px;
220+
margin-top: @itemGap;
221+
222+
input {
223+
text-overflow: ellipsis;
224+
}
225+
}
226+
227+
> div.terms {
203228
@termsPerRow: 2;
204229

205230
display: flex;
206231
flex-wrap: wrap;
207-
gap: @gap;
208-
margin-top: @gap;
232+
gap: @itemGap;
209233

210234
label {
211235
@termWidth: 100%/@termsPerRow;
212-
@totalGapWidthPerRow: (@termsPerRow - 1) * @gap;
236+
@totalGapWidthPerRow: (@termsPerRow - 1) * @itemGap;
213237

214238
min-width: ~"calc(@{termWidth} - (@{totalGapWidthPerRow} / @{termsPerRow}))";
215239
flex: 1 1 auto;
240+
}
241+
}
216242

217-
input {
218-
text-overflow: ellipsis;
243+
> ol.terms {
244+
padding: 0;
245+
margin-bottom: 0;
246+
list-style-type: none;
247+
248+
li:not(:first-child) {
249+
margin-top: @itemGap;
250+
}
251+
252+
li {
253+
display: flex;
254+
align-items: center;
255+
gap: .25em;
256+
257+
> label {
258+
flex: 1 1 auto;
259+
}
260+
261+
> [data-drag-initiator]::before {
262+
font-size: 1.75em;
263+
margin: 0;
219264
}
220265
}
221266
}

asset/css/variables.less

+2
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
@search-term-selected-color: @base-gray-light;
6666
@search-term-highlighted-bg: @base-primary-bg;
6767
@search-term-highlighted-color: @default-text-color-inverted;
68+
@search-term-drag-border-color: @base-gray;
6869

6970
@search-condition-remove-bg: @state-critical;
7071
@search-condition-remove-color: @default-text-color-inverted;
@@ -160,6 +161,7 @@
160161
--search-term-selected-color: var(--base-gray);
161162
--search-term-highlighted-bg: var(--primary-button-bg);
162163
--search-term-highlighted-color: var(--default-text-color-inverted);
164+
--search-term-drag-border-color: var(--base-gray);
163165

164166
--search-condition-remove-bg: var(--base-remove-bg);
165167
--search-condition-remove-color: var(--default-text-color-inverted);

asset/js/widget/BaseInput.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,8 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
310310
}
311311

312312
insertRenderedTerm(label) {
313-
let next = this.termContainer.querySelector(`[data-index="${ label.dataset.index + 1 }"]`);
313+
const termIndex = Number(label.dataset.index);
314+
const next = this.termContainer.querySelector(`[data-index="${ termIndex + 1 }"]`);
314315
this.termContainer.insertBefore(label, next);
315316
return label;
316317
}
@@ -464,10 +465,10 @@ define(["../notjQuery", "Completer"], function ($, Completer) {
464465
// Cut the term's data
465466
let [termData] = this.usedTerms.splice(termIndex, 1);
466467

467-
// Avoid saving the term, it's removed after all
468-
label.firstChild.skipSaveOnBlur = true;
469-
470468
if (updateDOM) {
469+
// Avoid saving the term, it's removed after all
470+
label.firstChild.skipSaveOnBlur = true;
471+
471472
// Remove it from the DOM
472473
this.removeRenderedTerm(label);
473474
}

asset/js/widget/TermInput.js

+71-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
1+
define(["../notjQuery", "../vendor/Sortable", "BaseInput"], function ($, Sortable, BaseInput) {
22

33
"use strict";
44

@@ -7,6 +7,7 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
77
super(input);
88

99
this.separator = this.input.dataset.termSeparator || ' ';
10+
this.ordered = 'maintainTermOrder' in this.input.dataset;
1011
this.readOnly = 'readOnlyTerms' in this.input.dataset;
1112
this.ignoreSpaceUntil = null;
1213
}
@@ -18,6 +19,16 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
1819
$(this.termContainer).on('click', '[data-index] > input', this.onTermClick, this);
1920
}
2021

22+
if (this.ordered) {
23+
$(this.termContainer).on('end', this.onDrop, this);
24+
25+
Sortable.create(this.termContainer, {
26+
scroll: true,
27+
direction: 'vertical',
28+
handle: '[data-drag-initiator]'
29+
});
30+
}
31+
2132
// TODO: Compatibility only. Remove as soon as possible once Web 2.12 (?) is out.
2233
// Or upon any other update which lets Web trigger a real submit upon auto submit.
2334
$(this.input.form).on('change', 'select.autosubmit', this.onSubmit, this);
@@ -131,6 +142,41 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
131142
return quoted.join(this.separator).trim();
132143
}
133144

145+
addRenderedTerm(label) {
146+
if (! this.ordered) {
147+
return super.addRenderedTerm(label);
148+
}
149+
150+
const listItem = document.createElement('li');
151+
listItem.appendChild(label);
152+
listItem.appendChild($.render('<i data-drag-initiator class="icon fa-bars fa"></i>'));
153+
this.termContainer.appendChild(listItem);
154+
}
155+
156+
insertRenderedTerm(label) {
157+
if (! this.ordered) {
158+
return super.insertRenderedTerm(label);
159+
}
160+
161+
const termIndex = Number(label.dataset.index);
162+
const nextListItemLabel = this.termContainer.querySelector(`[data-index="${ termIndex + 1 }"]`);
163+
const nextListItem = nextListItemLabel?.parentNode || null;
164+
const listItem = document.createElement('li');
165+
listItem.appendChild(label);
166+
listItem.appendChild($.render('<i data-drag-initiator class="icon fa-bars fa"></i>'));
167+
this.termContainer.insertBefore(listItem, nextListItem);
168+
169+
return label;
170+
}
171+
172+
removeRenderedTerm(label) {
173+
if (! this.ordered) {
174+
return super.removeRenderedTerm(label);
175+
}
176+
177+
label.parentNode.remove();
178+
}
179+
134180
complete(input, data) {
135181
data.exclude = this.usedTerms.map(termData => termData.search);
136182

@@ -159,6 +205,30 @@ define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
159205
this.moveFocusForward(termIndex - 1);
160206
}
161207

208+
onDrop(event) {
209+
if (event.to === event.from && event.newIndex === event.oldIndex) {
210+
// The user dropped the term at its previous position
211+
return;
212+
}
213+
214+
// The item is the list item, not the term's label
215+
const label = event.item.firstChild;
216+
217+
// Remove the term from the internal map, but not the DOM, as it's been moved already
218+
const termData = this.removeTerm(label, false);
219+
delete label.dataset.index; // Which is why we have to take it out of the equation for now
220+
221+
let newIndex = 0; // event.newIndex is intentionally not used, as we have our own indexing
222+
if (event.item.previousSibling) {
223+
newIndex = Number(event.item.previousSibling.firstChild.dataset.index) + 1;
224+
}
225+
226+
// This is essentially insertTerm() with custom DOM manipulation
227+
this.reIndexTerms(newIndex, 1, true); // Free up the new index
228+
this.registerTerm(termData, newIndex); // Re-register the term with the new index
229+
label.dataset.index = `${ newIndex }`; // Update the DOM, we didn't do that during removal
230+
}
231+
162232
onSubmit(event) {
163233
super.onSubmit(event);
164234

src/FormElement/TermInput.php

+29-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ class TermInput extends FieldsetElement
4040
/** @var bool Whether term direction is vertical */
4141
protected $verticalTermDirection = false;
4242

43+
/** @var bool Whether term order is significant */
44+
protected $ordered = false;
45+
4346
/** @var bool Whether registered terms are read-only */
4447
protected $readOnly = false;
4548

@@ -103,7 +106,31 @@ public function setVerticalTermDirection(bool $state = true): self
103106
*/
104107
public function getTermDirection(): ?string
105108
{
106-
return $this->verticalTermDirection ? 'vertical' : null;
109+
return $this->verticalTermDirection || $this->ordered ? 'vertical' : null;
110+
}
111+
112+
/**
113+
* Set whether term order is significant
114+
*
115+
* @param bool $state
116+
*
117+
* @return $this
118+
*/
119+
public function setOrdered(bool $state = true): self
120+
{
121+
$this->ordered = $state;
122+
123+
return $this;
124+
}
125+
126+
/**
127+
* Get whether term order is significant
128+
*
129+
* @return bool
130+
*/
131+
public function getOrdered(): bool
132+
{
133+
return $this->ordered;
107134
}
108135

109136
/**
@@ -442,6 +469,7 @@ public function getValueAttribute()
442469
'data-with-multi-completion' => true,
443470
'data-no-auto-submit-on-remove' => true,
444471
'data-term-direction' => $this->getTermDirection(),
472+
'data-maintain-term-order' => $this->getOrdered() && ! $this->getAttribute('disabled')->getValue(),
445473
'data-read-only-terms' => $this->getReadOnly(),
446474
'data-data-input' => '#' . $dataInputId,
447475
'data-term-input' => '#' . $termInputId,

src/FormElement/TermInput/TermContainer.php

+14-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class TermContainer extends BaseHtmlElement
2525
public function __construct(TermInput $input)
2626
{
2727
$this->input = $input;
28+
29+
if ($input->getOrdered()) {
30+
$this->tag = 'ol';
31+
}
2832
}
2933

3034
protected function assemble()
@@ -58,7 +62,16 @@ protected function assemble()
5862
);
5963
}
6064

61-
$this->addHtml($label);
65+
if ($this->tag === 'ol') {
66+
$this->addHtml(new HtmlElement(
67+
'li',
68+
null,
69+
$label,
70+
new Icon('bars', ['data-drag-initiator' => true])
71+
));
72+
} else {
73+
$this->addHtml($label);
74+
}
6275
}
6376
}
6477
}

0 commit comments

Comments
 (0)