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 { LitElement, html, css } from "lit";
import { state, property, query } from "lit/decorators.js"; import { state, property, query } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize"; 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 inputCss from "@shoelace-style/shoelace/dist/components/input/input.styles.js";
import union from "lodash/fp/union";
import debounce from "lodash/fp/debounce"; import debounce from "lodash/fp/debounce";
export type Tags = string[]; export type Tags = string[];
@ -105,6 +110,36 @@ export class TagInput extends LitElement {
display: none; 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 }) @property({ type: Array })
@ -129,14 +164,20 @@ export class TagInput extends LitElement {
@state() @state()
private dropdownIsOpen?: boolean; private dropdownIsOpen?: boolean;
@query(".form-control")
private formControl!: HTMLElement;
@query("#input") @query("#input")
private input?: HTMLInputElement; private input!: HTMLInputElement;
@query("#dropdown")
private dropdown!: HTMLDivElement;
@query("sl-menu") @query("sl-menu")
private menu!: SlMenu; private menu!: SlMenu;
@query("sl-popup") @query("sl-popup")
private popup!: SlPopup; private combobox!: SlPopup;
connectedCallback() { connectedCallback() {
if (this.initialTags) { if (this.initialTags) {
@ -153,13 +194,17 @@ export class TagInput extends LitElement {
this.setAttribute("data-invalid", ""); this.setAttribute("data-invalid", "");
} }
} }
if (changedProperties.has("dropdownIsOpen") && this.dropdownIsOpen) { if (changedProperties.has("dropdownIsOpen")) {
this.popup.reposition(); if (this.dropdownIsOpen) {
this.openDropdown();
} else {
this.closeDropdown();
}
} }
} }
reportValidity() { reportValidity() {
this.input?.reportValidity(); this.input.reportValidity();
} }
render() { render() {
@ -175,7 +220,18 @@ export class TagInput extends LitElement {
</label> </label>
<div <div
class="input input--medium input--standard" class="input input--medium input--standard"
tabindex="-1"
@click=${this.onInputWrapperClick} @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()} ${this.renderTags()}
<sl-popup <sl-popup
@ -192,8 +248,8 @@ export class TagInput extends LitElement {
style="min-width: ${placeholder.length}ch" style="min-width: ${placeholder.length}ch"
@focus=${this.onFocus} @focus=${this.onFocus}
@blur=${this.onBlur} @blur=${this.onBlur}
@keydown=${this.onKeydown}
@input=${this.onInput} @input=${this.onInput}
@keydown=${this.onKeydown}
@keyup=${this.onKeyup} @keyup=${this.onKeyup}
@paste=${this.onPaste} @paste=${this.onPaste}
?required=${this.required && !this.tags.length} ?required=${this.required && !this.tags.length}
@ -204,11 +260,17 @@ export class TagInput extends LitElement {
/> />
<div <div
id="dropdown" id="dropdown"
class="dropdown ${this.dropdownIsOpen === true class="dropdown hidden"
? "animateShow" @animationend=${(e: AnimationEvent) => {
: this.dropdownIsOpen === false const el = e.target as HTMLDivElement;
? "animateHide" if (e.animationName === "dropdownShow") {
: "hidden"}" el.classList.remove("animateShow");
}
if (e.animationName === "dropdownHide") {
el.classList.add("hidden");
el.classList.remove("animateHide");
}
}}
> >
<sl-menu <sl-menu
role="listbox" role="listbox"
@ -219,7 +281,7 @@ export class TagInput extends LitElement {
e.stopPropagation(); e.stopPropagation();
if (e.key === "Escape") { if (e.key === "Escape") {
this.dropdownIsOpen = false; this.dropdownIsOpen = false;
this.input?.focus(); this.input.focus();
} }
}} }}
@sl-select=${this.onSelect} @sl-select=${this.onSelect}
@ -251,9 +313,40 @@ export class TagInput extends LitElement {
} }
private renderTag = (content: string) => { private renderTag = (content: string) => {
const removeTag = () => { const removeTag = (e: CustomEvent | KeyboardEvent) => {
this.tags = this.tags.filter((v) => v !== content); this.tags = this.tags.filter((v) => v !== content);
this.dispatchChange(); 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` return html`
<btrix-tag <btrix-tag
@ -261,13 +354,33 @@ export class TagInput extends LitElement {
removable removable
@sl-remove=${removeTag} @sl-remove=${removeTag}
title=${content} 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) { private onSelect(e: CustomEvent) {
this.addTags([e.detail.item.value]); this.addTags([e.detail.item.value]);
this.input.focus();
} }
private onFocus(e: FocusEvent) { private onFocus(e: FocusEvent) {
@ -280,16 +393,18 @@ export class TagInput extends LitElement {
private onBlur(e: FocusEvent) { private onBlur(e: FocusEvent) {
const relatedTarget = e.relatedTarget as HTMLElement; const relatedTarget = e.relatedTarget as HTMLElement;
if (this.menu?.contains(relatedTarget)) { if (relatedTarget) {
// Keep focus on form control if moving to menu selection if (this.menu?.contains(relatedTarget)) {
return; // Keep focus on form control if moving to menu selection
} return;
if ( }
relatedTarget.tagName.includes("BUTTON") && if (
relatedTarget.getAttribute("type") === "reset" relatedTarget.tagName.includes("BUTTON") &&
) { relatedTarget.getAttribute("type") === "reset"
// Don't add tag if resetting form ) {
return; // Don't add tag if resetting form
return;
}
} }
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
(input.parentElement as HTMLElement).classList.remove("input--focused"); (input.parentElement as HTMLElement).classList.remove("input--focused");
@ -297,19 +412,45 @@ export class TagInput extends LitElement {
} }
private onKeydown(e: KeyboardEvent) { private onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown") { if (this.dropdownIsOpen && (e.key === "ArrowDown" || e.key === "Tab")) {
e.preventDefault(); 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; return;
} }
if (e.key === "," || e.key === "Enter") { switch (e.key) {
e.preventDefault(); case "Backspace":
case "Delete":
const input = e.target as HTMLInputElement; // TODO localize, handle RTL
const value = input.value.trim(); case "ArrowLeft": {
if (value) { if (this.input.selectionStart! > 0) return;
this.addTags([value]); 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) { private onPaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData("text"); const input = e.target as HTMLInputElement;
if (text) { if (!input.value) {
this.addTags(text.split(",")); 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) { private onInputWrapperClick(e: MouseEvent) {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
this.input?.focus(); this.input.focus();
} }
} }
private async addTags(tags: Tags) { private async addTags(tags: Tags) {
await this.updateComplete; await this.updateComplete;
this.tags = union( const repeatTags: Tags = [];
tags.map((v) => v.trim().toLocaleLowerCase()).filter((v) => v), const uniqueTags: Tags = [...this.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.dispatchChange();
this.dropdownIsOpen = false; this.dropdownIsOpen = false;
this.input!.value = ""; 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() { private async dispatchChange() {
await this.updateComplete; await this.updateComplete;
this.dispatchEvent( 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 SLTag from "@shoelace-style/shoelace/dist/components/tag/tag.js";
import tagStyles from "@shoelace-style/shoelace/dist/components/tag/tag.styles.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 { export class Tag extends SLTag {
static styles = css` static shadowRootOptions = {
${tagStyles} ...SLTag.shadowRootOptions,
delegatesFocus: true,
};
:host { static styles = [
max-width: 100%; tagStyles,
} css`
:host {
max-width: 100%;
}
.tag { :focus {
height: var(--tag-height, 1.5rem); outline: 0;
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 { :focus .tag {
max-width: 100%; background-color: var(--sl-color-blue-500);
white-space: nowrap; border-color: var(--sl-color-blue-500);
overflow: hidden; }
text-overflow: ellipsis;
}
.tag__remove { :focus .tag,
color: var(--sl-color-blue-600); :focus .tag__remove {
border-radius: 100%; color: var(--sl-color-neutral-0);
transition: background-color 0.1s; }
}
.tag__remove:hover { .tag,
background-color: var(--sl-color-blue-600); .tag__remove {
color: #fff; 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; pill = true;
render() {
const template = super.render();
return html`<span tabindex=${ifDefined(this.tabindex)}>${template}</span>`;
}
} }