/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/CodePicker.js
978 строк
33 KB
Starolat Sergei
fix: выделение текста в списках виджетов
15 июл 2026, 09:35
15 июл 2026, 09:35
f30dafd
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview CodePicker - Переиспользуемый компонент выбора Activity Codes (COMP-018) * * Режим mode="value" (default): * Список справочников → экран кодов выбранного справочника → выбор значений (checkbox, multiselect) * Режим mode="type": * Список справочников → клик сразу выбирает справочник (без перехода) * * @version 1.2.1 * * v1.2.1 — Поиск по значению справочника переносится внутрь справочника. * - Если справочник попал в результаты поиска из-за совпадения значения, * при переходе в него запрос копируется в поле поиска значений, чтобы * сразу отфильтровать подходящие коды. * * v1.2.0 — FEAT-004: множественный выбор значений справочника в режиме value. * - selectedValues: Map<typeId, Set<valueId>> * - Событие code-value-select содержит флаг ctrlKey (Ctrl/Cmd+Click). * - Визуально radio заменён на checkbox. */ import dataService from "../services/DataService.js"; import { Box, Check, ChevronLeft, Folder, X } from "./ConstantinIcons.js"; /** * @typedef {import('../types.js').CodeType} CodeType * @typedef {import('../types.js').CodeValue} CodeValue * @typedef {import('../types.js').CodePickerItem} CodePickerItem * @typedef {import('../types.js').CodePickerSelection} CodePickerSelection */ class CodePicker extends HTMLElement { constructor() { super(); /** @type {CodePickerItem[]} */ this.availableTypes = []; /** @type {number|string|null} */ this.selectedTypeId = null; /** @type {string} */ this.typeSearchQuery = ""; /** @type {string} */ this.valueSearchQuery = ""; /** @type {boolean} */ this.isLoading = false; /** @type {string|null} */ this.loadError = null; /** @type {number} */ this._typeSearchDebounce = 0; /** @type {number} */ this._valueSearchDebounce = 0; /** @type {Set<number|string>} */ this.highlightedTypeIds = new Set(); /** @type {Map<number|string, Set<number>>} */ this.selectedValues = new Map(); // typeId -> Set<valueId> /** @type {Function} */ this._onDatabaseReady = this._onDatabaseReady.bind(this); } // ==================== Lifecycle ==================== connectedCallback() { this.render(); this.setupEventListeners(); document.addEventListener("database-ready", this._onDatabaseReady); const dbId = this.getAttribute("database-id") || dataService.getActiveDatabaseId(); if (dbId) { this.loadAvailableCodeTypes(dbId); } } disconnectedCallback() { document.removeEventListener("database-ready", this._onDatabaseReady); this._clearDebounceTimers(); if (this._clickHandler) { this.removeEventListener("click", this._clickHandler); this._clickHandler = null; } if (this._keydownHandler) { this.removeEventListener("keydown", this._keydownHandler); this._keydownHandler = null; } if (this._inputHandler) { this.removeEventListener("input", this._inputHandler); this._inputHandler = null; } } _clearDebounceTimers() { if (this._typeSearchDebounce) { clearTimeout(this._typeSearchDebounce); this._typeSearchDebounce = 0; } if (this._valueSearchDebounce) { clearTimeout(this._valueSearchDebounce); this._valueSearchDebounce = 0; } } /** * @param {CustomEvent} e */ _onDatabaseReady(e) { const databaseId = e.detail?.databaseId; if (databaseId) { this.loadAvailableCodeTypes(databaseId); } } // ==================== Attributes ==================== static get observedAttributes() { return ["mode", "database-id"]; } /** * @param {string} name * @param {string|null} oldValue * @param {string|null} newValue */ attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === "database-id" && newValue) { this.loadAvailableCodeTypes(newValue); } } /** * @returns {'value'|'type'} */ get mode() { const m = this.getAttribute("mode"); return m === "type" ? "type" : "value"; } /** * Псевдо-справочники, доступные в режиме type (например, группировка по ресурсу). * @returns {CodePickerItem[]} */ _getPseudoTypes() { if (this.mode !== "type") return []; return [ { typeId: "resource", name: "Ресурс", values: [], isLoading: false, isPseudo: true, }, ]; } /** * Все отображаемые справочники: псевдо + реальные. * @returns {CodePickerItem[]} */ _getAllTypes() { return [...this._getPseudoTypes(), ...this.availableTypes]; } // ==================== Data Loading ==================== /** * @param {string} [databaseId] * @returns {Promise<void>} */ async loadAvailableCodeTypes(databaseId) { const dbId = databaseId || dataService.getActiveDatabaseId(); if (!dbId) { this.availableTypes = []; this.loadError = null; this.isLoading = false; this.render(); return; } this.isLoading = true; this.loadError = null; this.render(); try { /** @type {CodeType[]} */ const codeTypes = await dataService.getCodeTypes(dbId, { scheduleType: "current", }); /** @type {CodePickerItem[]} */ const available = []; for (const ct of codeTypes) { const values = await dataService.getCodeValues( String(ct.typeId), dbId, { scheduleType: "current" }, ); available.push({ typeId: Number(ct.typeId), name: ct.name, values: values.map((v) => ({ ...v, valueId: Number(v.valueId), typeId: Number(v.typeId), })), isLoading: false, }); } this.availableTypes = available; this.isLoading = false; } catch (error) { console.error("[CodePicker] Failed to load code types:", error); this.loadError = error instanceof Error ? error.message : "Ошибка загрузки"; this.isLoading = false; } this.render(); } // ==================== Search & Navigation ==================== /** * @param {number|string} typeId */ selectType(typeId) { const availableType = this._getAllTypes().find( (at) => at.typeId === typeId, ); if (!availableType) return; if (this.mode === "type") { this.dispatchEvent( new CustomEvent("code-type-select", { detail: { typeId: availableType.typeId, typeName: availableType.name, isPseudo: availableType.isPseudo || false, }, bubbles: true, }), ); return; } // В режиме value псевдо-справочники недоступны if (availableType.isPseudo) return; this.selectedTypeId = typeId; // Если справочник попал в результаты из-за совпадения по значению // (а не по имени), переносим запрос в поиск значений, чтобы сразу // показать те коды, ради которых справочник был отображен. if ( this.typeSearchQuery && !this._typeMatchesByName(availableType, this.typeSearchQuery) && this._typeMatchesByValue(availableType, this.typeSearchQuery) ) { this.valueSearchQuery = this.typeSearchQuery; } else { this.valueSearchQuery = ""; } this.render(); // Focus management: focus back button after render requestAnimationFrame(() => { const backBtn = this.querySelector(".code-values-back"); if (backBtn) backBtn.focus(); }); } backToTypesList() { const previousTypeId = this.selectedTypeId; this.selectedTypeId = null; this.valueSearchQuery = ""; this.render(); this.dispatchEvent( new CustomEvent("back-to-types", { detail: { previousTypeId }, bubbles: true, }), ); // Focus management: focus previously selected type item requestAnimationFrame(() => { const typeItem = this.querySelector( `.available-type-item[data-type-id="${previousTypeId}"]`, ); if (typeItem) typeItem.focus(); }); } /** * @param {string} query */ setTypeSearchQuery(query) { this.typeSearchQuery = query; this.render(); } /** * @param {string} query */ setValueSearchQuery(query) { this.valueSearchQuery = query; this.render(); } /** * @param {(number|string)[]} ids */ setHighlightedTypeIds(ids) { this.highlightedTypeIds = new Set(ids); this.render(); } /** * @param {Array<{typeId: number|string, valueId: number}>} selections */ setSelections(selections) { this.selectedValues = new Map(); for (const s of selections) { if (!s || s.typeId == null || s.valueId == null) continue; let set = this.selectedValues.get(s.typeId); if (!set) { set = new Set(); this.selectedValues.set(s.typeId, set); } set.add(s.valueId); } this.render(); } /** * @param {number|string} typeId * @param {number} valueId * @returns {boolean} */ _isValueSelected(typeId, valueId) { const set = this.selectedValues.get(typeId); return set ? set.has(valueId) : false; } /** * Проверяет, содержит ли имя справочника поисковый запрос. * @param {CodePickerItem} type * @param {string} query * @returns {boolean} * @private */ _typeMatchesByName(type, query) { const q = query.trim().toLowerCase(); if (!q) return true; return type.name.toLowerCase().includes(q); } /** * Проверяет, содержит ли хотя бы одно значение справочника поисковый запрос. * @param {CodePickerItem} type * @param {string} query * @returns {boolean} * @private */ _typeMatchesByValue(type, query) { const q = query.trim().toLowerCase(); if (!q || type.isPseudo) return false; return type.values.some( (v) => v.shortName.toLowerCase().includes(q) || (v.description && v.description.toLowerCase().includes(q)), ); } clearSearch() { this.typeSearchQuery = ""; this.valueSearchQuery = ""; const typeInput = this.querySelector("#type-search-input"); if (typeInput) typeInput.value = ""; const valueInput = this.querySelector("#value-search-input"); if (valueInput) valueInput.value = ""; this.render(); } /** * @returns {CodePickerItem[]} */ getFilteredTypes() { const allTypes = this._getAllTypes(); const q = this.typeSearchQuery.trim().toLowerCase(); if (!q) return allTypes; return allTypes.filter((at) => { if (this._typeMatchesByName(at, this.typeSearchQuery)) return true; return this._typeMatchesByValue(at, this.typeSearchQuery); }); } /** * @returns {CodeValue[]} */ getFilteredValues() { if (this.selectedTypeId == null) return []; const availableType = this.availableTypes.find( (at) => at.typeId === this.selectedTypeId, ); if (!availableType) return []; const q = this.valueSearchQuery.trim().toLowerCase(); if (!q) return availableType.values; return availableType.values.filter( (v) => v.shortName.toLowerCase().includes(q) || (v.description && v.description.toLowerCase().includes(q)), ); } // ==================== Event Handling ==================== setupEventListeners() { if (this._clickHandler) return; // already attached this._clickHandler = (e) => { const target = /** @type {HTMLElement} */ (e.target); // Выбор справочника const typeItem = target.closest(".available-type-item"); if (typeItem) { const raw = typeItem.dataset.typeId || ""; const typeId = raw === "resource" ? raw : parseInt(raw, 10) || null; if (typeId !== null) this.selectType(typeId); return; } // Назад к списку справочников const backBtn = target.closest(".code-values-back"); if (backBtn) { this.backToTypesList(); return; } // Выбор кода (checkbox) const codeOption = target.closest(".code-value-option"); if (codeOption) { const typeId = parseInt(codeOption.dataset.codeTypeId || "0", 10); const valueId = parseInt(codeOption.dataset.codeValueId || "0", 10); if (typeId && valueId) this._selectCodeValue(typeId, valueId, e.ctrlKey || e.metaKey); return; } // Очистка поиска по справочникам const clearTypeSearch = target.closest(".clear-type-search"); if (clearTypeSearch) { this.setTypeSearchQuery(""); const input = this.querySelector("#type-search-input"); if (input) input.value = ""; return; } // Очистка поиска по кодам const clearValueSearch = target.closest(".clear-value-search"); if (clearValueSearch) { this.setValueSearchQuery(""); const input = this.querySelector("#value-search-input"); if (input) input.value = ""; return; } // Повторить загрузку при ошибке const retryBtn = target.closest(".retry-load-btn"); if (retryBtn) { this.loadAvailableCodeTypes(); return; } }; this.addEventListener("click", this._clickHandler); // Поиск с debounce this._inputHandler = (e) => { const target = /** @type {HTMLElement} */ (e.target); if (target.id === "type-search-input") { const value = /** @type {HTMLInputElement} */ (target).value; if (this._typeSearchDebounce) clearTimeout(this._typeSearchDebounce); this._typeSearchDebounce = window.setTimeout(() => { this.setTypeSearchQuery(value); }, 150); return; } if (target.id === "value-search-input") { const value = /** @type {HTMLInputElement} */ (target).value; if (this._valueSearchDebounce) clearTimeout(this._valueSearchDebounce); this._valueSearchDebounce = window.setTimeout(() => { this.setValueSearchQuery(value); }, 150); return; } }; this.addEventListener("input", this._inputHandler); // Keyboard accessibility this._keydownHandler = (e) => { const target = /** @type {HTMLElement} */ (e.target); if (e.key !== "Enter" && e.key !== " ") return; const typeItem = target.closest(".available-type-item"); if (typeItem) { e.preventDefault(); const raw = typeItem.dataset.typeId || ""; const typeId = raw === "resource" ? raw : parseInt(raw, 10) || null; if (typeId !== null) this.selectType(typeId); return; } const codeOption = target.closest(".code-value-option"); if (codeOption) { e.preventDefault(); const typeId = parseInt(codeOption.dataset.codeTypeId || "0", 10); const valueId = parseInt(codeOption.dataset.codeValueId || "0", 10); if (typeId && valueId) this._selectCodeValue(typeId, valueId, e.ctrlKey || e.metaKey); return; } }; this.addEventListener("keydown", this._keydownHandler); } /** * @param {number} typeId * @param {number} valueId * @param {boolean} [ctrlKey] */ _selectCodeValue(typeId, valueId, ctrlKey = false) { const availableType = this.availableTypes.find( (at) => at.typeId === typeId, ); if (!availableType) return; const codeValue = availableType.values.find((v) => v.valueId === valueId); if (!codeValue) return; /** @type {CodePickerSelection} */ const selection = { typeId: Number(availableType.typeId), typeName: availableType.name, valueId: Number(codeValue.valueId), valueName: codeValue.shortName, color: codeValue.color || undefined, ctrlKey: Boolean(ctrlKey), }; this.dispatchEvent( new CustomEvent("code-value-select", { detail: selection, bubbles: true, }), ); } // ==================== Rendering ==================== /** * Возвращает реальный сфокусированный элемент, спускаясь по shadow roots. * document.activeElement возвращает host, если фокус внутри чужого Shadow DOM. * @returns {Element|null} * @private */ _getDeepActiveElement() { let el = document.activeElement; while (el && el.shadowRoot && el.shadowRoot.activeElement) { el = el.shadowRoot.activeElement; } return el; } render() { // Preserve focus before re-render (shadow-aware: document.activeElement // returns the shadow host, so walk into shadow roots to find the real input) const activeEl = this._getDeepActiveElement(); const activeId = activeEl && this.contains(activeEl) ? activeEl.id : null; const selectionStart = activeId && activeEl instanceof HTMLInputElement ? activeEl.selectionStart : null; const selectionEnd = activeId && activeEl instanceof HTMLInputElement ? activeEl.selectionEnd : null; this.innerHTML = ` <style> .available-types-view, .code-values-view { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow: hidden; } .code-picker-search { display: flex; align-items: center; gap: var(--space-2xs); padding: 4px var(--space-2xs); background: var(--surface-bg, var(--primary-bg)); flex-shrink: 0; } .code-picker-search .compact-input { flex: 1; height: 24px; padding: 0 6px; font-size: var(--font-size-xs); color: var(--text-primary); background: var(--input-bg, var(--surface-bg, var(--primary-bg))); border: 1px solid var(--border-color); border-radius: 2px; outline: none; } .code-picker-search .compact-input:focus { border-color: var(--accent-color); } .code-picker-search .compact-input::placeholder { color: var(--text-tertiary); } .code-picker-search .clear-search { width: 18px; height: 18px; padding: 0; font-size: 12px; line-height: 1; color: var(--text-tertiary); border: none; background: transparent; cursor: pointer; border-radius: 2px; display: flex; align-items: center; justify-content: center; } .code-picker-search .clear-search:hover { color: var(--text-primary); background: var(--hover-bg, rgba(0,0,0,0.04)); } .code-picker-types-list, .code-picker-values-list { min-height: 0; flex: 1; overflow-y: auto; } .code-picker-types-list::-webkit-scrollbar, .code-picker-values-list::-webkit-scrollbar { width: 8px; height: 8px; } .code-picker-types-list::-webkit-scrollbar-track, .code-picker-values-list::-webkit-scrollbar-track { background: var(--color-bg-secondary, #f5f5f5); } .code-picker-types-list::-webkit-scrollbar-thumb, .code-picker-values-list::-webkit-scrollbar-thumb { background: var(--color-bg-border, #ccc); border-radius: var(--radius-xs, 2px); } .code-picker-types-list::-webkit-scrollbar-thumb:hover, .code-picker-values-list::-webkit-scrollbar-thumb:hover { background: var(--color-typo-ghost, #999); } .available-type-item { display: flex; align-items: center; gap: 2px; width: 100%; padding: 2px var(--space-2xs); font-size: var(--font-size-xs); color: var(--color-typo-primary); background: transparent; border-radius: 2px; cursor: pointer; overflow: hidden; } .available-type-item:hover { background: var(--hover-bg, rgba(0,0,0,0.04)); } .available-type-item:focus-visible { outline: 2px solid var(--accent-color); outline-offset: -2px; } .available-type-item .folder-icon { font-size: 10px; color: var(--text-secondary); flex-shrink: 0; display: inline-flex; width: 12px; height: 12px; align-items: center; justify-content: center; } .available-type-item.pseudo-type-item .folder-icon { color: var(--accent-color); } .code-values-back { display: inline-flex; align-items: center; gap: 4px; padding: 4px var(--space-2xs); font-size: var(--font-size-xs); font-weight: 600; color: var(--color-typo-primary); background: transparent; border: none; border-bottom: 1px solid var(--border-light); border-radius: 0; cursor: pointer; text-align: left; margin-bottom: 2px; flex-shrink: 0; align-self: flex-start; max-width: 100%; overflow: hidden; } .code-values-back:hover { color: var(--color-typo-primary); } .code-values-back:focus-visible { outline: 2px solid var(--accent-color); outline-offset: -2px; } .code-values-back .chevron-left { color: var(--color-typo-primary); width: 12px; height: 12px; } .code-values-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; max-width: 100%; } .code-value-option { display: flex; align-items: center; padding: 2px var(--space-2xs); font-size: var(--font-size-xs); color: var(--color-typo-primary); cursor: pointer; border-radius: 2px; overflow: hidden; } .code-value-option.selected { background: var(--hover-bg, rgba(0,0,0,0.04)); color: var(--text-primary); } .code-value-option:hover { background: var(--hover-bg, rgba(0,0,0,0.04)); } .code-value-option:focus-visible { outline: 2px solid var(--accent-color); outline-offset: -2px; } .code-value-checkbox { width: 12px; height: 12px; border-radius: 2px; border: 1.5px solid var(--border-color); background: transparent; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; transition: all 0.15s ease; color: var(--accent-color); } .code-value-checkbox svg { width: 10px; height: 10px; opacity: 0; transition: opacity 0.15s ease; pointer-events: none; } .code-value-option.selected .code-value-checkbox { border-color: var(--accent-color); } .code-value-option.selected .code-value-checkbox svg { opacity: 1; } .color-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .code-value-name { font-weight: 500; color: var(--accent-color); flex-shrink: 0; margin-right: 4px; font-size: var(--font-size-xs); } .code-value-desc { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; color: var(--color-typo-primary); font-size: var(--font-size-xs); } .retry-load-btn { font-size: 10px; padding: 2px 8px; height: auto; } </style> <div class="code-picker-view"> ${this._renderContent()} </div> `; // Restore focus after re-render if (activeId) { const el = this.querySelector("#" + activeId); if (el) { el.focus(); if ( el instanceof HTMLInputElement && selectionStart !== null && selectionEnd !== null ) { el.setSelectionRange(selectionStart, selectionEnd); } } } } _renderContent() { if (this.isLoading) { return '<div class="compact-placeholder" role="status" style="min-height:40px;">Загрузка справочников...</div>'; } if (this.loadError) { return ` <div class="compact-placeholder" role="alert" style="min-height:40px;flex-direction:column;gap:4px;"> <span>${this._escapeHtml(this.loadError)}</span> <button class="retry-load-btn">Повторить</button> </div> `; } if (this._getAllTypes().length === 0) { return '<div class="compact-placeholder" role="status" style="min-height:40px;">Нет доступных справочников</div>'; } if (this.selectedTypeId != null && this.mode === "value") { return this._renderCodeValuesView(); } return this._renderTypesListView(); } _renderTypesListView() { const filtered = this.getFilteredTypes(); const hasQuery = this.typeSearchQuery.trim().length > 0; let listHtml = ""; if (filtered.length === 0) { listHtml = `<div class="compact-placeholder" role="status" style="min-height:32px;font-style:normal;">${hasQuery ? "Справочники не найдены" : "Нет доступных справочников"}</div>`; } else { listHtml = filtered .map((at) => { const isHighlighted = this.highlightedTypeIds.has(at.typeId); const activeClass = isHighlighted ? "has-active-filter" : ""; const pseudoClass = at.isPseudo ? "pseudo-type-item" : ""; const icon = at.isPseudo ? Box : Folder; return ` <div class="available-type-item ${activeClass} ${pseudoClass}" role="button" tabindex="0" data-type-id="${at.typeId}"> <span class="folder-icon">${icon}</span> <span class="available-type-name" title="${this._escapeHtml(at.name)}">${this._escapeHtml(at.name)}</span> </div> `; }) .join(""); } return ` <div class="available-types-view"> <div class="code-picker-search"> <input type="text" id="type-search-input" class="compact-input" placeholder="Поиск по справочникам" aria-label="Поиск по справочникам" value="${this._escapeHtml(this.typeSearchQuery)}"> <button class="icon-button clear-search clear-type-search" aria-label="Очистить поиск" ${!hasQuery ? 'style="display:none;"' : ""}>${X}</button> </div> <div class="code-picker-types-list" role="list"> ${listHtml} </div> </div> `; } _renderCodeValuesView() { const availableType = this.availableTypes.find( (at) => at.typeId === this.selectedTypeId, ); if (!availableType) return ""; const filteredValues = this.getFilteredValues(); const hasQuery = this.valueSearchQuery.trim().length > 0; let listHtml = ""; if (filteredValues.length === 0) { listHtml = `<div class="compact-placeholder" role="status" style="min-height:32px;font-style:normal;">${hasQuery ? "Коды не найдены" : "Нет доступных кодов"}</div>`; } else { listHtml = filteredValues .map((v) => { const isSelected = this._isValueSelected( availableType.typeId, v.valueId, ); const selectedClass = isSelected ? "selected" : ""; const ariaChecked = isSelected ? "true" : "false"; const colorDot = `<span class="color-dot" style="${v.color ? `background-color:${v.color}` : "visibility:hidden"}"></span>`; const desc = v.description ? `<span class="code-value-desc">${this._escapeHtml(v.description)}</span>` : ""; return ` <div class="code-value-option ${selectedClass}" role="checkbox" aria-checked="${ariaChecked}" tabindex="0" data-code-type-id="${availableType.typeId}" data-code-value-id="${v.valueId}"> <span class="code-value-checkbox">${Check}</span> ${colorDot} <span class="code-value-name" title="${this._escapeHtml(v.shortName)}">${this._escapeHtml(v.shortName)}</span> ${desc} </div> `; }) .join(""); } return ` <div class="code-values-view" data-type-id="${availableType.typeId}"> <div class="code-picker-search"> <input type="text" id="value-search-input" class="compact-input" placeholder="Поиск кода..." aria-label="Поиск кода в справочнике ${this._escapeHtml(availableType.name)}" value="${this._escapeHtml(this.valueSearchQuery)}"> <button class="icon-button clear-search clear-value-search" aria-label="Очистить поиск" ${!hasQuery ? 'style="display:none;"' : ""}>${X}</button> </div> <button class="code-values-back" aria-label="Вернуться к списку справочников"> <span class="chevron-left">${ChevronLeft}</span> <span class="code-values-title" title="${this._escapeHtml(availableType.name)}">${this._escapeHtml(availableType.name)}</span> </button> <div class="code-picker-values-list" role="list"> ${listHtml} </div> </div> `; } /** * @param {string} text * @returns {string} */ _escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } } customElements.define("code-picker", CodePicker);