/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/ResourceFilter.js
989 строк
34 KB
Starolat Sergei
fix: выделение текста в списках виджетов
15 июл 2026, 09:35
15 июл 2026, 09:35
f30dafd
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ResourceFilter — material resource filter panel for left sidebar (COMP-026) * @version 1.2.0 * * Renders a flat list of material resources with aggregated metrics: * targetUnits (Всего) — sum of target_units from current schedule * actualUnits (Факт) — sum of actual_units from current schedule * * Global filters (Activity Codes, WBS, Week Range) affect both the visible * resource list and the metric values. * * Selection delegates to FilterStateManager (SYS-006). Ctrl+Click for multiselect. * * v1.2.0 — UX-005: each resource row displays a checkbox (Consta-style) like * the code-value options in <code-picker>. Keyboard Enter/Space toggles selection. */ import dataService from "../services/DataService.js"; import filterState from "../services/FilterStateManager.js"; import dataIndexer from "../services/DataIndexer.js"; import { ArrowDown, Layout, Draggable, X, Check } from "./ConstantinIcons.js"; /** * @typedef {import('../types.js').ResourceFilterItem} ResourceFilterItem * @typedef {import('../types.js').FilterStateChangeDetail} FilterStateChangeDetail */ class ResourceFilter extends HTMLElement { constructor() { super(); /** @type {boolean} */ this._embedded = false; /** @type {ResourceFilterItem[]} */ this.resourceList = []; /** @type {Set<number>} */ this.selectedResourceIds = new Set(); /** @type {Set<number>} */ this.disabledResourceIds = new Set(); /** @type {boolean} */ this.isLoading = false; /** @type {string|null} */ this._loadError = null; /** @type {string} */ this.searchQuery = ""; /** @type {number|null} */ this._searchDebounce = null; /** @type {number|null} */ this._renderRaf = null; this._onDatabaseReady = this._onDatabaseReady.bind(this); this._onFilterStateChange = this._onFilterStateChange.bind(this); this._onScheduleModeChange = this._onScheduleModeChange.bind(this); this._disableHostDrag = this._disableHostDrag.bind(this); this._restoreHostDrag = this._restoreHostDrag.bind(this); } /** * Возвращает true, если компонент встроен в <filter-resource-tabs>. * В embedded-режиме не рендерится собственный .module-header. * @returns {boolean} */ get embedded() { return this._embedded; } connectedCallback() { this._embedded = this.hasAttribute("embedded"); const wasRendered = this.querySelector(".resource-filter-root") !== null; if (!wasRendered) { this.render(); this.setupEventListeners(); } const searchInput = this.querySelector("#resource-search-input"); if (searchInput) { searchInput.addEventListener("mousedown", this._disableHostDrag); searchInput.addEventListener("pointerdown", this._disableHostDrag); } document.addEventListener("database-ready", this._onDatabaseReady); document.addEventListener("filter-state-change", this._onFilterStateChange); document.addEventListener( "table-schedule-mode-change", this._onScheduleModeChange, ); // Sync local selection with central SSOT this.selectedResourceIds = new Set( filterState.getSelectedResourceFilterIds(), ); this.disabledResourceIds = new Set( filterState.getDisabledResourceFilterIds(), ); if (!wasRendered && dataService.getActiveDatabaseId()) { this.loadData(); } else if (wasRendered) { this._renderList(); } } disconnectedCallback() { document.removeEventListener("database-ready", this._onDatabaseReady); document.removeEventListener( "filter-state-change", this._onFilterStateChange, ); document.removeEventListener( "table-schedule-mode-change", this._onScheduleModeChange, ); const searchInput = this.querySelector("#resource-search-input"); if (searchInput) { searchInput.removeEventListener("mousedown", this._disableHostDrag); searchInput.removeEventListener("pointerdown", this._disableHostDrag); } document.removeEventListener("mouseup", this._restoreHostDrag); document.removeEventListener("pointerup", this._restoreHostDrag); if (this._searchDebounce) { clearTimeout(this._searchDebounce); this._searchDebounce = null; } if (this._renderRaf) { cancelAnimationFrame(this._renderRaf); this._renderRaf = null; } } // ==================== Event handlers ==================== /** * @param {CustomEvent} e */ _onDatabaseReady(e) { const databaseId = e.detail?.databaseId; if (databaseId) { this.loadData(databaseId); } } /** * @param {CustomEvent} e */ _onScheduleModeChange(e) { const { showCurrent, showTarget } = e.detail || {}; // Reset selection on schedule mode change (same as WbsFilter v1.1.0) if (this.selectedResourceIds.size > 0) { filterState.clearResourceFilter(); } const dbId = dataService.getActiveDatabaseId(); if (dbId) { this.loadData(dbId); } } /** * Sync selection with FilterStateManager and reload list when global filters change. * @param {CustomEvent} e */ _onFilterStateChange(e) { const { action, state } = e.detail || {}; if (!state) return; const relevantSelectionActions = ["resource-filter-change"]; const relevantReloadActions = [ "code-filter-set", "code-filter-remove", "code-filter-toggle", "code-filter-clear", "code-filter-replace", "wbs-filter-change", "week-filter-change", "schedule-mode-change", ]; if (relevantSelectionActions.includes(action)) { const newIds = new Set( state.selectedResourceFilterIds || state.resourceFilterIds || [], ); const newDisabled = new Set(state.disabledResourceFilterIds || []); const hasSelectionChanged = newIds.size !== this.selectedResourceIds.size || [...newIds].some((id) => !this.selectedResourceIds.has(id)) || [...this.selectedResourceIds].some((id) => !newIds.has(id)); const hasDisabledChanged = newDisabled.size !== this.disabledResourceIds.size || [...newDisabled].some((id) => !this.disabledResourceIds.has(id)) || [...this.disabledResourceIds].some((id) => !newDisabled.has(id)); if (hasSelectionChanged || hasDisabledChanged) { this.selectedResourceIds = newIds; this.disabledResourceIds = newDisabled; this._renderList(); } } if (relevantReloadActions.includes(action)) { const dbId = dataService.getActiveDatabaseId(); if (dbId) { this.loadData(dbId); } } } // ==================== Rendering ==================== render() { if (this._embedded) { this._renderEmbedded(); return; } this._renderFull(); } /** * Embedded-рендер: без .module-header, только поиск + список ресурсов. * @private */ _renderEmbedded() { this.innerHTML = this._renderStyles() + ` <div class="resource-filter-root resource-filter-root--embedded"> <div class="resource-filter-body"> <div class="resource-filter-search"> <input type="text" id="resource-search-input" class="compact-input" placeholder="Поиск ресурса..." aria-label="Поиск ресурса" value="${this._escapeHtml(this.searchQuery)}"> <button class="icon-button clear-search clear-resource-search" aria-label="Очистить поиск" ${!this.searchQuery.trim() ? 'style="display:none;"' : ""}>${X}</button> </div> <div class="resource-filter-header-row"> <span class="resource-filter-header-name">Название</span> <span class="resource-filter-header-metrics"> <span class="resource-filter-header-metric">Всего</span> <span class="resource-filter-header-metric">Факт</span> </span> </div> <div id="resource-filter-list" class="resource-filter-list" role="list" aria-label="Фильтр по ресурсам"> <!-- List renders here --> </div> </div> </div> `; this._renderList(); } /** * Полный рендер для standalone-использования. * @private */ _renderFull() { this.innerHTML = this._renderStyles() + ` <div class="module-header"> <h3><span class="draggable-icon" style="display: inline-flex; width: 20px; height: 20px; vertical-align: middle; margin-right: 2px; color: var(--text-tertiary);">${Draggable}</span>Ресурсы</h3> <div class="module-actions"> <button class="collapse-btn icon-button" title="Свернуть/Развернуть"><span class="arrow-icon">${ArrowDown}</span></button> <button class="dock-toggle-btn icon-button" data-target="resource-filter-module" title="Открепить/Закрепить">${Layout}</button> </div> </div> <div class="resource-filter-root"> <div class="resource-filter-body"> <div class="resource-filter-search"> <input type="text" id="resource-search-input" class="compact-input" placeholder="Поиск ресурса..." aria-label="Поиск ресурса" value="${this._escapeHtml(this.searchQuery)}"> <button class="icon-button clear-search clear-resource-search" aria-label="Очистить поиск" ${!this.searchQuery.trim() ? 'style="display:none;"' : ""}>${X}</button> </div> <div class="resource-filter-header-row"> <span class="resource-filter-header-name">Название</span> <span class="resource-filter-header-metrics"> <span class="resource-filter-header-metric">Всего</span> <span class="resource-filter-header-metric">Факт</span> </span> </div> <div id="resource-filter-list" class="resource-filter-list" role="list" aria-label="Фильтр по ресурсам"> <!-- List renders here --> </div> </div> </div> `; this._renderList(); } /** * Общие стили для обоих режимов. * @returns {string} * @private */ _renderStyles() { return ` <style> resource-filter { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } .resource-filter-root { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } .resource-filter-root--embedded { border-top: none; } .resource-filter-body { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } .resource-filter-search { display: flex; align-items: center; gap: var(--space-2xs, 4px); padding: var(--space-2xs, 4px) var(--space-xs, 8px); flex-shrink: 0; } .resource-filter-search .compact-input { flex: 1; height: 24px; padding: 0 6px; font-size: var(--size-text-xs, 11px); border: 1px solid var(--color-bg-border, rgba(0,32,51,0.26)); border-radius: var(--radius-s, 4px); background: var(--color-bg-default, #fff); color: var(--text-primary, #002033); outline: none; } .resource-filter-search .compact-input:focus { border-color: var(--color-control-bg-primary, #0071b2); } .resource-filter-search .compact-input::placeholder { color: var(--text-tertiary, rgba(0,32,51,0.4)); } .resource-filter-search .clear-search { width: 18px; height: 18px; font-size: 12px; padding: 0; display: inline-flex; align-items: center; justify-content: center; } .resource-filter-header-row { display: flex; align-items: center; padding: 2px 6px; height: 16px; font-size: 8px; color: var(--text-tertiary, rgba(0,32,51,0.35)); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--color-bg-border, rgba(0,32,51,0.1)); flex-shrink: 0; } .resource-filter-header-name { flex: 1; min-width: 0; } .resource-filter-header-metrics { display: flex; align-items: center; gap: var(--space-3xs, 2px); } .resource-filter-header-metric { min-width: 36px; text-align: right; } .resource-filter-list { display: block; padding: var(--space-2xs, 4px) 0; font-family: var(--font-primary, Inter, sans-serif); font-size: var(--size-text-xs, 11px); overflow-y: auto; flex: 1 1 auto; min-height: 0; contain: layout paint; } .resource-filter-checkbox { width: 12px; height: 12px; border-radius: 2px; border: 1.5px solid var(--color-bg-border, rgba(0,32,51,0.26)); background: transparent; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; transition: all 0.15s ease; color: var(--color-control-bg-primary, #0071b2); } .resource-filter-checkbox svg { width: 10px; height: 10px; opacity: 0; transition: opacity 0.15s ease; pointer-events: none; } .resource-filter-item { display: flex; align-items: center; gap: 4px; padding: 0 6px; height: 22px; cursor: pointer; white-space: nowrap; color: var(--text-primary, #002033); contain: layout paint; outline: none; } .resource-filter-item:hover { background: var(--color-bg-ghost, #f5f5f5); } .resource-filter-item:focus-visible { outline: 2px solid var(--color-control-bg-primary, #0071b2); outline-offset: -2px; } .resource-filter-item.selected .resource-filter-checkbox { border-color: var(--color-control-typo-primary, #fff); color: var(--color-control-typo-primary, #fff); } .resource-filter-item.selected .resource-filter-checkbox svg { opacity: 1; } .resource-filter-item.selected { background: var(--color-control-bg-primary, #0071b2); color: var(--color-control-typo-primary, #fff); } .resource-filter-item.selected:hover { background: var(--color-control-bg-primary, #0071b2); color: var(--color-control-typo-primary, #fff); } .resource-filter-item.disabled { opacity: 0.6; } .resource-filter-name { overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } .resource-filter-unit { color: var(--text-tertiary, rgba(0,32,51,0.4)); font-size: 9px; flex-shrink: 0; } .resource-filter-item.selected .resource-filter-unit { color: var(--color-control-typo-primary, #fff); opacity: 0.85; } .resource-filter-metrics { display: flex; align-items: center; gap: var(--space-3xs, 2px); margin-left: auto; flex-shrink: 0; } .resource-filter-metric { display: flex; flex-direction: column; align-items: flex-end; min-width: 36px; } .resource-filter-metric-label { font-size: 8px; color: var(--text-tertiary, rgba(0,32,51,0.35)); text-transform: uppercase; letter-spacing: 0.03em; line-height: 1; } .resource-filter-metric-value { font-size: var(--size-text-2xs, 10px); color: var(--text-secondary, rgba(0,32,51,0.6)); font-variant-numeric: tabular-nums; line-height: 1.2; } .resource-filter-item.selected .resource-filter-metric-label, .resource-filter-item.selected .resource-filter-metric-value { color: var(--color-control-typo-primary, #fff); opacity: 0.9; } .resource-filter-placeholder { padding: 12px 8px; text-align: center; color: var(--text-tertiary); font-size: var(--size-text-xs, 11px); } .resource-filter-loading { padding: 12px 8px; text-align: center; color: var(--text-tertiary); font-size: var(--size-text-xs, 11px); } .resource-filter-error { padding: 12px 8px; text-align: center; color: var(--color-typo-alert, #d33); font-size: var(--size-text-xs, 11px); } .resource-filter-error button { margin-top: 8px; padding: 2px 8px; font-size: var(--size-text-xs, 11px); cursor: pointer; } </style> `; } setupEventListeners() { this.addEventListener("input", (e) => { const target = /** @type {HTMLElement} */ (e.target); if (target.id === "resource-search-input") { const value = /** @type {HTMLInputElement} */ (target).value; if (this._searchDebounce) clearTimeout(this._searchDebounce); this._searchDebounce = window.setTimeout(() => { this.setSearchQuery(value); }, 150); } }); this.addEventListener("keydown", (e) => { if (e.key !== "Enter" && e.key !== " ") return; const target = /** @type {HTMLElement} */ (e.target); const item = target.closest(".resource-filter-item"); if (item) { e.preventDefault(); const resourceId = parseInt(item.dataset.resourceId || "0", 10); if (resourceId) { const isCtrl = e.ctrlKey || e.metaKey; this.selectResource(resourceId, isCtrl); } } }); this.addEventListener("click", (e) => { const target = /** @type {HTMLElement} */ (e.target); // Clear search if (target.closest(".clear-resource-search")) { const input = this.querySelector("#resource-search-input"); if (input) { input.value = ""; this.setSearchQuery(""); } return; } // Resource item click (checkbox or row) const item = target.closest(".resource-filter-item"); if (item) { const resourceId = parseInt(item.dataset.resourceId || "0", 10); if (resourceId) { const isCtrl = e.ctrlKey || e.metaKey; this.selectResource(resourceId, isCtrl); } return; } }); } // ==================== Data Loading ==================== /** * @param {string} [databaseId] * @returns {Promise<void>} */ async loadData(databaseId) { const dbId = databaseId || dataService.getActiveDatabaseId(); if (!dbId) { this.resourceList = []; this._loadError = null; this._renderList(); return; } this.isLoading = true; this._loadError = null; this._renderList(); try { const items = await this._fetchResourceItems(dbId); this.resourceList = items; this.isLoading = false; } catch (err) { console.error("[ResourceFilter] Failed to load resources:", err); this._loadError = err instanceof Error ? err.message : String(err); this.resourceList = []; this.isLoading = false; } this._renderList(); } /** * @param {string} dbId * @returns {Promise<ResourceFilterItem[]>} */ async _fetchResourceItems(dbId) { const indexes = dataIndexer.getIndexes(dbId); if (!indexes) { console.warn(`[ResourceFilter] Database indexes not found: ${dbId}`); return []; } // Read global filters from FilterStateManager const enabledCodeFilters = filterState.getEffectiveCodeFilters(); const wbsFilterIds = filterState.getEffectiveWbsFilterIds(); const weekRange = filterState.getEffectiveWeekRange(); const currentActivities = indexes.activitiesBySchedule.get("current"); if (!currentActivities || currentActivities.size === 0) { return []; } // Build allowed WBS IDs (selected + all descendants) /** @type {Set<number>} */ const allowedWbsIds = new Set(); if (wbsFilterIds.length > 0) { for (const wbsId of wbsFilterIds) { allowedWbsIds.add(wbsId); const descendants = indexes.wbsDescendantsCache.get(wbsId); if (descendants) { for (const descendantId of descendants) { allowedWbsIds.add(descendantId); } } } } // Build code filter maps: AND across types, OR inside type const codeFilterMaps = enabledCodeFilters .filter((f) => f.codeValueIds && f.codeValueIds.length > 0) .map((f) => ({ typeId: Number(f.codeTypeId), valueIds: new Set(f.codeValueIds.map((id) => Number(id))), })); const hasWbsFilter = allowedWbsIds.size > 0; const hasWeekRange = weekRange && (weekRange.startIdx != null || weekRange.endIdx != null); // Determine activities satisfying global filters /** @type {Set<number>} */ const allowedActivityIds = new Set(); for (const activity of currentActivities.values()) { if (hasWbsFilter && !allowedWbsIds.has(activity.wbsId)) { continue; } if ( hasWeekRange && !this._activityMatchesWeekRange(activity, indexes, weekRange) ) { continue; } if ( codeFilterMaps.length > 0 && !this._activityMatchesCodeFilters(activity, indexes, codeFilterMaps) ) { continue; } allowedActivityIds.add(activity.id); } // Aggregate target/actual units per material resource /** @type {Map<number, ResourceFilterItem>} */ const resultMap = new Map(); for (const [resourceId, resource] of indexes.resourcesById) { if (resource.type !== "material") continue; const assignments = indexes.assignmentsByResource.get(resourceId); if (!assignments || assignments.length === 0) continue; let targetUnits = 0; let actualUnits = 0; let hasMatchingAssignment = false; for (const ra of assignments) { if (ra.scheduleType && ra.scheduleType !== "current") continue; if (!allowedActivityIds.has(ra.activityId)) continue; targetUnits += Number(ra.targetUnits) || 0; actualUnits += Number(ra.actualUnits) || 0; hasMatchingAssignment = true; } if (!hasMatchingAssignment) continue; if (targetUnits === 0 && actualUnits === 0) continue; resultMap.set(resourceId, { resourceId, name: String(resource.name || ""), shortName: undefined, unitName: String(resource.unit || ""), targetUnits, actualUnits, }); } return Array.from(resultMap.values()).sort((a, b) => String(a.name).localeCompare(String(b.name)), ); } /** * @private * @param {import('../types.js').Activity} activity * @param {import('../types.js').DatabaseIndexes} indexes * @param {{startIdx: number|null, endIdx: number|null}} weekRange * @returns {boolean} */ _activityMatchesWeekRange(activity, indexes, weekRange) { const assignments = indexes.assignmentsByActivity.get(activity.id); if (!assignments || assignments.length === 0) return false; const startDefined = weekRange.startIdx !== null && weekRange.startIdx !== undefined; const endDefined = weekRange.endIdx !== null && weekRange.endIdx !== undefined; if (!startDefined && !endDefined) return true; const startValid = startDefined && Number.isFinite(Number(weekRange.startIdx)); const endValid = endDefined && Number.isFinite(Number(weekRange.endIdx)); if ((startDefined && !startValid) || (endDefined && !endValid)) { return true; } const startIdx = startValid ? Number(weekRange.startIdx) : -Infinity; const endIdx = endValid ? Number(weekRange.endIdx) : Infinity; const spread = indexes.spread; const spreadIndexByAssignment = indexes.spreadIndexByAssignment; for (const ra of assignments) { const assignmentId = ra.assignmentId; if (assignmentId == null) continue; const range = spreadIndexByAssignment.get(assignmentId); if (!range) continue; for (let i = range.start; i < range.end; i++) { const wi = spread.weekIndices[i]; if (wi >= startIdx && wi <= endIdx) return true; } } return false; } /** * @private * @param {import('../types.js').Activity} activity * @param {import('../types.js').DatabaseIndexes} indexes * @param {{typeId: number, valueIds: Set<number>}[]} codeFilterMaps * @returns {boolean} */ _activityMatchesCodeFilters(activity, indexes, codeFilterMaps) { const codesByType = indexes.activityCodesByType.get(activity.id); for (const filter of codeFilterMaps) { const valueId = codesByType?.get(filter.typeId); if (valueId == null || !filter.valueIds.has(valueId)) { return false; } } return true; } // ==================== Selection ==================== /** * @param {number} resourceId * @param {boolean} ctrlKey */ selectResource(resourceId, ctrlKey = false) { const had = this.selectedResourceIds.has(resourceId); let action = "add"; if (ctrlKey) { if (had) { this.selectedResourceIds.delete(resourceId); action = "remove"; } else { this.selectedResourceIds.add(resourceId); action = "add"; } } else { this.selectedResourceIds.clear(); if (had) { action = "remove"; } else { this.selectedResourceIds.add(resourceId); action = "add"; } } this._syncSelectedState(); // Delegate to FilterStateManager (SSOT) if (this.selectedResourceIds.size === 0) { filterState.clearResourceFilter(); } else { filterState.setResourceFilter(Array.from(this.selectedResourceIds)); } } clearSelection() { this.selectedResourceIds.clear(); this._syncSelectedState(); filterState.clearResourceFilter(); } // ==================== Search ==================== /** * @param {string} query */ setSearchQuery(query) { this.searchQuery = query; const clearBtn = this.querySelector(".clear-resource-search"); if (clearBtn) { clearBtn.style.display = query.trim() ? "" : "none"; } this._renderList(); } // ==================== List Rendering ==================== _renderList() { const listEl = this.querySelector("#resource-filter-list"); if (!listEl) return; if (this.isLoading) { listEl.innerHTML = `<div class="resource-filter-loading">Загрузка ресурсов…</div>`; return; } if (this._loadError) { listEl.innerHTML = ` <div class="resource-filter-error"> Ошибка загрузки: ${this._escapeHtml(this._loadError)} <br><button class="retry-btn">Повторить</button> </div> `; const retryBtn = listEl.querySelector(".retry-btn"); if (retryBtn) { retryBtn.addEventListener("click", () => { const dbId = dataService.getActiveDatabaseId(); if (dbId) this.loadData(dbId); }); } return; } const q = this.searchQuery.trim().toLowerCase(); const filtered = this.resourceList.filter((r) => { if (!q) return true; return ( (r.name || "").toLowerCase().includes(q) || (r.shortName || "").toLowerCase().includes(q) ); }); if (filtered.length === 0) { if (this.resourceList.length === 0) { listEl.innerHTML = `<div class="resource-filter-placeholder">Нет материальных ресурсов</div>`; } else { listEl.innerHTML = `<div class="resource-filter-placeholder">Ресурсы не найдены</div>`; } return; } const html = filtered .map((r) => { const selected = this.selectedResourceIds.has(r.resourceId); const disabled = selected && this.disabledResourceIds.has(r.resourceId); const classes = [ "resource-filter-item", selected ? "selected" : "", disabled ? "disabled" : "", ] .filter(Boolean) .join(" "); const nameHtml = this._escapeHtml(r.name); const checkboxHtml = `<span class="resource-filter-checkbox" aria-hidden="true">${selected ? Check : ""}</span>`; const unitHtml = r.unitName ? `<span class="resource-filter-unit">${this._escapeHtml(r.unitName)}</span>` : ""; const targetFmt = this._formatNumber(r.targetUnits); const actualFmt = this._formatNumber(r.actualUnits); return ` <div class="${classes}" data-resource-id="${r.resourceId}" role="listitem" aria-selected="${selected}" tabindex="0"> ${checkboxHtml} <span class="resource-filter-name" title="${nameHtml}">${nameHtml}</span> ${unitHtml} <span class="resource-filter-metrics"> <span class="resource-filter-metric"> <span class="resource-filter-metric-value">${targetFmt}</span> </span> <span class="resource-filter-metric"> <span class="resource-filter-metric-value">${actualFmt}</span> </span> </span> </div> `; }) .join(""); listEl.innerHTML = html; } _syncSelectedState() { const items = this.querySelectorAll(".resource-filter-item"); items.forEach((el) => { const rid = parseInt(el.dataset.resourceId || "0", 10); const selected = this.selectedResourceIds.has(rid); const disabled = selected && this.disabledResourceIds.has(rid); el.classList.toggle("selected", selected); el.classList.toggle("disabled", disabled); el.setAttribute("aria-selected", String(selected)); const checkbox = el.querySelector(".resource-filter-checkbox"); if (checkbox) { checkbox.innerHTML = selected ? Check : ""; } }); } // ==================== Utilities ==================== /** * @param {string} text * @returns {string} */ _escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } /** * @param {number} n * @returns {string} */ _formatNumber(n) { if (n === 0) return "0"; if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(1) + "M"; if (Math.abs(n) >= 1000) return (n / 1000).toFixed(1) + "k"; if (Number.isInteger(n)) return String(n); return n.toFixed(1); } _disableHostDrag() { const header = this.querySelector(".module-header") || this.querySelector("h3"); if (header && header.getAttribute("draggable") === "true") { header.setAttribute("draggable", "false"); this._dragRestored = false; document.addEventListener("mouseup", this._restoreHostDrag, { once: true, }); document.addEventListener("pointerup", this._restoreHostDrag, { once: true, }); } } _restoreHostDrag() { if (!this._dragRestored) { const header = this.querySelector(".module-header") || this.querySelector("h3"); if (header) header.setAttribute("draggable", "true"); this._dragRestored = true; } } } customElements.define("resource-filter", ResourceFilter); export { ResourceFilter };