/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/GroupingPanel.js
722 строки
23 KB
Starolat Sergei
fix: выделение текста в виджетах при перетаскивании за заголовок
15 июл 2026, 09:26
15 июл 2026, 09:26
0f40aca
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview GroupingPanel - Панель управления группировкой данных (v2.1.0) * * Использует <code-picker mode="type"> для выбора справочника. * Поддерживает множественные уровни группировки (cascade), * toggle включения/выключения и reorder через DnD/стрелки. * Поддерживает комбинированную группировку по Activity Codes и материальным ресурсам. * Генерирует событие `grouping-change` при любом изменении группировки. * * @version 2.1.0 */ import "./CodePicker.js"; import filterState from "../services/FilterStateManager.js"; import { ArrowDown, Layout, X, Draggable, Folder, Box, } from "./ConstantinIcons.js"; /** * @typedef {import('../types.js').Grouping} Grouping */ class GroupingPanel extends HTMLElement { constructor() { super(); /** @type {Grouping[]} */ this.groupings = []; /** @type {number} */ this._maxLevels = 5; /** @type {number|null} */ this._dragSourceIndex = null; this._disableHostDrag = this._disableHostDrag.bind(this); this._restoreHostDrag = this._restoreHostDrag.bind(this); this._filterStateHandler = this._onFilterStateChange.bind(this); } static get observedAttributes() { return ["max-levels"]; } /** * @param {string} name * @param {string|null} oldValue * @param {string|null} newValue */ attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === "max-levels") { const parsed = parseInt(newValue || "5", 10); this._maxLevels = Number.isNaN(parsed) ? 5 : parsed; } } connectedCallback() { // Initialize max-levels from attribute if present const parsed = parseInt(this.getAttribute("max-levels") || "5", 10); this._maxLevels = Number.isNaN(parsed) ? 5 : parsed; const wasRendered = this.querySelector(".grouping-panel-body") !== null; if (!wasRendered) { this.render(); this.setupEventListeners(); } this.addEventListener("mousedown", this._disableHostDrag); this.addEventListener("pointerdown", this._disableHostDrag); // SSOT: sync UI with FilterStateManager on connect (e.g. after layout restore or dock/undock) const stateGroupings = filterState.getState().groupings; if (!this._groupingsEqual(this.groupings, stateGroupings)) { this.groupings = stateGroupings.map((g) => ({ ...g })); this._recalcLevels(); this.renderActiveGroupings(); } document.addEventListener("filter-state-change", this._filterStateHandler); customElements .whenDefined("code-picker") .then(() => this._updateCodePicker()); } disconnectedCallback() { this.removeEventListener("mousedown", this._disableHostDrag); this.removeEventListener("pointerdown", this._disableHostDrag); document.removeEventListener("mouseup", this._restoreHostDrag); document.removeEventListener("pointerup", this._restoreHostDrag); document.removeEventListener( "filter-state-change", this._filterStateHandler, ); } _disableHostDrag(/** @type {MouseEvent} */ e) { const header = this.querySelector(".module-header") || this.querySelector("h3"); if (!header) return; if ( e.target.closest("input, textarea, select, button, [contenteditable]") ) { if (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; } } /** * Обработчик изменения состояния фильтров из FilterStateManager. * Синхронизирует UI панели с внешними изменениями группировок * (например, восстановление макета из localStorage). * @param {CustomEvent} e * @private */ _onFilterStateChange(e) { const action = e.detail?.action; if (action !== "grouping-change") return; const stateGroupings = e.detail?.state?.groupings || []; if (this._groupingsEqual(this.groupings, stateGroupings)) return; this.groupings = stateGroupings.map((g) => ({ ...g })); this._recalcLevels(); this.renderActiveGroupings(); this._updateCodePicker(); } /** * Сравнивает два массива группировок по существенным полям. * @param {Grouping[]} a * @param {Grouping[]} b * @returns {boolean} * @private */ _groupingsEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if ( (a[i].kind || "code") !== (b[i].kind || "code") || a[i].field !== b[i].field || a[i].typeId !== b[i].typeId || a[i].enabled !== b[i].enabled || a[i].level !== b[i].level ) { return false; } } return true; } render() { this.innerHTML = ` <style> .grouping-section { display: flex; flex-direction: column; min-height: 0; } .grouping-active-list { display: flex; flex-direction: column; padding: var(--space-2xs); min-height: 0; } .grouping-chip { display: flex; align-items: center; gap: var(--space-2xs); background: transparent; border: none; border-radius: 0; } .grouping-chip.grouping-chip--disabled { opacity: 0.55; } .grouping-chip.grouping-chip--dragging { opacity: 0.5; background: var(--hover-bg, rgba(0,0,0,0.04)); } .grouping-chip-handle { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 18px; cursor: grab; color: var(--text-tertiary); flex-shrink: 0; } .grouping-chip-handle:active { cursor: grabbing; } .grouping-toggle-folder { display: inline-flex; align-items: center; justify-content: center; width: 12px; height: 12px; cursor: pointer; flex-shrink: 0; color: var(--text-secondary); transition: all 0.15s ease; } .grouping-toggle-folder.active { color: var(--accent-color); } .grouping-toggle-folder.active svg { fill: var(--accent-color); stroke-width: 1.5px; } .grouping-chip-content { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 0; } .grouping-chip-value { font-size: var(--font-size-xs); color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; user-select: none; } .grouping-chip-actions { display: flex; align-items: center; gap: 2px; opacity: 0; transition: opacity 0.15s ease; } .grouping-chip:hover .grouping-chip-actions, .grouping-chip:focus-within .grouping-chip-actions { opacity: 1; } .available-settings { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; } .available-settings-content { min-height: 0; flex: 1; overflow: hidden; } grouping-panel .available-settings-content .code-picker-search .compact-input { border-radius: var(--radius-s, 4px); } </style> <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="grouping-module" title="Открепить/Закрепить">${Layout}</button> </div> </div> <div class="grouping-panel-body"> <div class="grouping-section" aria-label="Активные группировки"> <div id="active-groupings-list" class="grouping-active-list" role="list" aria-live="polite" aria-label="Список активных группировок"> <!-- Активные группировки рендерятся здесь --> </div> </div> <div class="grouping-section available-settings" aria-label="Доступные справочники для группировки"> <div id="available-settings-content" class="available-settings-content"> <code-picker mode="type"></code-picker> </div> </div> </div> `; this.renderActiveGroupings(); this._updateCodePicker(); } setupEventListeners() { // Делегирование кликов this.addEventListener("click", (e) => { const target = /** @type {HTMLElement} */ (e.target); // Удаление группировки const removeBtn = target.closest(".remove-grouping"); if (removeBtn) { const field = removeBtn.dataset.field || ""; if (field) this.removeGrouping(field); return; } // Переключение enabled const toggleFolder = target.closest(".grouping-toggle-folder"); if (toggleFolder) { const field = toggleFolder.dataset.field || ""; if (field) this.toggleGrouping(field); return; } // Переключение enabled по клику на название справочника const chipValue = target.closest(".grouping-chip-value"); if (chipValue) { const field = chipValue.dataset.field || ""; if (field) this.toggleGrouping(field); return; } }); // Выбор справочника из <code-picker mode="type"> this.addEventListener("code-type-select", (e) => { const detail = /** @type {CustomEvent} */ (e).detail; if (detail && detail.typeId != null) { this.addGrouping({ typeId: detail.typeId, typeName: detail.typeName || "", isPseudo: detail.isPseudo || false, }); } }); } // ==================== Grouping Management ==================== /** * @param {{typeId: number|string, typeName: string, isPseudo?: boolean}} selection */ addGrouping(selection) { const isResource = selection.isPseudo === true || selection.typeId === "resource"; if (isResource) { // Ресурсную группировку можно добавить только один раз if (this.groupings.some((g) => g.kind === "resource")) return; } else { // Проверяем дубликат кодовой группировки const field = String(selection.typeId); if (this.groupings.some((g) => g.field === field)) return; } // Проверяем max-levels (суммарно кодовые + ресурсные уровни) if (this.groupings.length >= this._maxLevels) return; /** @type {Grouping} */ const grouping = isResource ? { kind: "resource", field: "resource", displayName: selection.typeName || "Ресурс", level: this.groupings.length + 1, enabled: true, } : { kind: "code", field: String(selection.typeId), displayName: selection.typeName || `Справочник ${selection.typeId}`, level: this.groupings.length + 1, typeId: Number(selection.typeId), enabled: true, }; this.groupings.push(grouping); this.renderActiveGroupings(); this._updateCodePicker(); this.dispatchEvent( new CustomEvent("grouping-change", { detail: { groupings: this.groupings.map((g) => ({ ...g })), action: "add", grouping: { ...grouping }, }, bubbles: true, }), ); // SSOT: sync centralized filter state filterState.setGroupings(this.groupings); } /** * @param {string} field */ removeGrouping(field) { const index = this.groupings.findIndex((g) => g.field === field); if (index === -1) return; const removedGrouping = this.groupings[index]; this.groupings.splice(index, 1); this._recalcLevels(); this.renderActiveGroupings(); this._updateCodePicker(); this.dispatchEvent( new CustomEvent("grouping-change", { detail: { groupings: this.groupings.map((g) => ({ ...g })), action: "remove", grouping: { ...removedGrouping }, }, bubbles: true, }), ); // SSOT: sync centralized filter state filterState.setGroupings(this.groupings); } /** * @param {string} field */ toggleGrouping(field) { const grouping = this.groupings.find((g) => g.field === field); if (!grouping) return; grouping.enabled = !grouping.enabled; this.renderActiveGroupings(); this._updateCodePicker(); this.dispatchEvent( new CustomEvent("grouping-change", { detail: { groupings: this.groupings.map((g) => ({ ...g })), action: "toggle", grouping: { ...grouping }, }, bubbles: true, }), ); // SSOT: sync centralized filter state filterState.setGroupings(this.groupings); } /** * @param {number} fromIndex * @param {number} toIndex */ moveGrouping(fromIndex, toIndex) { if (fromIndex < 0 || fromIndex >= this.groupings.length) return; if (toIndex < 0 || toIndex >= this.groupings.length) return; if (fromIndex === toIndex) return; const [moved] = this.groupings.splice(fromIndex, 1); this.groupings.splice(toIndex, 0, moved); this._recalcLevels(); this.renderActiveGroupings(); this._updateCodePicker(); // Фокус на перемещённый элемент requestAnimationFrame(() => { const chip = this.querySelector( `.grouping-chip[data-field="${moved.field}"]`, ); const focusTarget = chip && chip.querySelector( ".grouping-move-up, .grouping-move-down, .remove-grouping", ); if (focusTarget) focusTarget.focus(); }); this.dispatchEvent( new CustomEvent("grouping-change", { detail: { groupings: this.groupings.map((g) => ({ ...g })), action: "move", grouping: { ...moved }, fromIndex, toIndex, }, bubbles: true, }), ); // SSOT: sync centralized filter state filterState.setGroupings(this.groupings); } clearGroupings() { this.groupings = []; this.renderActiveGroupings(); this._updateCodePicker(); this.dispatchEvent( new CustomEvent("grouping-change", { detail: { groupings: [], action: "clear", }, bubbles: true, }), ); // SSOT: sync centralized filter state filterState.setGroupings([]); } _recalcLevels() { this.groupings.forEach((g, index) => { g.level = index + 1; }); } // ==================== Rendering ==================== renderActiveGroupings() { const container = this.querySelector("#active-groupings-list"); if (!container) return; if (this.groupings.length === 0) { container.innerHTML = '<div class="compact-placeholder" role="status" style="min-height:24px;border:none;background:transparent;font-style:normal;">Нет активных группировок</div>'; return; } container.innerHTML = this.groupings .map((grouping, index) => { const disabledClass = grouping.enabled ? "" : "grouping-chip--disabled"; const folderActiveClass = grouping.enabled ? "active" : ""; const levelIndent = (grouping.level - 1) * 12; const icon = grouping.kind === "resource" ? Box : Folder; const kindLabel = grouping.kind === "resource" ? "Ресурс" : "Справочник"; return ` <div class="grouping-chip ${disabledClass}" data-field="${grouping.field}" role="listitem" draggable="true" aria-label="Группировка уровня ${grouping.level}: ${this._escapeHtml(grouping.displayName)}" style="margin-left: ${levelIndent}px"> <span class="grouping-chip-handle" title="Перетащите для изменения порядка" aria-hidden="true">${Draggable}</span> <span class="grouping-toggle-folder ${folderActiveClass}" data-field="${grouping.field}" role="switch" aria-checked="${grouping.enabled}" title="${grouping.enabled ? "Выключить" : "Включить"}"> ${icon} </span> <div class="grouping-chip-content" data-field="${grouping.field}"> <span class="grouping-chip-value" data-field="${grouping.field}" title="${this._escapeHtml(grouping.displayName)}">${this._escapeHtml(grouping.displayName)}</span> </div> <div class="grouping-chip-actions"> <button class="icon-button remove-grouping" data-field="${grouping.field}" aria-label="Удалить группировку ${this._escapeHtml(grouping.displayName)} (${kindLabel})" title="Удалить группировку"> ${X} </button> </div> </div> `; }) .join(""); this._setupDragAndDrop(); } _setupDragAndDrop() { const chips = this.querySelectorAll(".grouping-chip"); chips.forEach((chip, index) => { chip.addEventListener("dragstart", (e) => { e.stopPropagation(); this._dragSourceIndex = index; chip.classList.add("grouping-chip--dragging"); if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; } }); chip.addEventListener("dragend", (e) => { e.stopPropagation(); chip.classList.remove("grouping-chip--dragging"); this._dragSourceIndex = null; chips.forEach((c) => { c.style.borderTop = ""; c.style.borderBottom = ""; }); }); chip.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); if (this._dragSourceIndex === null || this._dragSourceIndex === index) return; if (e.dataTransfer) { e.dataTransfer.dropEffect = "move"; } // Визуальный индикатор const rect = chip.getBoundingClientRect(); const offset = e.clientY - rect.top; const isAfter = offset > rect.height / 2; chip.style.borderTop = ""; chip.style.borderBottom = ""; if (isAfter) { chip.style.borderBottom = "2px solid var(--accent-color)"; } else { chip.style.borderTop = "2px solid var(--accent-color)"; } }); chip.addEventListener("dragleave", (e) => { e.stopPropagation(); chip.style.borderTop = ""; chip.style.borderBottom = ""; }); chip.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); if (this._dragSourceIndex === null) return; const rect = chip.getBoundingClientRect(); const offset = e.clientY - rect.top; const isAfter = offset > rect.height / 2; let toIndex = index; if (isAfter) { toIndex = index + 1; } if (this._dragSourceIndex < toIndex) { toIndex -= 1; } const fromIndex = this._dragSourceIndex; this._dragSourceIndex = null; chips.forEach((c) => { c.style.borderTop = ""; c.style.borderBottom = ""; }); this.moveGrouping(fromIndex, toIndex); }); }); } _updateCodePicker() { const codePicker = this.querySelector("code-picker"); if (!codePicker) return; if (typeof codePicker.setHighlightedTypeIds === "function") { const activeTypeIds = this.groupings .filter((g) => g.enabled) .map((g) => (g.kind === "resource" ? g.field : g.typeId)) .filter((id) => id != null); codePicker.setHighlightedTypeIds(activeTypeIds); } } /** * @param {string} text * @returns {string} */ _escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } // ==================== Public API ==================== /** * @returns {Grouping[]} */ getGroupings() { return this.groupings; } /** * @param {Grouping[]} groupings */ setGroupings(groupings) { const newGroupings = groupings || []; if (this._groupingsEqual(this.groupings, newGroupings)) return; this.groupings = newGroupings; this._recalcLevels(); this.renderActiveGroupings(); this._updateCodePicker(); this.dispatchEvent( new CustomEvent("grouping-change", { detail: { groupings: this.groupings.map((g) => ({ ...g })), action: "set", }, bubbles: true, }), ); // SSOT: sync centralized filter state filterState.setGroupings(this.groupings); } // Legacy compatibility /** * @param {Object} filter * @deprecated */ addFilter(filter) { console.warn( "[GroupingPanel] addFilter() is deprecated in v2.0.0. Use addGrouping().", ); } } customElements.define("grouping-panel", GroupingPanel);