diff --git a/app/helpers.php b/app/helpers.php index 771aae0..d46a2ea 100755 --- a/app/helpers.php +++ b/app/helpers.php @@ -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); } diff --git a/public/assets/fusion.css b/public/assets/fusion.css index 4b34fd1..d1718ba 100644 --- a/public/assets/fusion.css +++ b/public/assets/fusion.css @@ -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); } /* ============================================================ diff --git a/public/assets/fusion.js b/public/assets/fusion.js index 7cae0fc..f43da84 100644 --- a/public/assets/fusion.js +++ b/public/assets/fusion.js @@ -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 = ``; - 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) => { diff --git a/public/index.php b/public/index.php index cc56278..7871a9b 100755 --- a/public/index.php +++ b/public/index.php @@ -2857,7 +2857,7 @@ pageHeader($me, $taskTotal);
-