mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-23 23:02:58 +00:00
Merge pull request #5 from koloml/tags-editor-refactor
Refactoring Tags Editor from Web Components API to Svelte component, improved accessibility for keyboards
This commit is contained in:
@@ -1,21 +1,94 @@
|
||||
<script>
|
||||
import "$lib/web-components/TagEditorComponent.js";
|
||||
|
||||
/**
|
||||
* List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
|
||||
* @type {string[]}
|
||||
*/
|
||||
export let tags = [];
|
||||
|
||||
let tagsAttribute = tags.join(',');
|
||||
/** @type {Set<string>} */
|
||||
let uniqueTags = new Set();
|
||||
|
||||
$: uniqueTags = new Set(tags);
|
||||
|
||||
/** @type {string} */
|
||||
let addedTagName = '';
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<string[]>} event
|
||||
* Create a callback function to pass into both mouse & keyboard events for tag removal.
|
||||
* @param {string} tagName
|
||||
* @return {function(Event)} Callback to pass as event listener.
|
||||
*/
|
||||
function onTagsChanged(event) {
|
||||
tags = event.detail;
|
||||
function createTagRemoveHandler(tagName) {
|
||||
return event => {
|
||||
if (event.type === 'click') {
|
||||
removeTag(tagName);
|
||||
}
|
||||
|
||||
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
|
||||
// To be more comfortable, automatically focus next available tag's remove button in the list.
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
const currenTagElement = event.currentTarget.closest('.tag');
|
||||
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
|
||||
const nextRemoveButton = nextTagElement?.querySelector('.remove');
|
||||
|
||||
if (nextRemoveButton instanceof HTMLElement) {
|
||||
nextRemoveButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: tagsAttribute = tags.join(',');
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function removeTag(tagName) {
|
||||
uniqueTags.delete(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function addTag(tagName) {
|
||||
uniqueTags.add(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding new tags to the list or removing them when backspace is pressed.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
function handleKeyPresses(event) {
|
||||
if (event.code === 'Enter' && addedTagName.length) {
|
||||
addTag(addedTagName)
|
||||
addedTagName = '';
|
||||
}
|
||||
|
||||
if (event.code === 'Backspace' && !addedTagName.length && tags?.length) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<tags-editor tags="{tagsAttribute}" on:change={onTagsChanged}></tags-editor>
|
||||
<div class="tags-editor">
|
||||
{#each uniqueTags.values() as tagName}
|
||||
<div class="tag">
|
||||
{tagName}
|
||||
<span class="remove" on:click={createTagRemoveHandler(tagName)}
|
||||
on:keydown={createTagRemoveHandler(tagName)}
|
||||
role="button" tabindex="0">x</span>
|
||||
</div>
|
||||
{/each}
|
||||
<input type="text" bind:value={addedTagName} on:keydown={handleKeyPresses}/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
export default class TagEditorComponent extends HTMLElement {
|
||||
/**
|
||||
* Array of elements representing tags.
|
||||
* @type {HTMLElement[]}
|
||||
*/
|
||||
#tagElements = [];
|
||||
|
||||
/**
|
||||
* Generated input for adding new tags to the tag list. Will be rendered on connecting.
|
||||
* @type {HTMLInputElement|undefined}
|
||||
*/
|
||||
#tagInput;
|
||||
|
||||
/**
|
||||
* Cached list of tag names. Changing this value will not automatically change the actual tags.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#tagsSet = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.#tagInput) {
|
||||
this.#tagInput = document.createElement('input');
|
||||
this.appendChild(this.#tagInput);
|
||||
this.#tagInput.addEventListener('keydown', this.#onKeyDownDetectActions.bind(this));
|
||||
}
|
||||
|
||||
if (!this.#tagElements.length) {
|
||||
this.#renderTags();
|
||||
}
|
||||
|
||||
this.addEventListener('click', this.#onClickDetectTagRemoval.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of tag elements based on the tag attribute. Should be called every time tag attribute is changed.
|
||||
*/
|
||||
#renderTags() {
|
||||
const tags = this.getAttribute(TagEditorComponent.#tagsAttribute) || '';
|
||||
|
||||
const updatedTagsSet = new Set(
|
||||
tags.split(',')
|
||||
.map(tagName => tagName.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
this.#tagsSet = new Set(updatedTagsSet.values());
|
||||
|
||||
this.#tagElements = this.#tagElements.filter(tagElement => {
|
||||
const tagName = tagElement.dataset.tag;
|
||||
|
||||
if (!updatedTagsSet.has(tagName)) {
|
||||
tagElement.remove();
|
||||
return false;
|
||||
}
|
||||
|
||||
updatedTagsSet.delete(tagName);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (let tagName of updatedTagsSet) {
|
||||
const tagElement = document.createElement('div');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
tagElement.dataset.tag = tagName;
|
||||
|
||||
const tagRemoveElement = document.createElement("span");
|
||||
tagRemoveElement.classList.add('remove');
|
||||
tagRemoveElement.innerText = 'x';
|
||||
|
||||
tagElement.appendChild(tagRemoveElement);
|
||||
|
||||
this.#tagInput.insertAdjacentElement('beforebegin', tagElement);
|
||||
this.#tagElements.push(tagElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect add/remove keyboard shortcuts on the input.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onKeyDownDetectActions(event) {
|
||||
const isTagSubmit = event.key === 'Enter';
|
||||
const isTagRemove = event.key === 'Backspace' && !this.#tagInput.value.length;
|
||||
|
||||
if (!isTagSubmit && !isTagRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTagSubmit) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const providedTagName = this.#tagInput.value.trim();
|
||||
|
||||
if (providedTagName && isTagSubmit) {
|
||||
if (!this.#tagsSet.has(providedTagName)) {
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet, providedTagName].join(',')
|
||||
);
|
||||
}
|
||||
|
||||
this.#tagInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTagRemove && this.#tagsSet.size) {
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet].slice(0, -1).join(',')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect clicks on the "remove" button inside tags.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#onClickDetectTagRemoval(event) {
|
||||
/** @type {HTMLElement} */
|
||||
const maybeRemoveTagElement = event.target;
|
||||
|
||||
if (!maybeRemoveTagElement.classList.contains('remove')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const tagElement = maybeRemoveTagElement.closest('.tag');
|
||||
|
||||
if (!tagElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = tagElement.dataset.tag;
|
||||
|
||||
if (this.#tagsSet.has(tagName)) {
|
||||
this.#tagsSet.delete(tagName);
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet].join(",")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
*/
|
||||
attributeChangedCallback(name, from, to) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === TagEditorComponent.#tagsAttribute) {
|
||||
this.#renderTags();
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(
|
||||
'change',
|
||||
{
|
||||
detail: [...this.#tagsSet.values()]
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return [this.#tagsAttribute];
|
||||
}
|
||||
|
||||
static #tagsAttribute = 'tags';
|
||||
}
|
||||
|
||||
if (!customElements.get('tags-editor')) {
|
||||
customElements.define('tags-editor', TagEditorComponent);
|
||||
} else {
|
||||
console.warn('Tags Component is attempting to initialize twice!');
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
@use '../colors';
|
||||
@import "input";
|
||||
|
||||
tags-editor {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
input {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,5 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
@import "injectable/tags-editor";
|
||||
@import "injectable/tag";
|
||||
@import "injectable/icons";
|
||||
@import "injectable/icons";
|
||||
|
||||
Reference in New Issue
Block a user