platina-core
Version:
UI Kit of SberMarketing
247 lines (246 loc) • 8.84 kB
JavaScript
import { derived, get, writable } from 'svelte/store';
import { getContext, setContext, tick } from 'svelte';
import { nanoid } from 'nanoid';
import { createNativeEventDispatcher } from '../utils/createNativeEventDispatcher';
const DEFAULT_PAGE_SIZE = 20;
const DEFAULT_SEARCH_DEBOUNCE = 300;
function defaultSearchBy(option) {
if (typeof option === 'object') {
return Object.values(option)
.map((v) => defaultSearchBy(v))
.join(' ')
.toLowerCase();
}
return option.toString();
}
export class Combobox {
props;
options;
selected;
stateMap;
search;
opened;
filtered;
ctxKey;
multiple;
disabled;
dispatch = null;
lastOptions = '';
searchIndex;
selectedItems = {};
groupBy;
searchBy;
keyBy;
// async-режим: callback + параметры; null если опции приходят через prop options
loadCallback;
pageSize;
searchDebounce;
loading;
hasMore;
currentSearch = '';
currentSkip = 0;
abortController = null;
searchTimer = null;
lastDebouncedSearch = '';
constructor({ selected, groupBy, searchBy, keyBy, multiple, config, disabled, loadOptions }) {
this.groupBy = groupBy;
this.searchBy = searchBy ?? defaultSearchBy;
this.keyBy = keyBy ?? this.searchBy;
this.props = { selected, groupBy, searchBy, multiple, disabled, config };
this.ctxKey = config?.ctxKey ?? nanoid(10);
this.multiple = multiple;
this.disabled = writable(disabled);
this.options = writable();
this.selected = writable(multiple ? (selected ?? []) : selected ? [selected] : []);
this.search = writable('');
this.opened = writable(false);
this.stateMap = writable();
this.searchIndex = {};
this.loadCallback = loadOptions?.callback ?? null;
this.pageSize = loadOptions?.pageSize ?? DEFAULT_PAGE_SIZE;
this.searchDebounce = loadOptions?.searchDebounce ?? DEFAULT_SEARCH_DEBOUNCE;
this.loading = writable(false);
this.hasMore = writable(Boolean(loadOptions));
this.stateMap.subscribe((state) => {
if (!state)
return;
const nextSelectedItems = {};
const selected = [];
for (const [index, checked] of Object.entries(state)) {
if (!checked)
continue;
const item = this.searchIndex[index] ?? this.selectedItems[index];
if (item === undefined)
continue;
nextSelectedItems[index] = item;
selected.push(item);
}
this.selectedItems = nextSelectedItems;
this.selected.set(selected);
tick().then(() => {
if (!this.dispatch)
return;
this.dispatch('select', this.multiple ? selected : selected[0]);
});
});
this.filtered = derived([this.options, this.search, this.selected], ([$options, $search, $selected]) => {
if (!$options)
return [];
if (this.loadCallback) {
const seen = new Set();
const merged = [];
for (const item of [...$selected, ...$options]) {
const key = this.keyBy(item);
if (seen.has(key))
continue;
seen.add(key);
merged.push(item);
}
return merged;
}
return Object.entries(this.searchIndex)
.filter(([index]) => index.toLowerCase().includes($search.toLowerCase()))
.map(([, option]) => option);
});
if (this.loadCallback) {
this.search.subscribe(($search) => {
if ($search === this.lastDebouncedSearch)
return;
if (this.searchTimer)
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.lastDebouncedSearch = $search;
this.fetchFirstPage($search);
}, this.searchDebounce);
});
}
setContext(this.ctxKey, this);
}
async fetchFirstPage(search) {
if (!this.loadCallback)
return;
this.abortController?.abort();
const ac = new AbortController();
this.abortController = ac;
this.currentSearch = search;
this.currentSkip = 0;
this.render([]);
this.loading.set(true);
try {
const result = await this.loadCallback({ search, skip: 0, take: this.pageSize, signal: ac.signal });
if (ac.signal.aborted)
return;
this.hasMore.set(result.length === this.pageSize);
this.currentSkip = result.length;
this.render(result);
}
catch (e) {
if (!ac.signal.aborted)
console.error(e);
}
finally {
if (!ac.signal.aborted)
this.loading.set(false);
}
}
async loadMore() {
if (!this.loadCallback)
return;
if (get(this.loading) || !get(this.hasMore))
return;
const ac = new AbortController();
this.abortController?.abort();
this.abortController = ac;
const search = this.currentSearch;
const skip = this.currentSkip;
this.loading.set(true);
try {
const result = await this.loadCallback({ search, skip, take: this.pageSize, signal: ac.signal });
if (ac.signal.aborted)
return;
this.hasMore.set(result.length === this.pageSize);
this.currentSkip += result.length;
this.render(result, { append: true });
}
catch (e) {
if (!ac.signal.aborted)
console.error(e);
}
finally {
if (!ac.signal.aborted)
this.loading.set(false);
}
}
destroy() {
if (this.searchTimer)
clearTimeout(this.searchTimer);
this.abortController?.abort();
}
initDispatcher(element) {
if (!element)
throw Error('Ошибка! HTML-элемент не определен');
this.dispatch = createNativeEventDispatcher(() => element);
}
render(options, { append = false } = {}) {
const baseOptions = append ? [...(get(this.options) ?? []), ...options] : options;
const memorizedOptions = JSON.stringify(baseOptions);
if (memorizedOptions === this.lastOptions)
return;
this.lastOptions = memorizedOptions;
const selected = get(this.selected);
const selectedSet = new Set(selected.map((s) => this.keyBy(s)));
this.searchIndex = Object.fromEntries(baseOptions.map((opt) => [this.keyBy(opt), opt]));
if (this.loadCallback) {
for (const s of selected) {
const k = this.keyBy(s);
if (!(k in this.searchIndex))
this.searchIndex[k] = s;
}
}
this.stateMap.set(Object.fromEntries(Object.keys(this.searchIndex).map((index) => [index, selectedSet.has(index)])));
this.options.set(baseOptions);
}
static get self() {
const ctxKey = getContext('ctxKey');
if (!ctxKey)
throw Error('Combobox: ctxKey must be set inside Combobox component');
return getContext(ctxKey);
}
toggle(option) {
const index = this.keyBy(option);
this.stateMap.update((state) => {
if (!this.multiple) {
for (const key of Object.keys(state)) {
state[key] = false;
}
state[index] = true;
this.opened.set(false);
return state;
}
state[index] = !state[index];
return state;
});
const state = get(this.stateMap);
if (!this.dispatch)
return;
this.dispatch('toggle', { option, state: state[index] });
}
toggleAll() {
if (!this.multiple)
throw Error('Combobox: ToggleAll is forbidden for single select version');
const isSelectedAll = this.isSelectedAll();
this.stateMap.update((v) => {
for (const k of Object.keys(v)) {
v[k] = !isSelectedAll;
}
return v;
});
}
isSelectedAll() {
const current = Object.values(get(this.stateMap));
return current.filter((v) => v).length === current.length;
}
close() {
this.opened.set(false);
}
}