import getKey from '../utils/getKey' import noop from '../utils/noop' import raf from '../utils/raf' import supportDom from '../decorators/supportDom'
@supportDom export default class TagInput {
constructor(dom, options = {}) { this.dom = dom this.defaultInputWidth = 128 this.validate = options.validate || (() => ({ isTag: true })) this.suggest = options.suggest || noop this.change = options.change || noop this.isComposing = false this.raf = raf this.id = 0 this.tags = [] this.init() } init() { this.setup() this.addEvents() } setup() { const { defaultInputWidth } = this const inputDiv = document.createElement('div') inputDiv.className = 'tag-input-box' const suggestInput = document.createElement('input') suggestInput.type = 'text' suggestInput.style.width = defaultInputWidth + 'px' suggestInput.className = 'tag-suggest-input' const input = document.createElement('input') input.type = 'text' input.style.width = defaultInputWidth + 'px' input.className = 'tag-main-input' inputDiv.appendChild(input) inputDiv.appendChild(suggestInput) this.input = input this.suggestInput = suggestInput this.canvas = document.createElement('canvas') this.inputDiv = inputDiv this.dom.append(inputDiv) } getTextWidth(text, font) { const context = this.canvas.getContext('2d') context.font = font const metrics = context.measureText(text) return metrics.width } getNextInputWidth(textWidth) { const { defaultInputWidth } = this // spaces before text and after text const delta = 5 const nextWidth = textWidth + delta if (nextWidth < defaultInputWidth) { return defaultInputWidth } return nextWidth } shake(duration = 500) { this.dom.classList.add('shake') setTimeout(() => { this.dom.classList.remove('shake') }, duration) } setTagAttrs(id, rows = [], options = {}) { const tag = this.tags.find(tag => tag.id === id) if (! tag) { return } const { elem } = tag const { timeout } = options if (this.timer) { clearTimeout(this.timer) this.timer = null } else { this.oldAttrs = rows.map(row => elem.getAttribute(row.name)) } rows.forEach(row => { elem.setAttribute(row.name, row.value) }) if (timeout) { this.timer = setTimeout(() => { if (document.body.contains(elem)) { const { oldAttrs } = this rows.forEach((row, i) => { elem.setAttribute(row.name, oldAttrs[i]) }) } }, timeout) } } getTag(row) { this.id += 1 const id = this.id const classname = row.classname ? ` ${row.classname}` : '' const tag = document.createElement('div') tag.className = 'tag' + classname tag.textContent = row.text const btn = document.createElement('button') btn.type = 'button' // https://wesbos.com/times-html-entity-close-button btn.textContent = '×' const handleBtnClick = event => { this.tags = this.tags.filter(row => row.elem !== tag) btn.removeEventListener('click', handleBtnClick) tag.remove() if (event) { this.change({ type: 'remove', removedId: id, tags: this.tags.slice() }) } } btn.addEventListener('click', handleBtnClick) tag.appendChild(btn) return { id, elem: tag, remove: handleBtnClick, ...row } } setTags(rows) { this.tags.forEach(tag => tag.remove()) const { dom, inputDiv } = this const tags = rows.map(row => this.getTag(row)) tags.forEach(tag => { dom.insertBefore(tag.elem, inputDiv) }) this.tags = tags this.change({ type: 'set', tags: this.tags.slice() }) } addTag(row, type = 'add') { const tag = this.getTag(row) this.tags.push(tag) this.dom.insertBefore(tag.elem, this.inputDiv) this.change({ type, tags: this.tags.slice() }) } async addTagIfNeeded() { const { input, suggestInput } = this const inputValue = suggestInput.value || input.value const res = await this.validate(inputValue) if (res.clear) { input.value = '' suggestInput.value = '' return } if (! res.isTag) { return this.shake() } input.value = '' suggestInput.value = '' const row = Object.assign({}, res, { text: inputValue }) this.addTag(row, 'input') } removeTagIfNeeded() { const lastTag = this.tags[this.tags.length - 1] if ((this.input.value === '') && lastTag) { lastTag.remove() this.change({ type: 'remove', removedId: lastTag.id, tags: this.tags.slice() }) } } addEvents() { const { input } = this const font = window.getComputedStyle(input) .getPropertyValue('font') this.addEvent(this.dom, 'click', event => { if (event.target === this.dom) { this.input.focus() } }) this.addEvent(input, 'compositionstart', event => { this.isComposing = true }) this.addEvent(input, 'compositionend', event => { this.isComposing = false }) let lastValue = '' this.addEvent(input, 'keydown', async event => { const key = getKey(event) if ((key === 'enter') && (! this.isComposing)) { event.preventDefault() event.stopPropagation() await this.addTagIfNeeded() } else if ((key === 'backspace') && (lastValue === '')) { this.removeTagIfNeeded() } lastValue = input.value }) this.addEvent(input, 'input', event => { this.suggestInputIfNeeded(input.value) this.raf(() => { const textWidth = this.getTextWidth(input.value, font) const nextWidth = this.getNextInputWidth(textWidth) input.style.width = nextWidth + 'px' }) }) } async suggestInputIfNeeded(value) { const suggestValue = await this.suggest(value) this.raf(() => { if (this.input.value === value) { this.suggestInput.value = (suggestValue || '') } }) } destroy() { this.tags.forEach(tag => tag.remove()) this.inputDiv && this.inputDiv.remove() this.canvas = null this.input = null this.suggestInput = null this.inputDiv = null }
}