From d1067e3e7299bc30171f64d82f0e054530e99548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 26 Oct 2026 13:55:48 +0300 Subject: [PATCH] file manager: filter as a collapsed checkbox dropdown Replace the +
+ + +
@@ -16155,45 +16158,82 @@ function pysimFsFilterRestore() { } pysimFsFilterRestore(); -function pysimFsFilterRender() { - const el = document.getElementById('pysim-fs-filter'); - if (!el) return; - const services = fsFilterServiceOptions(); - if (!FS_FILTER_GROUPS.length && !services.length) { - el.innerHTML = ''; - return; - } - const opt = o => ''; - let out = '' + - ''; - for (const group of FS_FILTER_GROUP_ORDER) { - const items = FS_FILTER_GROUPS.filter(g => g.group === group).map(opt); - if (group === 'Services') items.push(...services.map(opt)); - if (items.length) out += '' + items.join('') + ''; - } - el.innerHTML = out; - el.querySelectorAll('option').forEach(o => { o.selected = pysimFsFilter.has(o.value); }); +// The label of one filter entry: the custom flag, a category's own name or +// the feature name of a per-service entry. +function pysimFsFilterLabel(id) { + if (id === FS_FILTER_CUSTOM) return t('Custom files'); + const category = FS_FILTER_GROUPS.find(g => g.id === id); + if (category) return t(category.label); + if (id.indexOf('svc:') === 0) return id.slice(4); + return id; } -function pysimFsSetFilter() { - const el = document.getElementById('pysim-fs-filter'); - if (!el) return; - const prev = pysimFsFilter; - const next = new Set(Array.from(el.selectedOptions).map(o => o.value).filter(v => v)); - // `all` is exclusive: choosing it clears the category selections, choosing - // a category clears it. A native multiselect's onchange reports only the - // new selection, so the previous one tells the two gestures apart: `all` - // just appearing means it was clicked (it clears the rest); it staying - // next to a new category means the category was clicked (it drops `all`). - if (next.has(FS_FILTER_ALL)) { - if (!prev.has(FS_FILTER_ALL)) { - next.clear(); - next.add(FS_FILTER_ALL); - } else { - next.delete(FS_FILTER_ALL); +// The row's caption: the default or a single choice by name, otherwise the +// number of selected entries. +function pysimFsFilterSummary() { + if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return t('All'); + if (pysimFsFilter.size === 1) return pysimFsFilterLabel(Array.from(pysimFsFilter)[0]); + return t('Selected: {n}').replace('{n}', pysimFsFilter.size); +} + +// The collapsed filter: a single row that expands into a checkbox list - +// "All"/"Custom files", then a heading per group with its categories (the +// Services heading carries one checkbox per file-backed feature). +function pysimFsFilterRender() { + const menu = document.getElementById('pysim-fs-filter-menu'); + const btn = document.getElementById('pysim-fs-filter-btn'); + if (!menu) return; + const services = fsFilterServiceOptions(); + const item = (id, label) => + ''; + const heading = group => + '
' + esc(t(group)) + '
'; + if (!FS_FILTER_GROUPS.length && !services.length) { + menu.innerHTML = '
' + esc(t('(no filters yet)')) + '
'; + } else { + let out = item(FS_FILTER_ALL, t('All')) + item(FS_FILTER_CUSTOM, t('Custom files')); + for (const group of FS_FILTER_GROUP_ORDER) { + const categories = FS_FILTER_GROUPS.filter(g => g.group === group); + const extra = group === 'Services' ? services : []; + if (!categories.length && !extra.length) continue; + out += heading(group); + out += categories.map(g => item(g.id, t(g.label))).join(''); + out += extra.map(o => item(o.id, o.label)).join(''); } + menu.innerHTML = out; + } + if (btn) btn.textContent = pysimFsFilterSummary(); +} + +// Expand or collapse the menu (`force` overrides the toggle; the outside +// click and Escape handlers pass `false`). +function pysimFsFilterToggle(force) { + const menu = document.getElementById('pysim-fs-filter-menu'); + const btn = document.getElementById('pysim-fs-filter-btn'); + if (!menu) return; + const open = force === undefined ? menu.classList.contains('hidden') : !!force; + if (open) pysimFsFilterRender(); + menu.classList.toggle('hidden', !open); + if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false'); +} + +// One checkbox: `all` is exclusive, and an emptied selection means "all". +function pysimFsFilterSetEntry(entry, on) { + const next = new Set(pysimFsFilter); + if (entry === FS_FILTER_ALL) { + next.clear(); + next.add(FS_FILTER_ALL); + } else if (on) { + next.delete(FS_FILTER_ALL); + next.add(entry); + } else { + next.delete(entry); + if (!next.size) next.add(FS_FILTER_ALL); } - if (!next.size) next.add(FS_FILTER_ALL); pysimFsFilter = next; localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter))); pysimFsFilterRender(); @@ -27875,6 +27915,7 @@ const LANG_RU = { 'SMS-related': 'Связанное с SMS', 'CB-related': 'Связанное с CB', 'Custom files': 'Пользовательские файлы', + 'Selected: {n}': 'Выбрано: {n}', 'Probe all files': 'Проверить все файлы', 'Feature': 'Функция', 'SST': 'SST', @@ -28984,6 +29025,18 @@ updateConvFields(); updateSp(); uiccStdFilesLoad(); pysimFsFilterRender(); +// the file-tree filter's dropdown closes on an outside click or Escape (the +// button toggles it, so a click on the button itself is left to its handler) +document.addEventListener('click', e => { + const target = e.target; + if (!target || !target.closest) return; + if (!target.closest('#pysim-fs-filter-btn') && !target.closest('#pysim-fs-filter-menu')) { + pysimFsFilterToggle(false); + } +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') pysimFsFilterToggle(false); +}); updateSpKic(); updateSpKid(); chainInit('chain-sim'); diff --git a/frontend/tests/fs_filter.test.js b/frontend/tests/fs_filter.test.js index de283e3..814a198 100644 --- a/frontend/tests/fs_filter.test.js +++ b/frontend/tests/fs_filter.test.js @@ -59,13 +59,48 @@ globalThis.localStorage = { globalThis.esc = s => String(s); globalThis.t = k => k; globalThis.pysimFsRenderTree = () => { _treeRenders++; }; -let _select = null; -globalThis.document = { getElementById: id => (id === 'pysim-fs-filter' ? _select : null) }; + +// The filter's two DOM elements: the collapsed row's button and the hidden +// menu it expands into. The menu only needs a settable innerHTML (the +// render never reads the DOM back) and a classList for the open/closed state. +function fakeBtn() { + return { + textContent: '', + attrs: {}, + setAttribute(k, v) { this.attrs[k] = String(v); }, + getAttribute(k) { return this.attrs[k]; }, + }; +} + +function fakeMenu() { + const classes = new Set(['hidden']); + return { + _html: '', + classes, + classList: { + contains: c => classes.has(c), + toggle(c, force) { + const on = force === undefined ? !classes.has(c) : !!force; + if (on) classes.add(c); else classes.delete(c); + return on; + }, + }, + get innerHTML() { return this._html; }, + set innerHTML(v) { this._html = v; }, + }; +} + +const dom = { menu: null, btn: null }; +globalThis.document = { + getElementById: id => (id === 'pysim-fs-filter-menu' ? dom.menu + : (id === 'pysim-fs-filter-btn' ? dom.btn : null)), +}; let code = ''; for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex', - 'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterRender', 'pysimFsSetFilter', - 'pysimFsFilterRestore']) { + 'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterLabel', + 'pysimFsFilterSummary', 'pysimFsFilterRender', 'pysimFsFilterToggle', + 'pysimFsFilterSetEntry', 'pysimFsFilterRestore']) { code += extractFunc(html, f) + '\n'; } for (const c of ['FS_FILTER_GROUPS', 'FS_FILTER_GROUP_ORDER', 'SERVICES_FS_MAP']) code += extractConst(html, c); @@ -79,27 +114,39 @@ function withFilter(ids, fn) { try { return fn(); } finally { pysimFsFilter = saved; } } -function fakeSelect(selected) { - const options = (selected || []).map(v => ({ value: v, selected: true })); - return { - _html: '', - get innerHTML() { return this._html; }, - set innerHTML(v) { - this._html = v; - options.length = 0; - for (const m of v.matchAll(/