UNPKG

platina-core

Version:

UI Kit of SberMarketing

247 lines (246 loc) 8.84 kB
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); } }