-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
create a pattern autocompleter input
This commit introduces a new type of input field which can handle autocomplete with 'tokens', so that we can build patterns like `Example pattern {{token}} value`. The current state is somewhat functional, but still not polished.
- Loading branch information
1 parent
5d832b8
commit 1783195
Showing
7 changed files
with
501 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
336 changes: 336 additions & 0 deletions
336
frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,336 @@ | ||
/* | ||
* -- copyright | ||
* OpenProject is an open source project management software. | ||
* Copyright (C) the OpenProject GmbH | ||
* | ||
* This program is free software; you can redistribute it and/or | ||
* modify it under the terms of the GNU General Public License version 3. | ||
* | ||
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: | ||
* Copyright (C) 2006-2013 Jean-Philippe Lang | ||
* Copyright (C) 2010-2013 the ChiliProject Team | ||
* | ||
* This program is free software; you can redistribute it and/or | ||
* modify it under the terms of the GNU General Public License | ||
* as published by the Free Software Foundation; either version 2 | ||
* of the License, or (at your option) any later version. | ||
* | ||
* This program is distributed in the hope that it will be useful, | ||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
* GNU General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU General Public License | ||
* along with this program; if not, write to the Free Software | ||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||
* | ||
* See COPYRIGHT and LICENSE files for more details. | ||
* ++ | ||
*/ | ||
|
||
/* | ||
* CHECKLIST OF ALL THEM THINGS | ||
* | ||
* focus/highlight | ||
* [x] on enter | ||
* [x] on leave | ||
* click | ||
* [x] when the click hits a editable field | ||
* [x] when the click hits a token | ||
* [x] when the clicks hits only the parent div | ||
* [x] when the click hits the 'x' on a token | ||
* navigation | ||
* [x] arrow keys | ||
* [x] tab | ||
* editting | ||
* [x] backspace/delete when the next/previous character is a token | ||
* [x] make sure it's possible to add text in the beginning and the end of the input | ||
* [x] prevent enter key from entering new lines | ||
* [ ] copy | ||
* [ ] paste (with and without tokens) | ||
* [x] manually typing {{ tags | ||
* accessibility concerns | ||
* [x] aria tokens ---> PRIMER SEEMS TO TAKE CARE OF THESE | ||
* autocomplete popup | ||
* [x] autocomplete on key type | ||
* [x] adjust context of autocomplete based on the current word (e.g. typing in the middle of a word) | ||
* [x] clear autocomplete filtering | ||
* [x] actually insert autocompleted tag into the text | ||
* [x] keyboard support (UP/DOWN/SELECT) | ||
* styling | ||
* [x] primer CSS classes | ||
* [ ] actually do styling to look like figma | ||
*/ | ||
|
||
import { Controller } from '@hotwired/stimulus'; | ||
|
||
export default class PatternAutocompleterController extends Controller { | ||
static targets = [ | ||
'tokenTemplate', | ||
'content', | ||
'formInput', | ||
'suggestions', | ||
]; | ||
|
||
declare readonly tokenTemplateTarget:HTMLTemplateElement; | ||
declare readonly contentTarget:HTMLElement; | ||
declare readonly formInputTarget:HTMLInputElement; | ||
declare readonly suggestionsTarget:HTMLElement; | ||
|
||
static values = { patternInitial: String }; | ||
declare patternInitialValue:string; | ||
|
||
// holds the caret position in the component | ||
lastKnownCursorPosition:{ node:Node|null, offset:number } = { node: null, offset: 0 }; | ||
// holds the selected item in the suggestions list | ||
selectedSuggestion:{ element:HTMLElement|null, index:number } = { element: null, index: 0 }; | ||
|
||
connect() { | ||
this.contentTarget.innerHTML = this.toHtml(this.patternInitialValue) || ' '; | ||
} | ||
|
||
// Token events | ||
remove_token(event:PointerEvent) { | ||
const target = event.currentTarget as HTMLElement; | ||
|
||
if (target) { | ||
const tokenElement = target.closest('[data-role="token"]'); | ||
if (tokenElement) { | ||
tokenElement.remove(); | ||
} | ||
|
||
this.updateFormInputValue(); | ||
} | ||
} | ||
|
||
// Input field events | ||
input_keydown(event:KeyboardEvent) { | ||
if (event.key === 'Enter') { | ||
// prevent entering new line characters | ||
event.preventDefault(); | ||
|
||
const selectedItem = this.suggestionsTarget.querySelector('.selected') as HTMLElement; | ||
|
||
if (selectedItem) { | ||
this.insertTokenAtLastKnownCursorPosition(selectedItem.dataset.value!); | ||
this.clearSuggestionsFilter(); | ||
} | ||
} | ||
|
||
// move up and down the suggestions selection | ||
if (event.key === 'ArrowUp') { | ||
this.selectSuggestionAt(this.selectedSuggestion.index - 1); | ||
} | ||
if (event.key === 'ArrowDown') { | ||
this.selectSuggestionAt(this.selectedSuggestion.index + 1); | ||
} | ||
|
||
if (event.key === 'Escape') { | ||
this.hide(this.suggestionsTarget); | ||
} | ||
|
||
this.updateLastKnownCursorPosition(); | ||
} | ||
|
||
input_change(event:Event) { | ||
const target = event.currentTarget as HTMLElement; | ||
if (target) { | ||
this.updateFormInputValue(); | ||
} | ||
|
||
const word = this.currentWord(); | ||
if (word && word.length > 0) { | ||
this.filterSuggestions(word); | ||
this.selectSuggestionAt(0); | ||
this.show(this.suggestionsTarget); | ||
} else { | ||
this.clearSuggestionsFilter(); | ||
} | ||
|
||
this.ensureSpacesAround(); | ||
this.updateLastKnownCursorPosition(); | ||
} | ||
|
||
input_focus() { | ||
// handling the first time someone clicks on the input when the last item is a non contenteditable element | ||
if (!this.lastKnownCursorPosition.node && this.contentTarget.innerHTML.endsWith('>')) { | ||
const afterToken = this.contentTarget.appendChild(document.createTextNode(' ')); | ||
this.setCursorPositionAt(afterToken, 1); | ||
} | ||
} | ||
|
||
input_blur() { | ||
this.updateFormInputValue(); | ||
} | ||
|
||
// Autocomplete events | ||
suggestions_select(event:PointerEvent) { | ||
const target = event.currentTarget as HTMLElement; | ||
|
||
if (target) { | ||
this.insertTokenAtLastKnownCursorPosition(target.dataset.value!); | ||
this.clearSuggestionsFilter(); | ||
} | ||
} | ||
|
||
suggestions_toggle() { | ||
if (this.suggestionsTarget.getAttribute('hidden')) { | ||
this.show(this.suggestionsTarget); | ||
} else { | ||
this.clearSuggestionsFilter(); | ||
} | ||
} | ||
|
||
// internal methods | ||
private updateFormInputValue():void { | ||
this.formInputTarget.value = this.toBlueprint(); | ||
} | ||
|
||
private updateLastKnownCursorPosition():void { | ||
const selection = document.getSelection(); | ||
if (selection && selection.anchorNode?.nodeType === Node.TEXT_NODE) { | ||
this.lastKnownCursorPosition.node = selection.anchorNode; | ||
this.lastKnownCursorPosition.offset = selection.anchorOffset; | ||
} | ||
} | ||
|
||
private ensureSpacesAround():void { | ||
if (this.contentTarget.innerHTML.startsWith('<')) { | ||
this.contentTarget.insertBefore(document.createTextNode(' '), this.contentTarget.children[0]); | ||
} | ||
if (this.contentTarget.innerHTML.endsWith('>')) { | ||
this.contentTarget.appendChild(document.createTextNode(' ')); | ||
} | ||
} | ||
|
||
private insertTokenAtLastKnownCursorPosition(value:string) { | ||
if (this.lastKnownCursorPosition.node) { | ||
const targetNode = this.lastKnownCursorPosition.node; | ||
const targetOffset = this.lastKnownCursorPosition.offset; | ||
|
||
let pos = targetOffset - 1; | ||
while (pos > -1 && targetNode.textContent?.charAt(pos) !== ' ') { pos-=1; } | ||
const content = targetNode.textContent!; | ||
|
||
this.contentTarget.insertBefore(document.createTextNode(content.substring(0, pos + 1)), targetNode); | ||
this.contentTarget.insertBefore(this.tokenElementWithValue(value), targetNode); | ||
const afterToken = this.contentTarget.insertBefore(document.createTextNode(content.substring(targetOffset)), targetNode); | ||
this.contentTarget.removeChild(targetNode); | ||
|
||
this.setCursorPositionAt(afterToken, 0); | ||
|
||
this.ensureSpacesAround(); | ||
this.updateFormInputValue(); | ||
|
||
// hide suggestions | ||
this.hide(this.suggestionsTarget); | ||
} else { | ||
// investigate, these edge cases are important | ||
console.warn('last known position is wrong: ', this.lastKnownCursorPosition); | ||
} | ||
} | ||
|
||
private setCursorPositionAt(node:Node, offset:number):void { | ||
const range = document.createRange(); | ||
range.setStart(node, offset); | ||
range.collapse(true); | ||
|
||
document.getSelection()?.removeAllRanges(); | ||
document.getSelection()?.addRange(range); | ||
|
||
this.updateLastKnownCursorPosition(); | ||
} | ||
|
||
private currentWord():string|null { | ||
const selection = document.getSelection(); | ||
if (selection) { | ||
return (selection.anchorNode?.textContent?.slice(0, selection.anchorOffset) | ||
.split(' ') | ||
.pop() as string) | ||
.toLowerCase(); | ||
} | ||
|
||
return null; | ||
} | ||
|
||
private clearSuggestionsFilter():void { | ||
this.hide(this.suggestionsTarget); | ||
const suggestionElements = this.suggestionsTarget.children; | ||
for (let i = 0; i < suggestionElements.length; i+=1) { | ||
this.show(suggestionElements[i] as HTMLElement); | ||
} | ||
} | ||
|
||
private filterSuggestions(word:string):void { | ||
const suggestionElements = this.suggestionsTarget.children; | ||
for (let i = 0; i < suggestionElements.length; i+=1) { | ||
const suggestionElement = suggestionElements[i] as HTMLElement; | ||
if (!suggestionElement.dataset.value) { continue; } | ||
|
||
if (suggestionElement.textContent?.trim().toLowerCase().includes(word) || suggestionElement.dataset.value.includes(word)) { | ||
this.show(suggestionElement); | ||
} else { | ||
this.hide(suggestionElement); | ||
} | ||
} | ||
|
||
// show autocomplete | ||
this.show(this.suggestionsTarget); | ||
} | ||
|
||
private selectSuggestionAt(index:number):void { | ||
if (this.selectedSuggestion.element) { | ||
this.selectedSuggestion.element.classList.remove('selected'); | ||
this.selectedSuggestion.element = null; | ||
} | ||
|
||
const possibleTargets = this.suggestionsTarget.querySelectorAll('[data-role="suggestion-item"]:not([hidden])'); | ||
if (possibleTargets.length > 0) { | ||
if (index < 0) { index += possibleTargets.length; } | ||
index %= possibleTargets.length; | ||
const element = possibleTargets[index]; | ||
element.classList.add('selected'); | ||
this.selectedSuggestion.element = element as HTMLElement; | ||
this.selectedSuggestion.index = index; | ||
} | ||
} | ||
|
||
private hide(el:HTMLElement):void { | ||
el.setAttribute('hidden', 'hidden'); | ||
} | ||
|
||
private show(el:HTMLElement):void { | ||
el.removeAttribute('hidden'); | ||
} | ||
|
||
private tokenElementWithValue(value:string):HTMLElement { | ||
const target = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement; | ||
const contentElement = target.firstElementChild as HTMLElement; | ||
(contentElement.querySelector('[data-role="token-text"]') as HTMLElement).innerText = value; | ||
return contentElement; | ||
} | ||
|
||
private toHtml(blueprint:string):string { | ||
let htmlValue = blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.tokenElementWithValue(token).outerHTML); | ||
if (htmlValue.startsWith('<')) { htmlValue = ` ${htmlValue}`; } | ||
if (htmlValue.endsWith('>')) { htmlValue = `${htmlValue} `; } | ||
return htmlValue; | ||
} | ||
|
||
private toBlueprint():string { | ||
let result = ''; | ||
this.contentTarget.childNodes.forEach((node:Element) => { | ||
if (node.nodeType === Node.TEXT_NODE) { | ||
// Plain text node | ||
result += node.textContent; | ||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.role === 'token') { | ||
// Token element | ||
const tokenText = node.querySelector('[data-role="token-text"]'); | ||
if (tokenText) { | ||
result += `{{${tokenText.textContent?.trim()}}}`; | ||
} | ||
} | ||
}); | ||
return result.trim(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.