Improve tag input keyboard navigation (#650)

This commit is contained in:
sua yoo 2023-02-28 15:52:31 -08:00 committed by GitHub
parent d0182a3e13
commit a1f939ad29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 279 additions and 72 deletions

View File

@ -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,16 +393,18 @@ export class TagInput extends LitElement {
private onBlur(e: FocusEvent) {
const relatedTarget = e.relatedTarget as HTMLElement;
if (this.menu?.contains(relatedTarget)) {
// Keep focus on form control if moving to menu selection
return;
}
if (
relatedTarget.tagName.includes("BUTTON") &&
relatedTarget.getAttribute("type") === "reset"
) {
// Don't add tag if resetting form
return;
if (relatedTarget) {
if (this.menu?.contains(relatedTarget)) {
// Keep focus on form control if moving to menu selection
return;
}
if (
relatedTarget.tagName.includes("BUTTON") &&
relatedTarget.getAttribute("type") === "reset"
) {
// Don't add tag if resetting form
return;
}
}
const input = e.target as HTMLInputElement;
(input.parentElement as HTMLElement).classList.remove("input--focused");
@ -297,19 +412,45 @@ export class TagInput extends LitElement {
}
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") {
e.preventDefault();
const input = e.target as HTMLInputElement;
const value = input.value.trim();
if (value) {
this.addTags([value]);
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,29 +480,61 @@ export class TagInput extends LitElement {
}
private onPaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData("text");
if (text) {
this.addTags(text.split(","));
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;
this.dispatchEvent(

View File

@ -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,39 +13,71 @@ 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,
};
:host {
max-width: 100%;
}
static styles = [
tagStyles,
css`
:host {
max-width: 100%;
}
.tag {
height: var(--tag-height, 1.5rem);
background-color: var(--sl-color-blue-100);
border-color: var(--sl-color-blue-500);
color: var(--sl-color-blue-600);
font-family: var(--sl-font-sans);
}
:focus {
outline: 0;
}
.tag__content {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:focus .tag {
background-color: var(--sl-color-blue-500);
border-color: var(--sl-color-blue-500);
}
.tag__remove {
color: var(--sl-color-blue-600);
border-radius: 100%;
transition: background-color 0.1s;
}
:focus .tag,
:focus .tag__remove {
color: var(--sl-color-neutral-0);
}
.tag__remove:hover {
background-color: var(--sl-color-blue-600);
color: #fff;
}
`;
.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);
border-color: var(--sl-color-blue-500);
color: var(--sl-color-blue-600);
font-family: var(--sl-font-sans);
}
.tag__content {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag__remove {
color: var(--sl-color-blue-600);
border-radius: 100%;
}
.tag__remove:hover {
background-color: var(--sl-color-blue-600);
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>`;
}
}