diff --git a/components/ILIAS/UI/UI.php b/components/ILIAS/UI/UI.php index 375f5aa61145..395619bf91c2 100644 --- a/components/ILIAS/UI/UI.php +++ b/components/ILIAS/UI/UI.php @@ -584,8 +584,6 @@ public function init( new Component\Resource\ComponentJS($this, "js/Input/Field/file.js"); $contribute[Component\Resource\PublicAsset::class] = fn() => new Component\Resource\ComponentJS($this, "js/Input/Field/input.js"); - $contribute[Component\Resource\PublicAsset::class] = fn() => - new Component\Resource\ComponentJS($this, "js/Input/Field/tagInput.js"); $contribute[Component\Resource\PublicAsset::class] = fn() => new Component\Resource\ComponentJS($this, "js/Item/dist/notification.js"); $contribute[Component\Resource\PublicAsset::class] = fn() => diff --git a/components/ILIAS/UI/resources/js/Draggable/draggable.js b/components/ILIAS/UI/resources/js/Draggable/draggable.js new file mode 100644 index 000000000000..ee17de38334b --- /dev/null +++ b/components/ILIAS/UI/resources/js/Draggable/draggable.js @@ -0,0 +1,443 @@ +/** + * This file is part of ILIAS, a powerful learning management system + * published by ILIAS open source e-Learning e.V. + * + * ILIAS is licensed with the GPL-3.0, + * see https://www.gnu.org/licenses/gpl-3.0.en.html + * You should have received a copy of said license along with the + * source code, too. + * + * If this is not the case or you just want to try ILIAS, you'll find + * us at: + * https://www.ilias.de + * https://github.com/ILIAS-eLearning + */ + +/** + * @type {string} + */ +const activeClass = 't-draggable__dropzone--active'; + +/** + * @type {string} + */ +const hoverClass = 't-draggable__dropzone--hover'; + +/** + * @type {string} + */ +const draggingClass = 't-draggable--dragging'; + +/** + * @param {string} dragType Two possible values: 'move' or 'copy'. + * @param {HTMLElement} parentElement + * @param {string} draggableClass + * @param {string} placeholderClass + * @param {object} accessibility + * @param {function} onStartPrepareHandler This function will be called, when + * a drag operation starts. It allows you to ensure placeholders are in the right + * place and to do anything else important before a drag-operation can be started. + * @param {function} onChangeHandler This function is here to do two things: + * Put the Placeholders in the right place after a change and trigger any other + * changes necessary to make the parent usecase work. + */ +export default function makeDraggable( + dragType, + parentElement, + draggableClass, + placeholderClass, + accessibility, + onStartPrepareHandler, + onChangeHandler, +) { + /** + * @type {HTMLElement} + */ + let clonedElementForTouch; + + /** + * @type {HTMLElement} + */ + let currentHoverElementForTouch; + + /** + * @type {object} + */ + let nodesForKeyboardInteraction; + + /** + * @type {HTMLElement} + */ + let draggedElement; + + /** + * @param {Event} event + * @returns {void} + */ + function dragstartHandler(event) { + setTimeout(() => { + startMoving(event.target); + event.dataTransfer.dropEffect = dragType; + event.dataTransfer.effectAllowed = dragType; + event.dataTransfer.setDragImage(draggedElement, 0, 0); + }, 0); + } + + /** + * @param {Event} event + * @returns {void} + */ + function touchstartHandler(event) { + event.preventDefault(); + event.stopPropagation(); + startMoving(event.target.closest(`.${draggableClass}`)); + const width = draggedElement.offsetWidth; + const height = draggedElement.offsetHeight; + clonedElementForTouch = draggedElement.cloneNode(true); + draggedElement.parentNode.insertBefore(clonedElementForTouch, draggedElement); + draggedElement.style.position = 'fixed'; + draggedElement.style.left = `${event.touches[0].clientX - width / 2}px`; + draggedElement.style.top = `${event.touches[0].clientY - height / 2}px`; + draggedElement.style.width = `${width}px`; + draggedElement.style.height = `${height}px`; + draggedElement.addEventListener('touchmove', touchmoveHandler); + draggedElement.addEventListener('touchend', touchendHandler); + } + + /** + * @param {HTMLElement} target + * @returns {void} + */ + function startMoving(target) { + draggedElement = target; + target.classList.add(draggingClass); + + onStartPrepareHandler(target); + + parentElement.querySelectorAll(`.${placeholderClass}`).forEach( + (elem) => { + addPlaceholderEventListeners(elem); + elem.classList.add(activeClass); + }, + ); + + target.querySelectorAll(`.${placeholderClass}`).forEach( + (elem) => { elem.classList.remove(activeClass); }, + ); + } + + /** + * @param {Event} event + * @returns {void} + */ + function touchmoveHandler(event) { + event.preventDefault(); + draggedElement.style.left = `${event.touches[0].clientX - draggedElement.offsetWidth / 2}px`; + draggedElement.style.top = `${event.touches[0].clientY - draggedElement.offsetHeight / 2}px`; + + const documentElement = parentElement.ownerDocument; + if (event.touches[0].clientY > documentElement.clientHeight * 0.8) { + documentElement.scroll({ + left: 0, + top: event.touches[0].pageY * 0.8, + behavior: 'smooth', + }); + } + + if (event.touches[0].clientY < documentElement.clientHeight * 0.2) { + documentElement.scroll({ + left: 0, + top: event.touches[0].pageY * 0.8, + behavior: 'smooth', + }); + } + + const element = parentElement.ownerDocument.elementsFromPoint( + event.changedTouches[0].clientX, + event.changedTouches[0].clientY, + ).filter((elem) => elem.classList.contains(placeholderClass)); + + if ((element.length === 0 && typeof currentHoverElementForTouch !== 'undefined')) { + currentHoverElementForTouch.classList.remove(hoverClass); + currentHoverElementForTouch = undefined; + } + + if (element.length === 1 && currentHoverElementForTouch !== element[0]) { + if (typeof currentHoverElementForTouch !== 'undefined') { + currentHoverElementForTouch.classList.remove(hoverClass); + } + [currentHoverElementForTouch] = element; + currentHoverElementForTouch.classList.add(hoverClass); + } + } + + /** + * @param {Event} event + * @returns {void} + */ + function dragoverHandler(event) { + event.preventDefault(); + } + + /** + * @param {Event} event + * @returns {void} + */ + function dragenterHandler(event) { + event.target.classList.add(hoverClass); + } + + /** + * @param {Event} event + * @returns {void} + */ + function dragleaveHandler(event) { + event.target.classList.remove(hoverClass); + } + + function dragendHandler() { + draggedElement.classList.remove(draggingClass); + parentElement.querySelectorAll(`.${placeholderClass}`).forEach( + (elem) => { + elem.classList.remove(activeClass); + elem.classList.remove(hoverClass); + }, + ); + } + + /** + * @param {Event} event + * @returns {void} + */ + function dropHandler(event) { + event.preventDefault(); + stopMoving(event.target); + } + + /** + * @param {Event} event + * @returns {void} + */ + function touchendHandler(event) { + event.preventDefault(); + + const element = parentElement.ownerDocument.elementsFromPoint( + event.changedTouches[0].clientX, + event.changedTouches[0].clientY, + ).filter((elem) => elem.classList.contains(placeholderClass)); + + dragendHandler(); + clonedElementForTouch.remove(); + + if (element.length === 1) { + stopMoving(element[0]); + } + } + + /** + * @param {HTMLElement} target + * @returns {void} + */ + function stopMoving(target) { + const source = draggedElement.parentNode; + let droppedElement = draggedElement; + if (dragType !== 'move') { + droppedElement = draggedElement.cloneNode(true); + addDragEventListeners(droppedElement); + } + target.parentNode.insertBefore(droppedElement, target); + onChangeHandler(droppedElement, target, draggedElement, source); + } + + /** + * @param {HTMLElement} target + * @returns {object} + */ + function buildNodesForKeyboardInteraction(target) { + const nodes = { + nodeArray: [], + }; + Array.from( + parentElement.querySelectorAll(`.${draggableClass}, .${placeholderClass}`) + ).forEach( + (currentNode) => { + if (currentNode === target) { + nodes.nodeArray.push(currentNode); + nodes.currentPosition = nodes.nodeArray.length - 1; + return; + } + + if (currentNode.classList.contains(draggableClass)) { + return; + } + + nodes.nodeArray.push(currentNode); + } + ); + return nodes; + } + + /** + * @param {HTMLElement} target + * @returns {void} + */ + function startKeyboardInteraction(target) { + startMoving(target); + target.dataset.selected = true; + nodesForKeyboardInteraction = buildNodesForKeyboardInteraction(target); + + const controller = new AbortController(); + target.addEventListener( + 'blur', + () => { + abortKeyboardInteraction(target, controller); + }, + { signal: controller.signal }, + ); + accessibility.infoContainer.innerText = accessibility.texts.tagSelected(target); + } + + /** + * @param {HTMLElement} target + * @param {AbortController} controller + * @returns {void} + */ + function abortKeyboardInteraction(target, controller) { + target.dataset.selected = false; + accessibility.infoContainer.innerText = accessibility.texts.default(); + + if (typeof controller !== 'undefined') { + controller.abort(); + } + dragendHandler(); + stopMoving(target); + } + + /** + * @param {KeyEvent} event + * @returns {void} + */ + function keyboardControlHandler(event) { + if (event.key === 'Tab' && event.target.dataset.selected === 'true') { + event.preventDefault(); + return; + } + + if (event.key === ' ' && event.target.dataset.selected === 'true') { + event.target.dataset.selected = false; + dragendHandler(); + stopMoving(nodesForKeyboardInteraction.nodeArray[nodesForKeyboardInteraction.currentPosition]); + event.target.focus(); + return; + } + + if (event.key === ' ') { + startKeyboardInteraction(event.target); + return; + } + + if (event.key === 'Escape' && event.target.dataset.selected === 'true') { + abortKeyboardInteraction(event.target); + return; + } + + if (event.key === 'ArrowLeft' && event.target.dataset.selected === 'true') { + let previous = nodesForKeyboardInteraction.currentPosition - 1; + if (previous < 0) { + previous = nodesForKeyboardInteraction.nodeArray.length - 1; + } + parentElement.querySelector(`.${hoverClass}`)?.classList.remove(hoverClass); + nodesForKeyboardInteraction.nodeArray[previous].classList.add(hoverClass); + nodesForKeyboardInteraction.currentPosition = previous; + accessibility.infoContainer.innerText = accessibility.texts + .position(nodesForKeyboardInteraction.nodeArray[previous]); + return; + } + + if (event.key === 'ArrowRight' && event.target.dataset.selected === 'true') { + let next = nodesForKeyboardInteraction.currentPosition + 1; + if (next === nodesForKeyboardInteraction.nodeArray.length) { + next = 0; + } + parentElement.querySelector(`.${hoverClass}`)?.classList.remove(hoverClass); + nodesForKeyboardInteraction.nodeArray[next].classList.add(hoverClass); + nodesForKeyboardInteraction.currentPosition = next; + accessibility.infoContainer.innerText = accessibility.texts + .position(nodesForKeyboardInteraction.nodeArray[next]); + } + } + + /** + * @param {HTMLElement} elem + * @returns {void} + */ + function ensureDraggable(elem) { + if (!elem.hasAttribute('draggable')) { + elem.setAttribute('draggable', true); + } + } + + /** + * @param {HTMLElement} elem + * @returns {void} + */ + function addDragEventListeners(elem) { + elem.addEventListener('dragstart', dragstartHandler); + elem.addEventListener('dragend', dragendHandler); + elem.addEventListener('touchstart', touchstartHandler); + elem.addEventListener('keydown', keyboardControlHandler); + } + + /** + * @param {HTMLElement} elem + * @returns {void} + */ + function addPlaceholderEventListeners(elem) { + elem.removeEventListener('dragover', dragoverHandler); + elem.removeEventListener('dragenter', dragenterHandler); + elem.removeEventListener('dragleave', dragleaveHandler); + elem.removeEventListener('drop', dropHandler); + elem.removeEventListener('keydown', keyboardControlHandler); + elem.addEventListener('dragover', dragoverHandler); + elem.addEventListener('dragenter', dragenterHandler); + elem.addEventListener('dragleave', dragleaveHandler); + elem.addEventListener('drop', dropHandler); + elem.addEventListener('keydown', keyboardControlHandler); + } + + function initializeDraggableElements() { + parentElement.querySelectorAll(`.${draggableClass}`).forEach((elem) => { + ensureDraggable(elem); + addDragEventListeners(elem); + }); + } + + function initializeDomChangeObserver() { + const tagAddedObserver = new parentElement.ownerDocument.defaultView.MutationObserver( + (mutationList) => { + mutationList.forEach((mutation) => { + [...mutation.addedNodes].forEach((elem) => { + if (elem.classList.contains(draggableClass)) { + ensureDraggable(elem); + addDragEventListeners(elem); + } + }); + }); + }, + ); + + tagAddedObserver.observe(parentElement, { + attributes: false, + childList: true, + subtree: false, + }); + } + + function initializeAccessibility() { + accessibility.infoContainer.innerText = accessibility.texts.default(); + } + + initializeDraggableElements(); + initializeDomChangeObserver(); + initializeAccessibility(); +} diff --git a/components/ILIAS/UI/resources/js/Input/Field/dist/input.factory.min.js b/components/ILIAS/UI/resources/js/Input/Field/dist/input.factory.min.js index dc2dc3f768fa..979e78b5d9c1 100644 --- a/components/ILIAS/UI/resources/js/Input/Field/dist/input.factory.min.js +++ b/components/ILIAS/UI/resources/js/Input/Field/dist/input.factory.min.js @@ -12,4 +12,4 @@ * https://www.ilias.de * https://github.com/ILIAS-eLearning */ -!function(e,t,n){"use strict";class r{textarea;remainder=null;constructor(e){if(this.textarea=document.getElementById(e),null===this.textarea)throw new Error(`Could not find textarea for input-id '${e}'.`);if(this.shouldShowRemainder()){if(this.remainder=this.textarea.parentNode.querySelector('[data-action="remainder"]'),!this.remainder instanceof HTMLSpanElement)throw new Error(`Could not find remainder-element for input-id '${e}'.`);this.textarea.addEventListener("input",(()=>{this.updateRemainderCountHook()}))}}updateRemainderCountHook(){this.shouldShowRemainder()&&null!==this.remainder&&(this.remainder.innerHTML=(this.textarea.maxLength-this.textarea.value.length).toString())}updateTextareaContent(e,t=null,n=null){if(!this.isDisabled()){if(this.isContentTooLarge(e))return this.updateRemainderCountHook(),void this.textarea.focus();t=t??this.textarea.selectionStart,n=n??this.textarea.selectionEnd,this.textarea.value=e,tthis.textarea.selectionEnd?this.textarea.selectionStart:this.textarea.selectionEnd}getLinesBeforeSelection(){return o(this.textarea.value).slice(0,i(this.getTextBeforeSelection()))}getLinesAfterSelection(){const e=o(this.textarea.value);return e.slice(i(this.getTextBeforeSelection()+this.getTextOfSelection())+1,e.length)}getLinesOfSelection(){const e=o(this.textarea.value);return e.slice(this.getLinesBeforeSelection().length,e.length-this.getLinesAfterSelection().length)}isContentTooLarge(e){const t=this.getMaxLength();return!(t<0)&&t0}getMaxLength(){return Number(this.textarea.getAttribute("maxlength")??-1)}isDisabled(){return this.textarea.disabled}}function i(e){return(e.match(/\n/g)??[]).length}function o(e){return e.split(/\n/)}class s{instances=[];init(e){if(void 0!==this.instances[e])throw new Error(`Textarea with input-id '${e}' has already been initialized.`);this.instances[e]=new r(e)}get(e){return this.instances[e]??null}}class l{preview_parameter;preview_url;constructor(e,t){this.preview_parameter=e,this.preview_url=t}async getPreviewHtmlOf(e){if(0===e.length)return"";let t=new FormData;return t.append(this.preview_parameter,e),(await fetch(this.preview_url,{method:"POST",body:t})).text()}}const a="textarea",c="preview";class d extends r{preview_history=[];preview_renderer;content_wrappers;view_controls;actions;constructor(e,t){super(t);const n=this.textarea.closest(".c-field-markdown");if(null===n)throw new Error(`Could not find input-wrapper for input-id '${t}'.`);this.preview_renderer=e,this.content_wrappers=function(e){const t=new Map;return t.set(a,e.querySelector("textarea")),t.set(c,e.querySelector(".c-field-markdown__preview")),t.forEach((e=>{if(null===e)throw new Error("Could not find all content-wrappers for markdown-input.")})),t}(n),this.view_controls=function(e){const t=e.querySelector(".il-viewcontrol-mode")?.getElementsByTagName("button");if(!t instanceof HTMLCollection||2!==t.length)throw new Error("Could not find exactly two view-controls.");return[...t]}(n),this.actions=function(e){const t=e.querySelector(".c-field-markdown__actions")?.getElementsByTagName("button");if(t instanceof HTMLCollection)return[...t];return[]}(n);let r=!0;this.textarea.addEventListener("keydown",(e=>{r=this.handleEnterKeyBeforeInsertionHook(e)})),this.textarea.addEventListener("keyup",(e=>{this.handleEnterKeyAfterInsertionHook(e,r)})),this.actions.forEach((e=>{e.addEventListener("click",(e=>{this.performMarkdownActionHook(e)}))})),this.view_controls.forEach((e=>{e.addEventListener("click",(()=>{this.toggleViewingModeHook()}))}))}handleEnterKeyAfterInsertionHook(e,t){if(!t||!f(e))return;const n=this.getLinesBeforeSelection().pop();void 0!==n&&S(n)?this.applyTransformationToSelection(u):void 0!==n&&p(n)&&this.insertSingleEnumeration()}handleEnterKeyBeforeInsertionHook(e){if(!f(e))return!1;const t=this.getLinesOfSelection().shift();if(void 0===t||!((t.match(/((^(\s*-)|(^(\s*\d+\.)))\s*)$/g)??[]).length>0))return!0;let n=this.getLinesBeforeSelection().join("\n"),r=this.getLinesAfterSelection().join("\n");return n.length>0&&(n+="\n"),r.length>0&&(r=`\n${r}`),this.updateTextareaContent(n+r,this.getAbsoluteSelectionStart()-t.length,this.getAbsoluteSelectionEnd()-t.length),e.preventDefault(),!1}performMarkdownActionHook(e){const t=function(e){const t=e.closest("span[data-action]");if(!t instanceof HTMLSpanElement)return null;if(!t.hasAttribute("data-action"))return null;return t.dataset.action}(e.target);switch(t){case"insert-heading":this.insertCharactersAroundSelection("# ","");break;case"insert-link":this.insertCharactersAroundSelection("[","](url)");break;case"insert-bold":this.insertCharactersAroundSelection("**","**");break;case"insert-italic":this.insertCharactersAroundSelection("_","_");break;case"insert-bullet-points":this.applyTransformationToSelection(u);break;case"insert-enumeration":this.isMultilineTextSelected()?this.applyTransformationToSelection(h):this.insertSingleEnumeration();break;default:throw new Error(`Could not perform markdown-action '${t}'.`)}}toggleViewingModeHook(){this.content_wrappers.forEach((e=>{g(e,"hidden")})),this.view_controls.forEach((e=>{g(e,"engaged")})),this.isDisabled()||this.actions.forEach((e=>{e.disabled=!e.disabled;const t=e.querySelector(".glyph");null!==t&&g(t,"disabled")})),this.maybeUpdatePreviewContent()}insertSingleEnumeration(){const e=this.getLinesOfSelection();if(1!==e.length)return void this.textarea.focus();const t=this.getLinesBeforeSelection(),n=t.length-1;let r=n>=0?function(e){const t=e.match(/([0-9]+)/);if(null!==t)return parseInt(t[0]);return null}(t[n])??0:0;const i=h(e,++r),o=function(e,t=0){if(e.length<1)return[];const n=[];for(const r of e){if(!p(r))break;n.push(r.replace(/([0-9]+)/,(++t).toString()))}n.length>0&&(e=n.concat(e.slice(n.length)));return e}(this.getLinesAfterSelection(),r);let s=t.join("\n");const l=o.join("\n");let a=i.join("\n");s.length>0&&a.length>0&&(s+="\n"),a.length>0&&l.length>0&&(a+="\n");const c=s+a+l,d=c.length-this.textarea.value.length;this.updateTextareaContent(c,this.getAbsoluteSelectionStart()+d,this.getAbsoluteSelectionEnd()+d)}applyTransformationToSelection(e){if(!e instanceof Function)throw new Error(`Transformation must be an instance of Function, ${typeof e} given.`);const t=e(this.getLinesOfSelection());if(!t instanceof Array)throw new Error(`Transformation must return an instance of Array, ${typeof t} returned.`);const n=t.length>1;let r=this.getLinesBeforeSelection().join("\n");const i=this.getLinesAfterSelection().join("\n");let o=t.join("\n");r.length>0&&o.length>0&&(r+="\n"),o.length>0&&i.length>0&&(o+="\n");const s=r+o+i,l=s.length-this.textarea.value.length,a=n?r.length:this.getAbsoluteSelectionStart()+l,c=n?a+o.length-1:this.getAbsoluteSelectionEnd()+l;this.updateTextareaContent(s,a,c)}insertCharactersAroundSelection(e,t){const n=this.getTextBeforeSelection()+e+this.getTextOfSelection()+t+this.getTextAfterSelection(),r=this.getAbsoluteSelectionStart()+e.length,i=this.getAbsoluteSelectionEnd()+e.length;this.updateTextareaContent(n,r,i)}maybeUpdatePreviewContent(){const e=this.preview_history[this.preview_history.length-1]??"",t=this.textarea.value;t!==e&&(this.preview_history.push(t),this.preview_renderer.getPreviewHtmlOf(t).then((e=>{this.content_wrappers.get(c).innerHTML=e})))}getBulletPointTransformation(){return u}getEnumerationTransformation(){return h}}function u(e){const t=[],n=!S(e[0]??"");for(const r of e)t.push(n?`- ${r}`:m(r));return t}function h(e,t=1){const n=[],r=!p(e[0]??"");for(const i of e)n.push(r?`${t++}. ${i}`:m(i));return n}function g(e,t){e.classList.contains(t)?e.classList.remove(t):e.classList.add(t)}function f(e){return e instanceof KeyboardEvent&&"Enter"===e.code}function m(e){return e.replace(/((^(\s*[-])|(^(\s*\d+\.)))\s*)/g,"")}function S(e){return(e.match(/^(\s*[-])/g)??[]).length>0}function p(e){return(e.match(/^(\s*\d+\.)/g)??[]).length>0}class w{instances=[];init(e,t,n){if(void 0!==this.instances[e])throw new Error(`Markdown with input-id '${e}' has already been initialized.`);this.instances[e]=new d(new l(n,t),e)}get(e){return this.instances[e]??null}}class E{constructor(e,t,n,r,i,o=null,s=null,l=null){this.id=e,this.name=t,this.element=n,this.selectButton=r,this.drilldownParentLevel=i,this.drilldownButton=o,this.listElement=s,this.renderUrl=l}}const y="data-node-id",b="data-node-name",v="data-render-url",x="data-ddindex",A="c-input-node",C="c-input-tree_select",L=`${A}__async`,B=`${A}__leaf`,T=`${A}--selected`,N="hidden",q="disabled",k=".glyph",$=`.${A}`,_=`.${C}`,M=`.${C}__selection`,H='[data-action="remove"]',I='[data-action="select"]',D=`.${A}__select`,R=".c-drilldown__menulevel--trigger";function j(e){return function(e){return e.classList.contains(L)}(e)&&e.hasAttribute(v)?e.getAttribute(v):null}function O(e){return!e.classList.contains(B)&&e.classList.contains(A)}function U(e,t=null){return e.reduce(((e,t)=>{const n=function(e){const t=e.getAttribute(y);if(null===t)throw new Error("Could not find data-node-id attribute.");return t}(t);if(e.has(n))throw new Error(`Node '${n}' has already been parsed. There might be a rendering issue.`);return e.set(n,new E(n,function(e){const t=e.querySelector(`[${b}]`);if(null===t)throw new Error("Could not find element with data-node-name attribute.");return t.textContent}(t),t,function(e){const t=e.querySelector(`:scope > ${D}`);if(null===t)throw new Error("Could not find node select button.");return t}(t),function(e){const t=e.closest(`ul[${x}]`);if(null===t)throw new Error("Could not find drilldown menu of node.");return t.getAttribute(x)}(t),function(e){if(!O(e))return null;const t=e.querySelector(`${R}`);if(null===t)throw new Error("Could not find drilldown menu button of branch node.");return t}(t),function(e){if(!O(e))return null;const t=e.querySelector("ul");if(null===t)throw new Error("Could not find list element of branch node.");return t}(t),j(t)))}),new Map(t??[]))}function F(e,t){for(let n=0;n{this.#m()})),this.#f.querySelectorAll('[data-action="close"]').forEach((e=>{e.addEventListener("click",(()=>{this.#S()}))})),this.#d.querySelectorAll("li").forEach((e=>{const t=function(e){const t=e.getAttribute(y);if(null===t)throw new Error(`Could not find '${y}' attribbute of element.`);return t}(e);this.#p(e,t),this.#w(t)})),this.#l.addEngageListener((e=>{this.#E(e)})),this.#g.addEventListener("click",(()=>{this.#y()})),this.#e.forEach((e=>{this.#b(e)})),this.#v()}unselectNode(e){if(this.#x(e),this.#v(),this.#A(e),this.#e.has(e)){const t=this.#e.get(e);P(t.element,!1),this.#C(t.selectButton,t.name),this.updateNodeSelectButtonStates()}}selectNode(e){if(this.#w(e),this.#v(),this.#e.has(e)){const t=this.#e.get(e);P(t.element,!0),this.#L(t.selectButton,t.name),this.#B(t),this.updateNodeSelectButtonStates()}}updateNodeSelectButtonStates(){this.#e.forEach(((e,t)=>{this.#t.size>0?(e.selectButton.disabled=!this.#t.has(t),e.selectButton.querySelector(k).classList.toggle(q,!this.#t.has(t))):(e.selectButton.disabled=!1,e.selectButton.querySelector(k).classList.toggle(q,!1))}))}getSelection(){return new Set(this.#t)}getNodes(){return new Map(this.#e)}async#T(e){var t,n,r;if(!this.#n.has(e.id)&&!this.#r.has(e.id))try{this.#r.add(e.id);const i=await this.#o.loadContent(e.renderUrl);e.listElement.append(...i.children),this.#l.parseLevels();const o=U((r=e.listElement,Array.from(r.querySelectorAll($))),this.#e),s=(t=o,n=this.#e,Array.from(t.entries()).filter((([e])=>!n.has(e))).map((([,e])=>e)));this.#e=o,F(s,(e=>{this.#t.has(e.id)?this.selectNode(e.id):this.unselectNode(e.id),this.#b(e)})),this.#n.add(e.id)}catch(e){throw new Error(`Could not render async node children: ${e.message}`)}finally{this.#r.delete(e.id)}}#N(e){F(function(e,t,n=255){const r=[];let i=e;for(let e=0;e{const t=e.getAttribute(y);if(null===t||!this.#e.has(t))throw new Error(`Could not find '${y}' of node element.`);const n=this.#e.get(t);this.#q(n)}))}#q(e){const t=this.#i.createContent(this.#c).querySelector(".crumb");t.setAttribute(x,e.drilldownParentLevel),t.firstElementChild.textContent=e.name,t.addEventListener("click",(()=>{this.#l.engageLevel(e.drilldownParentLevel),e.drilldownButton.click()})),this.#a.append(t)}#m(){const e=this.#a.querySelectorAll(".crumb");e.item(e.length-1)?.remove()}#k(){F(this.#a.querySelectorAll(".crumb"),(e=>{e.remove()}))}#E(e){if("0"===e)return void this.#k();const t=this.#f.querySelector(`ul[${x}="${e}"]`)?.closest($)?.getAttribute(y);if(null===t||!this.#e.has(t))throw new Error(`Could not find node for drilldown-level '${e}'.`);this.#k(),this.#N(this.#e.get(t))}#$(e,t){e.addEventListener("click",(()=>{null!==t.renderUrl&&this.#T(t)}))}#p(e,t){e.querySelector(H)?.addEventListener("click",(()=>{this.unselectNode(t),e.remove()}))}#_(e,t){e.addEventListener("click",(()=>{this.#t.has(t.id)?this.unselectNode(t.id):this.selectNode(t.id)}))}#B(e){if(null!==this.#d.querySelector(`li[${y}="${e.id}"]`))return;const t=this.#i.createContent(this.#u),n=t.querySelector("[data-node-id]");n.setAttribute(y,e.id),n.querySelector(`[${b}]`).textContent=e.name,n.querySelector("input").value=e.id,this.#p(n,e.id),this.#d.append(...t.children)}#A(e){this.#d.querySelector(`li[${y}="${e}"]`)?.remove()}#b(e){this.#_(e.selectButton,e),null!==e.drilldownButton&&this.#$(e.drilldownButton,e)}#C(e,t){e.querySelector(H)?.classList.add(N),e.querySelector(I)?.classList.remove(N),e.setAttribute("aria-label",this.#M("select_node",t))}#L(e,t){e.querySelector(I)?.classList.add(N),e.querySelector(H)?.classList.remove(N),e.setAttribute("aria-label",this.#M("unselect_node",t))}#v(){this.#h.disabled=this.#t.size<=0}#x(e){this.#t.has(e)&&this.#t.delete(e)}#w(e){this.#t.has(e)||this.#t.add(e)}#M(e,...t){return function(e,...t){const n=[...t];return e.replace(/%s/g,(()=>n.shift()??""))}(this.#s.txt(e),t)}#S(){this.#f.close()}#y(){this.#f.showModal()}}class z extends K{#H;constructor(e,t,n,r,i,o,s,l,a,c,d,u,h,g){super(e,t,n,r,i,o,s,l,a,c,d,u,h),this.#H=g}selectNode(e){if(!this.#H){const t=Array.from(this.getSelection().add(e));this.#I(t,this.getNodes())}super.selectNode(e)}updateNodeSelectButtonStates(){if(this.#H)return;const e=this.getNodes();e.forEach((e=>{e.selectButton.disabled=!1,e.selectButton.querySelector(k).classList.remove(q)})),this.getSelection().forEach((t=>{const n=e.get(t);null!==n&&null!==n.listElement&&n.listElement.querySelectorAll(D).forEach((e=>{e.disabled=!0,e.querySelector(k).classList.add(q)}))}))}#I(e,t){for(let r=0;r{const r=e.getAttribute(n);if(!t.has(r))throw new Error(`Element references '${r}' which does not exist.`);e.setAttribute(n,t.get(r))}))}class W{#D;constructor(e){this.#D=e}createContent(e){const t=e.content.cloneNode(!0),n=new Map;return t.querySelectorAll("[id]").forEach((e=>{const t=function(e=""){return`${e}${Date.now().toString(36)}_${Math.random().toString(36).substring(2)}`}("il_ui_fw_");n.set(e.id,t),e.id=t})),t.querySelectorAll("[for]").forEach((e=>{e.htmlFor=n.get(e.htmlFor)})),V(t,n,"aria-describedby"),V(t,n,"aria-labelledby"),V(t,n,"aria-controls"),V(t,n,"aria-owns"),Q(this.#D,t.children)}}class G{#D;constructor(e){this.#D=e}loadContent(e){return fetch(e.toString()).then((e=>e.text())).then((e=>this.#R(e))).then((e=>Q(this.#D,e))).catch((t=>{throw new Error(`Could not render element(s) from '${e}': ${t.message}`)}))}#j(e){const t=this.#D.createElement("script");return e.hasAttribute("type")&&t.setAttribute("type",e.getAttribute("type")),e.hasAttribute("src")&&t.setAttribute("src",e.getAttribute("src")),e.textContent.length>0&&(t.textContent=e.textContent),t}#R(e){const t=this.#D.createElement("div");return t.innerHTML=e.trim(),t.querySelectorAll("script").forEach((e=>{const t=this.#j(e);e.replaceWith(t)})),t.children}}function J(e){return Array.from(e.querySelectorAll($))}class X{#O=new Map;#U;#F;#s;#D;constructor(e,t,n,r){this.#U=e,this.#F=t,this.#s=n,this.#D=r}initTreeMultiSelect(e,t){if(this.#O.has(e))throw new Error(`TreeSelect '${e}' already exists.`);const[n,r,i,o,s,l,a,c]=this.#P(e),d=this.#K(r),u=new z(U(J(a)),this.#U,new W(this.#D),new G(this.#D),this.#s,d,i,o,s,l,c,n,a,t);return this.#O.set(e,u),u}initTreeSelect(e){if(this.#O.has(e))throw new Error(`TreeSelect '${e}' already exists.`);const[t,n,r,i,o,s,l,a]=this.#P(e),c=this.#K(n),d=new K(U(J(l)),this.#U,new W(this.#D),new G(this.#D),this.#s,c,r,i,o,s,a,t,l);return this.#O.set(e,d),d}getInstance(e){return this.#O.has(e)?this.#O.get(e):null}#P(e){const t=this.#D.getElementById(e),n=t?.closest(_),r=n?.querySelector(".breadcrumb"),i=n?.querySelector(".modal-body > template"),o=n?.querySelector(M),s=o?.querySelector(":scope > template"),l=n?.querySelector("dialog"),a=l?.querySelector(".btn-primary");if(null===r||null===i||null===o||null===s||null===a||null===t||null===l)throw new Error(`Could not find some element(s) for Tree Select Input '${e}'.`);return[t,n,r,i,o,s,l,a]}#K(e){const t=e.querySelector(".c-drilldown");if(null===t||!t.hasAttribute("id"))throw new Error("Could not find drilldown element.");const n=this.#F.getInstance(t.id);if(null===t)throw new Error("Could not find drilldown instance.");return n}}class Y{#z;constructor(e){this.#z=e}on(e,t,n){this.#z(e).on(t,n)}off(e,t,n){this.#z(e).off(t,n)}}var Z;t.UI=t.UI||{},t.UI.Input=t.UI.Input||{},(Z=t.UI.Input).textarea=new s,Z.markdown=new w,Z.treeSelect=new X(new Y(e),t.UI.menu.drilldown,{txt:e=>t.Language.txt(e)},n)}($,il,document); +!function(e,t,n,r){"use strict";class i{textarea;remainder=null;constructor(e){if(this.textarea=document.getElementById(e),null===this.textarea)throw new Error(`Could not find textarea for input-id '${e}'.`);if(this.shouldShowRemainder()){if(this.remainder=this.textarea.parentNode.querySelector('[data-action="remainder"]'),!this.remainder instanceof HTMLSpanElement)throw new Error(`Could not find remainder-element for input-id '${e}'.`);this.textarea.addEventListener("input",(()=>{this.updateRemainderCountHook()}))}}updateRemainderCountHook(){this.shouldShowRemainder()&&null!==this.remainder&&(this.remainder.innerHTML=(this.textarea.maxLength-this.textarea.value.length).toString())}updateTextareaContent(e,t=null,n=null){if(!this.isDisabled()){if(this.isContentTooLarge(e))return this.updateRemainderCountHook(),void this.textarea.focus();t=t??this.textarea.selectionStart,n=n??this.textarea.selectionEnd,this.textarea.value=e,tthis.textarea.selectionEnd?this.textarea.selectionStart:this.textarea.selectionEnd}getLinesBeforeSelection(){return s(this.textarea.value).slice(0,o(this.getTextBeforeSelection()))}getLinesAfterSelection(){const e=s(this.textarea.value);return e.slice(o(this.getTextBeforeSelection()+this.getTextOfSelection())+1,e.length)}getLinesOfSelection(){const e=s(this.textarea.value);return e.slice(this.getLinesBeforeSelection().length,e.length-this.getLinesAfterSelection().length)}isContentTooLarge(e){const t=this.getMaxLength();return!(t<0)&&t0}getMaxLength(){return Number(this.textarea.getAttribute("maxlength")??-1)}isDisabled(){return this.textarea.disabled}}function o(e){return(e.match(/\n/g)??[]).length}function s(e){return e.split(/\n/)}class l{instances=[];init(e){if(void 0!==this.instances[e])throw new Error(`Textarea with input-id '${e}' has already been initialized.`);this.instances[e]=new i(e)}get(e){return this.instances[e]??null}}class a{preview_parameter;preview_url;constructor(e,t){this.preview_parameter=e,this.preview_url=t}async getPreviewHtmlOf(e){if(0===e.length)return"";let t=new FormData;return t.append(this.preview_parameter,e),(await fetch(this.preview_url,{method:"POST",body:t})).text()}}const c="textarea",d="preview";class u extends i{preview_history=[];preview_renderer;content_wrappers;view_controls;actions;constructor(e,t){super(t);const n=this.textarea.closest(".c-field-markdown");if(null===n)throw new Error(`Could not find input-wrapper for input-id '${t}'.`);this.preview_renderer=e,this.content_wrappers=function(e){const t=new Map;return t.set(c,e.querySelector("textarea")),t.set(d,e.querySelector(".c-field-markdown__preview")),t.forEach((e=>{if(null===e)throw new Error("Could not find all content-wrappers for markdown-input.")})),t}(n),this.view_controls=function(e){const t=e.querySelector(".il-viewcontrol-mode")?.getElementsByTagName("button");if(!t instanceof HTMLCollection||2!==t.length)throw new Error("Could not find exactly two view-controls.");return[...t]}(n),this.actions=function(e){const t=e.querySelector(".c-field-markdown__actions")?.getElementsByTagName("button");if(t instanceof HTMLCollection)return[...t];return[]}(n);let r=!0;this.textarea.addEventListener("keydown",(e=>{r=this.handleEnterKeyBeforeInsertionHook(e)})),this.textarea.addEventListener("keyup",(e=>{this.handleEnterKeyAfterInsertionHook(e,r)})),this.actions.forEach((e=>{e.addEventListener("click",(e=>{this.performMarkdownActionHook(e)}))})),this.view_controls.forEach((e=>{e.addEventListener("click",(()=>{this.toggleViewingModeHook()}))}))}handleEnterKeyAfterInsertionHook(e,t){if(!t||!m(e))return;const n=this.getLinesBeforeSelection().pop();void 0!==n&&S(n)?this.applyTransformationToSelection(h):void 0!==n&&w(n)&&this.insertSingleEnumeration()}handleEnterKeyBeforeInsertionHook(e){if(!m(e))return!1;const t=this.getLinesOfSelection().shift();if(void 0===t||!((t.match(/((^(\s*-)|(^(\s*\d+\.)))\s*)$/g)??[]).length>0))return!0;let n=this.getLinesBeforeSelection().join("\n"),r=this.getLinesAfterSelection().join("\n");return n.length>0&&(n+="\n"),r.length>0&&(r=`\n${r}`),this.updateTextareaContent(n+r,this.getAbsoluteSelectionStart()-t.length,this.getAbsoluteSelectionEnd()-t.length),e.preventDefault(),!1}performMarkdownActionHook(e){const t=function(e){const t=e.closest("span[data-action]");if(!t instanceof HTMLSpanElement)return null;if(!t.hasAttribute("data-action"))return null;return t.dataset.action}(e.target);switch(t){case"insert-heading":this.insertCharactersAroundSelection("# ","");break;case"insert-link":this.insertCharactersAroundSelection("[","](url)");break;case"insert-bold":this.insertCharactersAroundSelection("**","**");break;case"insert-italic":this.insertCharactersAroundSelection("_","_");break;case"insert-bullet-points":this.applyTransformationToSelection(h);break;case"insert-enumeration":this.isMultilineTextSelected()?this.applyTransformationToSelection(g):this.insertSingleEnumeration();break;default:throw new Error(`Could not perform markdown-action '${t}'.`)}}toggleViewingModeHook(){this.content_wrappers.forEach((e=>{f(e,"hidden")})),this.view_controls.forEach((e=>{f(e,"engaged")})),this.isDisabled()||this.actions.forEach((e=>{e.disabled=!e.disabled;const t=e.querySelector(".glyph");null!==t&&f(t,"disabled")})),this.maybeUpdatePreviewContent()}insertSingleEnumeration(){const e=this.getLinesOfSelection();if(1!==e.length)return void this.textarea.focus();const t=this.getLinesBeforeSelection(),n=t.length-1;let r=n>=0?function(e){const t=e.match(/([0-9]+)/);if(null!==t)return parseInt(t[0]);return null}(t[n])??0:0;const i=g(e,++r),o=function(e,t=0){if(e.length<1)return[];const n=[];for(const r of e){if(!w(r))break;n.push(r.replace(/([0-9]+)/,(++t).toString()))}n.length>0&&(e=n.concat(e.slice(n.length)));return e}(this.getLinesAfterSelection(),r);let s=t.join("\n");const l=o.join("\n");let a=i.join("\n");s.length>0&&a.length>0&&(s+="\n"),a.length>0&&l.length>0&&(a+="\n");const c=s+a+l,d=c.length-this.textarea.value.length;this.updateTextareaContent(c,this.getAbsoluteSelectionStart()+d,this.getAbsoluteSelectionEnd()+d)}applyTransformationToSelection(e){if(!e instanceof Function)throw new Error(`Transformation must be an instance of Function, ${typeof e} given.`);const t=e(this.getLinesOfSelection());if(!t instanceof Array)throw new Error(`Transformation must return an instance of Array, ${typeof t} returned.`);const n=t.length>1;let r=this.getLinesBeforeSelection().join("\n");const i=this.getLinesAfterSelection().join("\n");let o=t.join("\n");r.length>0&&o.length>0&&(r+="\n"),o.length>0&&i.length>0&&(o+="\n");const s=r+o+i,l=s.length-this.textarea.value.length,a=n?r.length:this.getAbsoluteSelectionStart()+l,c=n?a+o.length-1:this.getAbsoluteSelectionEnd()+l;this.updateTextareaContent(s,a,c)}insertCharactersAroundSelection(e,t){const n=this.getTextBeforeSelection()+e+this.getTextOfSelection()+t+this.getTextAfterSelection(),r=this.getAbsoluteSelectionStart()+e.length,i=this.getAbsoluteSelectionEnd()+e.length;this.updateTextareaContent(n,r,i)}maybeUpdatePreviewContent(){const e=this.preview_history[this.preview_history.length-1]??"",t=this.textarea.value;t!==e&&(this.preview_history.push(t),this.preview_renderer.getPreviewHtmlOf(t).then((e=>{this.content_wrappers.get(d).innerHTML=e})))}getBulletPointTransformation(){return h}getEnumerationTransformation(){return g}}function h(e){const t=[],n=!S(e[0]??"");for(const r of e)t.push(n?`- ${r}`:p(r));return t}function g(e,t=1){const n=[],r=!w(e[0]??"");for(const i of e)n.push(r?`${t++}. ${i}`:p(i));return n}function f(e,t){e.classList.contains(t)?e.classList.remove(t):e.classList.add(t)}function m(e){return e instanceof KeyboardEvent&&"Enter"===e.code}function p(e){return e.replace(/((^(\s*[-])|(^(\s*\d+\.)))\s*)/g,"")}function S(e){return(e.match(/^(\s*[-])/g)??[]).length>0}function w(e){return(e.match(/^(\s*\d+\.)/g)??[]).length>0}class y{instances=[];init(e,t,n){if(void 0!==this.instances[e])throw new Error(`Markdown with input-id '${e}' has already been initialized.`);this.instances[e]=new u(new a(n,t),e)}get(e){return this.instances[e]??null}}class v{constructor(e,t,n,r,i,o=null,s=null,l=null){this.id=e,this.name=t,this.element=n,this.selectButton=r,this.drilldownParentLevel=i,this.drilldownButton=o,this.listElement=s,this.renderUrl=l}}const E="data-node-id",b="data-node-name",x="data-render-url",L="data-ddindex",A="c-input-node",T="c-input-tree_select",C=`${A}__async`,$=`${A}__leaf`,B=`${A}--selected`,N="hidden",q="disabled",_=".glyph",k=`.${A}`,M=`.${T}`,D=`.${T}__selection`,I='[data-action="remove"]',H='[data-action="select"]',O=`.${A}__select`,R=".c-drilldown__menulevel--trigger";function P(e){return function(e){return e.classList.contains(C)}(e)&&e.hasAttribute(x)?e.getAttribute(x):null}function j(e){return!e.classList.contains($)&&e.classList.contains(A)}function U(e,t=null){return e.reduce(((e,t)=>{const n=function(e){const t=e.getAttribute(E);if(null===t)throw new Error("Could not find data-node-id attribute.");return t}(t);if(e.has(n))throw new Error(`Node '${n}' has already been parsed. There might be a rendering issue.`);return e.set(n,new v(n,function(e){const t=e.querySelector(`[${b}]`);if(null===t)throw new Error("Could not find element with data-node-name attribute.");return t.textContent}(t),t,function(e){const t=e.querySelector(`:scope > ${O}`);if(null===t)throw new Error("Could not find node select button.");return t}(t),function(e){const t=e.closest(`ul[${L}]`);if(null===t)throw new Error("Could not find drilldown menu of node.");return t.getAttribute(L)}(t),function(e){if(!j(e))return null;const t=e.querySelector(`${R}`);if(null===t)throw new Error("Could not find drilldown menu button of branch node.");return t}(t),function(e){if(!j(e))return null;const t=e.querySelector("ul");if(null===t)throw new Error("Could not find list element of branch node.");return t}(t),P(t)))}),new Map(t??[]))}function F(e,t){for(let n=0;n{this.#m()})),this.#f.querySelectorAll('[data-action="close"]').forEach((e=>{e.addEventListener("click",(()=>{this.#p()}))})),this.#d.querySelectorAll("li").forEach((e=>{const t=function(e){const t=e.getAttribute(E);if(null===t)throw new Error(`Could not find '${E}' attribbute of element.`);return t}(e);this.#S(e,t),this.#w(t)})),this.#l.addEngageListener((e=>{this.#y(e)})),this.#g.addEventListener("click",(()=>{this.#v()})),this.#e.forEach((e=>{this.#E(e)})),this.#b()}unselectNode(e){if(this.#x(e),this.#b(),this.#L(e),this.#e.has(e)){const t=this.#e.get(e);V(t.element,!1),this.#A(t.selectButton,t.name),this.updateNodeSelectButtonStates()}}selectNode(e){if(this.#w(e),this.#b(),this.#e.has(e)){const t=this.#e.get(e);V(t.element,!0),this.#T(t.selectButton,t.name),this.#C(t),this.updateNodeSelectButtonStates()}}updateNodeSelectButtonStates(){this.#e.forEach(((e,t)=>{this.#t.size>0?(e.selectButton.disabled=!this.#t.has(t),e.selectButton.querySelector(_).classList.toggle(q,!this.#t.has(t))):(e.selectButton.disabled=!1,e.selectButton.querySelector(_).classList.toggle(q,!1))}))}getSelection(){return new Set(this.#t)}getNodes(){return new Map(this.#e)}async#$(e){var t,n,r;if(!this.#n.has(e.id)&&!this.#r.has(e.id))try{this.#r.add(e.id);const i=await this.#o.loadContent(e.renderUrl);e.listElement.append(...i.children),this.#l.parseLevels();const o=U((r=e.listElement,Array.from(r.querySelectorAll(k))),this.#e),s=(t=o,n=this.#e,Array.from(t.entries()).filter((([e])=>!n.has(e))).map((([,e])=>e)));this.#e=o,F(s,(e=>{this.#t.has(e.id)?this.selectNode(e.id):this.unselectNode(e.id),this.#E(e)})),this.#n.add(e.id)}catch(e){throw new Error(`Could not render async node children: ${e.message}`)}finally{this.#r.delete(e.id)}}#B(e){F(function(e,t,n=255){const r=[];let i=e;for(let e=0;e{const t=e.getAttribute(E);if(null===t||!this.#e.has(t))throw new Error(`Could not find '${E}' of node element.`);const n=this.#e.get(t);this.#N(n)}))}#N(e){const t=this.#i.createContent(this.#c).querySelector(".crumb");t.setAttribute(L,e.drilldownParentLevel),t.firstElementChild.textContent=e.name,t.addEventListener("click",(()=>{this.#l.engageLevel(e.drilldownParentLevel),e.drilldownButton.click()})),this.#a.append(t)}#m(){const e=this.#a.querySelectorAll(".crumb");e.item(e.length-1)?.remove()}#q(){F(this.#a.querySelectorAll(".crumb"),(e=>{e.remove()}))}#y(e){if("0"===e)return void this.#q();const t=this.#f.querySelector(`ul[${L}="${e}"]`)?.closest(k)?.getAttribute(E);if(null===t||!this.#e.has(t))throw new Error(`Could not find node for drilldown-level '${e}'.`);this.#q(),this.#B(this.#e.get(t))}#_(e,t){e.addEventListener("click",(()=>{null!==t.renderUrl&&this.#$(t)}))}#S(e,t){e.querySelector(I)?.addEventListener("click",(()=>{this.unselectNode(t),e.remove()}))}#k(e,t){e.addEventListener("click",(()=>{this.#t.has(t.id)?this.unselectNode(t.id):this.selectNode(t.id)}))}#C(e){if(null!==this.#d.querySelector(`li[${E}="${e.id}"]`))return;const t=this.#i.createContent(this.#u),n=t.querySelector("[data-node-id]");n.setAttribute(E,e.id),n.querySelector(`[${b}]`).textContent=e.name,n.querySelector("input").value=e.id,this.#S(n,e.id),this.#d.append(...t.children)}#L(e){this.#d.querySelector(`li[${E}="${e}"]`)?.remove()}#E(e){this.#k(e.selectButton,e),null!==e.drilldownButton&&this.#_(e.drilldownButton,e)}#A(e,t){e.querySelector(I)?.classList.add(N),e.querySelector(H)?.classList.remove(N),e.setAttribute("aria-label",this.#M("select_node",t))}#T(e,t){e.querySelector(H)?.classList.add(N),e.querySelector(I)?.classList.remove(N),e.setAttribute("aria-label",this.#M("unselect_node",t))}#b(){this.#h.disabled=this.#t.size<=0}#x(e){this.#t.has(e)&&this.#t.delete(e)}#w(e){this.#t.has(e)||this.#t.add(e)}#M(e,...t){return function(e,...t){const n=[...t];return e.replace(/%s/g,(()=>n.shift()??""))}(this.#s.txt(e),t)}#p(){this.#f.close()}#v(){this.#f.showModal()}}class z extends Y{#D;constructor(e,t,n,r,i,o,s,l,a,c,d,u,h,g){super(e,t,n,r,i,o,s,l,a,c,d,u,h),this.#D=g}selectNode(e){if(!this.#D){const t=Array.from(this.getSelection().add(e));this.#I(t,this.getNodes())}super.selectNode(e)}updateNodeSelectButtonStates(){if(this.#D)return;const e=this.getNodes();e.forEach((e=>{e.selectButton.disabled=!1,e.selectButton.querySelector(_).classList.remove(q)})),this.getSelection().forEach((t=>{const n=e.get(t);null!==n&&null!==n.listElement&&n.listElement.querySelectorAll(O).forEach((e=>{e.disabled=!0,e.querySelector(_).classList.add(q)}))}))}#I(e,t){for(let r=0;r{const r=e.getAttribute(n);if(!t.has(r))throw new Error(`Element references '${r}' which does not exist.`);e.setAttribute(n,t.get(r))}))}class W{#H;constructor(e){this.#H=e}createContent(e){const t=e.content.cloneNode(!0),n=new Map;return t.querySelectorAll("[id]").forEach((e=>{const t=function(e=""){return`${e}${Date.now().toString(36)}_${Math.random().toString(36).substring(2)}`}("il_ui_fw_");n.set(e.id,t),e.id=t})),t.querySelectorAll("[for]").forEach((e=>{e.htmlFor=n.get(e.htmlFor)})),Q(t,n,"aria-describedby"),Q(t,n,"aria-labelledby"),Q(t,n,"aria-controls"),Q(t,n,"aria-owns"),K(this.#H,t.children)}}class X{#H;constructor(e){this.#H=e}loadContent(e){return fetch(e.toString()).then((e=>e.text())).then((e=>this.#O(e))).then((e=>K(this.#H,e))).catch((t=>{throw new Error(`Could not render element(s) from '${e}': ${t.message}`)}))}#R(e){const t=this.#H.createElement("script");return e.hasAttribute("type")&&t.setAttribute("type",e.getAttribute("type")),e.hasAttribute("src")&&t.setAttribute("src",e.getAttribute("src")),e.textContent.length>0&&(t.textContent=e.textContent),t}#O(e){const t=this.#H.createElement("div");return t.innerHTML=e.trim(),t.querySelectorAll("script").forEach((e=>{const t=this.#R(e);e.replaceWith(t)})),t.children}}function G(e){return Array.from(e.querySelectorAll(k))}class J{#P=new Map;#j;#U;#s;#H;constructor(e,t,n,r){this.#j=e,this.#U=t,this.#s=n,this.#H=r}initTreeMultiSelect(e,t){if(this.#P.has(e))throw new Error(`TreeSelect '${e}' already exists.`);const[n,r,i,o,s,l,a,c]=this.#F(e),d=this.#V(r),u=new z(U(G(a)),this.#j,new W(this.#H),new X(this.#H),this.#s,d,i,o,s,l,c,n,a,t);return this.#P.set(e,u),u}initTreeSelect(e){if(this.#P.has(e))throw new Error(`TreeSelect '${e}' already exists.`);const[t,n,r,i,o,s,l,a]=this.#F(e),c=this.#V(n),d=new Y(U(G(l)),this.#j,new W(this.#H),new X(this.#H),this.#s,c,r,i,o,s,a,t,l);return this.#P.set(e,d),d}getInstance(e){return this.#P.has(e)?this.#P.get(e):null}#F(e){const t=this.#H.getElementById(e),n=t?.closest(M),r=n?.querySelector(".breadcrumb"),i=n?.querySelector(".modal-body > template"),o=n?.querySelector(D),s=o?.querySelector(":scope > template"),l=n?.querySelector("dialog"),a=l?.querySelector(".btn-primary");if(null===r||null===i||null===o||null===s||null===a||null===t||null===l)throw new Error(`Could not find some element(s) for Tree Select Input '${e}'.`);return[t,n,r,i,o,s,l,a]}#V(e){const t=e.querySelector(".c-drilldown");if(null===t||!t.hasAttribute("id"))throw new Error("Could not find drilldown element.");const n=this.#U.getInstance(t.id);if(null===t)throw new Error("Could not find drilldown instance.");return n}}class Z{#Y;constructor(e){this.#Y=e}on(e,t,n){this.#Y(e).on(t,n)}off(e,t,n){this.#Y(e).off(t,n)}}const ee=[];let te,ne;const re="c-field-tag__dropzone";function ie(e){e.DOM.scope.querySelectorAll(`.${re}`).forEach((t=>{e.DOM.scope.removeChild(t)}))}function oe(e,t){"Delete"===t.key&&"true"!==t.target.dataset.selected&&e.removeTags(t.target)}function se(e,t,n,r,i,o,s){var l;ee[n.id]=new e(n,function(e,t){return{id:e,whitelist:t.options,enforceWhitelist:!t.userInput,duplicates:t.allowDuplicates,maxTags:t.maxItems,delimiters:null,a11y:{focusableTags:!0},originalInputValueFormat:e=>e.map((e=>e.value)),dropdown:{enabled:t.dropdownSuggestionsStartAfter,maxItems:t.dropdownMaxItems,closeOnSelect:t.dropdownCloseOnSelect,highlightFirst:t.highlight},transformTag(e){e.display||(e.display=e.value,e.value=encodeURIComponent(e.value)),e.display=e.display.replace(//g,">")},templates:{wrapper(e,t){return`
\n ${this.settings.templates.input.call(this)}\n ​\n
`},tag:t=>`
\n \n
\n ${t.display}\n
\n
`,dropdownItem:e=>`
\n ${e.display}\n
`}}}(n.id,r)),ee[n.id].addTags(i),(l=ee[n.id]).getTagElms().forEach((e=>{e.addEventListener("keydown",(e=>{oe(l,e)}))})),l.on("add",(e=>{e.detail.tag.addEventListener("keydown",(e=>{oe(l,e)}))})),void 0!==o&&ee[n.id].on("input",(e=>{!function(e,t,n,r,i,o){void 0!==te&&te.abort(),te=new AbortController,e.whitelist=null,"number"==typeof ne&&(e.DOM.scope.ownerDocument.defaultView.clearTimeout(ne),ne=void 0),i.detail.value.length{const t=i.detail.value;n.writeParameter(r,t),e.loading(!0),fetch(n.getUrl().toString(),{signal:te.signal}).then((e=>e.json())).catch((()=>{})).then((n=>{e.whitelist=n,e.loading(!1).dropdown.show(t)}))}),o))}(ee[n.id],r.suggestionStarts,o,s,e,r.autocompleteTriggerTimeout)})),r.orderable&&t("move",ee[n.id].DOM.scope,ee[n.id].settings.classNames.tag,re,{infoContainer:ee[n.id].DOM.scope.previousElementSibling,texts:{default:()=>r.accessibilityInfo.default,tagSelected:e=>r.accessibilityInfo.tagSelected.replace("%s",ee[n.id].getTagTextNode(e).innerText),position:e=>null===e.previousElementSibling?r.accessibilityInfo.positionInfoFirst:r.accessibilityInfo.positionInfo.replace("%s",ee[n.id].getTagTextNode(e.previousSibling).innerText)}},(e=>{!function(e,t){ie(e);const n=t.ownerDocument.defaultView.getComputedStyle(t),r=e.DOM.scope.ownerDocument.createElement("div");r.classList.add(re),r.style.height=n.height,r.style.width=n.width,r.style.marginRight=n.marginRight,r.style.marginBottom=n.marginBottom,r.style.marginLeft=n.marginLeft,r.style.marginTop=n.marginTop,e.DOM.scope.querySelectorAll(`.${e.settings.classNames.tag}`).forEach((e=>{e!==t&&(e.previousElementSibling===t||e.previousElementSibling?.classList.contains(re)||e.parentNode.insertBefore(r.cloneNode(!0),e),e.nextElementSibling!==t&&e.parentNode.insertBefore(r.cloneNode(!0),e.nextElementSibling))}))}(ee[n.id],e)}),(()=>{!function(e){ie(e),e.updateValueByDOMTags()}(ee[n.id])}))}const le="t-draggable__dropzone--active",ae="t-draggable__dropzone--hover",ce="t-draggable--dragging";function de(e,t,n,r,i,o,s){let l,a,c,d;function u(t){setTimeout((()=>{g(t.target),t.dataTransfer.dropEffect=e,t.dataTransfer.effectAllowed=e,t.dataTransfer.setDragImage(d,0,0)}),0)}function h(e){e.preventDefault(),e.stopPropagation(),g(e.target.closest(`.${n}`));const t=d.offsetWidth,r=d.offsetHeight;l=d.cloneNode(!0),d.parentNode.insertBefore(l,d),d.style.position="fixed",d.style.left=e.touches[0].clientX-t/2+"px",d.style.top=e.touches[0].clientY-r/2+"px",d.style.width=`${t}px`,d.style.height=`${r}px`,d.addEventListener("touchmove",f),d.addEventListener("touchend",v)}function g(e){d=e,e.classList.add(ce),o(e),t.querySelectorAll(`.${r}`).forEach((e=>{!function(e){e.removeEventListener("dragover",m),e.removeEventListener("dragenter",p),e.removeEventListener("dragleave",S),e.removeEventListener("drop",y),e.removeEventListener("keydown",L),e.addEventListener("dragover",m),e.addEventListener("dragenter",p),e.addEventListener("dragleave",S),e.addEventListener("drop",y),e.addEventListener("keydown",L)}(e),e.classList.add(le)})),e.querySelectorAll(`.${r}`).forEach((e=>{e.classList.remove(le)}))}function f(e){e.preventDefault(),d.style.left=e.touches[0].clientX-d.offsetWidth/2+"px",d.style.top=e.touches[0].clientY-d.offsetHeight/2+"px";const n=t.ownerDocument;e.touches[0].clientY>.8*n.clientHeight&&n.scroll({left:0,top:.8*e.touches[0].pageY,behavior:"smooth"}),e.touches[0].clientY<.2*n.clientHeight&&n.scroll({left:0,top:.8*e.touches[0].pageY,behavior:"smooth"});const i=t.ownerDocument.elementsFromPoint(e.changedTouches[0].clientX,e.changedTouches[0].clientY).filter((e=>e.classList.contains(r)));0===i.length&&void 0!==a&&(a.classList.remove(ae),a=void 0),1===i.length&&a!==i[0]&&(void 0!==a&&a.classList.remove(ae),[a]=i,a.classList.add(ae))}function m(e){e.preventDefault()}function p(e){e.target.classList.add(ae)}function S(e){e.target.classList.remove(ae)}function w(){d.classList.remove(ce),t.querySelectorAll(`.${r}`).forEach((e=>{e.classList.remove(le),e.classList.remove(ae)}))}function y(e){e.preventDefault(),E(e.target)}function v(e){e.preventDefault();const n=t.ownerDocument.elementsFromPoint(e.changedTouches[0].clientX,e.changedTouches[0].clientY).filter((e=>e.classList.contains(r)));w(),l.remove(),1===n.length&&E(n[0])}function E(t){const n=d.parentNode;let r=d;"move"!==e&&(r=d.cloneNode(!0),T(r)),t.parentNode.insertBefore(r,t),s(r,t,d,n)}function b(e){g(e),e.dataset.selected=!0,c=function(e){const i={nodeArray:[]};return Array.from(t.querySelectorAll(`.${n}, .${r}`)).forEach((t=>{if(t===e)return i.nodeArray.push(t),void(i.currentPosition=i.nodeArray.length-1);t.classList.contains(n)||i.nodeArray.push(t)})),i}(e);const o=new AbortController;e.addEventListener("blur",(()=>{x(e,o)}),{signal:o.signal}),i.infoContainer.innerText=i.texts.tagSelected(e)}function x(e,t){e.dataset.selected=!1,i.infoContainer.innerText=i.texts.default(),void 0!==t&&t.abort(),w(),E(e)}function L(e){if("Tab"!==e.key||"true"!==e.target.dataset.selected){if(" "===e.key&&"true"===e.target.dataset.selected)return e.target.dataset.selected=!1,w(),E(c.nodeArray[c.currentPosition]),void e.target.focus();if(" "!==e.key)if("Escape"!==e.key||"true"!==e.target.dataset.selected){if("ArrowLeft"===e.key&&"true"===e.target.dataset.selected){let e=c.currentPosition-1;return e<0&&(e=c.nodeArray.length-1),t.querySelector(`.${ae}`)?.classList.remove(ae),c.nodeArray[e].classList.add(ae),c.currentPosition=e,void(i.infoContainer.innerText=i.texts.position(c.nodeArray[e]))}if("ArrowRight"===e.key&&"true"===e.target.dataset.selected){let e=c.currentPosition+1;e===c.nodeArray.length&&(e=0),t.querySelector(`.${ae}`)?.classList.remove(ae),c.nodeArray[e].classList.add(ae),c.currentPosition=e,i.infoContainer.innerText=i.texts.position(c.nodeArray[e])}}else x(e.target);else b(e.target)}else e.preventDefault()}function A(e){e.hasAttribute("draggable")||e.setAttribute("draggable",!0)}function T(e){e.addEventListener("dragstart",u),e.addEventListener("dragend",w),e.addEventListener("touchstart",h),e.addEventListener("keydown",L)}t.querySelectorAll(`.${n}`).forEach((e=>{A(e),T(e)})),new t.ownerDocument.defaultView.MutationObserver((e=>{e.forEach((e=>{[...e.addedNodes].forEach((e=>{e.classList.contains(n)&&(A(e),T(e))}))}))})).observe(t,{attributes:!1,childList:!0,subtree:!1}),i.infoContainer.innerText=i.texts.default()}var ue;t.UI=t.UI||{},t.UI.Input=t.UI.Input||{},(ue=t.UI.Input).textarea=new l,ue.markdown=new y,ue.treeSelect=new J(new Z(e),t.UI.menu.drilldown,{txt:e=>t.Language.txt(e)},n),ue.tagInput=ue.tag||{},ue.tagInput.init=(e,t,n,i,o)=>se(r,de,e,t,n,i,o),ue.tagInput.getTagifyInstance=e=>ee[e]}($,il,document,Tagify); diff --git a/components/ILIAS/UI/resources/js/Input/Field/rollup.config.js b/components/ILIAS/UI/resources/js/Input/Field/rollup.config.js index c9db74217bb2..6c4743119f0b 100755 --- a/components/ILIAS/UI/resources/js/Input/Field/rollup.config.js +++ b/components/ILIAS/UI/resources/js/Input/Field/rollup.config.js @@ -33,6 +33,7 @@ export default { jquery: '$', ilias: 'il', document: 'document', + Tagify: 'Tagify', }, plugins: [ terser({ diff --git a/components/ILIAS/UI/resources/js/Input/Field/src/Tag/tag.js b/components/ILIAS/UI/resources/js/Input/Field/src/Tag/tag.js new file mode 100644 index 000000000000..84e55b745752 --- /dev/null +++ b/components/ILIAS/UI/resources/js/Input/Field/src/Tag/tag.js @@ -0,0 +1,321 @@ +/** + * This file is part of ILIAS, a powerful learning management system + * published by ILIAS open source e-Learning e.V. + * + * ILIAS is licensed with the GPL-3.0, + * see https://www.gnu.org/licenses/gpl-3.0.en.html + * You should have received a copy of said license along with the + * source code, too. + * + * If this is not the case or you just want to try ILIAS, you'll find + * us at: + * https://www.ilias.de + * https://github.com/ILIAS-eLearning + */ + +/** + * + * @type {Array} + */ +const instances = []; + +/** + * + * @type {AbortController} + */ +let abortController; + +/** + * + * @type {number} + */ +let timeout; + +/** + * @type {string} + */ +const tagOrderablePlaceholderClass = 'c-field-tag__dropzone'; + +/** + * @param {HTMLInput} input + * @param {Object} config + * @returns {Object} + */ +function buildSettings(inputId, config) { + return { + id: inputId, + whitelist: config.options, + enforceWhitelist: !config.userInput, + duplicates: config.allowDuplicates, + maxTags: config.maxItems, + delimiters: null, + a11y: { + focusableTags: true, + }, + originalInputValueFormat: (valuesArr) => valuesArr.map((item) => item.value), + dropdown: { + enabled: config.dropdownSuggestionsStartAfter, + maxItems: config.dropdownMaxItems, + closeOnSelect: config.dropdownCloseOnSelect, + highlightFirst: config.highlight, + }, + transformTag(tagData) { + if (!tagData.display) { + tagData.display = tagData.value; + tagData.value = encodeURIComponent(tagData.value); + } + tagData.display = tagData.display + .replace(//g, '>'); + }, + templates: { + wrapper(input, _s) { + return `
+ ${this.settings.templates.input.call(this)} + \u200B +
`; + }, + tag(tagData) { + return `
+ +
+ ${tagData.display} +
+
`; + }, + dropdownItem(tagData) { + return `
+ ${tagData.display} +
`; + }, + }, + }; +} + +/** + * @param {Tagify} instance + * @param {number} suggestionsStartAfter + * @param {URLBuilder} autocompleteEndpoint + * @param {URLBuilderToken} autocompleteToken + * @param {InputEvent} event + * @param {number} tagAutocompleteTriggerTimeout + * @returns {void} + */ +function retrieveAutocomplete( + instance, + suggestionsStartAfter, + autocompleteEndpoint, + autocompleteToken, + event, + tagAutocompleteTriggerTimeout, +) { + if (typeof abortController !== 'undefined') { + abortController.abort(); + } + abortController = new AbortController(); + + instance.whitelist = null; + + if (typeof timeout === 'number') { + instance.DOM.scope.ownerDocument.defaultView.clearTimeout(timeout); + timeout = undefined; + } + + if (event.detail.value.length < suggestionsStartAfter) { + return; + } + + timeout = instance.DOM.scope.ownerDocument.defaultView.setTimeout( + () => { + const searchTerm = event.detail.value; + autocompleteEndpoint.writeParameter(autocompleteToken, searchTerm); + instance.loading(true); + fetch(autocompleteEndpoint.getUrl().toString(), { signal: abortController.signal }) + .then((answer) => answer.json()) + .catch(() => {}) + .then((options) => { + instance.whitelist = options; + instance.loading(false).dropdown.show(searchTerm); + }); + }, + tagAutocompleteTriggerTimeout, + ); +} + +/* + * @param {Tagify} instance + * @returns {void} + */ +function removePlaceholders(instance) { + instance.DOM.scope.querySelectorAll(`.${tagOrderablePlaceholderClass}`).forEach( + (elem) => { + instance.DOM.scope.removeChild(elem); + }, + ); +} + +/** + * @param {Tagify} instance + * @param {HTMLElement} draggedElement + * @returns {void} + */ +function onDragStart(instance, draggedElement) { + removePlaceholders(instance); + + const style = draggedElement.ownerDocument.defaultView.getComputedStyle(draggedElement); + const dropzone = instance.DOM.scope.ownerDocument.createElement('div'); + dropzone.classList.add(tagOrderablePlaceholderClass); + dropzone.style.height = style.height; + dropzone.style.width = style.width; + dropzone.style.marginRight = style.marginRight; + dropzone.style.marginBottom = style.marginBottom; + dropzone.style.marginLeft = style.marginLeft; + dropzone.style.marginTop = style.marginTop; + instance.DOM.scope.querySelectorAll(`.${instance.settings.classNames.tag}`).forEach( + (elem) => { + if (elem === draggedElement) { + return; + } + if (elem.previousElementSibling !== draggedElement + && !elem.previousElementSibling?.classList.contains(tagOrderablePlaceholderClass)) { + elem.parentNode.insertBefore(dropzone.cloneNode(true), elem); + } + + if (elem.nextElementSibling === draggedElement) { + return; + } + + elem.parentNode.insertBefore(dropzone.cloneNode(true), elem.nextElementSibling); + }, + ); +} + +/** + * @param {Tagify} instance + * @param {KeyEvent} event + * @returns {void} + */ +function deleteTagOnKeypress(instance, event) { + if (event.key === 'Delete' && event.target.dataset.selected !== 'true') { + instance.removeTags(event.target); + } +} + +/** + * @param {Tagify} instance + * @returns {void} + */ +function onChange(instance) { + removePlaceholders(instance); + instance.updateValueByDOMTags(); +} + +/** + * @param {Tagify} instance + * @returns {void} + */ +function addEventListenersForDeletion(instance) { + instance.getTagElms().forEach((elem) => { + elem.addEventListener( + 'keydown', + (event) => { deleteTagOnKeypress(instance, event); }, + ); + }); + instance.on('add', (e) => { + e.detail.tag.addEventListener( + 'keydown', + (event) => { deleteTagOnKeypress(instance, event); }, + ); + }); +} + +/** + * + * @param {string} instanceId + * @returns {Tagify} + */ +export function getTagifyInstance(instanceId) { + return instances[instanceId]; +} + +/** + * @param {Tagify} Tagify + * @param {function} makeDraggable + * @param {HTMLInput} input + * @param {Object} config + * @param {array} value + * @param {URLBuilder} autocompleteEndpoint + * @param {URLBuilderToken} autocompleteToken + * @returns {void} + */ +export function init( + Tagify, + makeDraggable, + input, + config, + value, + autocompleteEndpoint, + autocompleteToken, +) { + instances[input.id] = new Tagify( + input, + buildSettings(input.id, config), + ); + instances[input.id].addTags(value); + addEventListenersForDeletion(instances[input.id]); + if (typeof autocompleteEndpoint !== 'undefined') { + instances[input.id].on('input', (event) => { + retrieveAutocomplete( + instances[input.id], + config.suggestionStarts, + autocompleteEndpoint, + autocompleteToken, + event, + config.autocompleteTriggerTimeout, + ); + }); + } + if (config.orderable) { + makeDraggable( + 'move', + instances[input.id].DOM.scope, + instances[input.id].settings.classNames.tag, + tagOrderablePlaceholderClass, + { + infoContainer: instances[input.id].DOM.scope.previousElementSibling, + texts: { + default() { + return config.accessibilityInfo.default; + }, + tagSelected(selectedTag) { + return config.accessibilityInfo.tagSelected.replace( + '%s', + instances[input.id].getTagTextNode(selectedTag).innerText, + ); + }, + position(selectedPlaceholder) { + if (selectedPlaceholder.previousElementSibling === null) { + return config.accessibilityInfo.positionInfoFirst; + } + + return config.accessibilityInfo.positionInfo.replace( + '%s', + instances[input.id].getTagTextNode(selectedPlaceholder.previousSibling).innerText, + ); + }, + }, + }, + (draggedElement) => { onDragStart(instances[input.id], draggedElement); }, + () => { onChange(instances[input.id]); }, + ); + } +} diff --git a/components/ILIAS/UI/resources/js/Input/Field/src/input.factory.js b/components/ILIAS/UI/resources/js/Input/Field/src/input.factory.js index 497552fb8985..bb4a471cee13 100755 --- a/components/ILIAS/UI/resources/js/Input/Field/src/input.factory.js +++ b/components/ILIAS/UI/resources/js/Input/Field/src/input.factory.js @@ -31,6 +31,9 @@ import TextareaFactory from './Textarea/textarea.factory.js'; import MarkdownFactory from './Markdown/markdown.factory.js'; import TreeSelectFactory from './TreeSelect/TreeSelectFactory.js'; import JQueryEventListener from '../../../Core/src/JQueryEventListener.js'; +import Tagify from 'Tagify'; +import * as tag from './Tag/tag.js'; +import draggable from '../../../Draggable/draggable.js'; il.UI = il.UI || {}; il.UI.Input = il.UI.Input || {}; @@ -45,4 +48,8 @@ il.UI.Input = il.UI.Input || {}; {txt: (s) => il.Language.txt(s)}, document, ); + Input.tagInput = Input.tag || {}; + Input.tagInput.init = (input, config, value, autocompleteEndpoint, autocompleteToken) => tag.init( + Tagify, draggable, input, config, value, autocompleteEndpoint, autocompleteToken); + Input.tagInput.getTagifyInstance = (input_id) => tag.getTagifyInstance(input_id); }(il.UI.Input)); diff --git a/components/ILIAS/UI/resources/js/Input/Field/tagInput.js b/components/ILIAS/UI/resources/js/Input/Field/tagInput.js deleted file mode 100755 index e5d76e140ab8..000000000000 --- a/components/ILIAS/UI/resources/js/Input/Field/tagInput.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * This file is part of ILIAS, a powerful learning management system - * published by ILIAS open source e-Learning e.V. - * - * ILIAS is licensed with the GPL-3.0, - * see https://www.gnu.org/licenses/gpl-3.0.en.html - * You should have received a copy of said license along with the - * source code, too. - * - * If this is not the case or you just want to try ILIAS, you'll find - * us at: - * https://www.ilias.de - * https://github.com/ILIAS-eLearning - */ - -/** - * Wraps the TagsInput - * - * @author Fabian Schmid - * @author Nils Haagen - */ -var il = il || {}; -il.UI = il.UI || {}; -il.UI.Input = il.UI.Input || {}; -(function ($) { - il.UI.Input.tagInput = (function ($) { - const instances = []; - const init = function (raw_id, config, value) { - let _CONFIG = {}; - const _getSettings = function () { - return { - whitelist: _CONFIG.options, - enforceWhitelist: !_CONFIG.userInput, - duplicates: _CONFIG.allowDuplicates, - maxTags: _CONFIG.maxItems, - originalInputValueFormat: (valuesArr) => valuesArr.map((item) => item.value), - dropdown: { - enabled: _CONFIG.dropdownSuggestionsStartAfter, - maxItems: _CONFIG.dropdownMaxItems, - closeOnSelect: _CONFIG.dropdownCloseOnSelect, - highlightFirst: _CONFIG.highlight, - }, - transformTag(tagData) { - if (!tagData.display) { - tagData.display = tagData.value; - tagData.value = encodeURI(tagData.value); - } - tagData.display = tagData.display - .replace(//g, '>'); - }, - }; - }; - - // Initialize ID and Configuration - _CONFIG = $.extend(_CONFIG, config); - _CONFIG.id = document.querySelector(`#${raw_id} .c-input__field .c-field-tag__wrapper input`)?.id; - - const settings = _getSettings(); - settings.delimiters = null; - settings.templates = {}; - settings.templates.tag = function (tagData) { - return ` - -
- ${tagData.display} -
-
`; - }; - settings.templates.dropdownItem = function (tagData) { - return `
- ${tagData.display} -
`; - }; - - const input = document.getElementById(_CONFIG.id); - const tagify = new Tagify(input, settings); - - tagify.addTags(value); - - instances[raw_id] = tagify; - }; - - const getTagifyInstance = function (raw_id) { - return instances[raw_id]; - }; - - return { - init, - getTagifyInstance, - }; - }($)); -}($, il.UI.Input)); diff --git a/components/ILIAS/UI/src/Component/Input/Field/Tag.php b/components/ILIAS/UI/src/Component/Input/Field/Tag.php index a196be809189..9058ba22bedb 100755 --- a/components/ILIAS/UI/src/Component/Input/Field/Tag.php +++ b/components/ILIAS/UI/src/Component/Input/Field/Tag.php @@ -23,6 +23,8 @@ use ILIAS\UI\Component\Input\Container\Form\FormInput; use ILIAS\UI\Component\Signal; use InvalidArgumentException; +use ILIAS\UI\URLBuilder; +use ILIAS\UI\URLBuilderToken; /** * Interface Tag @@ -33,23 +35,11 @@ */ interface Tag extends FormInput { - /** - * @return string[] of tags such as [ 'Interesting', 'Boring', 'Animating', 'Repetitious' ] - */ - public function getTags(): array; - /** * Get an input like this, but decide whether the user can provide own * tags or not. (Default: Allowed) */ - public function withUserCreatedTagsAllowed(bool $extendable): Tag; - - /** - * @see withUserCreatedTagsAllowed - * @return bool Whether the user is allowed to input more - * options than the given. - */ - public function areUserCreatedTagsAllowed(): bool; + public function withUserCreatedTagsAllowed(bool $extendable): self; /** * Get an input like this, but change the amount of characters the @@ -58,38 +48,38 @@ public function areUserCreatedTagsAllowed(): bool; * @param int $characters defaults to 1 * @throws InvalidArgumentException */ - public function withSuggestionsStartAfter(int $characters): Tag; - - /** - * @see withSuggestionsStartAfter - */ - public function getSuggestionsStartAfter(): int; + public function withSuggestionsStartAfter(int $characters): self; /** * Get an input like this, but limit the amount of characters one tag can be. (Default: unlimited) */ - public function withTagMaxLength(int $max_length): Tag; + public function withTagMaxLength(int $max_length): self; /** - * @see withTagMaxLength + * Get an input like this, but limit the amount of tags a user can select or provide. (Default: unlimited) */ - public function getTagMaxLength(): int; + public function withMaxTags(int $max_tags): self; /** - * Get an input like this, but limit the amount of tags a user can select or provide. (Default: unlimited) + * Get an input like this, but add an endpoint to get a list of possible options. + * The $autocomplete_endpoint MUST answer to a query with the provided text + * handed over in the parameter defined in $term_token. + * It MUST answer with a json array containing the options in the form of objects + * containing three properties "value", "display", and "searchBy". The property + * "value" MUST be save to transmit as url-parameter. */ - public function withMaxTags(int $max_tags): Tag; - + public function withAsyncAutocomplete(URLBuilder $autocomplete_endpoint, URLBuilderToken $term_token): self; /** - * @see withMaxTags + * Get an input like this, but allow sorting the items. (Default: not orderable) */ - public function getMaxTags(): int; + public function withOrderable(bool $orderable): Tag; + public function getOrderable(): bool; // Events - public function withAdditionalOnTagAdded(Signal $signal): Tag; + public function withAdditionalOnTagAdded(Signal $signal): self; - public function withAdditionalOnTagRemoved(Signal $signal): Tag; + public function withAdditionalOnTagRemoved(Signal $signal): self; } diff --git a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php index cd8ceaf18e3d..ca76fa02df84 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Renderer.php @@ -396,23 +396,35 @@ protected function renderTagField(F\Tag $component, RendererInterface $default_r $tpl = $this->getTemplate("tpl.tag_input.html", true, true); $this->applyName($component, $tpl); - $configuration = $component->getConfiguration(); + $configuration = $component->getConfiguration(fn(string $id): string => $this->txt($id)); $value = $component->getValue(); if ($value) { $value = array_map( function ($v) { - return ['value' => urlencode($v), 'display' => $v]; + return ['value' => rawurlencode($v), 'display' => $v]; }, $value ); } + $autocomplete_endpoint = 'undefined'; + $autocomplete_token = 'undefined'; + if ($component->getAsyncAutocompleteEndpoint() !== null) { + $autocomplete_endpoint = $component->getAsyncAutocompleteEndpoint() + ->renderObject([$component->getAsyncAutocompleteToken()]); + $autocomplete_token = $component->getAsyncAutocompleteToken()->render(); + } + $component = $component->withAdditionalOnLoadCode( - function ($id) use ($configuration, $value) { + function ($id) use ($configuration, $value, $autocomplete_endpoint, $autocomplete_token) { $encoded = json_encode($configuration); $value = json_encode($value); - return "il.UI.Input.tagInput.init('{$id}', {$encoded}, {$value});"; + return 'il.UI.Input.tagInput.init(document.querySelector(' + . "'#{$id} .c-field-tag'), {$encoded}, {$value}," + . " {$autocomplete_endpoint}, " + . " {$autocomplete_token}" + . ");"; } ); diff --git a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Tag.php b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Tag.php index 4131aa5b3e03..cc77721c583c 100755 --- a/components/ILIAS/UI/src/Implementation/Component/Input/Field/Tag.php +++ b/components/ILIAS/UI/src/Implementation/Component/Input/Field/Tag.php @@ -22,6 +22,7 @@ use ILIAS\Data\Factory as DataFactory; use ILIAS\Data\Result\Ok; +use ILIAS\Data\URI; use ILIAS\UI\Component as C; use ILIAS\UI\Component\Signal; use ILIAS\UI\Component\Input\InputData; @@ -32,6 +33,8 @@ use LogicException; use ILIAS\UI\Implementation\Component\JavaScriptBindable; use ILIAS\UI\Implementation\Component\Triggerer; +use ILIAS\UI\URLBuilder; +use ILIAS\UI\URLBuilderToken; /** * Class TagInput @@ -54,6 +57,9 @@ class Tag extends FormInput implements C\Input\Field\Tag protected bool $extendable = true; protected int $suggestion_starts_with = 1; protected array $tags = []; + protected ?URLBuilder $async_autocomplete_endpoint = null; + protected ?URLBuilderToken $async_autocomplete_token = null; + protected bool $orderable = false; public function __construct( DataFactory $data_factory, @@ -75,16 +81,17 @@ protected function addAdditionalTransformations(): void if (count($v) == 1 && $v[0] === '') { return []; } - $array = array_map("urldecode", $v); + $array = array_map('rawurldecode', $v); return array_map('strip_tags', $array); })); } - public function getConfiguration(): stdClass - { + public function getConfiguration( + Closure $txt + ): stdClass { $options = array_map( fn($tag) => [ - 'value' => urlencode(trim($tag)), + 'value' => rawurlencode(trim($tag)), 'display' => $tag, 'searchBy' => $tag ], @@ -102,6 +109,8 @@ public function getConfiguration(): stdClass $configuration->userInput = $this->areUserCreatedTagsAllowed(); $configuration->dropdownSuggestionsStartAfter = $this->getSuggestionsStartAfter(); $configuration->suggestionStarts = $this->getSuggestionsStartAfter(); + $configuration->autocompleteTriggerTimeout = 200; + $configuration->orderable = $this->getOrderable(); $configuration->maxChars = 2000; $configuration->suggestionLimit = 50; $configuration->debug = false; @@ -109,10 +118,26 @@ public function getConfiguration(): stdClass $configuration->highlight = true; $configuration->tagClass = "input-tag"; $configuration->tagTextProp = "displayValue"; + $configuration->accessibilityInfo = $this->buildAccessibilityInfo($txt); return $configuration; } + protected function buildAccessibilityInfo(Closure $txt): array + { + $default_text = $txt('edit_tag_accessibility_info'); + if ($this->getOrderable()) { + $default_text .= " {$txt('order_tags_accessibility_info')}"; + } + + return [ + 'default' => $default_text, + 'tagSelected' => $txt('tag_selected_accessibility_info'), + 'positionInfoFirst' => $txt('tag_position_first_accessibility_info'), + 'positionInfo' => $txt('tag_position_accessibility_info') + ]; + } + /** * @inheritDoc */ @@ -265,6 +290,53 @@ public function getMaxTags(): int return $this->max_tags; } + /** + * @inheritDoc + */ + public function withAsyncAutocomplete( + URLBuilder $autocomplete_endpoint, + URLBuilderToken $term_token + ): Tag { + $clone = clone $this; + $clone->async_autocomplete_endpoint = $autocomplete_endpoint; + $clone->async_autocomplete_token = $term_token; + return $clone; + } + + /** + * @inheritDoc + */ + public function getAsyncAutocompleteEndpoint(): ?URLBuilder + { + return $this->async_autocomplete_endpoint; + } + + /** + * @inheritDoc + */ + public function getAsyncAutocompleteToken(): ?URLBuilderToken + { + return $this->async_autocomplete_token; + } + + /** + * @inheritDoc + */ + public function withOrderable(bool $orderable): Tag + { + $clone = clone $this; + $clone->orderable = $orderable; + return $clone; + } + + /** + * @inheritDoc + */ + public function getOrderable(): bool + { + return $this->orderable; + } + /** * @inheritDoc */ diff --git a/components/ILIAS/UI/src/examples/Input/Field/Tag/base_with_orderable.php b/components/ILIAS/UI/src/examples/Input/Field/Tag/base_with_orderable.php new file mode 100755 index 000000000000..8c6ed8c95658 --- /dev/null +++ b/components/ILIAS/UI/src/examples/Input/Field/Tag/base_with_orderable.php @@ -0,0 +1,52 @@ + + * The example shows how to create and render a basic tag input field and attach it to a + * form. This example does not contain any data processing. + * + * expected output: > + * ILIAS shows an input field titled "Orderable TagInput". The Tag, 'Interesting', + * 'Boring', 'Animating' are already displayed and can be removed through clicking + * the "X". Tags can be sorted by dragging and dropping them in the + * --- + */ +function base_with_orderable() +{ + /** @var \ILIAS\DI\Container $DIC */ + global $DIC; + $ui = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + + $tag_input = $ui->input()->field()->tag( + 'Orderable TagInput', + ['Interesting', 'Boring', 'Animating', 'Repetitious'], + 'Just some tags' + )->withValue(['Interesting', 'Boring', 'Animating']) + ->withOrderable(true); + + $form = $ui->input()->container()->form()->standard('#', [$tag_input]); + + return $renderer->render($form); +} diff --git a/components/ILIAS/UI/src/examples/Input/Field/Tag/disabled.php b/components/ILIAS/UI/src/examples/Input/Field/Tag/disabled.php index 8dc6a400d827..a2e58419c0ae 100755 --- a/components/ILIAS/UI/src/examples/Input/Field/Tag/disabled.php +++ b/components/ILIAS/UI/src/examples/Input/Field/Tag/disabled.php @@ -42,7 +42,7 @@ function disabled() $tag_input = $ui->input() ->field() ->tag( - "Basic Tag", + "Basic TagInput", ['Interesting', 'Boring', 'Animating', 'Repetitious'], "Just some tags" )->withDisabled(true)->withValue(["Boring", "Animating"]); diff --git a/components/ILIAS/UI/src/examples/Input/Field/Tag/with_autocomplete_endpoint.php b/components/ILIAS/UI/src/examples/Input/Field/Tag/with_autocomplete_endpoint.php new file mode 100755 index 000000000000..c5d253c76032 --- /dev/null +++ b/components/ILIAS/UI/src/examples/Input/Field/Tag/with_autocomplete_endpoint.php @@ -0,0 +1,100 @@ + + * The example shows how to create and render a basic tag input field and attach it to a + * form. This example does not contain any data processing. + * + * expected output: > + * ILIAS shows an input field titled "Tag Input with Autocomplete". A completion of + * the tags will be displayed by ILIAS if an A, B, I or R is typed into the field. + * It is also possible to insert tags of your own and confirm those through hitting + * the Enter button on your keyboard. Afterwards the tags will be highlighted with color. + * An "X" is displayed directly next to each tag. Clicking the "X" will remove the tag. + * Clicking "Save" will reload the page and will set the Tag in the input field back to "Interesting". + * --- + */ +function with_autocomplete_endpoint() +{ + /** @var \ILIAS\DI\Container $DIC */ + global $DIC; + $ui = $DIC->ui()->factory(); + $renderer = $DIC->ui()->renderer(); + $refinery = $DIC->refinery(); + $http = $DIC->http(); + + $df = new \ILIAS\Data\Factory(); + + [$url_builder, $term_token] = (new URLBuilder($df->uri($http->request()->getUri()->__toString()))) + ->acquireParameter(['examples'], 'term'); + + $search_term = $http->wrapper()->query()->retrieve( + $term_token->getName(), + $refinery->byTrying([ + $refinery->kindlyTo()->string(), + $refinery->always('') + ]) + ); + + if ($search_term !== '') { + $response = json_encode( + array_reduce( + ['Interesting', 'Boring', 'Animating', 'Repetitious'], + static function (array $c, string $v) use ($refinery, $search_term): array { + if (stristr($v, $search_term)) { + $c[] = [ + 'value' => urlencode($refinery->encode()->htmlSpecialCharsAsEntities()->transform($v)), + 'display' => $v, + 'searchBy' => $v + ]; + } + return $c; + }, + [] + ) + ); + $http->saveResponse( + $http->response()->withBody( + Streams::ofString($response) + ) + ); + $http->sendResponse(); + $http->close(); + } + + $tag_input = $ui->input()->field()->tag( + "Tag Input with Autocomplete", + [] + )->withAsyncAutocomplete( + $url_builder, + $term_token + )->withUserCreatedTagsAllowed(false); + + return $renderer->render( + $ui->input()->container()->form()->standard("#", [$tag_input]) + ); +} diff --git a/components/ILIAS/UI/src/templates/default/Input/tpl.tag_input.html b/components/ILIAS/UI/src/templates/default/Input/tpl.tag_input.html index ae00fc0b8e42..cd2c8338b808 100755 --- a/components/ILIAS/UI/src/templates/default/Input/tpl.tag_input.html +++ b/components/ILIAS/UI/src/templates/default/Input/tpl.tag_input.html @@ -1,3 +1,4 @@
+ name="{NAME}" class="c-field-tag" {READONLY} value=""/>
diff --git a/components/ILIAS/UI/tests/Client/Input/Field/tag.test.js b/components/ILIAS/UI/tests/Client/Input/Field/tag.test.js new file mode 100644 index 000000000000..1d8d7a0072df --- /dev/null +++ b/components/ILIAS/UI/tests/Client/Input/Field/tag.test.js @@ -0,0 +1,99 @@ +/** + * This file is part of ILIAS, a powerful learning management system + * published by ILIAS open source e-Learning e.V. + * + * ILIAS is licensed with the GPL-3.0, + * see https://www.gnu.org/licenses/gpl-3.0.en.html + * You should have received a copy of said license along with the + * source code, too. + * + * If this is not the case or you just want to try ILIAS, you'll find + * us at: + * https://www.ilias.de + * https://github.com/ILIAS-eLearning + */ + +import { describe, it } from 'node:test'; +import { strict } from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import Tagify from '../../../../../../../node_modules/@yaireo/tagify/dist/tagify.js'; +import * as Tag from '../../../../resources/js/Input/Field/src/Tag/tag.js'; +import URLBuilder from '../../../../resources/js/Core/src/core.URLBuilder.js'; +import URLBuilderToken from '../../../../resources/js/Core/src//core.URLBuilderToken.js'; + +const input_id = 'my_element'; + +function buildInput() { + if (typeof global.document === 'undefined') { + global.window = (new JSDOM('')).window; + global.document = window.document; + global.DOMParser = window.DOMParser; + global.getComputedStyle = window.getComputedStyle; + global.EventTarget = window.EventTarget; + global.localStorage = { + value: undefined, + setItem(v) { this.value = v; }, + getItem() { this.value; }, + }; + } + const input = global.document.createElement('div'); + input.setAttribute('id', input_id); + document.body.appendChild(input); + + return input; +} + +function buildConfig() { + return { + allowDuplicates: false, + autocompleteTriggerTimeout: 200, + debug: false, + dropdownCloseOnSelect: false, + dropdownMaxItems: 200, + dropdownSuggestionsStartAfter: 1, + highlight: true, + id: null, + maxChars: 2000, + maxItems: 20, + options: [], + readonly: false, + selectedOptions: null, + suggestionLimit: 50, + suggestionStarts: 1, + tagClass: "input-tag", + tagTextProp: "displayValue", + userInput: false, + }; +} + +function buildTag(value) { + Tag.init( + Tagify, + buildInput(), + buildConfig(), + value, + new URLBuilder( + new URL("http://ilias.de/ilias.php?cmd=123&examples_term="), + new Map([["t_t",new URLBuilderToken(["t"], "t", "bf83e70336d140b479705a74")]]) + ), + new URLBuilderToken(["t"], "t", "bf83e70336d140b479705a74"), + ); +} + +describe('Tag Input', () => { + it('values are not changed', () => { + new Map([ + ['1,2,3', [1,2,3]], + ['%2B%2B1%23%2A,%5B-2%5D,%7B%3F3%7D', ['++1#*', '[-2]', '{?3}']], + ['some%27thing+%22else%22,%26%2F%5C' ['some\'thing "else"', '&/\\']], + ['f%C3%BCnf%2C+sechs,sieben%2C+acht', ['fünf, sechs', 'sieben, acht']], + ]).forEach( + (value, index) => { + buildTag(index); + console.log(value); + console.log(Tag.getTagifyInstance(input_id).value); + strict.equal(Tag.getTagifyInstance(input_id).value, value); + } + ); + }); +}); diff --git a/components/ILIAS/UI/tests/Component/Input/Field/TagInputTest.php b/components/ILIAS/UI/tests/Component/Input/Field/TagInputTest.php index 9d04a9bf9cbe..5b9bb9811916 100755 --- a/components/ILIAS/UI/tests/Component/Input/Field/TagInputTest.php +++ b/components/ILIAS/UI/tests/Component/Input/Field/TagInputTest.php @@ -24,9 +24,9 @@ require_once(__DIR__ . "/CommonFieldRendering.php"); use ILIAS\UI\Implementation\Component as I; -use ILIAS\UI\Implementation\Component\SignalGenerator; use ILIAS\Data; -use ILIAS\Refinery\Factory as Refinery; +use ILIAS\UI\URLBuilder; +use ILIAS\UI\URLBuilderToken; /** * Class TagInputTest @@ -231,4 +231,76 @@ public function testMaxTaglengthTagsNotOk(): void ) ); } + + public static function getUITagSpecialCharValues(): array + { + return [ + ['1', '2', '3'], + ['++1#*', '[-2]', '{?3}'], + ['some\'thing "else"', '&/\\'], + ['fünf, sechs', 'sieben, acht'], + ]; + } + + /** @dataProvider getUITagSpecialCharValues */ + public function testUITagInputSpecialChars(string ...$tags): void + { + $f = $this->getFieldFactory(); + $name = "name_0"; + $tag = $f->tag('', $tags)->withNameFrom($this->name_source); + + $encoded_tags = array_map('rawurlencode', $tags); + + $this->assertEquals( + $encoded_tags, + array_map( + fn($o) => $o['value'], + $tag->getConfiguration(fn(string $txt) => $txt)->options + ) + ); + + $raw_value = implode(',', $encoded_tags); + $tag_with_input = $tag->withInput(new DefInputData([$name => $raw_value])); + $content = $tag_with_input->getContent(); + $this->assertTrue($content->isOk()); + $this->assertEquals($tags, $content->value()); + } + + public function testTagWithAutocompleteEndpoint(): void + { + $url_builder = new URLBuilder(new Data\URI('http://wwww.ilias.de?ref_id=1')); + $token = new URLBuilderToken(['t'], 't'); + $f = $this->getFieldFactory(); + $tag = $f->tag('my_tag', []); + + $this->assertEquals(null, $tag->getAsyncAutocompleteEndpoint()); + $this->assertEquals(null, $tag->getAsyncAutocompleteToken()); + + $tag = $tag->withAsyncAutocomplete( + $url_builder, + $token + ); + $this->assertEquals($url_builder, $tag->getAsyncAutocompleteEndpoint()); + $this->assertEquals($token, $tag->getAsyncAutocompleteToken()); + } + + public function testTagWithAutocompleteEndpointJSAdded(): void + { + $token = $this->createMock(URLBuilderToken::class); + $token->expects($this->once()) + ->method('render'); + $url_builder = $this->createMock(URLBuilder::class); + $url_builder->expects($this->once()) + ->method('renderObject') + ->with([$token]); + + $f = $this->getFieldFactory(); + $tag = $f->tag('my_tag', [])->withAsyncAutocomplete( + $url_builder, + $token + ); + + $renderer = $this->getDefaultRenderer(); + $renderer->render($tag); + } } diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index 4a81ceef68e3..86dfcac4279d 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -17247,6 +17247,7 @@ ui#:#drilldown_no_items#:#Keine passenden Elemente ui#:#duration_default_label_end#:#Ende ui#:#duration_default_label_start#:#Beginn ui#:#duration_end_must_not_be_earlier_than_start#:#Der Beginn muss zeitlich vor dem Ende liegen. +ui#:#edit_tag_accessibility_info#:#Drücken Sie Enter, um den Tag zu bearbeiten. Drücken sie "Entf", um den Tag zu entfernen. ui#:#filter_nodes_in#:#Einträge in %s filtern ui#:#footer_icons#:#Footer Icons ui#:#footer_link_groups#:#Footer Link-Gruppen @@ -17272,12 +17273,16 @@ ui#:#order_option_generic_ascending#:#Aufsteigend ui#:#order_option_generic_descending#:#Absteigend ui#:#order_option_numerical_ascending#:#0 bis 9 ui#:#order_option_numerical_descending#:#9 bis 0 +ui#:#order_tags_accessibility_info#:#Drücken Sie die Leertaste, um die Tags zu sortieren. ui#:#presentation_table_collapse#:#Alle minimieren ui#:#presentation_table_expand#:#Alle zeigen ui#:#rating_average#:#Andere bewerteten mit %s von 5 ui#:#reset_stars#:#neutral ui#:#select_node#:#Knoten %s zur Auswahl hinzufügen ui#:#table_posinput_col_title#:#Position +ui#:#tag_position_accessibility_info#:#Ausgewählte Position: Nach "%s". +ui#:#tag_position_first_accessibility_info#:#Ausgewählte Position: Erstes Element. +ui#:#tag_selected_accessibility_info#:#Der Tag "%s" wurde ausgewählt. Sie können diesen nun mit der linken und rechten Pfeiltaste verschieben. Mit der Leertaste lassen Sie ihn auf die ausgewählte Position fallen. Die Escape-Taste bricht das Verschieben ab. ui#:#ui_chars_max#:#Maximum: ui#:#ui_chars_min#:#Minimum: ui#:#ui_chars_remaining#:#Verbleibende Buchstaben diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index f65d9a4477b1..d90308893f44 100755 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -17247,6 +17247,7 @@ ui#:#drilldown_no_items#:#No Matching Elements ui#:#duration_default_label_end#:#end ui#:#duration_default_label_start#:#start ui#:#duration_end_must_not_be_earlier_than_start#:#Start must not be later than end. +ui#:#edit_tag_accessibility_info#:#Press Enter to edit the tag. Press Del to delete the tag. ui#:#filter_nodes_in#:#Filter Nodes in %s ui#:#footer_icons#:#Footer Icons ui#:#footer_link_groups#:#Footer Link-Groups @@ -17272,12 +17273,16 @@ ui#:#order_option_generic_ascending#:#Ascending ui#:#order_option_generic_descending#:#Descending ui#:#order_option_numerical_ascending#:#0 to 9 ui#:#order_option_numerical_descending#:#9 to 0 +ui#:#order_tags_accessibility_info#:#Press Spacebar to reorder. ui#:#presentation_table_collapse#:#Collapse All ui#:#presentation_table_expand#:#Expand All ui#:#rating_average#:#Others rated %s of 5 ui#:#reset_stars#:#neutral ui#:#select_node#:#Add node %s to selection ui#:#table_posinput_col_title#:#Position +ui#:#tag_position_accessibility_info#:#Selected Position: After "%s". +ui#:#tag_position_first_accessibility_info#:#Selected Position: First. +ui#:#tag_selected_accessibility_info#:#The tag "%s" has been selected. Use left and right arrow keys to changed its position, Spacebar to drop, or the Escape key to cancel. ui#:#ui_chars_max#:#Maximum: ui#:#ui_chars_min#:#Minimum: ui#:#ui_chars_remaining#:#Characters remaining: diff --git a/templates/default/030-tools/_tool_draggable.scss b/templates/default/030-tools/_tool_draggable.scss new file mode 100755 index 000000000000..5e9da889cea0 --- /dev/null +++ b/templates/default/030-tools/_tool_draggable.scss @@ -0,0 +1,17 @@ +@mixin draggable-element { + &[draggable=true] { + cursor: move; + } + &.t-draggable--dragging { + opacity: 0.5; + } +} +@mixin draggable-dropzone($hover-color) { + &.t-draggable__dropzone--active { + display: block; + } + + &.t-draggable__dropzone--hover { + background-color: $hover-color; + } +} \ No newline at end of file diff --git a/templates/default/070-components/UI-framework/Input/_ui-component_tag.scss b/templates/default/070-components/UI-framework/Input/_ui-component_tag.scss index 8303eb282644..3515ef3454e5 100755 --- a/templates/default/070-components/UI-framework/Input/_ui-component_tag.scss +++ b/templates/default/070-components/UI-framework/Input/_ui-component_tag.scss @@ -1,7 +1,12 @@ -@use "../../../010-settings/"as *; -@use "../../../050-layout/basics"as *; +@use "../../../010-settings/" as *; +@use "../../../030-tools/tool_draggable" as *; +@use "../../../030-tools/tool_screen-reader-only" as *; +@use "../../../050-layout/basics" as *; //** Tag Input +$il-input-tag-dropzone-bg-color: $il-main-dark-bg; +$il-input-tag-dropzone-bg-color-hover: $il-highlight-bg; +$il-input-tag-dropzone-border: $il-main-border; $il-input-tag-color-bg: $il-btn-standard-bg; $il-input-tag-color-text: $il-btn-standard-color; $il-input-tag-color-disabled: #EEEEEE; @@ -9,7 +14,7 @@ $il-input-tag-color-disabled: #EEEEEE; .c-field-tag { width: 100%; background-color: $il-main-bg; - + --tags-focus-border-color: #{$il-input-tag-color-bg}; --tag-bg: #{$il-input-tag-color-bg}; --tag-hover: #{$il-input-tag-color-bg}; @@ -21,4 +26,20 @@ $il-input-tag-color-disabled: #EEEEEE; --tag-remove-btn-bg--hover: #{$il-input-tag-color-bg}; --tag-hide-transition: .0s; line-height: $il-line-height-base; +} + +.c-field-tag__tag { + @include draggable-element; +} + +.c-field-tag__dropzone { + display: none; + background: $il-input-tag-dropzone-bg-color; + border: $il-main-border; + + @include draggable-dropzone($il-input-tag-dropzone-bg-color-hover); +} + +.c-field-tag__assistive-text { + @include sr-only(); } \ No newline at end of file diff --git a/templates/default/delos.css b/templates/default/delos.css index 3a4aacca8fde..6641134ca9f2 100644 --- a/templates/default/delos.css +++ b/templates/default/delos.css @@ -8012,6 +8012,37 @@ button .minimize, button .close { line-height: 1.428571429; } +.c-field-tag__tag[draggable=true] { + cursor: move; +} +.c-field-tag__tag.t-draggable--dragging { + opacity: 0.5; +} + +.c-field-tag__dropzone { + display: none; + background: #f9f9f9; + border: 1px solid #dddddd; +} +.c-field-tag__dropzone.t-draggable__dropzone--active { + display: block; +} +.c-field-tag__dropzone.t-draggable__dropzone--hover { + background-color: rgb(226.2857142857, 231.6428571429, 238.7142857143); +} + +.c-field-tag__assistive-text { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + white-space: nowrap; + clip: rect(0, 0, 0, 0); + border: 0; +} + .c-field-password__revelation-glyph { float: right; margin-top: -21px;