'Custom input element with inline chips and text

I am trying to create a custom input element that has 2 parts.

  1. Allows the user to input text
  2. Allows the user to embed or remove chips in between the text on selecting an option from a dropdown.

I am using Angular 8 and kendo-ui. I've attached the image of the custom input.

I have made use of a contenteditable div to achieve this but the issue I have is once I add the chip, I cannot place the cursor after the chip and continue typing.

The custom input element -

enter image description here

My attempt in achieving this -

enter image description here

My approach -

addChip(event: Event) {

    this.selectedValue = event;
    const inputEl = document.querySelector('#inputArea');

    const closeButton = document.createElement('span');
    closeButton.classList.add('chip-close-button', 'k-icon', 'k-i-close-circle');
    closeButton.setAttribute('id', 'btnClose');
    closeButton.addEventListener('click', () => this.remove());

    const textNode = document.createTextNode(this.selectedValue?.text)

    const chip = document.createElement('span')

    KENDO_CHIP_CONFIG.attributes.forEach(HTMLattribute => {
      chip.setAttribute(HTMLattribute.attribute, HTMLattribute.value);
    })

    chip.appendChild(textNode)
    chip.appendChild(closeButton)
    chip.classList.add('chip')
    inputEl.appendChild(chip)
  }

  public remove(): void {
    const chip = document.querySelector('.chip') as HTMLElement;
    chip.remove()
    this.selectedValue = null;
  }

The HTML code-

<kendo-label [style.display]="'block'" [style]="'margin-bottom: 10px'" [text]="label" [for]="inputArea"></kendo-label>
<div class="textAreaContainer">
  <span class="inputArea" [formControl]="control" placeholder="placeholder" id="inputArea" #inputArea contenteditable>
  </span>
  <kendo-dropdownbutton class="dropDownButton" (itemClick)="addChip($event)" [data]="data" [icon]="icon">
  </kendo-dropdownbutton>
</div>


Solution 1:[1]

I found a way and created a separate component which handles this functionality.

chip-textbox.component.html

<div class="textAreaContainer">
<div #editableDiv class="inputArea" id="editableDiv"  (focusout)="checkChange($event)" [contentEditable]="true"> </div>
<kendo-dropdownbutton class="dropDownButton" (itemClick)="divClickAdd($event)" [data]="data" [icon]="icon">
</kendo-dropdownbutton>
</div>

chip-textbox.component.ts (A little verbose)

export class ChipTextboxComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy {
  private unSubscribe$: Subject<void> = new Subject<void>();
  @ViewChild('editableDiv') public divEditable!: ElementRef;
  @Input() public control: FormControl | AbstractControl;
  @Input() public placeholder: string;
  @Input() public icon: string = 'plus';
  @Input() public data: dynamicInputDropDownInterface[];
  @Input() public label: string;
  @Input() public iconClass: string;
  selectorID = `inputArea-${Math.floor(Math.random() * 1000)}`;
  public $event: Event;
  private clickListener: () => void;
  constructor(
    private renderer: Renderer2
  ) { }

  ngOnInit(): void {
  }
  ngAfterViewInit(): void {
    this.convertControlValueToHtml();
    this.clickListener = this.renderer.listen(this.divEditable.nativeElement, 'click', this.removeChip);
  }
  ngOnChanges(changes: SimpleChanges): void {
    this.control.valueChanges.pipe().subscribe(res => {
      this.convertControlValueToHtml()
    });
  }
  divClickAdd($event: any): void {
    const oldValue =this.control?.value ?? '&nbsp;';
    this.control.patchValue(oldValue + `{{product.${($event?.value).toLowerCase()}}}`);
    this.convertControlValueToHtml();
  }
  checkChange(event: any) {
    const keyCode = event.keyCode;
    if ((keyCode < 48 || keyCode > 57) && (keyCode < 65 || keyCode > 90 ) && (keyCode < 186 || keyCode > 192 )  && (keyCode < 219 || keyCode > 222 ) && keyCode !== 32 && keyCode !== 8) {
      return;
    }
    this.generateControlText();
  }
  generateControlText() {
    const text = this.divEditable.nativeElement.innerHTML;
    const rawValue = text.toLowerCase();
    let filteredValue;
    filteredValue = rawValue.replaceAll('&nbsp;', '');
    filteredValue = filteredValue.replaceAll('<br>', ' ');
    filteredValue = filteredValue.replaceAll('<span class="chip" contenteditable="false">', '{{product.');
    filteredValue = filteredValue.replaceAll(`<i class="k-icon k-i-close-circle"></i></span> `, '}}');
    this.control.patchValue(filteredValue);
    return filteredValue;
  }
  public removeChip({target}): void {
    if(target && target.nodeName ==='I') {
      target.parentNode.remove();
    }
    return;
  }
  convertControlValueToHtml(): void {
    const controlValue = this.control?.value;
    if (!controlValue) {
      return;
    }
    let filteredValue;
    filteredValue = controlValue.replaceAll('{{product.', ' <span class="chip" contenteditable="false">');
    filteredValue = filteredValue.replaceAll('}}',`<i class="k-icon k-i-close-circle"></i></span> &nbsp;`);
    this.divEditable.nativeElement.innerHTML = filteredValue;
    return;
  }

  ngOnDestroy(): void {
    this.unSubscribe$.next();
    this.unSubscribe$.complete();
    this.clickListener();
  }

}

chip-textbox.component.scss

.textAreaContainer {
  display: flex;
  flex-direction: row;
}

[contenteditable=true]:empty:before {
  content: attr(placeholder);
  pointer-events: none;
  display: block;
  /* For Firefox */
}

::ng-deep .inputArea {
  margin: 0;
  padding: 0.12rem 0.75rem;
  line-height: 48px;
  width: 100%;
  min-width: 0;
  border: 1px solid lightgray;
  border-radius: 5px;
  box-sizing: border-box;
  box-shadow: none;
  color: inherit;
  background: none;
  font: inherit;
  display: flex;
  flex-flow: row nowrap;
  align-items: center;
  flex: 0 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  -webkit-appearance: none;
  .chip{
    margin: 0 !important;
    display: inline-block;
    line-height: 22px;
    .k-i-close-circle{
      margin: 0 2px;
      cursor: pointer;
      &:hover{
        cursor: pointer;
      }
    }
  }
&:focus-within {
   background-color: white;
   border-color: #80bdff;
 }
}

.dropDownButton {
  margin-left: 10px;
}

tada! Danke schon!

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Srinath Kamath