Improve tag input keyboard navigation (#650)
This commit is contained in:
		
							parent
							
								
									d0182a3e13
								
							
						
					
					
						commit
						a1f939ad29
					
				@ -1,9 +1,14 @@
 | 
			
		||||
import { LitElement, html, css } from "lit";
 | 
			
		||||
import { state, property, query } from "lit/decorators.js";
 | 
			
		||||
import { msg, localized, str } from "@lit/localize";
 | 
			
		||||
import type { SlInput, SlMenu, SlPopup } from "@shoelace-style/shoelace";
 | 
			
		||||
import type {
 | 
			
		||||
  SlInput,
 | 
			
		||||
  SlMenu,
 | 
			
		||||
  SlMenuItem,
 | 
			
		||||
  SlPopup,
 | 
			
		||||
  SlTag,
 | 
			
		||||
} from "@shoelace-style/shoelace";
 | 
			
		||||
import inputCss from "@shoelace-style/shoelace/dist/components/input/input.styles.js";
 | 
			
		||||
import union from "lodash/fp/union";
 | 
			
		||||
import debounce from "lodash/fp/debounce";
 | 
			
		||||
 | 
			
		||||
export type Tags = string[];
 | 
			
		||||
@ -105,6 +110,36 @@ export class TagInput extends LitElement {
 | 
			
		||||
        display: none;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .shake {
 | 
			
		||||
      animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
 | 
			
		||||
      transform: translate3d(0, 0, 0);
 | 
			
		||||
      backface-visibility: hidden;
 | 
			
		||||
      perspective: 1000px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @keyframes shake {
 | 
			
		||||
      10%,
 | 
			
		||||
      90% {
 | 
			
		||||
        transform: translate3d(-1px, 0, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      20%,
 | 
			
		||||
      80% {
 | 
			
		||||
        transform: translate3d(2px, 0, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      30%,
 | 
			
		||||
      50%,
 | 
			
		||||
      70% {
 | 
			
		||||
        transform: translate3d(-3px, 0, 0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      40%,
 | 
			
		||||
      60% {
 | 
			
		||||
        transform: translate3d(3px, 0, 0);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  `;
 | 
			
		||||
 | 
			
		||||
  @property({ type: Array })
 | 
			
		||||
@ -129,14 +164,20 @@ export class TagInput extends LitElement {
 | 
			
		||||
  @state()
 | 
			
		||||
  private dropdownIsOpen?: boolean;
 | 
			
		||||
 | 
			
		||||
  @query(".form-control")
 | 
			
		||||
  private formControl!: HTMLElement;
 | 
			
		||||
 | 
			
		||||
  @query("#input")
 | 
			
		||||
  private input?: HTMLInputElement;
 | 
			
		||||
  private input!: HTMLInputElement;
 | 
			
		||||
 | 
			
		||||
  @query("#dropdown")
 | 
			
		||||
  private dropdown!: HTMLDivElement;
 | 
			
		||||
 | 
			
		||||
  @query("sl-menu")
 | 
			
		||||
  private menu!: SlMenu;
 | 
			
		||||
 | 
			
		||||
  @query("sl-popup")
 | 
			
		||||
  private popup!: SlPopup;
 | 
			
		||||
  private combobox!: SlPopup;
 | 
			
		||||
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    if (this.initialTags) {
 | 
			
		||||
@ -153,13 +194,17 @@ export class TagInput extends LitElement {
 | 
			
		||||
        this.setAttribute("data-invalid", "");
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (changedProperties.has("dropdownIsOpen") && this.dropdownIsOpen) {
 | 
			
		||||
      this.popup.reposition();
 | 
			
		||||
    if (changedProperties.has("dropdownIsOpen")) {
 | 
			
		||||
      if (this.dropdownIsOpen) {
 | 
			
		||||
        this.openDropdown();
 | 
			
		||||
      } else {
 | 
			
		||||
        this.closeDropdown();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  reportValidity() {
 | 
			
		||||
    this.input?.reportValidity();
 | 
			
		||||
    this.input.reportValidity();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render() {
 | 
			
		||||
@ -175,7 +220,18 @@ export class TagInput extends LitElement {
 | 
			
		||||
        </label>
 | 
			
		||||
        <div
 | 
			
		||||
          class="input input--medium input--standard"
 | 
			
		||||
          tabindex="-1"
 | 
			
		||||
          @click=${this.onInputWrapperClick}
 | 
			
		||||
          @focusout=${(e: FocusEvent) => {
 | 
			
		||||
            const currentTarget = e.currentTarget as SlMenuItem;
 | 
			
		||||
            const relatedTarget = e.relatedTarget as HTMLElement;
 | 
			
		||||
            if (
 | 
			
		||||
              this.dropdownIsOpen &&
 | 
			
		||||
              !currentTarget?.contains(relatedTarget)
 | 
			
		||||
            ) {
 | 
			
		||||
              this.dropdownIsOpen = false;
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          ${this.renderTags()}
 | 
			
		||||
          <sl-popup
 | 
			
		||||
@ -192,8 +248,8 @@ export class TagInput extends LitElement {
 | 
			
		||||
              style="min-width: ${placeholder.length}ch"
 | 
			
		||||
              @focus=${this.onFocus}
 | 
			
		||||
              @blur=${this.onBlur}
 | 
			
		||||
              @keydown=${this.onKeydown}
 | 
			
		||||
              @input=${this.onInput}
 | 
			
		||||
              @keydown=${this.onKeydown}
 | 
			
		||||
              @keyup=${this.onKeyup}
 | 
			
		||||
              @paste=${this.onPaste}
 | 
			
		||||
              ?required=${this.required && !this.tags.length}
 | 
			
		||||
@ -204,11 +260,17 @@ export class TagInput extends LitElement {
 | 
			
		||||
            />
 | 
			
		||||
            <div
 | 
			
		||||
              id="dropdown"
 | 
			
		||||
              class="dropdown ${this.dropdownIsOpen === true
 | 
			
		||||
                ? "animateShow"
 | 
			
		||||
                : this.dropdownIsOpen === false
 | 
			
		||||
                ? "animateHide"
 | 
			
		||||
                : "hidden"}"
 | 
			
		||||
              class="dropdown hidden"
 | 
			
		||||
              @animationend=${(e: AnimationEvent) => {
 | 
			
		||||
                const el = e.target as HTMLDivElement;
 | 
			
		||||
                if (e.animationName === "dropdownShow") {
 | 
			
		||||
                  el.classList.remove("animateShow");
 | 
			
		||||
                }
 | 
			
		||||
                if (e.animationName === "dropdownHide") {
 | 
			
		||||
                  el.classList.add("hidden");
 | 
			
		||||
                  el.classList.remove("animateHide");
 | 
			
		||||
                }
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <sl-menu
 | 
			
		||||
                role="listbox"
 | 
			
		||||
@ -219,7 +281,7 @@ export class TagInput extends LitElement {
 | 
			
		||||
                  e.stopPropagation();
 | 
			
		||||
                  if (e.key === "Escape") {
 | 
			
		||||
                    this.dropdownIsOpen = false;
 | 
			
		||||
                    this.input?.focus();
 | 
			
		||||
                    this.input.focus();
 | 
			
		||||
                  }
 | 
			
		||||
                }}
 | 
			
		||||
                @sl-select=${this.onSelect}
 | 
			
		||||
@ -251,9 +313,40 @@ export class TagInput extends LitElement {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private renderTag = (content: string) => {
 | 
			
		||||
    const removeTag = () => {
 | 
			
		||||
    const removeTag = (e: CustomEvent | KeyboardEvent) => {
 | 
			
		||||
      this.tags = this.tags.filter((v) => v !== content);
 | 
			
		||||
      this.dispatchChange();
 | 
			
		||||
 | 
			
		||||
      const tag = e.currentTarget as SlTag;
 | 
			
		||||
      const focusTarget = tag.previousElementSibling as HTMLElement | null;
 | 
			
		||||
      (focusTarget || this.input).focus();
 | 
			
		||||
    };
 | 
			
		||||
    const onKeydown = (e: KeyboardEvent) => {
 | 
			
		||||
      const el = e.currentTarget as SlTag;
 | 
			
		||||
      switch (e.key) {
 | 
			
		||||
        // TODO localize, handle RTL
 | 
			
		||||
        case "ArrowLeft": {
 | 
			
		||||
          const focusTarget = el.previousElementSibling as HTMLElement | null;
 | 
			
		||||
          focusTarget?.focus();
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
        case "ArrowRight": {
 | 
			
		||||
          let focusTarget = el.nextElementSibling as HTMLElement | null;
 | 
			
		||||
          if (!focusTarget) return;
 | 
			
		||||
          if (focusTarget === this.combobox) {
 | 
			
		||||
            focusTarget = this.input || null;
 | 
			
		||||
          }
 | 
			
		||||
          focusTarget?.focus();
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
        case "Backspace":
 | 
			
		||||
        case "Delete": {
 | 
			
		||||
          removeTag(e);
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
        default:
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    return html`
 | 
			
		||||
      <btrix-tag
 | 
			
		||||
@ -261,13 +354,33 @@ export class TagInput extends LitElement {
 | 
			
		||||
        removable
 | 
			
		||||
        @sl-remove=${removeTag}
 | 
			
		||||
        title=${content}
 | 
			
		||||
        >${content}</btrix-tag
 | 
			
		||||
        tabindex="-1"
 | 
			
		||||
        @keydown=${onKeydown}
 | 
			
		||||
        @animationend=${(e: AnimationEvent) => {
 | 
			
		||||
          if (e.animationName === "shake") {
 | 
			
		||||
            (e.target as SlTag).classList.remove("shake");
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        ${content}
 | 
			
		||||
      </btrix-tag>
 | 
			
		||||
    `;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private openDropdown() {
 | 
			
		||||
    this.combobox.reposition();
 | 
			
		||||
    this.dropdown.classList.add("animateShow");
 | 
			
		||||
    this.dropdown.classList.remove("hidden");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private closeDropdown() {
 | 
			
		||||
    this.combobox.reposition();
 | 
			
		||||
    this.dropdown.classList.add("animateHide");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private onSelect(e: CustomEvent) {
 | 
			
		||||
    this.addTags([e.detail.item.value]);
 | 
			
		||||
    this.input.focus();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private onFocus(e: FocusEvent) {
 | 
			
		||||
@ -280,6 +393,7 @@ export class TagInput extends LitElement {
 | 
			
		||||
 | 
			
		||||
  private onBlur(e: FocusEvent) {
 | 
			
		||||
    const relatedTarget = e.relatedTarget as HTMLElement;
 | 
			
		||||
    if (relatedTarget) {
 | 
			
		||||
      if (this.menu?.contains(relatedTarget)) {
 | 
			
		||||
        // Keep focus on form control if moving to menu selection
 | 
			
		||||
        return;
 | 
			
		||||
@ -291,25 +405,52 @@ export class TagInput extends LitElement {
 | 
			
		||||
        // Don't add tag if resetting form
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    const input = e.target as HTMLInputElement;
 | 
			
		||||
    (input.parentElement as HTMLElement).classList.remove("input--focused");
 | 
			
		||||
    this.addTags([input.value]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private onKeydown(e: KeyboardEvent) {
 | 
			
		||||
    if (e.key === "ArrowDown") {
 | 
			
		||||
    if (this.dropdownIsOpen && (e.key === "ArrowDown" || e.key === "Tab")) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      this.menu?.querySelector("sl-menu-item")?.focus();
 | 
			
		||||
      const menuItem = this.menu?.querySelector("sl-menu-item");
 | 
			
		||||
      if (menuItem) {
 | 
			
		||||
        // Reset roving tabindex set by shoelace
 | 
			
		||||
        this.menu!.setCurrentItem(menuItem);
 | 
			
		||||
        menuItem.focus();
 | 
			
		||||
      }
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (e.key === "," || e.key === "Enter") {
 | 
			
		||||
    switch (e.key) {
 | 
			
		||||
      case "Backspace":
 | 
			
		||||
      case "Delete":
 | 
			
		||||
      // TODO localize, handle RTL
 | 
			
		||||
      case "ArrowLeft": {
 | 
			
		||||
        if (this.input.selectionStart! > 0) return;
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        const focusTarget = this.combobox
 | 
			
		||||
          .previousElementSibling as HTMLElement | null;
 | 
			
		||||
        focusTarget?.focus();
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
      case "ArrowRight": {
 | 
			
		||||
        // if (isInputEl && this.input!.selectionEnd! > this.input!.value.length)
 | 
			
		||||
        //   return;
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
      case ",":
 | 
			
		||||
      case "Enter": {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
 | 
			
		||||
        const input = e.target as HTMLInputElement;
 | 
			
		||||
        const value = input.value.trim();
 | 
			
		||||
        if (value) {
 | 
			
		||||
          this.addTags([value]);
 | 
			
		||||
        }
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
      default:
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -339,28 +480,60 @@ export class TagInput extends LitElement {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private onPaste(e: ClipboardEvent) {
 | 
			
		||||
    const text = e.clipboardData?.getData("text");
 | 
			
		||||
    const input = e.target as HTMLInputElement;
 | 
			
		||||
    if (!input.value) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      const text = e.clipboardData
 | 
			
		||||
        ?.getData("text")
 | 
			
		||||
        // Remove zero-width characters
 | 
			
		||||
        .replace(/[\u200B-\u200D\uFEFF]/g, "")
 | 
			
		||||
        .trim();
 | 
			
		||||
      if (text) {
 | 
			
		||||
        this.addTags(text.split(","));
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private onInputWrapperClick(e: MouseEvent) {
 | 
			
		||||
    if (e.target === e.currentTarget) {
 | 
			
		||||
      this.input?.focus();
 | 
			
		||||
      this.input.focus();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async addTags(tags: Tags) {
 | 
			
		||||
    await this.updateComplete;
 | 
			
		||||
    this.tags = union(
 | 
			
		||||
      tags.map((v) => v.trim().toLocaleLowerCase()).filter((v) => v),
 | 
			
		||||
      this.tags
 | 
			
		||||
    );
 | 
			
		||||
    const repeatTags: Tags = [];
 | 
			
		||||
    const uniqueTags: Tags = [...this.tags];
 | 
			
		||||
 | 
			
		||||
    tags.forEach((str) => {
 | 
			
		||||
      const tag = str // Remove zero-width characters
 | 
			
		||||
        .replace(/[\u200B-\u200D\uFEFF]/g, "")
 | 
			
		||||
        .trim()
 | 
			
		||||
        .toLocaleLowerCase();
 | 
			
		||||
      if (tag) {
 | 
			
		||||
        if (uniqueTags.includes(tag)) {
 | 
			
		||||
          repeatTags.push(tag);
 | 
			
		||||
        } else {
 | 
			
		||||
          uniqueTags.push(tag);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    this.tags = uniqueTags;
 | 
			
		||||
    this.dispatchChange();
 | 
			
		||||
    this.dropdownIsOpen = false;
 | 
			
		||||
    this.input!.value = "";
 | 
			
		||||
    if (repeatTags.length) {
 | 
			
		||||
      repeatTags.forEach(this.shakeTag);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private shakeTag = (tag: string) => {
 | 
			
		||||
    const tagEl = this.formControl.querySelector(
 | 
			
		||||
      `btrix-tag[title="${tag}"]`
 | 
			
		||||
    ) as SlTag;
 | 
			
		||||
    if (!tagEl) return;
 | 
			
		||||
    tagEl.classList.add("shake");
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private async dispatchChange() {
 | 
			
		||||
    await this.updateComplete;
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,6 @@
 | 
			
		||||
import { css } from "lit";
 | 
			
		||||
import { css, html } from "lit";
 | 
			
		||||
import { state, property, query } from "lit/decorators.js";
 | 
			
		||||
import { ifDefined } from "lit/directives/if-defined.js";
 | 
			
		||||
import SLTag from "@shoelace-style/shoelace/dist/components/tag/tag.js";
 | 
			
		||||
import tagStyles from "@shoelace-style/shoelace/dist/components/tag/tag.styles.js";
 | 
			
		||||
 | 
			
		||||
@ -11,13 +13,37 @@ import tagStyles from "@shoelace-style/shoelace/dist/components/tag/tag.styles.j
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export class Tag extends SLTag {
 | 
			
		||||
  static styles = css`
 | 
			
		||||
    ${tagStyles}
 | 
			
		||||
  static shadowRootOptions = {
 | 
			
		||||
    ...SLTag.shadowRootOptions,
 | 
			
		||||
    delegatesFocus: true,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static styles = [
 | 
			
		||||
    tagStyles,
 | 
			
		||||
    css`
 | 
			
		||||
      :host {
 | 
			
		||||
        max-width: 100%;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :focus {
 | 
			
		||||
        outline: 0;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :focus .tag {
 | 
			
		||||
        background-color: var(--sl-color-blue-500);
 | 
			
		||||
        border-color: var(--sl-color-blue-500);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      :focus .tag,
 | 
			
		||||
      :focus .tag__remove {
 | 
			
		||||
        color: var(--sl-color-neutral-0);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .tag,
 | 
			
		||||
      .tag__remove {
 | 
			
		||||
        transition: background-color 0.1s, color 0.1s;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .tag {
 | 
			
		||||
        height: var(--tag-height, 1.5rem);
 | 
			
		||||
        background-color: var(--sl-color-blue-100);
 | 
			
		||||
@ -36,14 +62,22 @@ export class Tag extends SLTag {
 | 
			
		||||
      .tag__remove {
 | 
			
		||||
        color: var(--sl-color-blue-600);
 | 
			
		||||
        border-radius: 100%;
 | 
			
		||||
      transition: background-color 0.1s;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .tag__remove:hover {
 | 
			
		||||
        background-color: var(--sl-color-blue-600);
 | 
			
		||||
      color: #fff;
 | 
			
		||||
        color: var(--sl-color-neutral-0);
 | 
			
		||||
      }
 | 
			
		||||
  `;
 | 
			
		||||
    `,
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  @property({ type: String, noAccessor: true })
 | 
			
		||||
  tabindex?: string;
 | 
			
		||||
 | 
			
		||||
  pill = true;
 | 
			
		||||
 | 
			
		||||
  render() {
 | 
			
		||||
    const template = super.render();
 | 
			
		||||
    return html`<span tabindex=${ifDefined(this.tabindex)}>${template}</span>`;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user