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