@botandrose/input-tag
Version:
A declarative, zero-dependency, framework-agnostic custom element for tag input with autocomplete
732 lines (631 loc) • 20.5 kB
JavaScript
import Taggle from "./taggle.js"
import autocomplete from "autocompleter"
class TagOption extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this._shadowRoot.innerHTML = `
<style>
:host {
background: #588a00;
padding: 3px 10px 3px 10px !important;
margin-right: 4px !important;
margin-bottom: 2px !important;
display: inline-flex;
align-items: center;
float: none;
font-size: 1.25em;
line-height: 1;
min-height: 32px;
color: #fff;
text-transform: none;
border-radius: 3px;
position: relative;
cursor: pointer;
}
button {
z-index: 1;
border: none;
background: none;
font-size: 1.4em;
display: inline-block;
color: rgba(255, 255, 255, 0.6);
right: 10px;
height: 100%;
cursor: pointer;
}
</style>
<slot></slot>
<button type="button">×</button>
`;
this.buttonTarget = this._shadowRoot.querySelector("button")
this.buttonTarget.onclick = event => {
this.parentNode._taggle._remove(this, event)
}
}
get value() {
return this.getAttribute("value") || this.innerText
}
}
customElements.define("tag-option", TagOption);
class InputTag extends HTMLElement {
static get formAssociated() {
return true;
}
static get observedAttributes() {
return ['name', 'multiple', 'required', 'list'];
}
constructor() {
super();
this._internals = this.attachInternals();
this._shadowRoot = this.attachShadow({ mode: "open" });
this.observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
// Handle child list changes (tag-option elements added/removed)
this.unobserve();
this.processTagOptions();
this.observe();
} else if (mutation.type === 'attributes') {
// Handle attribute changes on tag-option elements
if (mutation.target !== this && mutation.target.tagName === 'TAG-OPTION') {
this.unobserve();
this.processTagOptions();
this.observe();
}
// Note: changes to this element's attributes are handled by attributeChangedCallback
}
}
});
}
unobserve() {
this.observer.disconnect();
}
observe() {
this.observer.observe(this, {
childList: true,
attributes: true,
subtree: true,
attributeFilter: ["value"],
});
}
processTagOptions() {
if(!this._taggle || !this._taggle.tag) return
const values = Array.from(this.children).map(e => e.value)
this._taggle.tag.elements = [...this.children]
this._taggle.tag.values = values
this._inputPosition = this._taggle.tag.values.length;
// Update the taggle display elements to match the current values
const taggleElements = this._taggle.tag.elements;
taggleElements.forEach((element, index) => {
if (element && element.setAttribute) {
element.setAttribute('data-value', values[index]);
}
});
// Update internal value to match
this.updateValue();
}
get form() {
return this._internals.form;
}
get name() {
return this.getAttribute("name");
}
get value() {
return this._internals.value;
}
set value(values) {
const oldValues = this._internals.value;
this._internals.value = values;
const formData = new FormData();
values.forEach(value => formData.append(this.name, value));
if(values.length === 0) formData.append(this.name, ""); // none value
this._internals.setFormValue(formData);
// Update taggle to match the new values
if (this._taggle && this.initialized) {
this.suppressEvents = true; // Prevent infinite loops
this._taggle.removeAll();
if (values.length > 0) {
this._taggle.add(values);
}
this.suppressEvents = false;
}
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
this.dispatchEvent(new CustomEvent("change", {
bubbles: true,
composed: true,
}));
}
}
reset() {
this._taggle.removeAll()
this._taggleInputTarget.value = ''
}
get options() {
const datalistId = this.getAttribute("list")
if(datalistId) {
const datalist = document.getElementById(datalistId)
if(datalist) {
return [...datalist.options].map(option => option.value)
}
}
return []
}
async connectedCallback() {
this.setAttribute('tabindex', '0');
this.addEventListener("focus", e => this.focus(e));
// Wait for child tag-option elements to be fully connected
await new Promise(resolve => setTimeout(resolve, 0));
this._shadowRoot.innerHTML = `
<style>
:host { display: block; }
:host *{
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
}
#container {
background: rgba(255, 255, 255, 0.8);
padding: 6px 6px 3px;
max-height: none;
display: flex;
margin: 0;
flex-wrap: wrap;
align-items: flex-start;
min-height: 48px;
line-height: 48px;
width: 100%;
border: 1px solid #d0d0d0;
outline: 1px solid transparent;
box-shadow: #ccc 0 1px 4px 0 inset;
border-radius: 2px;
cursor: text;
color: #333;
list-style: none;
padding-right: 32px;
}
input {
display: block;
height: 32px;
float: none;
margin: 0;
padding-left: 10px !important;
padding-right: 30px !important;
width: auto !important;
min-width: 70px;
font-size: 1.25em;
width: 100%;
line-height: 2;
padding: 0 0 0 10px;
border: 1px dashed #d0d0d0;
outline: 1px solid transparent;
background: #fff;
box-shadow: none;
border-radius: 2px;
cursor: text;
color: #333;
}
button {
width: 30px;
text-align: center;
line-height: 30px;
border: 1px solid #e0e0e0;
font-size: 2em;
color: #666;
position: absolute !important;
z-index: 10;
right: 0px;
top: 0;
font-weight: 400;
cursor: pointer;
background: none;
}
.taggle_sizer{
padding: 0;
margin: 0;
position: absolute;
top: -500px;
z-index: -1;
visibility: hidden;
}
.ui-autocomplete{
position: static !important;
width: 100% !important;
margin-top: 2px;
}
.ui-menu{
margin: 0;
padding: 6px;
box-shadow: #ccc 0 1px 6px;
z-index: 2;
display: flex;
flex-wrap: wrap;
background: #fff;
list-style: none;
font-size: 1.25em;
min-width: 200px;
}
.ui-menu .ui-menu-item{
display: inline-block;
margin: 0 0 2px;
line-height: 30px;
border: none;
padding: 0 10px;
text-indent: 0;
border-radius: 2px;
width: auto;
cursor: pointer;
color: #555;
}
.ui-menu .ui-menu-item::before{ display: none; }
.ui-menu .ui-menu-item:hover{ background: #e0e0e0; }
.ui-state-active{
padding: 0;
border: none;
background: none;
color: inherit;
}
</style>
<div style="position: relative;">
<div id="container">
<slot></slot>
</div>
<input
id="inputTarget"
type="hidden"
name="${this.name}"
/>
</div>
`;
this.form?.addEventListener("reset", this.reset.bind(this));
this.containerTarget = this.shadowRoot.querySelector("#container");
this.inputTarget = this.shadowRoot.querySelector("#inputTarget");
this.required = this.hasAttribute("required")
this.multiple = this.hasAttribute("multiple")
const maxTags = this.multiple ? undefined : 1
const placeholder = this.inputTarget.getAttribute("placeholder")
this.inputTarget.value = ""
this.inputTarget.id = ""
this._taggle = new Taggle(this, {
inputContainer: this.containerTarget,
preserveCase: true,
clearOnBlur: false,
hiddenInputName: this.name,
maxTags: maxTags,
placeholder: placeholder,
onTagAdd: (event, tag) => this.onTagAdd(event, tag),
onTagRemove: (event, tag) => this.onTagRemove(event, tag),
})
this._taggleInputTarget = this._taggle.getInput()
this._taggleInputTarget.id = this.id
this._taggleInputTarget.autocomplete = "off"
this._taggleInputTarget.setAttribute("data-turbo-permanent", true)
this._taggleInputTarget.addEventListener("keyup", e => this.keyup(e))
// Set initial value after taggle is initialized
this.value = this._taggle.getTagValues()
this.checkRequired()
this.buttonTarget = h(`<button class="add">+</button>`)
this.buttonTarget.addEventListener("click", e => this._add(e))
this._taggleInputTarget.insertAdjacentElement("afterend", this.buttonTarget)
this.autocompleteContainerTarget = h(`<ul>`);
// Insert autocomplete container into the positioned wrapper div
const wrapperDiv = this.shadowRoot.querySelector('div[style*="position: relative"]');
wrapperDiv.appendChild(this.autocompleteContainerTarget)
this.setupAutocomplete()
this.observe() // Start observing after taggle is set up
this.initialized = true
}
setupAutocomplete() {
autocomplete({
input: this._taggleInputTarget,
container: this.autocompleteContainerTarget,
className: "ui-menu ui-autocomplete",
fetch: (text, update) => {
const currentTags = this._taggle.getTagValues()
const suggestions = this.options.filter(tag =>
tag.toLowerCase().includes(text.toLowerCase()) &&
!currentTags.includes(tag)
)
update(suggestions)
},
render: item => h(`<li class="ui-menu-item">${item}</li>`),
onSelect: item => this._taggle.add(item),
minLength: 1,
customize: (input, inputRect, container, maxHeight) => {
// Position autocomplete below the input-tag container, accounting for dynamic height
this._updateAutocompletePosition(container);
// Store reference to update positioning when container height changes
this._autocompleteContainer = container;
}
})
}
disconnectedCallback() {
this.form?.removeEventListener("reset", this.reset.bind(this));
this.unobserve();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
// Only handle changes after the component is connected and initialized
if (!this._taggle) return;
switch (name) {
case 'name':
this.handleNameChange(newValue);
break;
case 'multiple':
this.handleMultipleChange(newValue !== null);
break;
case 'required':
this.handleRequiredChange(newValue !== null);
break;
case 'list':
this.handleListChange(newValue);
break;
}
}
checkRequired() {
const flag = this.required && this._taggle.getTagValues().length == 0
this._taggleInputTarget.required = flag
// Update ElementInternals validity to match internal input
if (flag) {
this._internals.setValidity({ valueMissing: true }, 'Please fill out this field.', this._taggleInputTarget)
} else {
this._internals.setValidity({})
}
}
// monkeypatch support for android comma
keyup(event) {
const key = event.which || event.keyCode
const normalKeyboard = key != 229
if(normalKeyboard) return
const value = this._taggleInputTarget.value
// backspace
if(value.length == 0) {
const values = this._taggle.tag.values
this._taggle.remove(values[values.length - 1])
return
}
// comma
if(/,$/.test(value)) {
const tag = value.replace(',', '')
this._taggle.add(tag)
this._taggleInputTarget.value = ''
return
}
}
_add(event) {
event.preventDefault()
this._taggle.add(this._taggleInputTarget.value)
this._taggleInputTarget.value = ''
}
onTagAdd(event, tag) {
if (!this.suppressEvents) {
const isNew = !this.options.includes(tag)
this.dispatchEvent(new CustomEvent("update", {
detail: { tag, isNew },
bubbles: true,
composed: true,
}));
}
this.syncValue()
this.checkRequired()
// Update autocomplete position if it's currently open
if (this._autocompleteContainer) {
// Use setTimeout to allow DOM to update first
setTimeout(() => this._updateAutocompletePosition(this._autocompleteContainer), 0)
}
}
onTagRemove(event, tag) {
if (!this.suppressEvents) {
this.dispatchEvent(new CustomEvent("update", {
detail: { tag },
bubbles: true,
composed: true,
}));
}
this.syncValue()
this.checkRequired()
// Update autocomplete position if it's currently open
if (this._autocompleteContainer) {
// Use setTimeout to allow DOM to update first
setTimeout(() => this._updateAutocompletePosition(this._autocompleteContainer), 0)
}
}
syncValue() {
// Directly update internals without triggering the setter
const values = this._taggle.getTagValues()
const oldValues = this._internals.value;
this._internals.value = values;
const formData = new FormData();
values.forEach(value => formData.append(this.name, value));
if(values.length === 0) formData.append(this.name, ""); // none value
this._internals.setFormValue(formData);
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
this.dispatchEvent(new CustomEvent("change", {
bubbles: true,
composed: true,
}));
}
}
// Public API methods
add(tags) {
if (!this._taggle) return
this._taggle.add(tags)
}
remove(tag) {
if (!this._taggle) return
this._taggle.remove(tag)
}
removeAll() {
if (!this._taggle) return
this._taggle.removeAll()
}
has(tag) {
if (!this._taggle) return false
return this._taggle.getTagValues().includes(tag)
}
get tags() {
if (!this._taggle) return []
return this._taggle.getTagValues()
}
// Private getter for testing autocomplete suggestions
get _autocompleteSuggestions() {
if (!this.autocompleteContainerTarget) return []
const items = this.autocompleteContainerTarget.querySelectorAll('.ui-menu-item')
return Array.from(items).map(item => item.textContent.trim())
}
// Update autocomplete position based on current container height
_updateAutocompletePosition(container) {
if (!container) return
const inputTagRect = this.containerTarget.getBoundingClientRect();
container.style.setProperty('position', 'absolute', 'important');
container.style.setProperty('top', `${inputTagRect.height}px`, 'important');
container.style.setProperty('left', '0', 'important');
container.style.setProperty('right', '0', 'important');
container.style.setProperty('width', '100%', 'important');
container.style.setProperty('z-index', '1000', 'important');
}
addAt(tag, index) {
if (!this._taggle) return
this._taggle.add(tag, index)
}
disable() {
if (this._taggle) {
this._taggle.disable()
}
}
enable() {
if (this._taggle) {
this._taggle.enable()
}
}
focus() {
if (this._taggleInputTarget) {
this._taggleInputTarget.focus()
}
}
checkValidity() {
if (this._taggle) {
this.checkRequired()
}
return this._internals.checkValidity()
}
reportValidity() {
if (this._taggle) {
this.checkRequired()
}
return this._internals.reportValidity()
}
handleNameChange(newName) {
// Update the hidden input name to match
const hiddenInput = this._shadowRoot.querySelector('input[type="hidden"]');
if (hiddenInput) {
hiddenInput.name = newName || '';
}
// Update the form value with the new name
if (this._internals.value) {
this.value = this._internals.value; // This will recreate FormData with new name
}
}
handleMultipleChange(isMultiple) {
if (!this._taggle) return;
// Update the internal multiple state
this.multiple = isMultiple;
// Get current tags
const currentTags = this._taggle.getTagValues();
if (!isMultiple && currentTags.length > 1) {
// Single mode: remove excess tag-option elements from DOM
const tagOptions = Array.from(this.children);
// Keep only the first tag-option element, remove the rest
for (let i = 1; i < tagOptions.length; i++) {
if (tagOptions[i]) {
this.removeChild(tagOptions[i]);
}
}
}
// Reinitialize taggle with new multiple setting
this.reinitializeTaggle();
// Restore tags, respecting the new multiple constraint
if (isMultiple) {
// Multiple mode: restore all remaining tags
if (currentTags.length > 0) {
this._taggle.add(currentTags);
}
} else {
// Single mode: keep only the first tag
if (currentTags.length > 0) {
this._taggle.add(currentTags[0]);
}
}
this.updateValue();
}
handleRequiredChange(isRequired) {
if (!this._taggle) return;
// Update the internal required state
this.required = isRequired;
// Update validation
this.checkRequired();
}
handleListChange(newListId) {
if (!this._taggle) return;
// The options getter will automatically read from the new datalist
// No additional action needed as autocomplete will pick up the change
}
reinitializeTaggle() {
// Clean up existing taggle if it exists
if (this._taggle && this._taggle.destroy) {
this._taggle.destroy();
}
// Get current configuration
const maxTags = this.hasAttribute("multiple") ? undefined : 1;
const placeholder = this.getAttribute("placeholder") || "";
// Create new taggle instance using original configuration pattern
this._taggle = new Taggle(this, {
inputContainer: this.containerTarget,
preserveCase: true,
clearOnBlur: false,
hiddenInputName: this.name,
maxTags: maxTags,
placeholder: placeholder,
onTagAdd: (event, tag) => this.onTagAdd(event, tag),
onTagRemove: (event, tag) => this.onTagRemove(event, tag),
});
// Re-get references since taggle was recreated
this._taggleInputTarget = this._taggle.getInput();
this._taggleInputTarget.id = this.id || "";
this._taggleInputTarget.autocomplete = "off";
this._taggleInputTarget.setAttribute("data-turbo-permanent", true);
this._taggleInputTarget.addEventListener("keyup", e => this.keyup(e));
// Re-setup autocomplete
this.setupAutocomplete();
// Re-process existing tag options
this.processTagOptions();
}
updateValue() {
if (!this._taggle) return;
// Update the internal value to match taggle state
const values = this._taggle.getTagValues();
const oldValues = this._internals.value;
this._internals.value = values;
const formData = new FormData();
values.forEach(value => formData.append(this.name, value));
if(values.length === 0) formData.append(this.name, ""); // none value
this._internals.setFormValue(formData);
// Check validity after updating
this.checkRequired();
if(this.initialized && !this.suppressEvents && JSON.stringify(oldValues) !== JSON.stringify(values)) {
this.dispatchEvent(new CustomEvent("change", {
bubbles: true,
composed: true,
}));
}
}
}
customElements.define("input-tag", InputTag);
function h(html) {
const container = document.createElement("div")
container.innerHTML = html
return container.firstElementChild
}