mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-08 23:50:47 +02:00
349 lines
11 KiB
JavaScript
349 lines
11 KiB
JavaScript
window.app = window.app || {};
|
|
window.app.components = window.app.components || {};
|
|
|
|
/**
|
|
* Generic custom select element.
|
|
* If max > 1 the select is multiple, otherwise - single (default).
|
|
*
|
|
* Note that if label or selected are custom DOM elements they need to be
|
|
* wrapped in a function to allow recreation when toggling the select options.
|
|
*
|
|
* @example
|
|
* ```js
|
|
* app.components.select({
|
|
* options: [
|
|
* { value: "opt1", label: "Opt 1" },
|
|
* { value: "opt2", label: "Opt 2", selected: "Opt 2 selected label" },
|
|
* { value: "opt2", label: () => t.div(null, "Custom element") },
|
|
* ],
|
|
* value: () => data.selected,
|
|
* onchange: (opts) => data.selected = opts.map((opt) => opt.value),
|
|
* })
|
|
* ```
|
|
*
|
|
* @param {Object} [propsArg]
|
|
* @return {Element}
|
|
*/
|
|
window.app.components.select = function(propsArg = {}) {
|
|
const props = store({
|
|
rid: undefined,
|
|
id: undefined, // used for associating with a label
|
|
name: undefined, // used for error matching
|
|
hidden: undefined,
|
|
inert: undefined,
|
|
className: "",
|
|
value: undefined,
|
|
options: [], // [{value, label?, selected?}, ...]
|
|
before: null,
|
|
after: null,
|
|
max: 1,
|
|
searchThreshold: 6,
|
|
required: false,
|
|
disabled: false,
|
|
placeholder: "- Select -",
|
|
noItemsFoundText: "No items found",
|
|
onchange: function(selectedOpts) {},
|
|
ondropdowntoggle: function(e) {},
|
|
});
|
|
|
|
const watchers = app.utils.extendStore(props, propsArg);
|
|
|
|
if (props.max <= 0) {
|
|
props.max = 1;
|
|
}
|
|
|
|
const internalData = store({
|
|
selected: [],
|
|
search: "",
|
|
get hasSearch() {
|
|
return internalData.search?.length > 0;
|
|
},
|
|
get allowRemove() {
|
|
return !props.disabled && (!props.required || props.max > 1);
|
|
},
|
|
});
|
|
|
|
function syncSelected() {
|
|
if (typeof props.value === "undefined") {
|
|
return; // nothing to sync
|
|
}
|
|
|
|
const optVals = app.utils.toArray(props.value, true);
|
|
const cappedOptVals = optVals.slice(0, props.max || 1);
|
|
|
|
if (optVals.length != cappedOptVals.length) {
|
|
console.warn(
|
|
`[select] the provided select values (${optVals.length}) are more than the allowed max selected options (${cappedOptVals.length}):`,
|
|
optVals,
|
|
);
|
|
props.value = props.max > 1 ? cappedOptVals : cappedOptVals[0];
|
|
}
|
|
|
|
internalData.selected = optVals
|
|
.map((value) => {
|
|
return props.options.find((opt) => opt.value === value);
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
watchers.push(
|
|
watch(
|
|
() => props.value,
|
|
() => syncSelected(),
|
|
),
|
|
);
|
|
|
|
async function toggle(opt) {
|
|
const idx = internalData.selected.findIndex((o) => o.value === opt.value);
|
|
if (idx >= 0) {
|
|
if (!internalData.allowRemove) {
|
|
dropdown?.hidePopover();
|
|
return; // no change
|
|
}
|
|
|
|
internalData.selected.splice(idx, 1);
|
|
} else {
|
|
// clear last redundant elements (leaving place for the new selected)
|
|
let toRemove = internalData.selected.length - props.max;
|
|
while (toRemove >= 0) {
|
|
internalData.selected.pop();
|
|
toRemove--;
|
|
}
|
|
|
|
internalData.selected.push(opt);
|
|
}
|
|
|
|
if (props.max <= 1) {
|
|
dropdown?.hidePopover();
|
|
}
|
|
|
|
if (props.onchange) {
|
|
await props.onchange(internalData.selected);
|
|
syncSelected(); // manually sync in case in the onchange handler the value didn't change
|
|
}
|
|
|
|
// trigger custom change event for clearing field errors
|
|
if (selectedContainer?.isConnected) {
|
|
selectedContainer.dispatchEvent(
|
|
new CustomEvent("change", {
|
|
detail: internalData.selected,
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function isSelected(opt) {
|
|
return internalData.selected.findIndex((o) => o.value === opt.value) >= 0;
|
|
}
|
|
|
|
const searchInput = t.input({
|
|
type: "text",
|
|
placeholder: "Search...",
|
|
value: () => internalData.search,
|
|
oninput: (e) => (internalData.search = e.target.value),
|
|
});
|
|
|
|
function clearSearch(focus = false) {
|
|
internalData.search = "";
|
|
|
|
if (focus) {
|
|
searchInput?.focus();
|
|
}
|
|
}
|
|
|
|
const noItemsFoundElem = t.div(
|
|
{ className: "txt-hint txt-center m-0 p-5", hidden: true },
|
|
(el) => {
|
|
if (typeof props.noItemsFoundText == "function") {
|
|
return props.noItemsFoundText(el);
|
|
}
|
|
return props.noItemsFoundText;
|
|
},
|
|
);
|
|
|
|
async function toggleNoItemsFoundElem() {
|
|
if (!dropdown) {
|
|
return;
|
|
}
|
|
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
if (dropdown.querySelector(".select-option:not([hidden])")) {
|
|
noItemsFoundElem.hidden = true;
|
|
} else {
|
|
noItemsFoundElem.hidden = false;
|
|
}
|
|
}
|
|
|
|
const dropdown = t.div(
|
|
{
|
|
tabIndex: -1,
|
|
popover: "auto",
|
|
className: "dropdown",
|
|
onbeforetoggle: (e) => {
|
|
if (e.newState == "closed") {
|
|
clearSearch();
|
|
}
|
|
return props.ondropdowntoggle?.(e);
|
|
},
|
|
},
|
|
t.div(
|
|
{
|
|
className: "fields dropdown-search",
|
|
hidden: () => props.options.length < props.searchThreshold,
|
|
},
|
|
t.div({ className: "field" }, searchInput),
|
|
t.div(
|
|
{
|
|
className: "field addon p-r-5",
|
|
hidden: () => !internalData.hasSearch,
|
|
},
|
|
t.button(
|
|
{
|
|
type: "button",
|
|
title: "Clear",
|
|
className: "btn sm secondary transparent circle",
|
|
onclick: () => clearSearch(true),
|
|
},
|
|
t.i({ className: "ri-close-line", ariaHidden: true }),
|
|
),
|
|
),
|
|
),
|
|
() => props.before?.__raw || props.before,
|
|
() => {
|
|
return props.options.map((opt) => {
|
|
return t.button(
|
|
{
|
|
type: "button",
|
|
className: () => `dropdown-item select-option ${isSelected(opt) ? "active" : ""}`,
|
|
onclick: () => {
|
|
toggle(opt);
|
|
return false;
|
|
},
|
|
},
|
|
opt.label || opt.value,
|
|
);
|
|
});
|
|
},
|
|
noItemsFoundElem,
|
|
() => props.after?.__raw || props.after,
|
|
);
|
|
|
|
const selectedContainer = t.button(
|
|
{
|
|
type: "button",
|
|
id: () => props.id,
|
|
name: () => props.name,
|
|
disabled: () => props.disabled,
|
|
className: () => `selected-container ${props.className}`,
|
|
popoverTargetElement: dropdown,
|
|
onclick: (e) => {
|
|
e.stopPropagation();
|
|
},
|
|
},
|
|
() => {
|
|
if (!internalData.selected.length) {
|
|
return t.span(
|
|
{ rid: "selected-placeholder", className: "placeholder" },
|
|
(el) => {
|
|
if (typeof props.placeholder == "function") {
|
|
return props.placeholder(el);
|
|
}
|
|
return props.placeholder;
|
|
},
|
|
);
|
|
}
|
|
|
|
return internalData.selected.map((opt) => {
|
|
return t.div({ className: "selected-item" }, opt.selected || opt.label || opt.value, () => {
|
|
if (!internalData.allowRemove) {
|
|
return;
|
|
}
|
|
|
|
return t.i({
|
|
tabIndex: -1,
|
|
role: "button",
|
|
className: "ri-close-line link-hint btn-option-unset",
|
|
ariaLabel: app.attrs.tooltip("Unset"),
|
|
onclick: () => {
|
|
toggle(opt);
|
|
return false;
|
|
},
|
|
});
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
watchers.push(
|
|
watch(
|
|
() => props.options,
|
|
() => {
|
|
toggleNoItemsFoundElem();
|
|
},
|
|
),
|
|
);
|
|
|
|
// search watcher
|
|
let searchDebounce;
|
|
watchers.push(
|
|
watch(
|
|
() => internalData.search,
|
|
() => {
|
|
const normalizedSearch = internalData.search.toLowerCase().replaceAll(" ", "");
|
|
|
|
clearTimeout(searchDebounce);
|
|
searchDebounce = setTimeout(() => {
|
|
const options = dropdown.querySelectorAll(".select-option");
|
|
|
|
if (!normalizedSearch.length) {
|
|
options.forEach((opt) => (opt.hidden = false));
|
|
} else {
|
|
options.forEach((opt) => {
|
|
const txt = opt.textContent.toLowerCase().replaceAll(" ", "");
|
|
if (!txt.includes(normalizedSearch)) {
|
|
opt.hidden = true;
|
|
} else {
|
|
opt.hidden = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleNoItemsFoundElem();
|
|
}, 100);
|
|
},
|
|
),
|
|
);
|
|
|
|
return t.div(
|
|
{
|
|
rid: props.rid,
|
|
hidden: () => props.hidden,
|
|
inert: () => props.inert,
|
|
onmount: (el) => {
|
|
el.addEventListener("focusout", function(e) {
|
|
if (!e.relatedTarget || !el.contains(e.relatedTarget)) {
|
|
dropdown?.hidePopover();
|
|
}
|
|
});
|
|
},
|
|
onunmount: () => {
|
|
clearTimeout(searchDebounce);
|
|
watchers.forEach((w) => w.unwatch());
|
|
},
|
|
className: () => {
|
|
return [
|
|
"input",
|
|
"select",
|
|
props.max > 1 ? "multiple" : "single",
|
|
props.disabled ? "disabled" : "",
|
|
props.required ? "required" : "",
|
|
].join(" ");
|
|
},
|
|
},
|
|
selectedContainer,
|
|
dropdown,
|
|
);
|
|
};
|