Last active
February 5, 2022 10:23
-
-
Save egeozcan/cdc90e290271f3ea4b6801dcf1aad719 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
document.addEventListener('alpine:init', () => { | |
window.Alpine.data('autocompleter', ({ | |
selectedResults, | |
max, | |
min, | |
ownerId, | |
url, | |
elName, | |
filterEls = [], | |
addUrl = "", | |
extraInfo = "", | |
}) => { | |
if (typeof filterEls === "string") { | |
try { | |
filterEls = JSON.parse(filterEls); | |
} catch (e) { | |
filterEls = [ filterEls ]; | |
} | |
} | |
return { | |
max: parseInt(max) || 0, | |
min: parseInt(min) || 0, | |
ownerId: parseInt(ownerId) || 0, | |
results: [], | |
selectedIndex: -1, | |
errorMessage: false, | |
dropdownActive: false, | |
selectedResults: selectedResults || [], | |
selectedIds: new Set(), | |
url, | |
addUrl, | |
extraInfo, | |
filterEls, | |
requestAborter: null, | |
addModeForTag: false, | |
loading: false, | |
init() { | |
selectedResults.forEach(val => { | |
this.selectedIds.add(val.ID); | |
}); | |
this.$watch('selectedResults', values => { | |
this.selectedIds.clear(); | |
values.forEach(val => { | |
this.selectedIds.add(val.ID); | |
}); | |
this.$dispatch('multiple-input', { value: selectedResults, name: elName }); | |
}); | |
this.$el.closest('form').addEventListener('submit', (e) => { | |
if (selectedResults.length < min) { | |
e.preventDefault(); | |
this.errorMessage = 'Please select at least ' + min + ' ' + (min === 1 ? 'value' : 'values'); | |
} | |
}); | |
}, | |
async addVal() { | |
if (this.loading) { | |
return; | |
} | |
this.loading = true; | |
try { | |
const newVal = await fetch(this.addUrl, { | |
method: 'POST', | |
body: JSON.stringify({ Name: this.addModeForTag, ...this.getAdditionalParams() }), | |
headers: { | |
"Content-Type": "application/json", | |
}, | |
}).then(x => x.json()); | |
this.selectedResults.push(newVal); | |
this.ensureMaxItems(); | |
} catch (e) { | |
this.errorMessage = `Could not add ${this.addModeForTag}` | |
} finally { | |
this.loading = false; | |
this.exitAdd(); | |
} | |
}, | |
exitAdd() { | |
if (this.loading) { | |
return; | |
} | |
this.addModeForTag = ''; | |
}, | |
pushVal($event) { | |
if (this.loading) { | |
return; | |
} | |
/* | |
The dropdown is not open and/or there are no selected results | |
*/ | |
if (!this.results[this.selectedIndex] || !this.dropdownActive) { | |
if (!this.addUrl) { | |
return; | |
} | |
const value = this.$refs?.autocompleter?.value; | |
/* | |
We have an add url, so maybe try adding the option if it wasn't in the list already | |
*/ | |
if (!this.results.find(x => x.name === value)) { | |
this.addModeForTag = value; | |
} else { | |
this.addModeForTag = ""; | |
this.dropdownActive = true; | |
} | |
return; | |
} | |
this.selectedResults.push(this.results[this.selectedIndex]); | |
this.ensureMaxItems(); | |
$event.target.value = ''; | |
$event.target.dispatchEvent(new Event('input')); | |
}, | |
ensureMaxItems() { | |
while (this.max !== 0 && this.selectedResults.length > Math.max(this.max, 0)) { | |
this.selectedResults.splice(0, 1); | |
} | |
}, | |
getItemDisplayName(item) { | |
if (!this.extraInfo || !item[this.extraInfo]?.Name) { | |
return item.Name; | |
} | |
return `${item.Name} (${item[this.extraInfo].Name})` | |
}, | |
inputEvents: { | |
['@keydown.escape'](e) { | |
if (!this.dropdownActive) { | |
return; | |
} | |
e.preventDefault(); | |
this.dropdownActive = false; | |
}, | |
['@keydown.arrow-up.prevent']() { | |
this.selectedIndex = this.selectedIndex - 1; | |
if (this.selectedIndex < 0) { | |
this.selectedIndex = this.results.length - 1; | |
} | |
}, | |
['@keydown.arrow-down.prevent']() { | |
this.selectedIndex = (this.selectedIndex + 1) % this.results.length; | |
}, | |
['@keydown.enter.prevent'](e) { | |
this.pushVal(e); | |
if (this.selectedResults.length === max) { | |
setTimeout(() => { | |
this.dropdownActive = false; | |
}, 100); | |
} | |
}, | |
['@blur'](e) { | |
if (document.activeElement === e.target) { | |
return; | |
} | |
setTimeout(() => { | |
this.dropdownActive = false; | |
}, 10); | |
}, | |
['@focus']() { | |
this.dropdownActive = true; | |
this.$event.target.dispatchEvent(new Event('input')); | |
}, | |
['@input']() { | |
const target = this.$event.target; | |
const value = target.value; | |
this.results = this.results.filter(val => !this.selectedIds.has(val.ID)); | |
if (this.requestAborter) { | |
this.requestAborter(); | |
this.requestAborter = null; | |
} | |
const params = new URLSearchParams({ name: target.value, ...this.getAdditionalParams() }) | |
const { | |
abort, | |
ready | |
} = abortableFetch(url + '?' + params.toString(), {}) | |
ready.then(x => x.json()).then(values => { | |
if (value !== target.value) { | |
return; | |
} | |
this.results = values.filter(val => !this.selectedIds.has(val.ID)); | |
if (this.results.length && document.activeElement === target) { | |
this.dropdownActive = true; | |
this.selectedIndex = 0; | |
} | |
}).catch(err => { | |
this.errorMessage = err.toString(); | |
}); | |
this.requestAborter = abort; | |
} | |
}, | |
getAdditionalParams() { | |
const params = { }; | |
if (this.ownerId) { | |
params.ownerId = this.ownerId; | |
} | |
if (this.filterEls && Array.isArray(this.filterEls)) { | |
for (const filter of this.filterEls) { | |
document.querySelectorAll(`input[name=${filter.nameInput}]`).forEach((input) => { | |
params[filter.nameGet] = input.value; | |
}); | |
} | |
} | |
return params; | |
} | |
} | |
}) | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div | |
x-data="autocompleter({ | |
selectedResults: {{ selectedItems|json }} || [], | |
min: parseInt('{{ min }}') || 0, | |
max: parseInt('{{ max }}') || 0, | |
ownerId: parseInt('{{ ownerId }}') || 0, | |
url: '{{ url }}', | |
addUrl: '{{ addUrl }}', | |
elName: '{{ elName }}', | |
filterEls: '{{ filterEls }}' || [] | |
})" | |
class="relative w-full" | |
> | |
<label class="block text-sm font-medium text-gray-700 mt-3" for="{{ id }}">{{ title }}</label> | |
{% include "/partials/form/formParts/errorMessage.tpl" %} | |
<template x-if="addModeForTag == ''"> | |
<div> | |
<input | |
id="{{ id }}" | |
x-ref="autocompleter" | |
type="text" | |
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md mt-2" | |
x-bind="inputEvents" | |
x-init="setTimeout(() => { addModeForTag !== false && $el.focus(); }, 1)" | |
> | |
<template x-if="dropdownActive && results.length > 0"> | |
<div class="absolute mt-1 w-full border bg-white shadow-xl rounded z-50"> | |
<div class="p-3"> | |
<div x-ref="list"> | |
<template x-for="(result, index) in results" :key="index"> | |
<span | |
:active="false" | |
class="cursor-pointer p-2 flex block w-full rounded" | |
:class="{'bg-blue-500': index === selectedIndex}" | |
@mousedown="pushVal" | |
@mouseover="selectedIndex = index;" | |
> | |
<span | |
x-text="result.Name" | |
class="overflow-ellipsis overflow-hidden" | |
:title="result.Name" | |
></span> | |
</span> | |
</template> | |
</div> | |
</div> | |
</div> | |
</template> | |
<template x-for="(result, index) in selectedResults"> | |
<p class=" | |
inline-flex rounded-md items-center py-0.5 pl-2.5 pr-1 text-sm font-medium bg-indigo-100 | |
text-indigo-700 my-1 mr-1 | |
"> | |
<span class="break-all" x-text="result.Name"></span> | |
<button | |
@click="selectedResults.splice(index, 1);" | |
type="button" | |
title="remove" | |
class=" | |
flex-shrink-0 ml-0.5 h-4 w-4 rounded-md inline-flex items-center justify-center | |
text-indigo-400 hover:bg-indigo-200 hover:text-indigo-500 focus:outline-none | |
focus:bg-indigo-500 focus:text-white" | |
> | |
<span x-text="'Remove ' + result.Name" class="sr-only"></span> | |
<svg class="h-2 w-2" stroke="currentColor" fill="none" viewBox="0 0 8 8"> | |
<path stroke-linecap="round" stroke-width="1.5" d="M1 1l6 6m0-6L1 7" /> | |
</svg> | |
</button> | |
</p> | |
</template> | |
</div> | |
</template> | |
<template x-if="addModeForTag"> | |
<div class="flex gap-2 items-stretch justify-between mt-2"> | |
<button | |
type="button" | |
class=" | |
border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 | |
hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 | |
inline-flex justify-center items-center py-1 px-2" | |
x-text="'Add ' + addModeForTag + '?'" | |
x-init="setTimeout(() => $el.focus(), 1)" | |
@keydown.escape.prevent="exitAdd" | |
@keydown.enter.prevent="addVal" | |
@keyup.prevent="" | |
></button> | |
<button | |
type="button" | |
class=" | |
border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 | |
hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 | |
inline-flex justify-center items-center py-1 px-2" | |
x-ref="cancelAdd" | |
@click="exitAdd" | |
@keydown.escape.prevent="exitAdd" | |
>Cancel</button> | |
</div> | |
</template> | |
<template x-for="(result, index) in selectedResults"> | |
<input type="hidden" name="{{ elName }}" :value="result.ID"> | |
</template> | |
</div> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment