From dfdba6295a3268541e9f599e7ace491b2b66db43 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:00:02 +0100 Subject: [PATCH 1/4] Avoid generic sortable styles --- panel/src/scss/components/_pages-tree.scss | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/panel/src/scss/components/_pages-tree.scss b/panel/src/scss/components/_pages-tree.scss index a650994fe..41b3dcfb4 100644 --- a/panel/src/scss/components/_pages-tree.scss +++ b/panel/src/scss/components/_pages-tree.scss @@ -158,22 +158,22 @@ display: none; } -.sortable-chosen .pages-tree-item-cell { +.pages-tree-item.sortable-chosen .pages-tree-item-cell { background-color: transparent; opacity: 0.5; } -.sortable-chosen .pages-tree-children, -.sortable-fallback .pages-tree-children { +.pages-tree-item.sortable-chosen .pages-tree-children, +.pages-tree-item.sortable-fallback .pages-tree-children { display: none !important; } -.sortable-chosen .pages-tree-children-toggle .icon, -.sortable-fallback .pages-tree-children-toggle .icon { +.pages-tree-item.sortable-chosen .pages-tree-children-toggle .icon, +.pages-tree-item.sortable-fallback .pages-tree-children-toggle .icon { transform: none !important; } -.sortable-fallback .pages-tree-item-cell { +.pages-tree-item.sortable-fallback .pages-tree-item-cell { opacity: 1; } @@ -185,14 +185,14 @@ transform: rotate(-180deg); } -.is-dragging { - cursor: grab !important; +.pages-tree.is-dragging { + cursor: grabbing !important; } -.is-dragging > .is-not-orderable * { +.pages-tree.is-dragging > .is-not-orderable * { cursor: no-drop !important; } -.is-dragging > .is-not-orderable .pages-tree-row:hover { +.pages-tree.is-dragging > .is-not-orderable .pages-tree-row:hover { background-color: var(--color-danger-900); } From bbbebc842b1fda518447c1706ce1b0f2a2a0a277 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:08:14 +0100 Subject: [PATCH 2/4] Make tag input reorderable --- .../src/scss/components/forms/_forms-tag.scss | 10 +- panel/src/ts/components/inputs/tag-input.ts | 99 ++++++++++++++----- panel/views/fields/tags.php | 27 ++--- 3 files changed, 99 insertions(+), 37 deletions(-) diff --git a/panel/src/scss/components/forms/_forms-tag.scss b/panel/src/scss/components/forms/_forms-tag.scss index 02a394fdb..fbe9f6312 100644 --- a/panel/src/scss/components/forms/_forms-tag.scss +++ b/panel/src/scss/components/forms/_forms-tag.scss @@ -57,9 +57,12 @@ border-radius: $border-radius; margin-bottom: 0.25rem; background-color: var(--color-base-700); - cursor: default; font-size: $font-size-sm; @include user-select-none; + + &.sortable-chosen:not(.sortable-drag) { + background-color: var(--color-accent-900); + } } .form-input-tag .tag:first-child { @@ -92,3 +95,8 @@ content: "\00d7"; font-weight: 600; } + +.form-input-tag.is-dragging, +.form-input-tag.is-dragging * { + cursor: grabbing !important; +} diff --git a/panel/src/ts/components/inputs/tag-input.ts b/panel/src/ts/components/inputs/tag-input.ts index 5a042c32d..95caf137c 100644 --- a/panel/src/ts/components/inputs/tag-input.ts +++ b/panel/src/ts/components/inputs/tag-input.ts @@ -1,24 +1,27 @@ import { $, $$ } from "../../utils/selectors"; import { escapeRegExp, makeDiacriticsRegExp } from "../../utils/validation"; import { debounce } from "../../utils/events"; +import Sortable from "sortablejs"; interface TagInputOptions { labels: { addKeyCodes: string[]; accept: "options" | "any"; + orderable: boolean; }; } export class TagInput { constructor(input: HTMLInputElement, userOptions: Partial) { - const defaults = { addKeyCodes: ["Comma"], accept: "options" }; + const defaults = { addKeyCodes: ["Comma"], accept: "options", orderable: true }; const options = Object.assign({}, defaults, userOptions); let tags: string[] = []; - let placeholder: string, dropdown: HTMLElement; + let placeholder: string, dropdown: HTMLElement | undefined; const field = document.createElement("div"); + const list = document.createElement("span"); const innerInput = document.createElement("input"); const hiddenInput = document.createElement("input"); @@ -31,6 +34,10 @@ export class TagInput { const isRequired = input.hasAttribute("required"); const isDisabled = input.hasAttribute("disabled"); + if (!("orderable" in input.dataset)) { + options.orderable = false; + } + field.className = "form-input-tag"; innerInput.className = "form-input tag-inner-input"; @@ -56,6 +63,7 @@ export class TagInput { } (input.parentNode as ParentNode).replaceChild(field, input); + field.appendChild(list); field.appendChild(innerInput); field.appendChild(hiddenInput); @@ -64,7 +72,7 @@ export class TagInput { tags.forEach((value, index) => { value = value.trim(); tags[index] = value; - insertTag(value); + insertTag(value, list); }); } @@ -79,6 +87,41 @@ export class TagInput { innerInput.focus(); event.preventDefault(); }); + + if (options.orderable) { + Sortable.create(list, { + forceFallback: true, + animation: 150, + filter: ".tag-remove", + + onStart() { + field.classList.add("is-dragging"); + if (dropdown) { + dropdown.style.display = "none"; + } + }, + + onFilter(event: Sortable.SortableEvent) { + if (event.target.matches(".tag-remove")) { + removeTag(event.item.innerText); + list.removeChild(event.item); + } + }, + + onEnd(event: Sortable.SortableEvent) { + field.classList.remove("is-dragging"); + const newIndex = event.newIndex; + const oldIndex = event.oldIndex; + innerInput.blur(); + innerInput.focus(); + if (newIndex === oldIndex || newIndex === undefined || oldIndex === undefined) { + return; + } + tags.splice(newIndex, 0, tags.splice(oldIndex, 1)[0]); + updateTags(); + }, + }); + } } function createDropdown() { @@ -109,14 +152,14 @@ export class TagInput { field.appendChild(dropdown); innerInput.addEventListener("focus", () => { - if (getComputedStyle(dropdown).display === "none") { + if (dropdown && getComputedStyle(dropdown).display === "none") { updateDropdown(); dropdown.scrollTop = 0; } }); innerInput.addEventListener("blur", () => { - if (getComputedStyle(dropdown).display !== "none") { + if (dropdown && getComputedStyle(dropdown).display !== "none") { updateDropdown(); dropdown.style.display = "none"; } @@ -128,26 +171,26 @@ export class TagInput { updateDropdown(); break; case "Enter": - if (getComputedStyle(dropdown).display !== "none") { + if (dropdown && getComputedStyle(dropdown).display !== "none") { addTagFromSelectedDropdownItem(); event.preventDefault(); } break; case "ArrowUp": - if (getComputedStyle(dropdown).display !== "none") { + if (dropdown && getComputedStyle(dropdown).display !== "none") { selectPrevDropdownItem(); event.preventDefault(); } break; case "ArrowDown": - if (getComputedStyle(dropdown).display !== "none") { + if (dropdown && getComputedStyle(dropdown).display !== "none") { selectNextDropdownItem(); event.preventDefault(); } break; default: if (options.addKeyCodes.includes(event.code)) { - if (getComputedStyle(dropdown).display !== "none") { + if (dropdown && getComputedStyle(dropdown).display !== "none") { addTagFromSelectedDropdownItem(); event.preventDefault(); } @@ -161,13 +204,17 @@ export class TagInput { const value = innerInput.value.trim(); switch (event.key) { case "Escape": - dropdown.style.display = "none"; + if (dropdown) { + dropdown.style.display = "none"; + } break; case "ArrowUp": case "ArrowDown": return true; default: - dropdown.style.display = "block"; + if (dropdown) { + dropdown.style.display = "block"; + } filterDropdown(value); if (value.length > 0) { selectFirstDropdownItem(); @@ -195,8 +242,9 @@ export class TagInput { case "Backspace": if (value === "") { removeTag(tags[tags.length - 1]); - if (innerInput.previousSibling) { - (innerInput.parentNode as ParentNode).removeChild(innerInput.previousSibling); + const lastTag = list.childNodes[list.childNodes.length - 1]; + if (lastTag) { + list.removeChild(lastTag); } event.preventDefault(); } else { @@ -258,19 +306,19 @@ export class TagInput { return false; } - function insertTag(value: string) { + function insertTag(value: string, parent: HTMLElement) { const tag = document.createElement("span"); const tagRemove = document.createElement("i"); tag.className = "tag"; tag.innerHTML = value; tag.style.marginRight = ".25rem"; - (innerInput.parentNode as ParentNode).insertBefore(tag, innerInput); + parent.appendChild(tag); tagRemove.className = "tag-remove"; tagRemove.setAttribute("role", "button"); tagRemove.addEventListener("mousedown", (event) => { removeTag(value); - (tag.parentNode as ParentNode).removeChild(tag); + parent.removeChild(tag); event.preventDefault(); }); tag.appendChild(tagRemove); @@ -279,15 +327,13 @@ export class TagInput { function addTag(value: string) { if (validateTag(value)) { tags.push(value); - insertTag(value); + insertTag(value, list); updateTags(); } else { updatePlaceholder(); } innerInput.value = ""; - if (dropdown) { - updateDropdown(); - } + updateDropdown(); } function removeTag(value: string) { @@ -296,9 +342,7 @@ export class TagInput { tags.splice(index, 1); updateTags(); } - if (dropdown) { - updateDropdown(); - } + updateDropdown(); } function clearInput() { @@ -307,6 +351,9 @@ export class TagInput { } function updateDropdown() { + if (!dropdown) { + return; + } let visibleItems = 0; $$(".dropdown-item", dropdown).forEach((element) => { if (!tags.includes(element.dataset.value as string)) { @@ -325,6 +372,9 @@ export class TagInput { } function filterDropdown(value: string) { + if (!dropdown) { + return; + } let visibleItems = 0; dropdown.style.display = "block"; $$(".dropdown-item", dropdown).forEach((element) => { @@ -348,6 +398,9 @@ export class TagInput { } function scrollToDropdownItem(item: HTMLElement) { + if (!dropdown) { + return; + } const dropdownScrollTop = dropdown.scrollTop; const dropdownHeight = dropdown.clientHeight; const dropdownScrollBottom = dropdownScrollTop + dropdownHeight; diff --git a/panel/views/fields/tags.php b/panel/views/fields/tags.php index ad8354ad8..a7a58a9fe 100644 --- a/panel/views/fields/tags.php +++ b/panel/views/fields/tags.php @@ -2,17 +2,18 @@
icon($field->get('icon', 'tag')) ?> attr([ - 'class' => 'form-input', - 'type' => 'text', - 'id' => $field->name(), - 'name' => $field->formName(), - 'value' => implode(', ', (array) $field->value()), - 'placeholder' => $field->placeholder(), - 'required' => $field->isRequired(), - 'disabled' => $field->isDisabled(), - 'hidden' => $field->isHidden(), - 'data-field' => 'tags', - 'data-options' => $field->has('options') ? Formwork\Parsers\Json::encode($field->options()) : null, - 'data-accept' => $field->get('accept', 'options'), + 'class' => 'form-input', + 'type' => 'text', + 'id' => $field->name(), + 'name' => $field->formName(), + 'value' => implode(', ', (array) $field->value()), + 'placeholder' => $field->placeholder(), + 'required' => $field->isRequired(), + 'disabled' => $field->isDisabled(), + 'hidden' => $field->isHidden(), + 'data-field' => 'tags', + 'data-options' => $field->has('options') ? Formwork\Parsers\Json::encode($field->options()) : null, + 'data-accept' => $field->get('accept', 'options'), + 'data-orderable' => $field->is('orderable', true), ]) ?>> -
+ \ No newline at end of file From 71fac14cb581bbc840d946384bfe6959d9fb76d7 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 16 Feb 2025 16:13:30 +0100 Subject: [PATCH 3/4] Fix `TagInputOptions` interface --- panel/src/ts/components/inputs/tag-input.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/panel/src/ts/components/inputs/tag-input.ts b/panel/src/ts/components/inputs/tag-input.ts index 95caf137c..06aa74020 100644 --- a/panel/src/ts/components/inputs/tag-input.ts +++ b/panel/src/ts/components/inputs/tag-input.ts @@ -4,11 +4,10 @@ import { debounce } from "../../utils/events"; import Sortable from "sortablejs"; interface TagInputOptions { - labels: { - addKeyCodes: string[]; - accept: "options" | "any"; - orderable: boolean; - }; + labels: { [key: string]: string }; + addKeyCodes: string[]; + accept: "options" | "any"; + orderable: boolean; } export class TagInput { From 8ff2f98ee478446351da76f4fed79460ba87b60a Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 16 Feb 2025 17:20:43 +0100 Subject: [PATCH 4/4] Add limit option to tag fields --- formwork/fields/tags.php | 8 ++++ panel/src/scss/components/_animations.scss | 4 +- panel/src/ts/components/inputs/tag-input.ts | 46 +++++++++++++++------ panel/views/fields/tags.php | 1 + 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/formwork/fields/tags.php b/formwork/fields/tags.php index 618c4baa5..7cc21c316 100644 --- a/formwork/fields/tags.php +++ b/formwork/fields/tags.php @@ -34,6 +34,10 @@ $value = array_filter($value, static fn($item): bool => Constraint::matchesRegex($item, $field->get('pattern'))); } + if ($field->has('limit') && count($value) > $field->get('limit')) { + throw new ValidationException(sprintf('Field "%s" of type "%s" has a limit of %d items', $field->name(), $field->type(), $field->get('limit'))); + } + return array_values(array_filter($value)); }, @@ -42,5 +46,9 @@ return $options !== null ? Arr::from($options) : null; }, + + 'limit' => function ($field): ?int { + return $field->get('limit', null); + }, ]; }; diff --git a/panel/src/scss/components/_animations.scss b/panel/src/scss/components/_animations.scss index 2e66e5833..11f539a44 100644 --- a/panel/src/scss/components/_animations.scss +++ b/panel/src/scss/components/_animations.scss @@ -31,12 +31,12 @@ 10%, 50%, 90% { - transform: translate3d(-10px, 0, 0); + transform: translate3d(-4px, 0, 0); } 30%, 70% { - transform: translate3d(10px, 0, 0); + transform: translate3d(16px, 0, 0); } } diff --git a/panel/src/ts/components/inputs/tag-input.ts b/panel/src/ts/components/inputs/tag-input.ts index 06aa74020..6fc39a5ed 100644 --- a/panel/src/ts/components/inputs/tag-input.ts +++ b/panel/src/ts/components/inputs/tag-input.ts @@ -6,13 +6,14 @@ import Sortable from "sortablejs"; interface TagInputOptions { labels: { [key: string]: string }; addKeyCodes: string[]; + limit: number; accept: "options" | "any"; orderable: boolean; } export class TagInput { constructor(input: HTMLInputElement, userOptions: Partial) { - const defaults = { addKeyCodes: ["Comma"], accept: "options", orderable: true }; + const defaults = { addKeyCodes: ["Comma"], limit: Infinity, accept: "options", orderable: true }; const options = Object.assign({}, defaults, userOptions); @@ -33,6 +34,10 @@ export class TagInput { const isRequired = input.hasAttribute("required"); const isDisabled = input.hasAttribute("disabled"); + if ("limit" in input.dataset) { + options.limit = parseInt(input.dataset.limit as string); + } + if (!("orderable" in input.dataset)) { options.orderable = false; } @@ -129,11 +134,12 @@ export class TagInput { const isAssociative = !Array.isArray(list); if ("accept" in input.dataset) { - options.accept = input.dataset.accept ?? "options"; + options.accept = (input.dataset.accept ?? "options") as "options" | "any"; } dropdown = document.createElement("div"); dropdown.className = "dropdown-list"; + dropdown.style.display = "none"; for (const key in list) { const item = document.createElement("div"); @@ -211,8 +217,8 @@ export class TagInput { case "ArrowDown": return true; default: - if (dropdown) { - dropdown.style.display = "block"; + if (tags.length >= options.limit) { + event.preventDefault(); } filterDropdown(value); if (value.length > 0) { @@ -236,6 +242,7 @@ export class TagInput { }); innerInput.addEventListener("keydown", (event) => { + innerInput.classList.remove("form-input-invalid"); const value = innerInput.value.trim(); switch (event.key) { case "Backspace": @@ -252,10 +259,14 @@ export class TagInput { break; case "Enter": case "Comma": + event.preventDefault(); if (value !== "") { - addTag(value); + if (tags.length >= options.limit || tags.includes(value)) { + innerInput.classList.add("form-input-invalid"); + } else { + addTag(value); + } } - event.preventDefault(); break; case "Escape": clearInput(); @@ -263,9 +274,15 @@ export class TagInput { event.preventDefault(); break; default: - if (value !== "" && options.addKeyCodes.includes(event.code)) { - addTag(value); + if (options.addKeyCodes.includes(event.key)) { event.preventDefault(); + if (value !== "") { + if (tags.length >= options.limit || tags.includes(value)) { + innerInput.classList.add("form-input-invalid"); + } else { + addTag(value); + } + } break; } if (value.length > 0) { @@ -296,6 +313,9 @@ export class TagInput { } function validateTag(value: string) { + if (tags.length >= options.limit) { + return false; + } if (!tags.includes(value)) { if (dropdown && options.accept === "options") { return $(`[data-value="${value}"]`, dropdown) !== null; @@ -353,6 +373,10 @@ export class TagInput { if (!dropdown) { return; } + dropdown.style.display = "none"; + if (tags.length >= options.limit) { + return; + } let visibleItems = 0; $$(".dropdown-item", dropdown).forEach((element) => { if (!tags.includes(element.dataset.value as string)) { @@ -365,8 +389,6 @@ export class TagInput { }); if (visibleItems > 0) { dropdown.style.display = "block"; - } else { - dropdown.style.display = "none"; } } @@ -375,7 +397,7 @@ export class TagInput { return; } let visibleItems = 0; - dropdown.style.display = "block"; + dropdown.style.display = "none"; $$(".dropdown-item", dropdown).forEach((element) => { if (value === "") { return true; @@ -391,8 +413,6 @@ export class TagInput { }); if (visibleItems > 0) { dropdown.style.display = "block"; - } else { - dropdown.style.display = "none"; } } diff --git a/panel/views/fields/tags.php b/panel/views/fields/tags.php index a7a58a9fe..f1e92d935 100644 --- a/panel/views/fields/tags.php +++ b/panel/views/fields/tags.php @@ -12,6 +12,7 @@ 'disabled' => $field->isDisabled(), 'hidden' => $field->isHidden(), 'data-field' => 'tags', + 'data-limit' => $field->get('limit'), 'data-options' => $field->has('options') ? Formwork\Parsers\Json::encode($field->options()) : null, 'data-accept' => $field->get('accept', 'options'), 'data-orderable' => $field->is('orderable', true),