HEL-430: replace directory search picker with collapsible tree

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
美术师
2026-09-03 08:40:53 +08:00
co-authored by Cursor multica-agent
parent 8ceafe70b1
commit fbafb8015d
4 changed files with 219 additions and 131 deletions
+2 -7
View File
@@ -167,13 +167,8 @@ function directoryPickerItems(array $dirs): array
}
unset($children);
$pathMap = dirPathMap($dirs);
$nameCounts = [];
foreach ($dirs as $d) {
$n = (string) $d['name'];
$nameCounts[$n] = ($nameCounts[$n] ?? 0) + 1;
}
$out = [];
$walk = static function (int $parent) use (&$walk, $byParent, $pathMap, $nameCounts, &$out): void {
$walk = static function (int $parent) use (&$walk, $byParent, $pathMap, &$out): void {
foreach ($byParent[$parent] ?? [] as $d) {
$id = (int) $d['id'];
$name = (string) $d['name'];
@@ -182,7 +177,7 @@ function directoryPickerItems(array $dirs): array
'name' => $name,
'path' => (string) ($pathMap[$id] ?? $name),
'level' => (int) ($d['level'] ?? 1),
'dup' => ($nameCounts[$name] ?? 0) > 1,
'parent_id' => $parent,
];
$walk($id);
}
+31 -15
View File
@@ -287,28 +287,44 @@ img { display: block; }
max-height: min(420px, calc(100vh - 16px));
padding: 0; overflow: hidden;
}
.v-fusion .dir-picker-search {
padding: 8px; border-bottom: 1px solid var(--hairline);
background: var(--surface);
}
.v-fusion[data-theme="night"] .dir-picker-search { background: var(--surface-2); }
.v-fusion .dir-picker-search .search { min-width: 0; width: 100%; height: 32px; }
.v-fusion .dir-picker-list {
overflow-y: auto; overscroll-behavior: contain; padding: 6px;
flex: 1; min-height: 0;
}
.v-fusion .dir-picker-group {
font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
color: var(--text-3); padding: 8px 10px 4px;
.v-fusion .dir-picker-reset {
margin: 0 0 4px; padding: 0 0 4px;
border-bottom: 1px solid var(--hairline);
}
.v-fusion .dir-picker-opt {
display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
white-space: normal;
.v-fusion .dir-tree-row {
display: flex; align-items: center; gap: 2px;
padding-left: calc(var(--dir-indent, 0) * 12px);
}
.v-fusion .dir-tree-toggle {
flex: 0 0 24px; width: 24px; height: 24px; border-radius: 7px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--text-3);
transition: background-color 120ms ease, color 120ms ease, transform 160ms var(--ease-out);
}
.v-fusion .dir-tree-toggle.is-leaf { pointer-events: none; }
.v-fusion .dir-tree-toggle:active:not(.is-leaf) { transform: scale(0.94); }
@media (hover: hover) and (pointer: fine) {
.v-fusion .dir-tree-toggle:hover:not(.is-leaf) { background: var(--hover); color: var(--text); }
}
.v-fusion .dir-tree-toggle:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
.v-fusion .dir-tree-chevron {
display: block; width: 12px; height: 12px;
transition: transform 120ms var(--ease-out);
}
.v-fusion .dir-tree-toggle[aria-expanded="true"] .dir-tree-chevron { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) {
.v-fusion .dir-tree-toggle, .v-fusion .dir-tree-chevron { transition: none; }
}
.v-fusion .dir-tree-row .dir-picker-opt {
flex: 1; min-width: 0;
display: block; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.v-fusion .dir-picker-opt-name { font-size: 13px; line-height: 1.35; }
.v-fusion .dir-picker-opt-path {
font-size: 11.5px; color: var(--text-3); font-weight: 400; line-height: 1.3;
}
.v-fusion .dir-picker-opt.is-active:not([aria-selected="true"]) { background: var(--hover); }
/* ============================================================
+185 -108
View File
@@ -513,7 +513,7 @@ let dpView = null;
let dpFocusYmd = null;
let popRepositionBound = false;
let dirPickerItems = [];
let dirPickerQuery = "";
let dirExpanded = new Set();
function popRoot() {
return root() || document.body;
@@ -541,7 +541,7 @@ function ensurePops() {
dirPopEl.id = "jn-dir-picker";
dirPopEl.className = "jn-pop dir-picker-pop";
dirPopEl.hidden = true;
dirPopEl.setAttribute("role", "listbox");
dirPopEl.setAttribute("role", "tree");
dirPopEl.setAttribute("aria-label", "选择归档目录");
host.appendChild(menuEl);
host.appendChild(dpEl);
@@ -664,100 +664,153 @@ function commitSelect(index) {
select.focus();
}
const DIR_RECENT_KEY = "jn_recent_dirs";
function dirRecentIds() {
try {
const raw = JSON.parse(localStorage.getItem(DIR_RECENT_KEY) || "[]");
return Array.isArray(raw) ? raw.map((x) => +x).filter((id) => id > 0) : [];
} catch (e) {
return [];
}
function dirParentId(item) {
return +(item && item.parent_id ? item.parent_id : 0);
}
function rememberDir(id) {
function dirByParentMap() {
const map = new Map();
dirPickerItems.forEach((it) => {
const pid = dirParentId(it);
if (!map.has(pid)) map.set(pid, []);
map.get(pid).push(it);
});
return map;
}
function dirItemById(id) {
id = +id;
if (!id) return;
const next = [id, ...dirRecentIds().filter((x) => x !== id)].slice(0, 6);
try { localStorage.setItem(DIR_RECENT_KEY, JSON.stringify(next)); } catch (e) {}
return dirPickerItems.find((it) => +it.id === id) || null;
}
function dirMatches(item, q) {
if (!q) return true;
const n = (item.name || "").toLowerCase();
const p = (item.path || "").toLowerCase();
return n.includes(q) || p.includes(q);
function dirAncestorIds(id) {
const out = [];
let cur = dirItemById(id);
while (cur) {
const pid = dirParentId(cur);
if (!pid) break;
out.push(pid);
cur = dirItemById(pid);
}
return out;
}
function dirOptButton(item, selectedId, extraClass, indent) {
function seedDirExpanded(selectedId) {
dirExpanded = new Set(dirAncestorIds(selectedId).map(String));
}
function dirChevron() {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("class", "dir-tree-chevron");
svg.setAttribute("width", "12");
svg.setAttribute("height", "12");
svg.setAttribute("viewBox", "0 0 12 12");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS(ns, "path");
path.setAttribute("d", "M4.25 2.5 8.25 6l-4 3.5");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.5");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.appendChild(path);
return svg;
}
function dirAllButton(selectedId) {
const wrap = document.createElement("div");
wrap.className = "dir-picker-reset";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "jn-menu-opt dir-picker-opt" + (extraClass ? " " + extraClass : "");
btn.setAttribute("role", "option");
btn.className = "jn-menu-opt dir-picker-opt";
btn.setAttribute("role", "treeitem");
btn.setAttribute("aria-level", "1");
btn.dataset.id = "0";
const name = document.createElement("span");
name.className = "dir-picker-opt-name";
name.textContent = "全部目录";
btn.appendChild(name);
if (+selectedId === 0) btn.setAttribute("aria-selected", "true");
wrap.appendChild(btn);
return wrap;
}
function dirTreeRow(item, selectedId, hasKids, expanded) {
const row = document.createElement("div");
row.className = "dir-tree-row";
row.style.setProperty("--dir-indent", String(Math.max(0, (item.level || 1) - 1)));
if (hasKids) {
const tog = document.createElement("button");
tog.type = "button";
tog.className = "dir-tree-toggle";
tog.dataset.dirToggle = String(item.id);
tog.setAttribute("aria-expanded", expanded ? "true" : "false");
tog.setAttribute("aria-label", (expanded ? "收起 " : "展开 ") + item.name);
tog.appendChild(dirChevron());
row.appendChild(tog);
} else {
const sp = document.createElement("span");
sp.className = "dir-tree-toggle is-leaf";
sp.setAttribute("aria-hidden", "true");
row.appendChild(sp);
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = "jn-menu-opt dir-picker-opt";
btn.setAttribute("role", "treeitem");
btn.setAttribute("aria-level", String(item.level || 1));
btn.dataset.id = String(item.id);
if (indent) btn.style.paddingLeft = (10 + Math.max(0, (item.level || 1) - 1) * 14) + "px";
if (hasKids) btn.setAttribute("aria-expanded", expanded ? "true" : "false");
btn.title = item.path || item.name;
const name = document.createElement("span");
name.className = "dir-picker-opt-name";
name.textContent = item.name;
btn.appendChild(name);
if (item.dup || (item.level || 1) > 1 || extraClass === "is-recent") {
const path = document.createElement("span");
path.className = "dir-picker-opt-path";
path.textContent = item.path;
btn.appendChild(path);
}
if (+selectedId === +item.id) btn.setAttribute("aria-selected", "true");
return btn;
row.appendChild(btn);
return row;
}
function renderDirPicker() {
if (!dirPopEl) return;
const wrap = popAnchor && popAnchor.closest("[data-dir-picker]");
const selectedId = wrap ? +(wrap.dataset.selected || 0) : 0;
const q = dirPickerQuery.trim().toLowerCase();
const search = dirPopEl.querySelector("[data-dir-picker-q]");
const keepQ = search ? search.value : dirPickerQuery;
const byParent = dirByParentMap();
dirPopEl.innerHTML = "";
const head = document.createElement("div");
head.className = "dir-picker-search";
head.innerHTML = `<div class="search"><span aria-hidden="true">⌕</span><input type="search" data-dir-picker-q placeholder="搜索目录名或完整路径" aria-label="搜索目录" autocomplete="off"></div>`;
const inp = head.querySelector("input");
inp.value = keepQ;
dirPopEl.appendChild(head);
const list = document.createElement("div");
list.className = "dir-picker-list";
const allBtn = document.createElement("button");
allBtn.type = "button";
allBtn.className = "jn-menu-opt dir-picker-opt";
allBtn.setAttribute("role", "option");
allBtn.dataset.id = "0";
const allName = document.createElement("span");
allName.className = "dir-picker-opt-name";
allName.textContent = "全部目录";
allBtn.appendChild(allName);
if (selectedId === 0) allBtn.setAttribute("aria-selected", "true");
list.appendChild(allBtn);
const byId = new Map(dirPickerItems.map((it) => [+it.id, it]));
const recents = dirRecentIds().map((id) => byId.get(id)).filter(Boolean).filter((it) => dirMatches(it, q));
if (recents.length) {
const lab = document.createElement("div");
lab.className = "dir-picker-group";
lab.textContent = "最近使用";
list.appendChild(lab);
recents.forEach((it) => list.appendChild(dirOptButton(it, selectedId, "is-recent", false)));
}
const tree = dirPickerItems.filter((it) => dirMatches(it, q));
const treeLab = document.createElement("div");
treeLab.className = "dir-picker-group";
treeLab.textContent = q ? "匹配目录" : "目录树";
list.appendChild(treeLab);
if (!tree.length) {
const empty = document.createElement("div");
empty.className = "dir-picker-empty";
empty.textContent = "无匹配目录";
list.appendChild(empty);
} else {
tree.forEach((it) => list.appendChild(dirOptButton(it, selectedId, "", true)));
}
list.appendChild(dirAllButton(selectedId));
const walk = (parentId) => {
(byParent.get(parentId) || []).forEach((it) => {
const kids = byParent.get(+it.id) || [];
const expanded = dirExpanded.has(String(it.id));
list.appendChild(dirTreeRow(it, selectedId, kids.length > 0, expanded));
if (kids.length && expanded) walk(+it.id);
});
};
walk(0);
dirPopEl.appendChild(list);
}
function dirPickerOptions() {
return dirPopEl ? $$(".jn-menu-opt", dirPopEl) : [];
return dirPopEl ? $$(".dir-picker-opt", dirPopEl) : [];
}
function dirPickerFocusables() {
if (!dirPopEl) return [];
return $$("button:not([disabled])", dirPopEl);
}
function restoreDirFocus(id, wantToggle) {
if (!dirPopEl || id == null) return;
const sel = wantToggle
? dirPopEl.querySelector(`[data-dir-toggle="${id}"]`)
: dirPopEl.querySelector(`.dir-picker-opt[data-id="${id}"]`);
if (sel) sel.focus();
}
function toggleDirExpand(id, force) {
const key = String(id);
const open = force === true ? true : force === false ? false : !dirExpanded.has(key);
if (open) dirExpanded.add(key);
else dirExpanded.delete(key);
const ae = document.activeElement;
const keepId = ae && dirPopEl && dirPopEl.contains(ae)
? (ae.dataset.dirToggle || ae.dataset.id)
: key;
const wantToggle = !!(ae && ae.hasAttribute && ae.hasAttribute("data-dir-toggle"));
renderDirPicker();
if (popAnchor) placePopover(dirPopEl, popAnchor);
restoreDirFocus(keepId, wantToggle);
}
function moveDirHighlight(delta) {
const opts = dirPickerOptions();
@@ -770,6 +823,10 @@ function moveDirHighlight(delta) {
opts[next].classList.add("is-active");
opts[next].focus();
}
function dirOptHasKids(id) {
const kids = dirByParentMap().get(+id);
return !!(kids && kids.length);
}
function openDirPicker(trigger) {
if (!trigger) return;
const wrap = trigger.closest("[data-dir-picker]");
@@ -780,7 +837,7 @@ function openDirPicker(trigger) {
try { dirPickerItems = JSON.parse(wrap.getAttribute("data-dirs") || "[]"); }
catch (e) { dirPickerItems = []; }
if (!Array.isArray(dirPickerItems)) dirPickerItems = [];
dirPickerQuery = "";
seedDirExpanded(+(wrap.dataset.selected || 0));
popAnchor = trigger;
popKind = "dir";
trigger.setAttribute("aria-expanded", "true");
@@ -788,8 +845,11 @@ function openDirPicker(trigger) {
renderDirPicker();
placePopover(dirPopEl, trigger);
bindPopReposition(true);
const q = dirPopEl.querySelector("[data-dir-picker-q]");
if (q) q.focus();
const selected = dirPopEl.querySelector(".dir-picker-opt[aria-selected='true']") || dirPopEl.querySelector(".dir-picker-opt");
if (selected) {
selected.focus();
selected.scrollIntoView({ block: "nearest" });
}
}
function commitDirPicker(id) {
const trigger = popAnchor;
@@ -797,7 +857,6 @@ function commitDirPicker(id) {
const form = wrap && wrap.closest("form");
const hidden = wrap && wrap.querySelector('input[name="dir_id"]');
if (!hidden || !form) { closePops(); return; }
rememberDir(id);
hidden.value = String(id || 0);
closePops();
form.submit();
@@ -1191,7 +1250,13 @@ document.addEventListener("click", (e) => {
document.addEventListener("click", (e) => {
const t = e.target;
if (!dirPopEl || dirPopEl.hidden || !dirPopEl.contains(t)) return;
const opt = t.closest(".jn-menu-opt");
const tog = t.closest("[data-dir-toggle]");
if (tog) {
e.preventDefault();
toggleDirExpand(tog.dataset.dirToggle);
return;
}
const opt = t.closest(".dir-picker-opt");
if (opt && !opt.disabled) commitDirPicker(+opt.dataset.id);
});
@@ -1276,20 +1341,6 @@ document.addEventListener("change", (e) => {
document.addEventListener("input", (e) => {
if (e.target.closest(".form-field") || isDateField(e.target)) setErr(e.target, "");
if (e.target.matches && e.target.matches("[data-dir-picker-q]") && popKind === "dir") {
dirPickerQuery = e.target.value || "";
const keep = e.target;
const start = keep.selectionStart;
const end = keep.selectionEnd;
renderDirPicker();
const next = dirPopEl.querySelector("[data-dir-picker-q]");
if (next) {
next.focus();
try { next.setSelectionRange(start, end); } catch (err) {}
}
repositionOpenPop();
return;
}
if (e.target.closest("[data-calc-interest]") || e.target.closest("[data-calc-discount]")) {
const ci = e.target.closest("[data-calc-interest]");
if (ci) {
@@ -1315,31 +1366,59 @@ document.addEventListener("keydown", (e) => {
}
if (popKind === "dir" && dirPopEl && !dirPopEl.hidden) {
const opts = dirPickerOptions();
const focusables = dirPickerFocusables();
const activeOpt = document.activeElement && document.activeElement.classList.contains("dir-picker-opt")
? document.activeElement
: (dirPopEl.querySelector(".dir-picker-opt.is-active") || null);
const activeId = activeOpt ? +activeOpt.dataset.id : 0;
if (e.key === "Escape") { e.preventDefault(); closePops(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); moveDirHighlight(1); return; }
if (e.key === "ArrowUp") { e.preventDefault(); moveDirHighlight(-1); return; }
if (e.key === "ArrowRight" && activeId) {
e.preventDefault();
if (dirOptHasKids(activeId) && !dirExpanded.has(String(activeId))) toggleDirExpand(activeId, true);
else moveDirHighlight(1);
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
if (activeId && dirExpanded.has(String(activeId))) {
toggleDirExpand(activeId, false);
return;
}
const parentId = activeId ? dirParentId(dirItemById(activeId)) : 0;
if (parentId) restoreDirFocus(parentId, false);
else restoreDirFocus(0, false);
return;
}
if (e.key === "Home" && opts[0]) { e.preventDefault(); opts.forEach((el) => el.classList.remove("is-active")); opts[0].classList.add("is-active"); opts[0].focus(); return; }
if (e.key === "End" && opts.length) { e.preventDefault(); opts.forEach((el) => el.classList.remove("is-active")); opts[opts.length - 1].classList.add("is-active"); opts[opts.length - 1].focus(); return; }
if (e.key === "Enter") {
const active = document.activeElement && document.activeElement.classList.contains("jn-menu-opt")
? document.activeElement
: (dirPopEl.querySelector(".jn-menu-opt.is-active") || opts[0]);
if (active && dirPopEl.contains(active) && !e.target.matches("[data-dir-picker-q]")) {
if (e.key === "Enter" || e.key === " ") {
const tog = e.target.closest && e.target.closest("[data-dir-toggle]");
if (tog) {
e.preventDefault();
commitDirPicker(+active.dataset.id);
toggleDirExpand(tog.dataset.dirToggle);
return;
}
if (e.target.matches && e.target.matches("[data-dir-picker-q]")) {
const hit = (e.target.classList && e.target.classList.contains("dir-picker-opt") && dirPopEl.contains(e.target))
? e.target
: (dirPopEl.querySelector(".dir-picker-opt.is-active") || opts[0]);
if (hit) {
e.preventDefault();
const q = (dirPickerQuery || "").trim();
const hit = q
? (opts.find((el) => el.classList.contains("is-active") && el.dataset.id !== "0") || opts.find((el) => el.dataset.id !== "0") || opts[0])
: (dirPopEl.querySelector(".jn-menu-opt.is-active") || opts[0]);
if (hit) commitDirPicker(+hit.dataset.id);
return;
commitDirPicker(+hit.dataset.id);
}
return;
}
if (e.key === "Tab") {
const i = focusables.indexOf(document.activeElement);
if (e.shiftKey) {
if (i <= 0) { e.preventDefault(); closePops(); }
} else if (i === -1 || i >= focusables.length - 1) {
e.preventDefault();
closePops();
}
return;
}
if (e.key === "Tab") { closePops(); return; }
}
if (popKind === "menu" && menuEl && !menuEl.hidden) {
const opts = $$(".jn-menu-opt:not(:disabled)", menuEl);
@@ -1505,8 +1584,6 @@ document.addEventListener("DOMContentLoaded", () => {
syncCheckAll();
initCalc();
enhanceDateInputs();
const picker = document.querySelector("[data-dir-picker]");
if (picker && +picker.dataset.selected > 0) rememberDir(+picker.dataset.selected);
const mo = new MutationObserver((recs) => {
recs.forEach((r) => {
r.addedNodes.forEach((n) => {
+1 -1
View File
@@ -2857,7 +2857,7 @@ pageHeader($me, $taskTotal);
<form method="get" action="index.php" class="filter-bar" style="margin-top:12px;">
<div class="dir-picker" data-dir-picker data-selected="<?= $dirId ?>" data-dirs="<?= e(json_encode($dirPickerItems, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?>">
<input type="hidden" name="dir_id" value="<?= $dirId ?>">
<button type="button" class="dir-picker-trigger" data-dir-picker-trigger aria-haspopup="listbox" aria-expanded="false" aria-controls="jn-dir-picker" aria-label="按归档目录筛选" title="<?= e($dirPickerLabel) ?>">
<button type="button" class="dir-picker-trigger" data-dir-picker-trigger aria-haspopup="tree" aria-expanded="false" aria-controls="jn-dir-picker" aria-label="按归档目录筛选" title="<?= e($dirPickerLabel) ?>">
<span class="dir-picker-trigger-text"><?= e($dirPickerLabel) ?></span>
</button>
</div>