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,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(
|
||||
|
@ -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>`;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user