@github/auto-complete-element
Advanced tools
Comparing version 3.0.1 to 3.0.2
@@ -1,383 +0,346 @@ | ||
class AutocompleteEvent extends CustomEvent { | ||
constructor(type, init) { | ||
super(type, init); | ||
this.relatedTarget = init.relatedTarget; | ||
} | ||
} | ||
function debounce(callback, wait) { | ||
let timeout; | ||
return function () { | ||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { | ||
args[_key] = arguments[_key]; | ||
} | ||
clearTimeout(timeout); | ||
timeout = setTimeout(() => { | ||
clearTimeout(timeout); | ||
callback(...args); | ||
}, wait); | ||
}; | ||
} | ||
const requests = new WeakMap(); | ||
function fragment(el, url) { | ||
const xhr = new XMLHttpRequest(); | ||
xhr.open('GET', url, true); | ||
xhr.setRequestHeader('Accept', 'text/fragment+html'); | ||
return request(el, xhr); | ||
} | ||
function request(el, xhr) { | ||
const pending = requests.get(el); | ||
if (pending) pending.abort(); | ||
requests.set(el, xhr); | ||
const clear = () => requests.delete(el); | ||
const result = send(xhr); | ||
result.then(clear, clear); | ||
return result; | ||
} | ||
function send(xhr) { | ||
return new Promise((resolve, reject) => { | ||
xhr.onload = function () { | ||
if (xhr.status >= 200 && xhr.status < 300) { | ||
resolve(xhr.responseText); | ||
} else { | ||
reject(new Error(xhr.responseText)); | ||
} | ||
}; | ||
xhr.onerror = reject; | ||
xhr.send(); | ||
}); | ||
} | ||
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/); | ||
class Combobox { | ||
constructor(input, list) { | ||
this.input = input; | ||
this.list = list; | ||
this.isComposing = false; | ||
if (!list.id) { | ||
list.id = "combobox-".concat(Math.random().toString().slice(2, 6)); | ||
constructor(input, list) { | ||
this.input = input; | ||
this.list = list; | ||
this.isComposing = false; | ||
if (!list.id) { | ||
list.id = `combobox-${Math.random() | ||
.toString() | ||
.slice(2, 6)}`; | ||
} | ||
this.keyboardEventHandler = event => keyboardBindings(event, this); | ||
this.compositionEventHandler = event => trackComposition(event, this); | ||
this.inputHandler = this.clearSelection.bind(this); | ||
input.setAttribute('role', 'combobox'); | ||
input.setAttribute('aria-controls', list.id); | ||
input.setAttribute('aria-expanded', 'false'); | ||
input.setAttribute('aria-autocomplete', 'list'); | ||
input.setAttribute('aria-haspopup', 'listbox'); | ||
} | ||
this.keyboardEventHandler = event => keyboardBindings(event, this); | ||
this.compositionEventHandler = event => trackComposition(event, this); | ||
this.inputHandler = this.clearSelection.bind(this); | ||
input.setAttribute('role', 'combobox'); | ||
input.setAttribute('aria-controls', list.id); | ||
input.setAttribute('aria-expanded', 'false'); | ||
input.setAttribute('aria-autocomplete', 'list'); | ||
input.setAttribute('aria-haspopup', 'listbox'); | ||
} | ||
destroy() { | ||
this.clearSelection(); | ||
this.stop(); | ||
this.input.removeAttribute('role'); | ||
this.input.removeAttribute('aria-controls'); | ||
this.input.removeAttribute('aria-expanded'); | ||
this.input.removeAttribute('aria-autocomplete'); | ||
this.input.removeAttribute('aria-haspopup'); | ||
} | ||
start() { | ||
this.input.setAttribute('aria-expanded', 'true'); | ||
this.input.addEventListener('compositionstart', this.compositionEventHandler); | ||
this.input.addEventListener('compositionend', this.compositionEventHandler); | ||
this.input.addEventListener('input', this.inputHandler); | ||
this.input.addEventListener('keydown', this.keyboardEventHandler); | ||
this.list.addEventListener('click', commitWithElement); | ||
} | ||
stop() { | ||
this.clearSelection(); | ||
this.input.setAttribute('aria-expanded', 'false'); | ||
this.input.removeEventListener('compositionstart', this.compositionEventHandler); | ||
this.input.removeEventListener('compositionend', this.compositionEventHandler); | ||
this.input.removeEventListener('input', this.inputHandler); | ||
this.input.removeEventListener('keydown', this.keyboardEventHandler); | ||
this.list.removeEventListener('click', commitWithElement); | ||
} | ||
navigate() { | ||
let indexDiff = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; | ||
const focusEl = Array.from(this.list.querySelectorAll('[aria-selected="true"]')).filter(visible)[0]; | ||
const els = Array.from(this.list.querySelectorAll('[role="option"]')).filter(visible); | ||
const focusIndex = els.indexOf(focusEl); | ||
if (focusIndex === els.length - 1 && indexDiff === 1 || focusIndex === 0 && indexDiff === -1) { | ||
this.clearSelection(); | ||
this.input.focus(); | ||
return; | ||
destroy() { | ||
this.clearSelection(); | ||
this.stop(); | ||
this.input.removeAttribute('role'); | ||
this.input.removeAttribute('aria-controls'); | ||
this.input.removeAttribute('aria-expanded'); | ||
this.input.removeAttribute('aria-autocomplete'); | ||
this.input.removeAttribute('aria-haspopup'); | ||
} | ||
let indexOfItem = indexDiff === 1 ? 0 : els.length - 1; | ||
if (focusEl && focusIndex >= 0) { | ||
const newIndex = focusIndex + indexDiff; | ||
if (newIndex >= 0 && newIndex < els.length) indexOfItem = newIndex; | ||
start() { | ||
this.input.setAttribute('aria-expanded', 'true'); | ||
this.input.addEventListener('compositionstart', this.compositionEventHandler); | ||
this.input.addEventListener('compositionend', this.compositionEventHandler); | ||
this.input.addEventListener('input', this.inputHandler); | ||
this.input.addEventListener('keydown', this.keyboardEventHandler); | ||
this.list.addEventListener('click', commitWithElement); | ||
} | ||
const target = els[indexOfItem]; | ||
if (!target) return; | ||
for (const el of els) { | ||
if (target === el) { | ||
this.input.setAttribute('aria-activedescendant', target.id); | ||
target.setAttribute('aria-selected', 'true'); | ||
scrollTo(this.list, target); | ||
} else { | ||
el.setAttribute('aria-selected', 'false'); | ||
} | ||
stop() { | ||
this.clearSelection(); | ||
this.input.setAttribute('aria-expanded', 'false'); | ||
this.input.removeEventListener('compositionstart', this.compositionEventHandler); | ||
this.input.removeEventListener('compositionend', this.compositionEventHandler); | ||
this.input.removeEventListener('input', this.inputHandler); | ||
this.input.removeEventListener('keydown', this.keyboardEventHandler); | ||
this.list.removeEventListener('click', commitWithElement); | ||
} | ||
} | ||
clearSelection() { | ||
this.input.removeAttribute('aria-activedescendant'); | ||
for (const el of this.list.querySelectorAll('[aria-selected="true"]')) { | ||
el.setAttribute('aria-selected', 'false'); | ||
navigate(indexDiff = 1) { | ||
const focusEl = Array.from(this.list.querySelectorAll('[aria-selected="true"]')).filter(visible)[0]; | ||
const els = Array.from(this.list.querySelectorAll('[role="option"]')).filter(visible); | ||
const focusIndex = els.indexOf(focusEl); | ||
if ((focusIndex === els.length - 1 && indexDiff === 1) || (focusIndex === 0 && indexDiff === -1)) { | ||
this.clearSelection(); | ||
this.input.focus(); | ||
return; | ||
} | ||
let indexOfItem = indexDiff === 1 ? 0 : els.length - 1; | ||
if (focusEl && focusIndex >= 0) { | ||
const newIndex = focusIndex + indexDiff; | ||
if (newIndex >= 0 && newIndex < els.length) | ||
indexOfItem = newIndex; | ||
} | ||
const target = els[indexOfItem]; | ||
if (!target) | ||
return; | ||
for (const el of els) { | ||
if (target === el) { | ||
this.input.setAttribute('aria-activedescendant', target.id); | ||
target.setAttribute('aria-selected', 'true'); | ||
scrollTo(this.list, target); | ||
} | ||
else { | ||
el.setAttribute('aria-selected', 'false'); | ||
} | ||
} | ||
} | ||
} | ||
clearSelection() { | ||
this.input.removeAttribute('aria-activedescendant'); | ||
for (const el of this.list.querySelectorAll('[aria-selected="true"]')) { | ||
el.setAttribute('aria-selected', 'false'); | ||
} | ||
} | ||
} | ||
function keyboardBindings(event, combobox) { | ||
if (event.shiftKey || event.metaKey || event.altKey) return; | ||
if (!ctrlBindings && event.ctrlKey) return; | ||
if (combobox.isComposing) return; | ||
switch (event.key) { | ||
case 'Enter': | ||
case 'Tab': | ||
if (commit(combobox.input, combobox.list)) { | ||
event.preventDefault(); | ||
} | ||
break; | ||
case 'Escape': | ||
combobox.clearSelection(); | ||
break; | ||
case 'ArrowDown': | ||
combobox.navigate(1); | ||
event.preventDefault(); | ||
break; | ||
case 'ArrowUp': | ||
combobox.navigate(-1); | ||
event.preventDefault(); | ||
break; | ||
case 'n': | ||
if (ctrlBindings && event.ctrlKey) { | ||
combobox.navigate(1); | ||
event.preventDefault(); | ||
} | ||
break; | ||
case 'p': | ||
if (ctrlBindings && event.ctrlKey) { | ||
combobox.navigate(-1); | ||
event.preventDefault(); | ||
} | ||
break; | ||
default: | ||
if (event.ctrlKey) break; | ||
combobox.clearSelection(); | ||
} | ||
if (event.shiftKey || event.metaKey || event.altKey) | ||
return; | ||
if (!ctrlBindings && event.ctrlKey) | ||
return; | ||
if (combobox.isComposing) | ||
return; | ||
switch (event.key) { | ||
case 'Enter': | ||
case 'Tab': | ||
if (commit(combobox.input, combobox.list)) { | ||
event.preventDefault(); | ||
} | ||
break; | ||
case 'Escape': | ||
combobox.clearSelection(); | ||
break; | ||
case 'ArrowDown': | ||
combobox.navigate(1); | ||
event.preventDefault(); | ||
break; | ||
case 'ArrowUp': | ||
combobox.navigate(-1); | ||
event.preventDefault(); | ||
break; | ||
case 'n': | ||
if (ctrlBindings && event.ctrlKey) { | ||
combobox.navigate(1); | ||
event.preventDefault(); | ||
} | ||
break; | ||
case 'p': | ||
if (ctrlBindings && event.ctrlKey) { | ||
combobox.navigate(-1); | ||
event.preventDefault(); | ||
} | ||
break; | ||
default: | ||
if (event.ctrlKey) | ||
break; | ||
combobox.clearSelection(); | ||
} | ||
} | ||
function commitWithElement(event) { | ||
if (!(event.target instanceof Element)) return; | ||
const target = event.target.closest('[role="option"]'); | ||
if (!target) return; | ||
if (target.getAttribute('aria-disabled') === 'true') return; | ||
fireCommitEvent(target); | ||
if (!(event.target instanceof Element)) | ||
return; | ||
const target = event.target.closest('[role="option"]'); | ||
if (!target) | ||
return; | ||
if (target.getAttribute('aria-disabled') === 'true') | ||
return; | ||
fireCommitEvent(target); | ||
} | ||
function commit(input, list) { | ||
const target = list.querySelector('[aria-selected="true"]'); | ||
if (!target) return false; | ||
if (target.getAttribute('aria-disabled') === 'true') return true; | ||
target.click(); | ||
return true; | ||
const target = list.querySelector('[aria-selected="true"]'); | ||
if (!target) | ||
return false; | ||
if (target.getAttribute('aria-disabled') === 'true') | ||
return true; | ||
target.click(); | ||
return true; | ||
} | ||
function fireCommitEvent(target) { | ||
target.dispatchEvent(new CustomEvent('combobox-commit', { | ||
bubbles: true | ||
})); | ||
target.dispatchEvent(new CustomEvent('combobox-commit', { bubbles: true })); | ||
} | ||
function visible(el) { | ||
return !el.hidden && !(el instanceof HTMLInputElement && el.type === 'hidden') && (el.offsetWidth > 0 || el.offsetHeight > 0); | ||
return (!el.hidden && | ||
!(el instanceof HTMLInputElement && el.type === 'hidden') && | ||
(el.offsetWidth > 0 || el.offsetHeight > 0)); | ||
} | ||
function trackComposition(event, combobox) { | ||
combobox.isComposing = event.type === 'compositionstart'; | ||
const list = document.getElementById(combobox.input.getAttribute('aria-controls') || ''); | ||
if (!list) return; | ||
combobox.clearSelection(); | ||
combobox.isComposing = event.type === 'compositionstart'; | ||
const list = document.getElementById(combobox.input.getAttribute('aria-controls') || ''); | ||
if (!list) | ||
return; | ||
combobox.clearSelection(); | ||
} | ||
function scrollTo(container, target) { | ||
if (!inViewport(container, target)) { | ||
container.scrollTop = target.offsetTop; | ||
} | ||
if (!inViewport(container, target)) { | ||
container.scrollTop = target.offsetTop; | ||
} | ||
} | ||
function inViewport(container, element) { | ||
const scrollTop = container.scrollTop; | ||
const containerBottom = scrollTop + container.clientHeight; | ||
const top = element.offsetTop; | ||
const bottom = top + element.clientHeight; | ||
return top >= scrollTop && bottom <= containerBottom; | ||
const scrollTop = container.scrollTop; | ||
const containerBottom = scrollTop + container.clientHeight; | ||
const top = element.offsetTop; | ||
const bottom = top + element.clientHeight; | ||
return top >= scrollTop && bottom <= containerBottom; | ||
} | ||
class Autocomplete { | ||
constructor(container, input, results) { | ||
this.container = container; | ||
this.input = input; | ||
this.results = results; | ||
this.combobox = new Combobox(input, results); | ||
this.results.hidden = true; | ||
this.input.setAttribute('autocomplete', 'off'); | ||
this.input.setAttribute('spellcheck', 'false'); | ||
this.interactingWithList = false; | ||
this.onInputChange = debounce(this.onInputChange.bind(this), 300); | ||
this.onResultsMouseDown = this.onResultsMouseDown.bind(this); | ||
this.onInputBlur = this.onInputBlur.bind(this); | ||
this.onInputFocus = this.onInputFocus.bind(this); | ||
this.onKeydown = this.onKeydown.bind(this); | ||
this.onCommit = this.onCommit.bind(this); | ||
this.input.addEventListener('keydown', this.onKeydown); | ||
this.input.addEventListener('focus', this.onInputFocus); | ||
this.input.addEventListener('blur', this.onInputBlur); | ||
this.input.addEventListener('input', this.onInputChange); | ||
this.results.addEventListener('mousedown', this.onResultsMouseDown); | ||
this.results.addEventListener('combobox-commit', this.onCommit); | ||
} | ||
destroy() { | ||
this.input.removeEventListener('keydown', this.onKeydown); | ||
this.input.removeEventListener('focus', this.onInputFocus); | ||
this.input.removeEventListener('blur', this.onInputBlur); | ||
this.input.removeEventListener('input', this.onInputChange); | ||
this.results.removeEventListener('mousedown', this.onResultsMouseDown); | ||
this.results.removeEventListener('combobox-commit', this.onCommit); | ||
} | ||
onKeydown(event) { | ||
if (event.key === 'Escape' && this.container.open) { | ||
this.container.open = false; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} else if (event.altKey && event.key === 'ArrowUp' && this.container.open) { | ||
this.container.open = false; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} else if (event.altKey && event.key === 'ArrowDown' && !this.container.open) { | ||
if (!this.input.value.trim()) return; | ||
this.container.open = true; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
class AutocompleteEvent extends CustomEvent { | ||
constructor(type, init) { | ||
super(type, init); | ||
this.relatedTarget = init.relatedTarget; | ||
} | ||
} | ||
} | ||
onInputFocus() { | ||
this.fetchResults(); | ||
} | ||
function debounce(callback, wait = 0) { | ||
let timeout; | ||
return function (...Rest) { | ||
clearTimeout(timeout); | ||
timeout = window.setTimeout(() => { | ||
clearTimeout(timeout); | ||
callback(...Rest); | ||
}, wait); | ||
}; | ||
} | ||
onInputBlur() { | ||
if (this.interactingWithList) { | ||
this.interactingWithList = false; | ||
return; | ||
} | ||
const requests = new WeakMap(); | ||
function fragment(el, url) { | ||
const xhr = new XMLHttpRequest(); | ||
xhr.open('GET', url, true); | ||
xhr.setRequestHeader('Accept', 'text/fragment+html'); | ||
return request(el, xhr); | ||
} | ||
function request(el, xhr) { | ||
const pending = requests.get(el); | ||
if (pending) | ||
pending.abort(); | ||
requests.set(el, xhr); | ||
const clear = () => requests.delete(el); | ||
const result = send(xhr); | ||
result.then(clear, clear); | ||
return result; | ||
} | ||
function send(xhr) { | ||
return new Promise((resolve, reject) => { | ||
xhr.onload = function () { | ||
if (xhr.status >= 200 && xhr.status < 300) { | ||
resolve(xhr.responseText); | ||
} | ||
else { | ||
reject(new Error(xhr.responseText)); | ||
} | ||
}; | ||
xhr.onerror = reject; | ||
xhr.send(); | ||
}); | ||
} | ||
this.container.open = false; | ||
} | ||
onCommit(_ref) { | ||
let { | ||
target | ||
} = _ref; | ||
const selected = target; | ||
if (!(selected instanceof HTMLElement)) return; | ||
this.container.open = false; | ||
if (selected instanceof HTMLAnchorElement) return; | ||
const value = selected.getAttribute('data-autocomplete-value') || selected.textContent; | ||
this.container.value = value; | ||
} | ||
onResultsMouseDown() { | ||
this.interactingWithList = true; | ||
} | ||
onInputChange() { | ||
this.container.removeAttribute('value'); | ||
this.fetchResults(); | ||
} | ||
identifyOptions() { | ||
let id = 0; | ||
for (const el of this.results.querySelectorAll('[role="option"]:not([id])')) { | ||
el.id = "".concat(this.results.id, "-option-").concat(id++); | ||
class Autocomplete { | ||
constructor(container, input, results) { | ||
this.container = container; | ||
this.input = input; | ||
this.results = results; | ||
this.combobox = new Combobox(input, results); | ||
this.results.hidden = true; | ||
this.input.setAttribute('autocomplete', 'off'); | ||
this.input.setAttribute('spellcheck', 'false'); | ||
this.interactingWithList = false; | ||
this.onInputChange = debounce(this.onInputChange.bind(this), 300); | ||
this.onResultsMouseDown = this.onResultsMouseDown.bind(this); | ||
this.onInputBlur = this.onInputBlur.bind(this); | ||
this.onInputFocus = this.onInputFocus.bind(this); | ||
this.onKeydown = this.onKeydown.bind(this); | ||
this.onCommit = this.onCommit.bind(this); | ||
this.input.addEventListener('keydown', this.onKeydown); | ||
this.input.addEventListener('focus', this.onInputFocus); | ||
this.input.addEventListener('blur', this.onInputBlur); | ||
this.input.addEventListener('input', this.onInputChange); | ||
this.results.addEventListener('mousedown', this.onResultsMouseDown); | ||
this.results.addEventListener('combobox-commit', this.onCommit); | ||
} | ||
} | ||
fetchResults() { | ||
const query = this.input.value.trim(); | ||
if (!query) { | ||
this.container.open = false; | ||
return; | ||
destroy() { | ||
this.input.removeEventListener('keydown', this.onKeydown); | ||
this.input.removeEventListener('focus', this.onInputFocus); | ||
this.input.removeEventListener('blur', this.onInputBlur); | ||
this.input.removeEventListener('input', this.onInputChange); | ||
this.results.removeEventListener('mousedown', this.onResultsMouseDown); | ||
this.results.removeEventListener('combobox-commit', this.onCommit); | ||
} | ||
const src = this.container.src; | ||
if (!src) return; | ||
const url = new URL(src, window.location.href); | ||
const params = new URLSearchParams(url.search.slice(1)); | ||
params.append('q', query); | ||
url.search = params.toString(); | ||
this.container.dispatchEvent(new CustomEvent('loadstart')); | ||
fragment(this.input, url.toString()).then(html => { | ||
this.results.innerHTML = html; | ||
this.identifyOptions(); | ||
const hasResults = !!this.results.querySelector('[role="option"]'); | ||
this.container.open = hasResults; | ||
this.container.dispatchEvent(new CustomEvent('load')); | ||
this.container.dispatchEvent(new CustomEvent('loadend')); | ||
}).catch(() => { | ||
this.container.dispatchEvent(new CustomEvent('error')); | ||
this.container.dispatchEvent(new CustomEvent('loadend')); | ||
}); | ||
} | ||
open() { | ||
if (!this.results.hidden) return; | ||
this.combobox.start(); | ||
this.results.hidden = false; | ||
} | ||
close() { | ||
if (this.results.hidden) return; | ||
this.combobox.stop(); | ||
this.results.hidden = true; | ||
} | ||
onKeydown(event) { | ||
if (event.key === 'Escape' && this.container.open) { | ||
this.container.open = false; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} | ||
else if (event.altKey && event.key === 'ArrowUp' && this.container.open) { | ||
this.container.open = false; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} | ||
else if (event.altKey && event.key === 'ArrowDown' && !this.container.open) { | ||
if (!this.input.value.trim()) | ||
return; | ||
this.container.open = true; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} | ||
} | ||
onInputFocus() { | ||
this.fetchResults(); | ||
} | ||
onInputBlur() { | ||
if (this.interactingWithList) { | ||
this.interactingWithList = false; | ||
return; | ||
} | ||
this.container.open = false; | ||
} | ||
onCommit({ target }) { | ||
const selected = target; | ||
if (!(selected instanceof HTMLElement)) | ||
return; | ||
this.container.open = false; | ||
if (selected instanceof HTMLAnchorElement) | ||
return; | ||
const value = selected.getAttribute('data-autocomplete-value') || selected.textContent; | ||
this.container.value = value; | ||
} | ||
onResultsMouseDown() { | ||
this.interactingWithList = true; | ||
} | ||
onInputChange() { | ||
this.container.removeAttribute('value'); | ||
this.fetchResults(); | ||
} | ||
identifyOptions() { | ||
let id = 0; | ||
for (const el of this.results.querySelectorAll('[role="option"]:not([id])')) { | ||
el.id = `${this.results.id}-option-${id++}`; | ||
} | ||
} | ||
fetchResults() { | ||
const query = this.input.value.trim(); | ||
if (!query) { | ||
this.container.open = false; | ||
return; | ||
} | ||
const src = this.container.src; | ||
if (!src) | ||
return; | ||
const url = new URL(src, window.location.href); | ||
const params = new URLSearchParams(url.search.slice(1)); | ||
params.append('q', query); | ||
url.search = params.toString(); | ||
this.container.dispatchEvent(new CustomEvent('loadstart')); | ||
fragment(this.input, url.toString()) | ||
.then(html => { | ||
this.results.innerHTML = html; | ||
this.identifyOptions(); | ||
const hasResults = !!this.results.querySelector('[role="option"]'); | ||
this.container.open = hasResults; | ||
this.container.dispatchEvent(new CustomEvent('load')); | ||
this.container.dispatchEvent(new CustomEvent('loadend')); | ||
}) | ||
.catch(() => { | ||
this.container.dispatchEvent(new CustomEvent('error')); | ||
this.container.dispatchEvent(new CustomEvent('loadend')); | ||
}); | ||
} | ||
open() { | ||
if (!this.results.hidden) | ||
return; | ||
this.combobox.start(); | ||
this.results.hidden = false; | ||
} | ||
close() { | ||
if (this.results.hidden) | ||
return; | ||
this.combobox.stop(); | ||
this.results.hidden = true; | ||
} | ||
} | ||
@@ -387,85 +350,75 @@ | ||
class AutocompleteElement extends HTMLElement { | ||
constructor() { | ||
super(); | ||
} | ||
connectedCallback() { | ||
const listId = this.getAttribute('for'); | ||
if (!listId) return; | ||
const input = this.querySelector('input'); | ||
const results = document.getElementById(listId); | ||
if (!(input instanceof HTMLInputElement) || !results) return; | ||
state.set(this, new Autocomplete(this, input, results)); | ||
results.setAttribute('role', 'listbox'); | ||
} | ||
disconnectedCallback() { | ||
const autocomplete = state.get(this); | ||
if (autocomplete) { | ||
autocomplete.destroy(); | ||
state.delete(this); | ||
constructor() { | ||
super(); | ||
} | ||
} | ||
get src() { | ||
return this.getAttribute('src') || ''; | ||
} | ||
set src(url) { | ||
this.setAttribute('src', url); | ||
} | ||
get value() { | ||
return this.getAttribute('value') || ''; | ||
} | ||
set value(value) { | ||
this.setAttribute('value', value); | ||
} | ||
get open() { | ||
return this.hasAttribute('open'); | ||
} | ||
set open(value) { | ||
if (value) { | ||
this.setAttribute('open', ''); | ||
} else { | ||
this.removeAttribute('open'); | ||
connectedCallback() { | ||
const listId = this.getAttribute('for'); | ||
if (!listId) | ||
return; | ||
const input = this.querySelector('input'); | ||
const results = document.getElementById(listId); | ||
if (!(input instanceof HTMLInputElement) || !results) | ||
return; | ||
state.set(this, new Autocomplete(this, input, results)); | ||
results.setAttribute('role', 'listbox'); | ||
} | ||
} | ||
static get observedAttributes() { | ||
return ['open', 'value']; | ||
} | ||
attributeChangedCallback(name, oldValue, newValue) { | ||
if (oldValue === newValue) return; | ||
const autocomplete = state.get(this); | ||
if (!autocomplete) return; | ||
switch (name) { | ||
case 'open': | ||
newValue === null ? autocomplete.close() : autocomplete.open(); | ||
break; | ||
case 'value': | ||
if (newValue !== null) { | ||
autocomplete.input.value = newValue; | ||
disconnectedCallback() { | ||
const autocomplete = state.get(this); | ||
if (autocomplete) { | ||
autocomplete.destroy(); | ||
state.delete(this); | ||
} | ||
this.dispatchEvent(new AutocompleteEvent('auto-complete-change', { | ||
bubbles: true, | ||
relatedTarget: autocomplete.input | ||
})); | ||
break; | ||
} | ||
} | ||
get src() { | ||
return this.getAttribute('src') || ''; | ||
} | ||
set src(url) { | ||
this.setAttribute('src', url); | ||
} | ||
get value() { | ||
return this.getAttribute('value') || ''; | ||
} | ||
set value(value) { | ||
this.setAttribute('value', value); | ||
} | ||
get open() { | ||
return this.hasAttribute('open'); | ||
} | ||
set open(value) { | ||
if (value) { | ||
this.setAttribute('open', ''); | ||
} | ||
else { | ||
this.removeAttribute('open'); | ||
} | ||
} | ||
static get observedAttributes() { | ||
return ['open', 'value']; | ||
} | ||
attributeChangedCallback(name, oldValue, newValue) { | ||
if (oldValue === newValue) | ||
return; | ||
const autocomplete = state.get(this); | ||
if (!autocomplete) | ||
return; | ||
switch (name) { | ||
case 'open': | ||
newValue === null ? autocomplete.close() : autocomplete.open(); | ||
break; | ||
case 'value': | ||
if (newValue !== null) { | ||
autocomplete.input.value = newValue; | ||
} | ||
this.dispatchEvent(new AutocompleteEvent('auto-complete-change', { | ||
bubbles: true, | ||
relatedTarget: autocomplete.input | ||
})); | ||
break; | ||
} | ||
} | ||
} | ||
if (!window.customElements.get('auto-complete')) { | ||
window.AutocompleteElement = AutocompleteElement; | ||
window.customElements.define('auto-complete', AutocompleteElement); | ||
window.AutocompleteElement = AutocompleteElement; | ||
window.customElements.define('auto-complete', AutocompleteElement); | ||
} | ||
@@ -472,0 +425,0 @@ |
import Combobox from '@github/combobox-nav'; | ||
class AutocompleteEvent extends CustomEvent { | ||
constructor(type, init) { | ||
super(type, init); | ||
this.relatedTarget = init.relatedTarget; | ||
} | ||
constructor(type, init) { | ||
super(type, init); | ||
this.relatedTarget = init.relatedTarget; | ||
} | ||
} | ||
function debounce(callback, wait) { | ||
let timeout; | ||
return function () { | ||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { | ||
args[_key] = arguments[_key]; | ||
} | ||
clearTimeout(timeout); | ||
timeout = setTimeout(() => { | ||
clearTimeout(timeout); | ||
callback(...args); | ||
}, wait); | ||
}; | ||
function debounce(callback, wait = 0) { | ||
let timeout; | ||
return function (...Rest) { | ||
clearTimeout(timeout); | ||
timeout = window.setTimeout(() => { | ||
clearTimeout(timeout); | ||
callback(...Rest); | ||
}, wait); | ||
}; | ||
} | ||
@@ -28,167 +23,155 @@ | ||
function fragment(el, url) { | ||
const xhr = new XMLHttpRequest(); | ||
xhr.open('GET', url, true); | ||
xhr.setRequestHeader('Accept', 'text/fragment+html'); | ||
return request(el, xhr); | ||
const xhr = new XMLHttpRequest(); | ||
xhr.open('GET', url, true); | ||
xhr.setRequestHeader('Accept', 'text/fragment+html'); | ||
return request(el, xhr); | ||
} | ||
function request(el, xhr) { | ||
const pending = requests.get(el); | ||
if (pending) pending.abort(); | ||
requests.set(el, xhr); | ||
const clear = () => requests.delete(el); | ||
const result = send(xhr); | ||
result.then(clear, clear); | ||
return result; | ||
const pending = requests.get(el); | ||
if (pending) | ||
pending.abort(); | ||
requests.set(el, xhr); | ||
const clear = () => requests.delete(el); | ||
const result = send(xhr); | ||
result.then(clear, clear); | ||
return result; | ||
} | ||
function send(xhr) { | ||
return new Promise((resolve, reject) => { | ||
xhr.onload = function () { | ||
if (xhr.status >= 200 && xhr.status < 300) { | ||
resolve(xhr.responseText); | ||
} else { | ||
reject(new Error(xhr.responseText)); | ||
} | ||
}; | ||
xhr.onerror = reject; | ||
xhr.send(); | ||
}); | ||
return new Promise((resolve, reject) => { | ||
xhr.onload = function () { | ||
if (xhr.status >= 200 && xhr.status < 300) { | ||
resolve(xhr.responseText); | ||
} | ||
else { | ||
reject(new Error(xhr.responseText)); | ||
} | ||
}; | ||
xhr.onerror = reject; | ||
xhr.send(); | ||
}); | ||
} | ||
class Autocomplete { | ||
constructor(container, input, results) { | ||
this.container = container; | ||
this.input = input; | ||
this.results = results; | ||
this.combobox = new Combobox(input, results); | ||
this.results.hidden = true; | ||
this.input.setAttribute('autocomplete', 'off'); | ||
this.input.setAttribute('spellcheck', 'false'); | ||
this.interactingWithList = false; | ||
this.onInputChange = debounce(this.onInputChange.bind(this), 300); | ||
this.onResultsMouseDown = this.onResultsMouseDown.bind(this); | ||
this.onInputBlur = this.onInputBlur.bind(this); | ||
this.onInputFocus = this.onInputFocus.bind(this); | ||
this.onKeydown = this.onKeydown.bind(this); | ||
this.onCommit = this.onCommit.bind(this); | ||
this.input.addEventListener('keydown', this.onKeydown); | ||
this.input.addEventListener('focus', this.onInputFocus); | ||
this.input.addEventListener('blur', this.onInputBlur); | ||
this.input.addEventListener('input', this.onInputChange); | ||
this.results.addEventListener('mousedown', this.onResultsMouseDown); | ||
this.results.addEventListener('combobox-commit', this.onCommit); | ||
} | ||
destroy() { | ||
this.input.removeEventListener('keydown', this.onKeydown); | ||
this.input.removeEventListener('focus', this.onInputFocus); | ||
this.input.removeEventListener('blur', this.onInputBlur); | ||
this.input.removeEventListener('input', this.onInputChange); | ||
this.results.removeEventListener('mousedown', this.onResultsMouseDown); | ||
this.results.removeEventListener('combobox-commit', this.onCommit); | ||
} | ||
onKeydown(event) { | ||
if (event.key === 'Escape' && this.container.open) { | ||
this.container.open = false; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} else if (event.altKey && event.key === 'ArrowUp' && this.container.open) { | ||
this.container.open = false; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} else if (event.altKey && event.key === 'ArrowDown' && !this.container.open) { | ||
if (!this.input.value.trim()) return; | ||
this.container.open = true; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
constructor(container, input, results) { | ||
this.container = container; | ||
this.input = input; | ||
this.results = results; | ||
this.combobox = new Combobox(input, results); | ||
this.results.hidden = true; | ||
this.input.setAttribute('autocomplete', 'off'); | ||
this.input.setAttribute('spellcheck', 'false'); | ||
this.interactingWithList = false; | ||
this.onInputChange = debounce(this.onInputChange.bind(this), 300); | ||
this.onResultsMouseDown = this.onResultsMouseDown.bind(this); | ||
this.onInputBlur = this.onInputBlur.bind(this); | ||
this.onInputFocus = this.onInputFocus.bind(this); | ||
this.onKeydown = this.onKeydown.bind(this); | ||
this.onCommit = this.onCommit.bind(this); | ||
this.input.addEventListener('keydown', this.onKeydown); | ||
this.input.addEventListener('focus', this.onInputFocus); | ||
this.input.addEventListener('blur', this.onInputBlur); | ||
this.input.addEventListener('input', this.onInputChange); | ||
this.results.addEventListener('mousedown', this.onResultsMouseDown); | ||
this.results.addEventListener('combobox-commit', this.onCommit); | ||
} | ||
} | ||
onInputFocus() { | ||
this.fetchResults(); | ||
} | ||
onInputBlur() { | ||
if (this.interactingWithList) { | ||
this.interactingWithList = false; | ||
return; | ||
destroy() { | ||
this.input.removeEventListener('keydown', this.onKeydown); | ||
this.input.removeEventListener('focus', this.onInputFocus); | ||
this.input.removeEventListener('blur', this.onInputBlur); | ||
this.input.removeEventListener('input', this.onInputChange); | ||
this.results.removeEventListener('mousedown', this.onResultsMouseDown); | ||
this.results.removeEventListener('combobox-commit', this.onCommit); | ||
} | ||
this.container.open = false; | ||
} | ||
onCommit(_ref) { | ||
let { | ||
target | ||
} = _ref; | ||
const selected = target; | ||
if (!(selected instanceof HTMLElement)) return; | ||
this.container.open = false; | ||
if (selected instanceof HTMLAnchorElement) return; | ||
const value = selected.getAttribute('data-autocomplete-value') || selected.textContent; | ||
this.container.value = value; | ||
} | ||
onResultsMouseDown() { | ||
this.interactingWithList = true; | ||
} | ||
onInputChange() { | ||
this.container.removeAttribute('value'); | ||
this.fetchResults(); | ||
} | ||
identifyOptions() { | ||
let id = 0; | ||
for (const el of this.results.querySelectorAll('[role="option"]:not([id])')) { | ||
el.id = "".concat(this.results.id, "-option-").concat(id++); | ||
onKeydown(event) { | ||
if (event.key === 'Escape' && this.container.open) { | ||
this.container.open = false; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} | ||
else if (event.altKey && event.key === 'ArrowUp' && this.container.open) { | ||
this.container.open = false; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} | ||
else if (event.altKey && event.key === 'ArrowDown' && !this.container.open) { | ||
if (!this.input.value.trim()) | ||
return; | ||
this.container.open = true; | ||
event.stopPropagation(); | ||
event.preventDefault(); | ||
} | ||
} | ||
} | ||
fetchResults() { | ||
const query = this.input.value.trim(); | ||
if (!query) { | ||
this.container.open = false; | ||
return; | ||
onInputFocus() { | ||
this.fetchResults(); | ||
} | ||
const src = this.container.src; | ||
if (!src) return; | ||
const url = new URL(src, window.location.href); | ||
const params = new URLSearchParams(url.search.slice(1)); | ||
params.append('q', query); | ||
url.search = params.toString(); | ||
this.container.dispatchEvent(new CustomEvent('loadstart')); | ||
fragment(this.input, url.toString()).then(html => { | ||
this.results.innerHTML = html; | ||
this.identifyOptions(); | ||
const hasResults = !!this.results.querySelector('[role="option"]'); | ||
this.container.open = hasResults; | ||
this.container.dispatchEvent(new CustomEvent('load')); | ||
this.container.dispatchEvent(new CustomEvent('loadend')); | ||
}).catch(() => { | ||
this.container.dispatchEvent(new CustomEvent('error')); | ||
this.container.dispatchEvent(new CustomEvent('loadend')); | ||
}); | ||
} | ||
open() { | ||
if (!this.results.hidden) return; | ||
this.combobox.start(); | ||
this.results.hidden = false; | ||
} | ||
close() { | ||
if (this.results.hidden) return; | ||
this.combobox.stop(); | ||
this.results.hidden = true; | ||
} | ||
onInputBlur() { | ||
if (this.interactingWithList) { | ||
this.interactingWithList = false; | ||
return; | ||
} | ||
this.container.open = false; | ||
} | ||
onCommit({ target }) { | ||
const selected = target; | ||
if (!(selected instanceof HTMLElement)) | ||
return; | ||
this.container.open = false; | ||
if (selected instanceof HTMLAnchorElement) | ||
return; | ||
const value = selected.getAttribute('data-autocomplete-value') || selected.textContent; | ||
this.container.value = value; | ||
} | ||
onResultsMouseDown() { | ||
this.interactingWithList = true; | ||
} | ||
onInputChange() { | ||
this.container.removeAttribute('value'); | ||
this.fetchResults(); | ||
} | ||
identifyOptions() { | ||
let id = 0; | ||
for (const el of this.results.querySelectorAll('[role="option"]:not([id])')) { | ||
el.id = `${this.results.id}-option-${id++}`; | ||
} | ||
} | ||
fetchResults() { | ||
const query = this.input.value.trim(); | ||
if (!query) { | ||
this.container.open = false; | ||
return; | ||
} | ||
const src = this.container.src; | ||
if (!src) | ||
return; | ||
const url = new URL(src, window.location.href); | ||
const params = new URLSearchParams(url.search.slice(1)); | ||
params.append('q', query); | ||
url.search = params.toString(); | ||
this.container.dispatchEvent(new CustomEvent('loadstart')); | ||
fragment(this.input, url.toString()) | ||
.then(html => { | ||
this.results.innerHTML = html; | ||
this.identifyOptions(); | ||
const hasResults = !!this.results.querySelector('[role="option"]'); | ||
this.container.open = hasResults; | ||
this.container.dispatchEvent(new CustomEvent('load')); | ||
this.container.dispatchEvent(new CustomEvent('loadend')); | ||
}) | ||
.catch(() => { | ||
this.container.dispatchEvent(new CustomEvent('error')); | ||
this.container.dispatchEvent(new CustomEvent('loadend')); | ||
}); | ||
} | ||
open() { | ||
if (!this.results.hidden) | ||
return; | ||
this.combobox.start(); | ||
this.results.hidden = false; | ||
} | ||
close() { | ||
if (this.results.hidden) | ||
return; | ||
this.combobox.stop(); | ||
this.results.hidden = true; | ||
} | ||
} | ||
@@ -198,85 +181,75 @@ | ||
class AutocompleteElement extends HTMLElement { | ||
constructor() { | ||
super(); | ||
} | ||
connectedCallback() { | ||
const listId = this.getAttribute('for'); | ||
if (!listId) return; | ||
const input = this.querySelector('input'); | ||
const results = document.getElementById(listId); | ||
if (!(input instanceof HTMLInputElement) || !results) return; | ||
state.set(this, new Autocomplete(this, input, results)); | ||
results.setAttribute('role', 'listbox'); | ||
} | ||
disconnectedCallback() { | ||
const autocomplete = state.get(this); | ||
if (autocomplete) { | ||
autocomplete.destroy(); | ||
state.delete(this); | ||
constructor() { | ||
super(); | ||
} | ||
} | ||
get src() { | ||
return this.getAttribute('src') || ''; | ||
} | ||
set src(url) { | ||
this.setAttribute('src', url); | ||
} | ||
get value() { | ||
return this.getAttribute('value') || ''; | ||
} | ||
set value(value) { | ||
this.setAttribute('value', value); | ||
} | ||
get open() { | ||
return this.hasAttribute('open'); | ||
} | ||
set open(value) { | ||
if (value) { | ||
this.setAttribute('open', ''); | ||
} else { | ||
this.removeAttribute('open'); | ||
connectedCallback() { | ||
const listId = this.getAttribute('for'); | ||
if (!listId) | ||
return; | ||
const input = this.querySelector('input'); | ||
const results = document.getElementById(listId); | ||
if (!(input instanceof HTMLInputElement) || !results) | ||
return; | ||
state.set(this, new Autocomplete(this, input, results)); | ||
results.setAttribute('role', 'listbox'); | ||
} | ||
} | ||
static get observedAttributes() { | ||
return ['open', 'value']; | ||
} | ||
attributeChangedCallback(name, oldValue, newValue) { | ||
if (oldValue === newValue) return; | ||
const autocomplete = state.get(this); | ||
if (!autocomplete) return; | ||
switch (name) { | ||
case 'open': | ||
newValue === null ? autocomplete.close() : autocomplete.open(); | ||
break; | ||
case 'value': | ||
if (newValue !== null) { | ||
autocomplete.input.value = newValue; | ||
disconnectedCallback() { | ||
const autocomplete = state.get(this); | ||
if (autocomplete) { | ||
autocomplete.destroy(); | ||
state.delete(this); | ||
} | ||
this.dispatchEvent(new AutocompleteEvent('auto-complete-change', { | ||
bubbles: true, | ||
relatedTarget: autocomplete.input | ||
})); | ||
break; | ||
} | ||
} | ||
get src() { | ||
return this.getAttribute('src') || ''; | ||
} | ||
set src(url) { | ||
this.setAttribute('src', url); | ||
} | ||
get value() { | ||
return this.getAttribute('value') || ''; | ||
} | ||
set value(value) { | ||
this.setAttribute('value', value); | ||
} | ||
get open() { | ||
return this.hasAttribute('open'); | ||
} | ||
set open(value) { | ||
if (value) { | ||
this.setAttribute('open', ''); | ||
} | ||
else { | ||
this.removeAttribute('open'); | ||
} | ||
} | ||
static get observedAttributes() { | ||
return ['open', 'value']; | ||
} | ||
attributeChangedCallback(name, oldValue, newValue) { | ||
if (oldValue === newValue) | ||
return; | ||
const autocomplete = state.get(this); | ||
if (!autocomplete) | ||
return; | ||
switch (name) { | ||
case 'open': | ||
newValue === null ? autocomplete.close() : autocomplete.open(); | ||
break; | ||
case 'value': | ||
if (newValue !== null) { | ||
autocomplete.input.value = newValue; | ||
} | ||
this.dispatchEvent(new AutocompleteEvent('auto-complete-change', { | ||
bubbles: true, | ||
relatedTarget: autocomplete.input | ||
})); | ||
break; | ||
} | ||
} | ||
} | ||
if (!window.customElements.get('auto-complete')) { | ||
window.AutocompleteElement = AutocompleteElement; | ||
window.customElements.define('auto-complete', AutocompleteElement); | ||
window.AutocompleteElement = AutocompleteElement; | ||
window.customElements.define('auto-complete', AutocompleteElement); | ||
} | ||
@@ -283,0 +256,0 @@ |
{ | ||
"name": "@github/auto-complete-element", | ||
"version": "3.0.1", | ||
"version": "3.0.2", | ||
"description": "Auto-complete input values from server results", | ||
@@ -9,8 +9,8 @@ "repository": "github/auto-complete-element", | ||
"module": "dist/index.js", | ||
"types": "index.d.ts", | ||
"types": "dist/index.d.ts", | ||
"scripts": { | ||
"clean": "rm -rf dist", | ||
"lint": "github-lint", | ||
"lint": "eslint . --ext .ts,.js && tsc --noEmit", | ||
"prebuild": "npm run clean && npm run lint", | ||
"build": "rollup -c && cp src/index.js.flow dist/index.esm.js.flow && cp src/index.js.flow dist/index.umd.js.flow", | ||
"build": "tsc && rollup -c", | ||
"pretest": "npm run build", | ||
@@ -27,4 +27,3 @@ "test": "karma start test/karma.config.cjs", | ||
"files": [ | ||
"dist", | ||
"index.d.ts" | ||
"dist" | ||
], | ||
@@ -35,9 +34,6 @@ "dependencies": { | ||
"devDependencies": { | ||
"@babel/core": "^7.7.0", | ||
"@github/prettier-config": "0.0.4", | ||
"babel-preset-github": "^3.2.1", | ||
"chai": "^4.2.0", | ||
"eslint": "^6.6.0", | ||
"eslint-plugin-github": "^3.2.1", | ||
"flow-bin": "^0.111.1", | ||
"eslint-plugin-github": "^4.0.1", | ||
"karma": "^5.0.4", | ||
@@ -49,5 +45,5 @@ "karma-chai": "^0.1.0", | ||
"mocha": "^7.1.2", | ||
"rollup": "^1.26.3", | ||
"rollup-plugin-babel": "^4.3.3", | ||
"rollup-plugin-node-resolve": "^5.2.0" | ||
"rollup": "^2.12.0", | ||
"rollup-plugin-node-resolve": "^5.2.0", | ||
"typescript": "^3.9.3" | ||
}, | ||
@@ -54,0 +50,0 @@ "eslintIgnore": [ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
38539
13
16
971