site: decouple state from event handlers

Try to avoid directly setting state from event handlers and instead
provide utility functions that mutate state in a consistent way.
This commit is contained in:
Lucas Gabriel Vuotto 2025-04-28 06:52:23 +00:00
parent b1cd0dad8d
commit 8168bb9715
2 changed files with 42 additions and 29 deletions

View file

@ -6,7 +6,7 @@ function mySplice(s, idx, del, n) {
return s.substring(0, idx) + n + s.substring(idx + del); return s.substring(0, idx) + n + s.substring(idx + del);
} }
function suggestionPosition(el) { function suggestionIndex(el) {
// el.parentNode is ul#tags-suggestion, which include a template. That's why // el.parentNode is ul#tags-suggestion, which include a template. That's why
// a -1 is needed here. // a -1 is needed here.
const idx = [...el.parentNode.children].indexOf(el); const idx = [...el.parentNode.children].indexOf(el);
@ -52,50 +52,63 @@ document.addEventListener('alpine:init', () => {
this.suggestions = await response.json(); this.suggestions = await response.json();
}, },
completeSuggestion(evt) { completeSuggestion() {
if (this.currentSuggestion === null) if (this.currentSuggestion === null)
return; return;
const value = this.$refs.tags.value; const value = this.$refs.tags.value;
const pos = this.inputCursor - this.query.length; const pos = Math.max(this.inputCursor - this.query.length, 0);
const tag = this.suggestions[this.currentSuggestion].display; const tag = this.suggestions[this.currentSuggestion].display;
const newValue = mySplice(value, pos, this.query.length, tag + ' ');
this.$refs.tags.value = mySplice(value, pos, this.query.length,
tag + ' ');
this.reset(); this.reset();
this.$refs.tags.value = newValue;
this.inputCursor = this.inputCursor =
this.$refs.tags.selectionStart = this.$refs.tags.selectionStart =
this.$refs.tags.selectionEnd = this.$refs.tags.selectionEnd =
pos + tag.length + 1; pos + tag.length + 1;
this.$refs.tags.focus(); this.$refs.tags.focus();
// Don't switch focus if we're completing after a Tab press.
if (evt.type === "keydown" && evt.keyCode === 9)
evt.preventDefault();
}, },
selectCurrentSuggestion(evt) { completeClickedSuggestion(evt) {
this.currentSuggestion = suggestionPosition(evt.currentTarget); this.currentSuggestion = suggestionIndex(evt.currentTarget);
this.completeSuggestion(evt); this.completeSuggestion();
}, },
setCurrentSuggestion(evt, amount = 0) { setCurrentSuggestion(idx) {
const len = this.suggestions.length;
if (len === 0)
return;
currentSuggestionElement(this.currentSuggestion) currentSuggestionElement(this.currentSuggestion)
?.classList.remove("suggestion-hover"); ?.classList.remove("suggestion-hover");
if (evt.type === 'mouseenter') idx = idx % len;
this.currentSuggestion = suggestionPosition(evt.currentTarget); if (idx < 0)
else { idx += len;
const len = this.suggestions.length; this.currentSuggestion = idx;
let cur = this.currentSuggestion;
if (cur === null)
cur = amount > 0 ? -1 : amount < 0 ? len : 0;
this.currentSuggestion = (cur + amount + len) % len;
}
currentSuggestionElement(this.currentSuggestion) currentSuggestionElement(this.currentSuggestion)
?.classList.add("suggestion-hover"); ?.classList.add("suggestion-hover");
} },
setPointerSuggestion(evt) {
this.setCurrentSuggestion(suggestionIndex(evt.currentTarget));
},
moveCurrentSuggestion(amount, defaultValue) {
this.setCurrentSuggestion(this.currentSuggestion === null ?
defaultValue : this.currentSuggestion + amount);
},
nextSuggestion(evt) {
this.moveCurrentSuggestion(1, 0);
},
previousSuggestion(evt) {
this.moveCurrentSuggestion(-1, -1);
},
})); }));
}); });

View file

@ -11,16 +11,16 @@
x-ref="tags" x-ref="tags"
@focus="fetchSuggestions" @focus="fetchSuggestions"
@input.debounce="fetchSuggestions" @input.debounce="fetchSuggestions"
@keydown.tab="completeSuggestion" @keydown.tab.prevent="completeSuggestion"
@keydown.up.prevent="setCurrentSuggestion($event, -1)" @keydown.up.prevent="previousSuggestion"
@keydown.down.prevent="setCurrentSuggestion($event, +1)" @keydown.down.prevent="nextSuggestion"
@keydown.escape="reset"> @keydown.escape="reset">
<ul id="tags-suggestions" <ul id="tags-suggestions"
x-cloak x-show="suggestions.length > 0"> x-cloak x-show="suggestions.length > 0">
<template x-for="suggestion in suggestions"> <template x-for="suggestion in suggestions">
<li <li
@click="selectCurrentSuggestion" @click="completeClickedSuggestion"
@mouseenter="setCurrentSuggestion"> @mouseenter="setPointerSuggestion">
<span <span
x-text="suggestion.display" x-text="suggestion.display"
:class="suggestion.kind_class"> :class="suggestion.kind_class">