Skip to content

Instantly share code, notes, and snippets.

@egeozcan
Last active February 5, 2022 10:23
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save egeozcan/cdc90e290271f3ea4b6801dcf1aad719 to your computer and use it in GitHub Desktop.
Save egeozcan/cdc90e290271f3ea4b6801dcf1aad719 to your computer and use it in GitHub Desktop.
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;
}
}
})
})
<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