Some frontend validation logic

This commit is contained in:
Simon Martens
2026-01-22 16:46:31 +01:00
parent 1749d0e224
commit 17ab271de3
13 changed files with 2787 additions and 1532 deletions

View File

@@ -15,6 +15,8 @@ export class AlmanachEditPage extends HTMLElement {
this._saveEndpoint = "";
this._deleteEndpoint = "";
this._isSaving = false;
this._preferredSeriesRelationId = "";
this._preferredSeriesSeriesId = "";
this._handleSaveClick = this._handleSaveClick.bind(this);
this._handleResetClick = this._handleResetClick.bind(this);
this._handleDeleteClick = this._handleDeleteClick.bind(this);
@@ -27,6 +29,7 @@ export class AlmanachEditPage extends HTMLElement {
setTimeout(() => {
this._initForm();
this._initPlaces();
this._initPreferredSeries();
this._initSaveHandling();
this._initStatusSelect();
}, 0);
@@ -171,6 +174,16 @@ export class AlmanachEditPage extends HTMLElement {
}
}
_initPreferredSeries() {
const preferredSelect = this.querySelector("#preferred-series-field");
if (!preferredSelect) {
return;
}
this._preferredSeriesRelationId = preferredSelect.getAttribute("data-preferred-relation-id") || "";
this._preferredSeriesSeriesId = preferredSelect.getAttribute("data-preferred-series-id") || "";
}
_teardownSaveHandling() {
if (this._saveButton) {
this._saveButton.removeEventListener("click", this._handleSaveClick);
@@ -383,6 +396,54 @@ export class AlmanachEditPage extends HTMLElement {
targetField: "series",
});
const newSeriesRelations = this._collectNewRelations("entries_series");
const preferredSeriesId = this._readValue(formData, "preferred_series_id");
if (!preferredSeriesId) {
throw new Error("Reihentitel ist erforderlich.");
}
const applyPreferred = (relation) => {
relation.type = PREFERRED_SERIES_RELATION;
relation.uncertain = false;
};
let preferredApplied = false;
seriesRelations.forEach((relation) => {
if (relation.target_id === preferredSeriesId) {
applyPreferred(relation);
preferredApplied = true;
}
});
newSeriesRelations.forEach((relation) => {
if (relation.target_id === preferredSeriesId) {
applyPreferred(relation);
preferredApplied = true;
}
});
if (!preferredApplied) {
if (this._preferredSeriesRelationId && this._preferredSeriesSeriesId === preferredSeriesId) {
seriesRelations.push({
id: this._preferredSeriesRelationId,
target_id: preferredSeriesId,
type: PREFERRED_SERIES_RELATION,
uncertain: false,
});
} else {
newSeriesRelations.push({
target_id: preferredSeriesId,
type: PREFERRED_SERIES_RELATION,
uncertain: false,
});
}
}
if (
this._preferredSeriesRelationId &&
this._preferredSeriesSeriesId &&
this._preferredSeriesSeriesId !== preferredSeriesId &&
!deletedSeriesRelationIds.includes(this._preferredSeriesRelationId)
) {
deletedSeriesRelationIds.push(this._preferredSeriesRelationId);
}
const preferredCount = [...seriesRelations, ...newSeriesRelations].filter(
(relation) => relation.type === PREFERRED_SERIES_RELATION,
).length;
@@ -572,7 +633,8 @@ export class AlmanachEditPage extends HTMLElement {
return;
}
this._statusEl.textContent = "";
this._statusEl.classList.remove("text-red-700", "text-green-700");
this._statusEl.classList.remove("text-red-700", "text-green-700", "save-feedback-error", "save-feedback-success");
this._statusEl.classList.add("hidden");
}
_showStatus(message, type) {
@@ -581,10 +643,11 @@ export class AlmanachEditPage extends HTMLElement {
}
this._clearStatus();
this._statusEl.textContent = message;
this._statusEl.classList.remove("hidden");
if (type === "success") {
this._statusEl.classList.add("text-green-700");
this._statusEl.classList.add("text-green-700", "save-feedback-success");
} else if (type === "error") {
this._statusEl.classList.add("text-red-700");
this._statusEl.classList.add("text-red-700", "save-feedback-error");
}
}

View File

@@ -166,6 +166,42 @@
@apply inline-flex items-center px-1 text-gray-500;
}
.save-feedback {
@apply text-sm font-semibold px-3 py-2 rounded-xs border bg-stone-50 text-gray-700;
}
.save-feedback-error {
@apply bg-red-50 border-red-200 text-red-800;
}
.save-feedback-success {
@apply bg-green-50 border-green-200 text-green-800;
}
.lf-warn-icon {
@apply ml-1 mr-2;
}
.lf-link-button {
@apply ml-1 mr-2;
}
.lf-clear-button {
@apply ml-1 mr-2;
}
.lf-dup-warning {
@apply mt-0 mb-1 pl-3;
}
.preferred-series-select .ssr-clear-button {
@apply text-sm ml-1 mr-2;
}
.preferred-series-select .ssr-wrapper > .flex {
@apply gap-3;
}
.dbform div-menu {
@apply relative inline-block;
}

View File

@@ -0,0 +1,590 @@
const LF_WRAPPER_CLASS = "lookup-field";
const LF_INPUT_CLASS = "lf-input";
const LF_LIST_CLASS = "lf-list";
const LF_OPTION_CLASS = "lf-option";
const LF_HIDDEN_INPUT_CLASS = "lf-hidden-input";
const LF_CLEAR_BUTTON_CLASS = "lf-clear-button";
const LF_LINK_BUTTON_CLASS = "lf-link-button";
const LF_WARN_ICON_CLASS = "lf-warn-icon";
const LF_DUP_WARNING_CLASS = "lf-dup-warning";
const LF_DEFAULT_MIN_CHARS = 1;
const LF_DEFAULT_LIMIT = 10;
const LF_FETCH_DEBOUNCE_MS = 250;
export class LookupField extends HTMLElement {
constructor() {
super();
this._endpoint = "";
this._resultKey = "items";
this._minChars = LF_DEFAULT_MIN_CHARS;
this._limit = LF_DEFAULT_LIMIT;
this._autocomplete = true;
this._placeholder = "";
this._required = false;
this._multiline = false;
this._valueName = "";
this._textName = "";
this._valueFn = null;
this._linkFn = null;
this._validFn = null;
this._dupEndpoint = "";
this._dupResultKey = "";
this._dupCurrentId = "";
this._dupExact = true;
this._options = [];
this._selected = null;
this._highlightedIndex = -1;
this._fetchTimeout = null;
this._fetchController = null;
this._dupTimeout = null;
this._listVisible = false;
this._input = null;
this._hiddenInput = null;
this._list = null;
this._clearButton = null;
this._linkButton = null;
this._warnIcon = null;
this._dupWarning = null;
this._boundHandleInput = this._handleInput.bind(this);
this._boundHandleFocus = this._handleFocus.bind(this);
this._boundHandleKeyDown = this._handleKeyDown.bind(this);
this._boundHandleClear = this._handleClear.bind(this);
this._boundHandleClickOutside = this._handleClickOutside.bind(this);
}
static get observedAttributes() {
return [
"name",
"value",
"placeholder",
"data-endpoint",
"data-result-key",
"data-minchars",
"data-limit",
"data-autocomplete",
"data-required",
"data-multiline",
"data-value-name",
"data-text-name",
"data-value-fn",
"data-link-fn",
"data-valid-fn",
"data-dup-endpoint",
"data-dup-result-key",
"data-dup-current-id",
"data-dup-exact",
"data-initial-id",
"data-initial-name",
"data-initial-musenalm-id",
];
}
connectedCallback() {
this._render();
this._bindElements();
this._syncFromAttributes();
this._applyInitialValue();
this._updateValidity();
this._maybeCheckDuplicates(this._input?.value || "");
}
disconnectedCallback() {
document.removeEventListener("click", this._boundHandleClickOutside);
if (this._input) {
this._input.removeEventListener("input", this._boundHandleInput);
this._input.removeEventListener("focus", this._boundHandleFocus);
this._input.removeEventListener("keydown", this._boundHandleKeyDown);
}
if (this._clearButton) {
this._clearButton.removeEventListener("click", this._boundHandleClear);
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) {
return;
}
if (!this._input) {
return;
}
this._syncFromAttributes();
if (name === "value") {
this._applyInitialValue();
}
}
_render() {
const isMultiline = this.getAttribute("data-multiline") === "true";
const hasTextName = this.hasAttribute("data-text-name");
const textName = hasTextName ? this.getAttribute("data-text-name") || "" : "";
const valueName = this.getAttribute("data-value-name") || "";
const placeholder = this.getAttribute("placeholder") || "";
const inputId = this.getAttribute("id") ? `${this.getAttribute("id")}-input` : "";
const initialValue = this.getAttribute("value") || "";
const noEnter = this.getAttribute("data-no-enter") === "true";
const extraClass = noEnter ? " no-enter" : "";
const fallbackName = this.getAttribute("name") || "";
const finalTextName = hasTextName ? textName : fallbackName;
const textNameAttr = finalTextName ? ` name="${finalTextName}"` : "";
const inputMarkup = isMultiline
? `<textarea id="${inputId}" class="${LF_INPUT_CLASS} inputinput w-full${extraClass}" rows="1" placeholder="${placeholder}"${textNameAttr}>${initialValue}</textarea>`
: `<input id="${inputId}" type="text" class="${LF_INPUT_CLASS} inputinput w-full${extraClass}" placeholder="${placeholder}" value="${initialValue}"${textNameAttr} />`;
const hiddenInputMarkup = valueName ? `<input type="hidden" class="${LF_HIDDEN_INPUT_CLASS}" name="${valueName}" value="" />` : "";
this.innerHTML = `
<div class="${LF_WRAPPER_CLASS} relative">
<div class="flex items-center gap-2">
${inputMarkup.replace(/(class="[^"]*)"/, `$1" ${textNameAttr}`)}
<a class="${LF_LINK_BUTTON_CLASS} hidden text-sm text-gray-600 hover:text-gray-900 no-underline" aria-label="Auswahl öffnen" target="_blank" rel="noopener">
<i class="ri-external-link-line"></i>
</a>
<span class="${LF_WARN_ICON_CLASS} hidden text-red-700 text-lg" aria-hidden="true">
<i class="ri-error-warning-line"></i>
</span>
<button type="button" class="${LF_CLEAR_BUTTON_CLASS} text-sm text-gray-600 hover:text-gray-900" aria-label="Eingabe löschen">
<i class="ri-close-line"></i>
</button>
</div>
${hiddenInputMarkup}
<div class="${LF_LIST_CLASS} absolute left-0 right-0 mt-1 border border-stone-200 rounded-xs bg-white shadow-sm z-10 hidden max-h-64 overflow-auto"></div>
<div class="${LF_DUP_WARNING_CLASS} hidden text-sm text-blue-700 mt-1 flex items-center gap-2">
<i class="ri-information-line"></i>
<span data-role="dup-text"></span>
</div>
</div>
`;
}
_bindElements() {
this._input = this.querySelector(`.${LF_INPUT_CLASS}`);
this._hiddenInput = this.querySelector(`.${LF_HIDDEN_INPUT_CLASS}`);
this._list = this.querySelector(`.${LF_LIST_CLASS}`);
this._clearButton = this.querySelector(`.${LF_CLEAR_BUTTON_CLASS}`);
this._linkButton = this.querySelector(`.${LF_LINK_BUTTON_CLASS}`);
this._warnIcon = this.querySelector(`.${LF_WARN_ICON_CLASS}`);
this._dupWarning = this.querySelector(`.${LF_DUP_WARNING_CLASS}`);
if (this._input) {
this._input.addEventListener("input", this._boundHandleInput);
this._input.addEventListener("focus", this._boundHandleFocus);
this._input.addEventListener("keydown", this._boundHandleKeyDown);
}
if (this._clearButton) {
this._clearButton.addEventListener("click", this._boundHandleClear);
}
document.addEventListener("click", this._boundHandleClickOutside);
}
_syncFromAttributes() {
this._endpoint = this.getAttribute("data-endpoint") || "";
this._resultKey = this.getAttribute("data-result-key") || "items";
this._minChars = this._parsePositiveInt(this.getAttribute("data-minchars"), LF_DEFAULT_MIN_CHARS);
this._limit = this._parsePositiveInt(this.getAttribute("data-limit"), LF_DEFAULT_LIMIT);
this._autocomplete = this.getAttribute("data-autocomplete") !== "false";
this._placeholder = this.getAttribute("placeholder") || "";
this._required = this.getAttribute("data-required") === "true";
this._multiline = this.getAttribute("data-multiline") === "true";
this._valueName = this.getAttribute("data-value-name") || "";
this._textName = this.hasAttribute("data-text-name") ? this.getAttribute("data-text-name") || "" : "";
this._valueFn = this._getFn(this.getAttribute("data-value-fn"));
this._linkFn = this._getFn(this.getAttribute("data-link-fn"));
this._validFn = this._getFn(this.getAttribute("data-valid-fn"));
this._dupEndpoint = this.getAttribute("data-dup-endpoint") || "";
this._dupResultKey = this.getAttribute("data-dup-result-key") || "";
this._dupCurrentId = this.getAttribute("data-dup-current-id") || "";
this._dupExact = this.getAttribute("data-dup-exact") !== "false";
const initialName = this.getAttribute("data-initial-name") || "";
const initialId = this.getAttribute("data-initial-id") || "";
const initialMusenalmId = this.getAttribute("data-initial-musenalm-id") || "";
if (initialId && initialName && !this._selected) {
this._selected = { id: initialId, name: initialName, musenalm_id: initialMusenalmId || undefined };
this._syncHiddenInput();
if (this._input && !this._input.value) {
this._input.value = initialName;
}
}
if (this._input) {
this._input.placeholder = this._placeholder;
}
}
_getFn(name) {
if (!name) {
return null;
}
const fn = window[name];
return typeof fn === "function" ? fn : null;
}
_applyInitialValue() {
const valueAttr = this.getAttribute("value") || "";
if (this._input && valueAttr && !this._input.value) {
this._input.value = valueAttr;
}
}
_handleInput(event) {
const value = event.target.value.trim();
this._selected = null;
this._highlightedIndex = -1;
this._syncHiddenInput();
this._updateValidity();
this._maybeCheckDuplicates(value);
if (!this._autocomplete) {
return;
}
if (value.length < this._minChars) {
this._options = [];
this._renderOptions();
this._hideList();
return;
}
this._debouncedFetch(value);
}
_handleFocus() {
if (this._options.length > 0) {
this._showList();
}
}
_handleKeyDown(event) {
if (event.key === "Escape") {
this._hideList();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
this._moveHighlight(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
this._moveHighlight(-1);
return;
}
if (event.key === "Home") {
event.preventDefault();
this._setHighlight(0);
return;
}
if (event.key === "End") {
event.preventDefault();
this._setHighlight(this._options.length - 1);
return;
}
if (event.key === "Enter") {
if (this._options.length === 0) {
return;
}
event.preventDefault();
const index = this._highlightedIndex >= 0 ? this._highlightedIndex : 0;
this._selectOption(this._options[index]);
}
}
_handleClear(event) {
event.preventDefault();
if (this._input) {
this._input.value = "";
}
this._selected = null;
this._options = [];
this._syncHiddenInput();
this._updateValidity();
this._renderOptions();
this._hideList();
this._maybeCheckDuplicates("");
this.dispatchEvent(new CustomEvent("lfchange", { bubbles: true, detail: { item: null } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
}
_handleClickOutside(event) {
if (!this.contains(event.target)) {
this._hideList();
}
}
_debouncedFetch(query) {
if (this._fetchTimeout) {
clearTimeout(this._fetchTimeout);
}
this._fetchTimeout = setTimeout(() => {
this._fetchOptions(query);
}, LF_FETCH_DEBOUNCE_MS);
}
async _fetchOptions(query) {
if (!this._endpoint) {
return;
}
if (this._fetchController) {
this._fetchController.abort();
}
this._fetchController = new AbortController();
const url = new URL(this._endpoint, window.location.origin);
url.searchParams.set("q", query);
if (this._limit > 0) {
url.searchParams.set("limit", String(this._limit));
}
try {
const resp = await fetch(url.toString(), { signal: this._fetchController.signal });
if (!resp.ok) {
return;
}
const data = await resp.json();
const items = Array.isArray(data?.[this._resultKey]) ? data[this._resultKey] : [];
this._options = items
.filter((item) => item && item.id && item.name)
.map((item) => {
if ("musenalm_id" in item && item.musenalm_id) {
return item;
}
const musenalmId = item.MusenalmID || item.musenalmId || item.musenalmID || "";
return musenalmId ? { ...item, musenalm_id: musenalmId } : item;
});
this._highlightedIndex = this._options.length > 0 ? 0 : -1;
this._maybeAutoSelectExactMatch(query);
this._renderOptions();
if (this._options.length > 0) {
if (this._options.length === 1 && this._isExactMatch(query, this._options[0]?.name || "")) {
this._hideList();
} else {
this._showList();
}
} else {
this._hideList();
}
} catch (err) {
if (err?.name === "AbortError") {
return;
}
}
}
_renderOptions() {
if (!this._list) {
return;
}
this._list.innerHTML = "";
this._options.forEach((item, idx) => {
const option = document.createElement("button");
option.type = "button";
option.setAttribute("data-index", String(idx));
option.className = `${LF_OPTION_CLASS} w-full text-left px-3 py-2 hover:bg-slate-100 transition-colors`;
const isHighlighted = idx === this._highlightedIndex;
option.classList.toggle("bg-slate-100", isHighlighted);
option.setAttribute("aria-selected", isHighlighted ? "true" : "false");
const nameEl = document.createElement("div");
nameEl.className = "text-sm font-semibold text-gray-800";
nameEl.textContent = item.name;
option.appendChild(nameEl);
if (item.detail) {
const detailEl = document.createElement("div");
detailEl.className = "text-xs text-gray-600";
detailEl.textContent = item.detail;
option.appendChild(detailEl);
}
option.addEventListener("click", () => {
this._selectOption(item);
});
this._list.appendChild(option);
});
}
_selectOption(item) {
this._selected = item;
if (this._input) {
this._input.value = item.name || "";
}
this._syncHiddenInput();
this._updateValidity();
this._hideList();
this.dispatchEvent(new CustomEvent("lfchange", { bubbles: true, detail: { item } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
}
_isExactMatch(query, name) {
const lhs = (query || "").trim().toLowerCase();
const rhs = (name || "").trim().toLowerCase();
return lhs !== "" && lhs === rhs;
}
_maybeAutoSelectExactMatch(query) {
const match = this._options.find((item) => this._isExactMatch(query, item?.name || ""));
if (!match) {
return;
}
const prevId = this._selected?.id || "";
this._selected = match;
this._syncHiddenInput();
this._updateValidity();
if (match.id !== prevId) {
this.dispatchEvent(new CustomEvent("lfchange", { bubbles: true, detail: { item: match } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
}
}
_setHighlight(index) {
if (this._options.length === 0) {
this._highlightedIndex = -1;
return;
}
const nextIndex = Math.max(0, Math.min(index, this._options.length - 1));
this._highlightedIndex = nextIndex;
this._renderOptions();
this._scrollHighlightedIntoView();
this._showList();
}
_moveHighlight(delta) {
if (this._options.length === 0) {
this._highlightedIndex = -1;
return;
}
const startIndex = this._highlightedIndex >= 0 ? this._highlightedIndex : 0;
const nextIndex = Math.max(0, Math.min(startIndex + delta, this._options.length - 1));
this._highlightedIndex = nextIndex;
this._renderOptions();
this._scrollHighlightedIntoView();
this._showList();
}
_scrollHighlightedIntoView() {
if (!this._list || this._highlightedIndex < 0) {
return;
}
const option = this._list.querySelector(`[data-index="${this._highlightedIndex}"]`);
if (option) {
option.scrollIntoView({ block: "nearest" });
}
}
_syncHiddenInput() {
if (!this._hiddenInput) {
return;
}
let nextValue = "";
if (this._valueFn && this._selected) {
nextValue = String(this._valueFn({ item: this._selected, displayValue: this._input?.value || "" }) || "");
} else if (this._selected?.id) {
nextValue = this._selected.id;
}
this._hiddenInput.value = nextValue;
}
_updateValidity() {
const displayValue = (this._input?.value || "").trim();
const hiddenValue = (this._hiddenInput?.value || "").trim();
let isValid = true;
if (this._validFn) {
isValid = Boolean(this._validFn({ value: hiddenValue || displayValue, displayValue, hiddenValue, item: this._selected }));
} else if (this._required) {
isValid = (hiddenValue || displayValue).length > 0;
}
const linkUrl = this._linkFn ? this._linkFn({ item: this._selected, value: hiddenValue || displayValue }) : "";
if (this._warnIcon && this._linkButton) {
if (!isValid) {
this._warnIcon.classList.remove("hidden");
this._linkButton.classList.add("hidden");
} else if (linkUrl) {
this._warnIcon.classList.add("hidden");
this._linkButton.classList.remove("hidden");
this._linkButton.setAttribute("href", linkUrl);
} else {
this._warnIcon.classList.add("hidden");
this._linkButton.classList.add("hidden");
}
}
if (this._clearButton) {
this._clearButton.classList.toggle("hidden", displayValue.length === 0);
}
}
_maybeCheckDuplicates(value) {
if (!this._dupEndpoint || !this._dupResultKey || !this._dupWarning) {
return;
}
if (this._dupTimeout) {
clearTimeout(this._dupTimeout);
}
this._dupTimeout = setTimeout(() => {
this._checkDuplicates(value);
}, LF_FETCH_DEBOUNCE_MS);
}
async _checkDuplicates(value) {
if (!this._dupEndpoint || !this._dupResultKey || !this._dupWarning) {
return;
}
const trimmed = (value || "").trim();
if (!trimmed) {
this._dupWarning.classList.add("hidden");
return;
}
try {
const url = new URL(this._dupEndpoint, window.location.origin);
url.searchParams.set("q", trimmed);
url.searchParams.set("limit", "100");
const resp = await fetch(url.toString());
if (!resp.ok) {
return;
}
const data = await resp.json();
const results = data[this._dupResultKey] || [];
let filtered = results;
if (this._dupCurrentId) {
filtered = results.filter((item) => item.id !== this._dupCurrentId);
}
const matches = this._dupExact
? filtered.filter((item) => item.name && item.name.toLowerCase() === trimmed.toLowerCase())
: filtered;
if (matches.length > 0) {
const textEl = this._dupWarning.querySelector("[data-role='dup-text']");
if (textEl) {
textEl.textContent = `Der Name ist bereits vorhanden (${matches.length} Treffer)`;
}
this._dupWarning.classList.remove("hidden");
} else {
this._dupWarning.classList.add("hidden");
}
} catch {
this._dupWarning.classList.add("hidden");
}
}
_parsePositiveInt(value, fallback) {
const parsed = parseInt(value || "", 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
_showList() {
if (!this._list) {
return;
}
this._listVisible = true;
this._list.classList.remove("hidden");
}
_hideList() {
if (!this._list) {
return;
}
this._listVisible = false;
this._list.classList.add("hidden");
}
}

View File

@@ -29,6 +29,7 @@ import { EditPage } from "./edit-page.js";
import { FabMenu } from "./fab-menu.js";
import { DuplicateWarningChecker } from "./duplicate-warning.js";
import { ContentImages } from "./content-images.js";
import { LookupField } from "./lookup-field.js";
const FILTER_LIST_ELEMENT = "filter-list";
const FAB_MENU_ELEMENT = "fab-menu";
@@ -51,6 +52,12 @@ const RELATIONS_EDITOR_ELEMENT = "relations-editor";
const EDIT_PAGE_ELEMENT = "edit-page";
const DUPLICATE_WARNING_ELEMENT = "duplicate-warning-checker";
const CONTENT_IMAGES_ELEMENT = "content-images";
const LOOKUP_FIELD_ELEMENT = "lookup-field";
window.lookupSeriesValue = ({ item }) => item?.id || "";
window.lookupSeriesLink = ({ item }) => (item?.musenalm_id ? `/reihe/${item.musenalm_id}` : "");
window.lookupRequiredText = ({ displayValue }) => Boolean((displayValue || "").trim());
window.lookupRequiredId = ({ hiddenValue }) => Boolean((hiddenValue || "").trim());
customElements.define(INT_LINK_ELEMENT, IntLink);
customElements.define(ABBREV_TOOLTIPS_ELEMENT, AbbreviationTooltips);
@@ -73,6 +80,7 @@ customElements.define(EDIT_PAGE_ELEMENT, EditPage);
customElements.define(FAB_MENU_ELEMENT, FabMenu);
customElements.define(DUPLICATE_WARNING_ELEMENT, DuplicateWarningChecker);
customElements.define(CONTENT_IMAGES_ELEMENT, ContentImages);
customElements.define(LOOKUP_FIELD_ELEMENT, LookupField);
function PathPlusQuery() {
const path = window.location.pathname;
@@ -469,4 +477,4 @@ window.HookupRBChange = HookupRBChange;
window.FormLoad = FormLoad;
window.TextareaAutoResize = TextareaAutoResize;
export { FilterList, ScrollButton, AbbreviationTooltips, MultiSelectSimple, MultiSelectRole, ToolTip, PopupImage, TabList, FilterPill, ImageReel, IntLink, ItemsEditor, SingleSelectRemote, AlmanachEditPage, RelationsEditor, EditPage, FabMenu };
export { FilterList, ScrollButton, AbbreviationTooltips, MultiSelectSimple, MultiSelectRole, ToolTip, PopupImage, TabList, FilterPill, ImageReel, IntLink, ItemsEditor, SingleSelectRemote, AlmanachEditPage, RelationsEditor, EditPage, FabMenu, LookupField };

View File

@@ -26,6 +26,11 @@ export class SingleSelectRemote extends HTMLElement {
this._fetchTimeout = null;
this._fetchController = null;
this._listVisible = false;
this._linkBase = "";
this._linkTarget = "_blank";
this._linkButton = null;
this._showWarningIcon = false;
this._linkField = "id";
this._boundHandleInput = this._handleInput.bind(this);
this._boundHandleFocus = this._handleFocus.bind(this);
this._boundHandleKeyDown = this._handleKeyDown.bind(this);
@@ -34,7 +39,19 @@ export class SingleSelectRemote extends HTMLElement {
}
static get observedAttributes() {
return ["data-endpoint", "data-result-key", "data-minchars", "data-limit", "placeholder", "name"];
return [
"data-endpoint",
"data-result-key",
"data-minchars",
"data-limit",
"placeholder",
"name",
"data-link-base",
"data-link-target",
"data-link-field",
"data-initial-link-id",
"data-show-warning-icon",
];
}
connectedCallback() {
@@ -49,6 +66,13 @@ export class SingleSelectRemote extends HTMLElement {
this._minChars = this._parsePositiveInt(this.getAttribute("data-minchars"), SSR_DEFAULT_MIN_CHARS);
this._limit = this._parsePositiveInt(this.getAttribute("data-limit"), SSR_DEFAULT_LIMIT);
this._placeholder = this.getAttribute("placeholder") || "Search...";
const initialId = this.getAttribute("data-initial-id") || "";
const initialName = this.getAttribute("data-initial-name") || "";
const initialLinkId = this.getAttribute("data-initial-link-id") || "";
this._linkBase = this.getAttribute("data-link-base") || "";
this._linkTarget = this.getAttribute("data-link-target") || "_blank";
this._linkField = this.getAttribute("data-link-field") || "id";
this._showWarningIcon = this.getAttribute("data-show-warning-icon") === "true";
if (this._input) {
this._input.placeholder = this._placeholder;
@@ -57,10 +81,21 @@ export class SingleSelectRemote extends HTMLElement {
this._input.addEventListener("keydown", this._boundHandleKeyDown);
}
this._linkButton = this.querySelector("[data-role='ssr-open-link']");
if (this._clearButton) {
this._clearButton.addEventListener("click", this._boundHandleClear);
}
if (initialId && initialName) {
this._selected = { id: initialId, name: initialName, linkId: initialLinkId };
if (this._input) {
this._input.value = initialName;
}
this._syncHiddenInput();
}
this._updateLinkButton();
document.addEventListener("click", this._boundHandleClickOutside);
}
@@ -89,6 +124,10 @@ export class SingleSelectRemote extends HTMLElement {
if (this._input) this._input.placeholder = this._placeholder;
}
if (name === "name" && this._hiddenInput) this._hiddenInput.name = newValue || "";
if (name === "data-link-base") this._linkBase = newValue || "";
if (name === "data-link-target") this._linkTarget = newValue || "_blank";
if (name === "data-link-field") this._linkField = newValue || "id";
if (name === "data-show-warning-icon") this._showWarningIcon = newValue === "true";
}
_handleInput(event) {
@@ -154,6 +193,7 @@ export class SingleSelectRemote extends HTMLElement {
this._options = [];
if (this._input) this._input.value = "";
this._syncHiddenInput();
this._updateLinkButton();
this._renderOptions();
this._hideList();
this.dispatchEvent(new CustomEvent("ssrchange", { bubbles: true, detail: { item: null } }));
@@ -208,9 +248,14 @@ export class SingleSelectRemote extends HTMLElement {
this._options = filteredItems;
this._highlightedIndex = this._options.length > 0 ? 0 : -1;
this._maybeAutoSelectExactMatch(query);
this._renderOptions();
if (this._options.length > 0) {
this._showList();
if (this._options.length === 1 && this._isExactMatch(query, this._options[0]?.name || "")) {
this._hideList();
} else {
this._showList();
}
} else {
this._hideList();
}
@@ -221,6 +266,30 @@ export class SingleSelectRemote extends HTMLElement {
}
}
_isExactMatch(query, name) {
const lhs = (query || "").trim().toLowerCase();
const rhs = (name || "").trim().toLowerCase();
return lhs !== "" && lhs === rhs;
}
_maybeAutoSelectExactMatch(query) {
if (!query) {
return;
}
const match = this._options.find((item) => this._isExactMatch(query, item?.name || ""));
if (!match) {
return;
}
const prevId = this._selected?.id || "";
this._selected = match;
this._syncHiddenInput();
this._updateLinkButton();
if (match.id !== prevId) {
this.dispatchEvent(new CustomEvent("ssrchange", { bubbles: true, detail: { item: match } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
}
}
_renderOptions() {
if (!this._list) {
return;
@@ -309,6 +378,7 @@ export class SingleSelectRemote extends HTMLElement {
this._input.value = item.name || "";
}
this._syncHiddenInput();
this._updateLinkButton();
this._hideList();
this.dispatchEvent(new CustomEvent("ssrchange", { bubbles: true, detail: { item } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
@@ -359,6 +429,14 @@ export class SingleSelectRemote extends HTMLElement {
spellcheck="false"
placeholder="${this._placeholder}"
/>
<a
class="ssr-open-link hidden text-sm text-gray-600 hover:text-gray-900 no-underline"
data-role="ssr-open-link"
aria-label="Auswahl öffnen"
target="${this._linkTarget}"
rel="noreferrer">
<i data-role="ssr-open-link-icon" class="ri-external-link-line"></i>
</a>
<button type="button" class="${SSR_CLEAR_BUTTON_CLASS} text-sm text-gray-600 hover:text-gray-900">
<i class="ri-close-line"></i>
</button>
@@ -368,4 +446,34 @@ export class SingleSelectRemote extends HTMLElement {
</div>
`;
}
_updateLinkButton() {
if (!this._linkButton) {
return;
}
const linkValue = this._selected?.[this._linkField] || this._selected?.linkId || this._selected?.id;
const icon = this._linkButton.querySelector("[data-role='ssr-open-link-icon']");
if (!linkValue || !this._linkBase) {
if (this._showWarningIcon) {
this._linkButton.classList.remove("hidden");
this._linkButton.removeAttribute("href");
this._linkButton.classList.add("ssr-open-link-warning");
this._linkButton.setAttribute("aria-label", "Auswahl fehlt");
if (icon) {
icon.className = "ri-error-warning-line";
}
} else {
this._linkButton.classList.add("hidden");
this._linkButton.removeAttribute("href");
}
return;
}
this._linkButton.classList.remove("hidden");
this._linkButton.classList.remove("ssr-open-link-warning");
this._linkButton.setAttribute("href", `${this._linkBase}${linkValue}`);
this._linkButton.setAttribute("aria-label", "Auswahl öffnen");
if (icon) {
icon.className = "ri-external-link-line";
}
}
}