Skip to content

Commit

Permalink
TermInput: Support reorder by drag and drop
Browse files Browse the repository at this point in the history
  • Loading branch information
nilmerg committed May 7, 2024
1 parent 56b58ae commit 3b7ef7d
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 10 deletions.
53 changes: 47 additions & 6 deletions asset/css/search-base.less
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions asset/css/variables.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
72 changes: 71 additions & 1 deletion asset/js/widget/TermInput.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
define(["../notjQuery", "BaseInput"], function ($, BaseInput) {
define(["../notjQuery", "../vendor/Sortable", "BaseInput"], function ($, Sortable, BaseInput) {

"use strict";

Expand All @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -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('<i data-drag-initiator class="fa fa-bars bars-icon"></i>'));
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('<i data-drag-initiator class="fa fa-bars bars-icon"></i>'));
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);

Expand Down Expand Up @@ -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);

Expand Down
30 changes: 29 additions & 1 deletion src/FormElement/TermInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions src/FormElement/TermInput/TermContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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()
Expand All @@ -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(),
Expand All @@ -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);
}
}
}
}

0 comments on commit 3b7ef7d

Please sign in to comment.