vomebook commited on
Commit
4d75b2f
·
verified ·
1 Parent(s): 657bc37

fix(a11y): improve CCRD keyboard and dialog accessibility

Browse files

Keyboard-operable dynamic controls, ARIA state, modal focus management, and live status semantics. Search/data contracts unchanged.

Files changed (3) hide show
  1. static/app.js +130 -27
  2. static/index.html +20 -20
  3. static/style.css +39 -1
static/app.js CHANGED
@@ -64,6 +64,8 @@ let initComplete = false;
64
  let snippetObserver = null;
65
  let snippetActive = 0;
66
  let previewController = null;
 
 
67
  let keepalivePending = null;
68
  let lastKeepaliveAt = Date.now();
69
  const snippetQueue = [];
@@ -321,6 +323,7 @@ function setCachedSearch(key, value) {
321
  }
322
 
323
  function cacheDom() {
 
324
  DOM.headerTitle = $("#header-title");
325
  DOM.headerLogo = $("#header-logo");
326
  DOM.searchInput = $("#search-input");
@@ -336,6 +339,7 @@ function cacheDom() {
336
  DOM.sidebarTitle = $("#sidebar-title");
337
  DOM.sidebarContent = $("#sidebar-content");
338
  DOM.resultCount = $("#result-count");
 
339
  DOM.resultsContainer = $("#results-container");
340
  DOM.resultsList = $("#results-list");
341
  DOM.resultsLoading = $("#results-loading");
@@ -372,6 +376,7 @@ function cacheDom() {
372
  DOM.multiZipDownload = $("#multi-zip-download");
373
  DOM.multiSelectedCount = $("#multi-selected-count");
374
  DOM.randomBookBtn = $("#random-book-btn");
 
375
  DOM.overlay = $("#overlay");
376
  DOM.toast = $("#toast");
377
  }
@@ -542,10 +547,10 @@ function renderHistoryDropdown() {
542
  setHistoryDropdownOpen(false);
543
  return;
544
  }
545
- DOM.searchHistoryDropdown.innerHTML = items.map((item) => `
546
- <div class="history-item" data-query="${escapeHTML(item)}">
547
- <span class="history-text">${escapeHTML(item)}</span>
548
- <button type="button" class="history-del" data-del="${escapeHTML(item)}" aria-label="删除历史">×</button>
549
  </div>`).join("") + '<div class="history-footer"><button type="button" class="history-clear-all">清空历史</button></div>';
550
  setHistoryDropdownOpen(true);
551
  }
@@ -591,6 +596,7 @@ function applyMobileMode() {
591
  if (DOM.mobileToggleIcon) {
592
  DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-phone" : "ui-icon ui-icon-desktop";
593
  }
 
594
  if (DOM.sidebarExpandBtn) {
595
  DOM.sidebarExpandBtn.style.display = (!STATE.isMobile && STATE.source) ? "" : "none";
596
  if (STATE.isMobile) {
@@ -942,11 +948,11 @@ async function doSearch() {
942
 
943
  function resultPathHtml(item) {
944
  const sourceSeparator = (item.Folder || []).length ? '<span class="path-sep">/</span>' : "";
945
- const sourcePrefix = `<span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="">${highlightText(item.SourceName, STATE.query)}${sourceSeparator}</span>`;
946
  const rest = (item.Folder || []).map((part, index) => {
947
  const path = item.Folder.slice(0, index + 1).join("/");
948
  const separator = index < item.Folder.length - 1 ? '<span class="path-sep">/</span>' : "";
949
- return `<span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="${escapeHTML(path)}">${highlightText(part, STATE.query)}${separator}</span>`;
950
  }).join("");
951
  return sourcePrefix + rest;
952
  }
@@ -960,8 +966,8 @@ function resultItemHtml(item) {
960
  : STATE.fulltext ? highlightText(item.snippet, STATE.query, snippetHighlights) : escapeHTML(item.snippet || "");
961
  const snippetHtml = `<div class="result-snippet" data-snippet-doc-id="${escapeHTML(item.doc_id)}" data-snippet-pending="${pendingSnippet ? "1" : "0"}">${snippet}</div>`;
962
  return `
963
- <div class="result-item" data-doc-id="${escapeHTML(item.doc_id)}">
964
- <input type="checkbox" class="result-checkbox" data-doc-id="${escapeHTML(item.doc_id)}" ${STATE.selectedIds.has(item.doc_id) ? "checked" : ""}>
965
  <div class="result-file-icon">${ICON_HTML.file}</div>
966
  <div class="result-info">
967
  <div class="result-title">${titleHtml}</div>
@@ -969,8 +975,8 @@ function resultItemHtml(item) {
969
  <div class="result-meta"><span class="result-size">${escapeHTML(formatSize(item.Size))}</span></div>
970
  </div>
971
  <div class="result-actions">
972
- <button class="result-action-btn" data-action="preview" data-doc-id="${escapeHTML(item.doc_id)}">在线预览</button>
973
- <a class="result-action-btn primary" href="/api/download/${encodeURIComponent(item.doc_id)}">下载</a>
974
  </div>
975
  ${snippetHtml}
976
  </div>`;
@@ -1096,7 +1102,52 @@ async function loadExactSnippet(task) {
1096
  task.node.innerHTML = highlightText(data.snippet || "", task.query, data.highlights || []);
1097
  }
1098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1099
  async function openPreview(docId, keyword, options = {}) {
 
 
 
 
 
 
1100
  if (options.sync !== false) {
1101
  STATE.previewDocId = docId;
1102
  syncUrl(history.state && history.state.preview === true);
@@ -1132,12 +1183,18 @@ async function openPreview(docId, keyword, options = {}) {
1132
  <button type="button" class="icon-btn-sm preview-theme-btn" data-action="toggle-preview-theme" aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${themeIcon(STATE.isDark)}</button>
1133
  </div>
1134
  </div>
1135
- <pre class="preview-body"></pre>`;
 
 
 
 
1136
  const previewBody = DOM.previewPanel.querySelector(".preview-body");
1137
  if (shouldHighlight) previewBody.innerHTML = text;
1138
  else previewBody.textContent = rawText;
1139
  DOM.previewPanel.style.display = STATE.isMobile ? "flex" : "block";
1140
  document.body.classList.add("preview-open");
 
 
1141
  if (motionAllowed()) {
1142
  DOM.previewPanel.classList.remove("preview-enter");
1143
  void DOM.previewPanel.offsetWidth;
@@ -1175,18 +1232,14 @@ function closePreview() {
1175
  return;
1176
  }
1177
  STATE.previewDocId = null;
1178
- DOM.previewPanel.innerHTML = "";
1179
- DOM.previewPanel.style.display = "none";
1180
- document.body.classList.remove("preview-open");
1181
  observeResultSnippets();
1182
  syncUrl();
1183
  }
1184
 
1185
  function restorePreviewFromUrl(options = {}) {
1186
  if (!STATE.previewDocId) {
1187
- DOM.previewPanel.innerHTML = "";
1188
- DOM.previewPanel.style.display = "none";
1189
- document.body.classList.remove("preview-open");
1190
  return;
1191
  }
1192
  openPreview(STATE.previewDocId, STATE.query, { sync: false, scroll: options.scroll === true });
@@ -1386,6 +1439,7 @@ function refreshFolderTreeSelectionState() {
1386
  const collapsed = !!STATE.folderCollapsed[path];
1387
  toggle.classList.toggle("expanded", !collapsed);
1388
  toggle.setAttribute("aria-label", collapsed ? "展开子文件夹" : "收起子文件夹");
 
1389
  toggle.setAttribute("title", collapsed ? "展开" : "收起");
1390
  });
1391
  }
@@ -1491,7 +1545,7 @@ function renderFolderNodes(container, nodes, depth) {
1491
  row.className = "filter-folder-item";
1492
  row.dataset.folderPath = node.path;
1493
  row.style.setProperty("--fdepth", itemDepth);
1494
- row.innerHTML = `${hasChildren ? `<button type="button" class="tree-toggle" data-folder-path="${escapeHTML(node.path)}" aria-label="展开子文件夹" title="展开"><span class="tree-toggle-glyph" aria-hidden="true"></span></button>` : '<span class="tree-toggle-placeholder"></span>'}<input type="checkbox" data-folder-path="${escapeHTML(node.path)}" value="${escapeHTML(node.path)}"><span class="folder-name">${escapeHTML(node.name)}</span><span class="folder-count">${(node.count || 0).toLocaleString()}</span>`;
1495
  const toggle = row.querySelector(".tree-toggle");
1496
  if (toggle) {
1497
  toggle.addEventListener("click", (event) => {
@@ -1570,17 +1624,17 @@ function retrySidebar(source, path, routeId, requestSequence) {
1570
 
1571
  function renderSourceListSidebar(sources) {
1572
  DOM.sidebarTitle.textContent = "数据包";
1573
- DOM.sidebarContent.innerHTML = (sources || []).map((source) => `<div class="repo-list-item" data-source="${escapeHTML(source.slug)}">${ICON_HTML.source}<span class="repo-name">${escapeHTML(source.name)}</span><span class="repo-count">${(source.count || 0).toLocaleString()}</span></div>`).join("");
1574
  }
1575
 
1576
  function browserContentHTML(data, path, includeBreadcrumb) {
1577
  const parts = path ? path.split("/") : [];
1578
- let html = '<div class="back-to-global" id="back-home">返回全局搜索</div>';
1579
  if (includeBreadcrumb) {
1580
- html += `<div class="sidebar-breadcrumb"><span class="crumb-item" data-folder-nav="">根目录</span>${parts.map((part, index) => `<span class="crumb-sep">/</span><span class="crumb-item" data-folder-nav="${escapeHTML(parts.slice(0, index + 1).join("/"))}">${escapeHTML(part)}</span>`).join("")}</div>`;
1581
  }
1582
- html += (data.folders || []).map((item) => `<div class="browser-item" data-folder-open="${escapeHTML(item.path)}">${ICON_HTML.folder}<span class="browser-name">${escapeHTML(item.name)}</span><span class="browser-count">${(item.count || 0).toLocaleString()}</span></div>`).join("");
1583
- html += (data.files || []).map((item) => `<div class="browser-item" data-doc-id="${escapeHTML(item.doc_id)}">${ICON_HTML.file}<span class="browser-name">${escapeHTML(item.name)}.txt</span><span class="browser-size">${escapeHTML(formatSize(item.size))}</span></div>`).join("");
1584
  return html;
1585
  }
1586
 
@@ -1649,12 +1703,26 @@ async function openFolder(path, routeId = routeRenderId) {
1649
  }
1650
 
1651
  function updateSidebarVisibility() {
 
1652
  DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen);
1653
  DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen);
1654
  DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen);
1655
  DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen);
1656
  DOM.overlay.style.display = "";
1657
  DOM.overlay.classList.toggle("open", STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen));
 
 
 
 
 
 
 
 
 
 
 
 
 
1658
  }
1659
 
1660
  function recoverSidebarState() {
@@ -1746,11 +1814,13 @@ function attachEvents() {
1746
  if (STATE.isMobile && STATE.leftSidebarOpen) STATE.rightSidebarOpen = false;
1747
  updateSidebarVisibility();
1748
  syncUrl();
 
1749
  });
1750
  if (DOM.sidebarExpandBtn) {
1751
  DOM.sidebarExpandBtn.addEventListener("click", () => {
1752
  DOM.leftSidebar.classList.toggle("expanded-wide");
1753
  DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔";
 
1754
  syncUrl();
1755
  });
1756
  }
@@ -1759,11 +1829,13 @@ function attachEvents() {
1759
  if (STATE.isMobile && STATE.rightSidebarOpen) STATE.leftSidebarOpen = false;
1760
  updateSidebarVisibility();
1761
  syncUrl();
 
1762
  });
1763
  DOM.closeFiltersBtn.addEventListener("click", () => {
1764
  STATE.rightSidebarOpen = false;
1765
  updateSidebarVisibility();
1766
  syncUrl();
 
1767
  });
1768
  DOM.overlay.addEventListener("click", () => {
1769
  STATE.leftSidebarOpen = false;
@@ -1778,6 +1850,7 @@ function attachEvents() {
1778
  localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop");
1779
  applyUiUrlState();
1780
  updateSidebarVisibility();
 
1781
  syncUrl();
1782
  });
1783
  let historyDropdownActive = false;
@@ -2027,6 +2100,12 @@ function attachEvents() {
2027
  doSearch();
2028
  }
2029
  });
 
 
 
 
 
 
2030
  DOM.sidebarContent.addEventListener("click", async (event) => {
2031
  const sourceItem = event.target.closest("[data-source]");
2032
  if (sourceItem) {
@@ -2083,12 +2162,38 @@ function attachEvents() {
2083
  if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
2084
  });
2085
  document.addEventListener("keydown", (event) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2086
  if (event.key === "Escape") {
2087
  setHistoryDropdownOpen(false);
2088
- if (STATE.previewDocId) closePreview();
2089
  STATE.leftSidebarOpen = false;
2090
  STATE.rightSidebarOpen = false;
2091
  updateSidebarVisibility();
 
2092
  return;
2093
  }
2094
  if (event.key === "/" && document.activeElement !== DOM.searchInput) {
@@ -2110,9 +2215,7 @@ function attachEvents() {
2110
  if (nextPreviewDocId) {
2111
  restorePreviewFromUrl({ scroll: false });
2112
  } else {
2113
- DOM.previewPanel.innerHTML = "";
2114
- DOM.previewPanel.style.display = "none";
2115
- document.body.classList.remove("preview-open");
2116
  }
2117
  return;
2118
  }
 
64
  let snippetObserver = null;
65
  let snippetActive = 0;
66
  let previewController = null;
67
+ let previewReturnFocus = null;
68
+ let previewReturnFocusSelector = "";
69
  let keepalivePending = null;
70
  let lastKeepaliveAt = Date.now();
71
  const snippetQueue = [];
 
323
  }
324
 
325
  function cacheDom() {
326
+ DOM.header = $("#header");
327
  DOM.headerTitle = $("#header-title");
328
  DOM.headerLogo = $("#header-logo");
329
  DOM.searchInput = $("#search-input");
 
339
  DOM.sidebarTitle = $("#sidebar-title");
340
  DOM.sidebarContent = $("#sidebar-content");
341
  DOM.resultCount = $("#result-count");
342
+ DOM.statusBar = $("#status-bar");
343
  DOM.resultsContainer = $("#results-container");
344
  DOM.resultsList = $("#results-list");
345
  DOM.resultsLoading = $("#results-loading");
 
376
  DOM.multiZipDownload = $("#multi-zip-download");
377
  DOM.multiSelectedCount = $("#multi-selected-count");
378
  DOM.randomBookBtn = $("#random-book-btn");
379
+ DOM.footer = $("#footer");
380
  DOM.overlay = $("#overlay");
381
  DOM.toast = $("#toast");
382
  }
 
547
  setHistoryDropdownOpen(false);
548
  return;
549
  }
550
+ DOM.searchHistoryDropdown.innerHTML = items.map((item) => `
551
+ <div class="history-item">
552
+ <button type="button" class="history-select" data-query="${escapeHTML(item)}" aria-label="搜索历史:${escapeHTML(item)}"><span class="history-text">${escapeHTML(item)}</span></button>
553
+ <button type="button" class="history-del" data-del="${escapeHTML(item)}" aria-label="删除历史:${escapeHTML(item)}">×</button>
554
  </div>`).join("") + '<div class="history-footer"><button type="button" class="history-clear-all">清空历史</button></div>';
555
  setHistoryDropdownOpen(true);
556
  }
 
596
  if (DOM.mobileToggleIcon) {
597
  DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-phone" : "ui-icon ui-icon-desktop";
598
  }
599
+ DOM.mobileToggleBtn.setAttribute("aria-pressed", String(STATE.isMobile));
600
  if (DOM.sidebarExpandBtn) {
601
  DOM.sidebarExpandBtn.style.display = (!STATE.isMobile && STATE.source) ? "" : "none";
602
  if (STATE.isMobile) {
 
948
 
949
  function resultPathHtml(item) {
950
  const sourceSeparator = (item.Folder || []).length ? '<span class="path-sep">/</span>' : "";
951
+ const sourcePrefix = `<span class="path-folder" role="button" tabindex="0" data-source="${escapeHTML(item.Source)}" data-folder="" aria-label="按数据包筛选:${escapeHTML(item.SourceName)}">${highlightText(item.SourceName, STATE.query)}${sourceSeparator}</span>`;
952
  const rest = (item.Folder || []).map((part, index) => {
953
  const path = item.Folder.slice(0, index + 1).join("/");
954
  const separator = index < item.Folder.length - 1 ? '<span class="path-sep">/</span>' : "";
955
+ return `<span class="path-folder" role="button" tabindex="0" data-source="${escapeHTML(item.Source)}" data-folder="${escapeHTML(path)}" aria-label="按文件夹筛选:${escapeHTML(path)}">${highlightText(part, STATE.query)}${separator}</span>`;
956
  }).join("");
957
  return sourcePrefix + rest;
958
  }
 
966
  : STATE.fulltext ? highlightText(item.snippet, STATE.query, snippetHighlights) : escapeHTML(item.snippet || "");
967
  const snippetHtml = `<div class="result-snippet" data-snippet-doc-id="${escapeHTML(item.doc_id)}" data-snippet-pending="${pendingSnippet ? "1" : "0"}">${snippet}</div>`;
968
  return `
969
+ <div class="result-item" role="listitem" data-doc-id="${escapeHTML(item.doc_id)}">
970
+ <input type="checkbox" class="result-checkbox" aria-label="选择文件:${escapeHTML(item.File)}" data-doc-id="${escapeHTML(item.doc_id)}" ${STATE.selectedIds.has(item.doc_id) ? "checked" : ""}>
971
  <div class="result-file-icon">${ICON_HTML.file}</div>
972
  <div class="result-info">
973
  <div class="result-title">${titleHtml}</div>
 
975
  <div class="result-meta"><span class="result-size">${escapeHTML(formatSize(item.Size))}</span></div>
976
  </div>
977
  <div class="result-actions">
978
+ <button class="result-action-btn" data-action="preview" data-doc-id="${escapeHTML(item.doc_id)}" aria-label="预览:${escapeHTML(item.File)}">在线预览</button>
979
+ <a class="result-action-btn primary" href="/api/download/${encodeURIComponent(item.doc_id)}" aria-label="下载:${escapeHTML(item.File)}">下载</a>
980
  </div>
981
  ${snippetHtml}
982
  </div>`;
 
1102
  task.node.innerHTML = highlightText(data.snippet || "", task.query, data.highlights || []);
1103
  }
1104
 
1105
+ function previewFocusableElements() {
1106
+ if (!DOM.previewPanel || DOM.previewPanel.hidden) return [];
1107
+ return Array.from(DOM.previewPanel.querySelectorAll("button, a[href], input, select, textarea, [tabindex]:not([tabindex='-1'])"))
1108
+ .filter((node) => !node.disabled && !node.hidden && node.getClientRects().length);
1109
+ }
1110
+
1111
+ function setPreviewModalState(open) {
1112
+ const background = [DOM.header, DOM.leftSidebar, DOM.rightSidebar, DOM.statusBar, DOM.resultsContainer, DOM.loadInfo, DOM.footer];
1113
+ for (const node of background) {
1114
+ if (!node) continue;
1115
+ node.inert = open;
1116
+ if (open) node.setAttribute("aria-hidden", "true");
1117
+ else node.removeAttribute("aria-hidden");
1118
+ }
1119
+ }
1120
+
1121
+ function restorePreviewFocus() {
1122
+ const node = previewReturnFocus;
1123
+ const selector = previewReturnFocusSelector;
1124
+ previewReturnFocus = null;
1125
+ previewReturnFocusSelector = "";
1126
+ requestAnimationFrame(() => {
1127
+ const target = node && node.isConnected ? node : selector ? document.querySelector(selector) : null;
1128
+ if (!target || target.inert) return;
1129
+ target.focus({ preventScroll: true });
1130
+ });
1131
+ }
1132
+
1133
+ function clearPreviewDom() {
1134
+ DOM.previewPanel.innerHTML = "";
1135
+ DOM.previewPanel.hidden = true;
1136
+ DOM.previewPanel.style.display = "none";
1137
+ DOM.previewPanel.removeAttribute("aria-labelledby");
1138
+ document.body.classList.remove("preview-open");
1139
+ setPreviewModalState(false);
1140
+ updateSidebarVisibility();
1141
+ restorePreviewFocus();
1142
+ }
1143
+
1144
  async function openPreview(docId, keyword, options = {}) {
1145
+ if (!STATE.previewDocId && !DOM.previewPanel.contains(document.activeElement)) {
1146
+ previewReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
1147
+ if (previewReturnFocus && previewReturnFocus.matches("[data-action='preview'][data-doc-id]")) {
1148
+ previewReturnFocusSelector = `[data-action='preview'][data-doc-id="${CSS.escape(previewReturnFocus.dataset.docId)}"]`;
1149
+ }
1150
+ }
1151
  if (options.sync !== false) {
1152
  STATE.previewDocId = docId;
1153
  syncUrl(history.state && history.state.preview === true);
 
1183
  <button type="button" class="icon-btn-sm preview-theme-btn" data-action="toggle-preview-theme" aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${themeIcon(STATE.isDark)}</button>
1184
  </div>
1185
  </div>
1186
+ <pre class="preview-body"></pre>`;
1187
+ const previewTitle = DOM.previewPanel.querySelector(".preview-title");
1188
+ if (previewTitle) previewTitle.id = "preview-title";
1189
+ DOM.previewPanel.setAttribute("aria-labelledby", "preview-title");
1190
+ DOM.previewPanel.hidden = false;
1191
  const previewBody = DOM.previewPanel.querySelector(".preview-body");
1192
  if (shouldHighlight) previewBody.innerHTML = text;
1193
  else previewBody.textContent = rawText;
1194
  DOM.previewPanel.style.display = STATE.isMobile ? "flex" : "block";
1195
  document.body.classList.add("preview-open");
1196
+ setPreviewModalState(true);
1197
+ requestAnimationFrame(() => DOM.previewPanel.querySelector("[data-action='close-preview']")?.focus({ preventScroll: true }));
1198
  if (motionAllowed()) {
1199
  DOM.previewPanel.classList.remove("preview-enter");
1200
  void DOM.previewPanel.offsetWidth;
 
1232
  return;
1233
  }
1234
  STATE.previewDocId = null;
1235
+ clearPreviewDom();
 
 
1236
  observeResultSnippets();
1237
  syncUrl();
1238
  }
1239
 
1240
  function restorePreviewFromUrl(options = {}) {
1241
  if (!STATE.previewDocId) {
1242
+ clearPreviewDom();
 
 
1243
  return;
1244
  }
1245
  openPreview(STATE.previewDocId, STATE.query, { sync: false, scroll: options.scroll === true });
 
1439
  const collapsed = !!STATE.folderCollapsed[path];
1440
  toggle.classList.toggle("expanded", !collapsed);
1441
  toggle.setAttribute("aria-label", collapsed ? "展开子文件夹" : "收起子文件夹");
1442
+ toggle.setAttribute("aria-expanded", String(!collapsed));
1443
  toggle.setAttribute("title", collapsed ? "展开" : "收起");
1444
  });
1445
  }
 
1545
  row.className = "filter-folder-item";
1546
  row.dataset.folderPath = node.path;
1547
  row.style.setProperty("--fdepth", itemDepth);
1548
+ row.innerHTML = `${hasChildren ? `<button type="button" class="tree-toggle" data-folder-path="${escapeHTML(node.path)}" aria-label="展开子文件夹:${escapeHTML(node.name)}" aria-expanded="false" title="展开"><span class="tree-toggle-glyph" aria-hidden="true"></span></button>` : '<span class="tree-toggle-placeholder"></span>'}<input type="checkbox" data-folder-path="${escapeHTML(node.path)}" value="${escapeHTML(node.path)}" aria-label="选择文件夹:${escapeHTML(node.name)}"><span class="folder-name">${escapeHTML(node.name)}</span><span class="folder-count">${(node.count || 0).toLocaleString()}</span>`;
1549
  const toggle = row.querySelector(".tree-toggle");
1550
  if (toggle) {
1551
  toggle.addEventListener("click", (event) => {
 
1624
 
1625
  function renderSourceListSidebar(sources) {
1626
  DOM.sidebarTitle.textContent = "数据包";
1627
+ DOM.sidebarContent.innerHTML = (sources || []).map((source) => `<button type="button" class="repo-list-item" data-source="${escapeHTML(source.slug)}" aria-label="打开数据包:${escapeHTML(source.name)}">${ICON_HTML.source}<span class="repo-name">${escapeHTML(source.name)}</span><span class="repo-count">${(source.count || 0).toLocaleString()}</span></button>`).join("");
1628
  }
1629
 
1630
  function browserContentHTML(data, path, includeBreadcrumb) {
1631
  const parts = path ? path.split("/") : [];
1632
+ let html = '<button type="button" class="back-to-global" id="back-home">返回全局搜索</button>';
1633
  if (includeBreadcrumb) {
1634
+ html += `<div class="sidebar-breadcrumb" aria-label="文件夹路径"><button type="button" class="crumb-item" data-folder-nav="">根目录</button>${parts.map((part, index) => `<span class="crumb-sep" aria-hidden="true">/</span><button type="button" class="crumb-item" data-folder-nav="${escapeHTML(parts.slice(0, index + 1).join("/"))}">${escapeHTML(part)}</button>`).join("")}</div>`;
1635
  }
1636
+ html += (data.folders || []).map((item) => `<button type="button" class="browser-item" data-folder-open="${escapeHTML(item.path)}" aria-label="打开文件夹:${escapeHTML(item.name)}">${ICON_HTML.folder}<span class="browser-name">${escapeHTML(item.name)}</span><span class="browser-count">${(item.count || 0).toLocaleString()}</span></button>`).join("");
1637
+ html += (data.files || []).map((item) => `<button type="button" class="browser-item" data-doc-id="${escapeHTML(item.doc_id)}" aria-label="预览文件:${escapeHTML(item.name)}.txt">${ICON_HTML.file}<span class="browser-name">${escapeHTML(item.name)}.txt</span><span class="browser-size">${escapeHTML(formatSize(item.size))}</span></button>`).join("");
1638
  return html;
1639
  }
1640
 
 
1703
  }
1704
 
1705
  function updateSidebarVisibility() {
1706
+ const modalOpen = Boolean(STATE.previewDocId && DOM.previewPanel && !DOM.previewPanel.hidden);
1707
  DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen);
1708
  DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen);
1709
  DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen);
1710
  DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen);
1711
  DOM.overlay.style.display = "";
1712
  DOM.overlay.classList.toggle("open", STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen));
1713
+ DOM.hamburgerBtn.setAttribute("aria-expanded", String(STATE.leftSidebarOpen));
1714
+ DOM.hamburgerBtn.setAttribute("aria-label", STATE.leftSidebarOpen ? "关闭数据包导航" : "打开数据包导航");
1715
+ DOM.settingsBtn.setAttribute("aria-expanded", String(STATE.rightSidebarOpen));
1716
+ DOM.settingsBtn.setAttribute("aria-label", STATE.rightSidebarOpen ? "关闭搜索过滤" : "打开搜索过滤");
1717
+ DOM.leftSidebar.setAttribute("aria-hidden", String(!STATE.leftSidebarOpen || modalOpen));
1718
+ DOM.rightSidebar.setAttribute("aria-hidden", String(!STATE.rightSidebarOpen || modalOpen));
1719
+ DOM.leftSidebar.inert = !STATE.leftSidebarOpen || modalOpen;
1720
+ DOM.rightSidebar.inert = !STATE.rightSidebarOpen || modalOpen;
1721
+ if (DOM.sidebarExpandBtn) {
1722
+ const expanded = DOM.leftSidebar.classList.contains("expanded-wide");
1723
+ DOM.sidebarExpandBtn.setAttribute("aria-expanded", String(expanded));
1724
+ DOM.sidebarExpandBtn.setAttribute("aria-label", expanded ? "收起数据包导航" : "展开数据包导航");
1725
+ }
1726
  }
1727
 
1728
  function recoverSidebarState() {
 
1814
  if (STATE.isMobile && STATE.leftSidebarOpen) STATE.rightSidebarOpen = false;
1815
  updateSidebarVisibility();
1816
  syncUrl();
1817
+ if (!STATE.leftSidebarOpen) DOM.hamburgerBtn.focus({ preventScroll: true });
1818
  });
1819
  if (DOM.sidebarExpandBtn) {
1820
  DOM.sidebarExpandBtn.addEventListener("click", () => {
1821
  DOM.leftSidebar.classList.toggle("expanded-wide");
1822
  DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔";
1823
+ DOM.sidebarExpandBtn.setAttribute("aria-expanded", String(DOM.leftSidebar.classList.contains("expanded-wide")));
1824
  syncUrl();
1825
  });
1826
  }
 
1829
  if (STATE.isMobile && STATE.rightSidebarOpen) STATE.leftSidebarOpen = false;
1830
  updateSidebarVisibility();
1831
  syncUrl();
1832
+ if (!STATE.rightSidebarOpen) DOM.settingsBtn.focus({ preventScroll: true });
1833
  });
1834
  DOM.closeFiltersBtn.addEventListener("click", () => {
1835
  STATE.rightSidebarOpen = false;
1836
  updateSidebarVisibility();
1837
  syncUrl();
1838
+ DOM.settingsBtn.focus({ preventScroll: true });
1839
  });
1840
  DOM.overlay.addEventListener("click", () => {
1841
  STATE.leftSidebarOpen = false;
 
1850
  localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop");
1851
  applyUiUrlState();
1852
  updateSidebarVisibility();
1853
+ DOM.mobileToggleBtn.setAttribute("aria-pressed", String(STATE.isMobile));
1854
  syncUrl();
1855
  });
1856
  let historyDropdownActive = false;
 
2100
  doSearch();
2101
  }
2102
  });
2103
+ DOM.resultsList.addEventListener("keydown", (event) => {
2104
+ const folder = event.target.closest(".path-folder[role='button']");
2105
+ if (!folder || (event.key !== "Enter" && event.key !== " ")) return;
2106
+ event.preventDefault();
2107
+ folder.click();
2108
+ });
2109
  DOM.sidebarContent.addEventListener("click", async (event) => {
2110
  const sourceItem = event.target.closest("[data-source]");
2111
  if (sourceItem) {
 
2162
  if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
2163
  });
2164
  document.addEventListener("keydown", (event) => {
2165
+ if (STATE.previewDocId && !DOM.previewPanel.hidden && DOM.previewPanel.style.display !== "none") {
2166
+ if (event.key === "Escape") {
2167
+ event.preventDefault();
2168
+ closePreview();
2169
+ return;
2170
+ }
2171
+ if (event.key === "Tab") {
2172
+ const focusable = previewFocusableElements();
2173
+ if (!focusable.length) {
2174
+ event.preventDefault();
2175
+ DOM.previewPanel.focus({ preventScroll: true });
2176
+ return;
2177
+ }
2178
+ const first = focusable[0];
2179
+ const last = focusable[focusable.length - 1];
2180
+ if (event.shiftKey && document.activeElement === first) {
2181
+ event.preventDefault();
2182
+ last.focus();
2183
+ } else if (!event.shiftKey && document.activeElement === last) {
2184
+ event.preventDefault();
2185
+ first.focus();
2186
+ }
2187
+ return;
2188
+ }
2189
+ return;
2190
+ }
2191
  if (event.key === "Escape") {
2192
  setHistoryDropdownOpen(false);
 
2193
  STATE.leftSidebarOpen = false;
2194
  STATE.rightSidebarOpen = false;
2195
  updateSidebarVisibility();
2196
+ DOM.hamburgerBtn.focus({ preventScroll: true });
2197
  return;
2198
  }
2199
  if (event.key === "/" && document.activeElement !== DOM.searchInput) {
 
2215
  if (nextPreviewDocId) {
2216
  restorePreviewFromUrl({ scroll: false });
2217
  } else {
2218
+ clearPreviewDom();
 
 
2219
  }
2220
  return;
2221
  }
static/index.html CHANGED
@@ -16,7 +16,7 @@
16
 
17
  <header id="header">
18
  <div class="header-left">
19
- <button id="hamburger-btn" class="icon-btn" aria-label="菜单"><span class="ui-icon ui-icon-menu" aria-hidden="true"></span></button>
20
  <a id="header-logo" class="header-logo" href="https://huggingface.co/VOMEBOOK" title="VOMEBOOK">
21
  <span id="header-title">VOMEBOOK</span>
22
  </a>
@@ -24,22 +24,22 @@
24
  <div class="header-center">
25
  <div class="search-box">
26
  <span class="search-icon"><span class="ui-icon ui-icon-search" aria-hidden="true"></span></span>
27
- <input type="search" id="search-input" class="search-input" placeholder="全文搜索 TXT 正文..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
28
  <kbd class="search-kbd">/</kbd>
29
- <div id="search-history-dropdown" class="search-history-dropdown" style="display:none"></div>
30
  </div>
31
  </div>
32
  <div class="header-right">
33
- <button id="settings-btn" class="icon-btn" aria-label="搜索设置"><span class="ui-icon ui-icon-sliders" aria-hidden="true"></span></button>
34
- <button id="mobile-toggle-btn" class="icon-btn" aria-label="切换手机或电脑模式" title="手机/电脑模式切换"><span id="mobile-toggle-icon" class="ui-icon ui-icon-devices" aria-hidden="true"></span></button>
35
  <button id="theme-btn" class="icon-btn" aria-label="切换到白天模式" title="白天模式"><svg class="theme-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 15.5A8 8 0 1 1 8.5 4 6.5 6.5 0 0 0 20 15.5Z"/></svg></button>
36
  </div>
37
 
38
  </header>
39
  <div id="app-body" class="app-body">
40
 
41
- <aside id="left-sidebar" class="sidebar left-sidebar">
42
- <div class="sidebar-header"><span id="sidebar-title">数据包</span><button id="sidebar-expand-btn" class="icon-btn-sm" title="展开/收起侧边栏" style="display:none">↔</button></div>
43
  <div id="sidebar-content" class="sidebar-content"><div class="sidebar-loading">加载中...</div></div>
44
 
45
  </aside>
@@ -47,14 +47,14 @@
47
  <main id="main-content" class="main-content">
48
  <div id="status-bar" class="status-bar">
49
  <div class="status-left">
50
- <span id="result-count" class="result-count"></span>
51
  <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
52
  </div>
53
  <div class="status-right">
54
  <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
55
  <div class="sort-group">
56
  <span class="sort-label">排序:</span>
57
- <select id="sort-select" class="sort-select">
58
  <option value="relevance">相关度</option>
59
  <option value="name">文件名</option>
60
  <option value="size">文件大小</option>
@@ -69,8 +69,8 @@
69
  <button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
70
  <span id="multi-selected-count" class="multi-selected-count"></span>
71
  </div>
72
- <div id="results-container" class="results-container" aria-busy="false">
73
- <div id="results-list" class="results-list"></div>
74
  <div id="results-loading" class="loading-spinner" style="display:none"><div class="spinner"></div></div>
75
  <div id="empty-state" class="empty-state" style="display:none">
76
  <div class="empty-title">没有找到结果</div>
@@ -78,13 +78,13 @@
78
  <button id="empty-random-btn" class="random-big-btn">随机来一篇</button>
79
  </div>
80
  </div>
81
- <div id="load-info" class="load-info" style="display:none">已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条</div>
82
- <div id="preview-panel" class="preview-panel" style="display:none"></div>
83
 
84
  </main>
85
 
86
- <aside id="right-sidebar" class="sidebar right-sidebar">
87
- <div class="sidebar-header"><span>搜索过滤</span><button id="close-filters-btn" class="icon-btn-sm" aria-label="关闭过滤">×</button></div>
88
  <div class="sidebar-content">
89
  <div class="filter-section" id="fulltext-toggle-section">
90
  <label class="toggle-row">
@@ -125,11 +125,11 @@
125
  <div class="filter-section">
126
  <div class="filter-section-title">文件大小</div>
127
  <div class="filter-size-row">
128
- <input type="number" id="filter-min-size" class="size-input" placeholder="最小" min="0" step="0.1">
129
- <select id="filter-min-unit" class="size-unit"><option value="B">B</option><option value="KB">KB</option><option value="MB" selected>MB</option><option value="GB">GB</option></select>
130
  <span class="size-sep">—</span>
131
- <input type="number" id="filter-max-size" class="size-input" placeholder="最大" min="0" step="0.1">
132
- <select id="filter-max-unit" class="size-unit"><option value="B">B</option><option value="KB">KB</option><option value="MB" selected>MB</option><option value="GB">GB</option></select>
133
  </div>
134
  </div>
135
  </div>
@@ -137,7 +137,7 @@
137
  </aside>
138
  </div>
139
 
140
- <footer class="footer">
141
  <div class="hitokoto"></div>
142
  <button id="random-book-btn" class="random-book-btn" title="随机来一篇">
143
  <span class="ui-icon ui-icon-book" aria-hidden="true"></span>
 
16
 
17
  <header id="header">
18
  <div class="header-left">
19
+ <button id="hamburger-btn" class="icon-btn" aria-label="打开数据包导航" aria-controls="left-sidebar" aria-expanded="true"><span class="ui-icon ui-icon-menu" aria-hidden="true"></span></button>
20
  <a id="header-logo" class="header-logo" href="https://huggingface.co/VOMEBOOK" title="VOMEBOOK">
21
  <span id="header-title">VOMEBOOK</span>
22
  </a>
 
24
  <div class="header-center">
25
  <div class="search-box">
26
  <span class="search-icon"><span class="ui-icon ui-icon-search" aria-hidden="true"></span></span>
27
+ <input type="search" id="search-input" class="search-input" aria-label="搜索 TXT 文件和正文" placeholder="全文搜索 TXT 正文..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
28
  <kbd class="search-kbd">/</kbd>
29
+ <div id="search-history-dropdown" class="search-history-dropdown" role="region" aria-label="搜索历史" style="display:none"></div>
30
  </div>
31
  </div>
32
  <div class="header-right">
33
+ <button id="settings-btn" class="icon-btn" aria-label="打开搜索过滤" aria-controls="right-sidebar" aria-expanded="false"><span class="ui-icon ui-icon-sliders" aria-hidden="true"></span></button>
34
+ <button id="mobile-toggle-btn" class="icon-btn" aria-label="切换手机或电脑模式" aria-pressed="false" title="手机/电脑模式切换"><span id="mobile-toggle-icon" class="ui-icon ui-icon-devices" aria-hidden="true"></span></button>
35
  <button id="theme-btn" class="icon-btn" aria-label="切换到白天模式" title="白天模式"><svg class="theme-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M20 15.5A8 8 0 1 1 8.5 4 6.5 6.5 0 0 0 20 15.5Z"/></svg></button>
36
  </div>
37
 
38
  </header>
39
  <div id="app-body" class="app-body">
40
 
41
+ <aside id="left-sidebar" class="sidebar left-sidebar" aria-labelledby="sidebar-title">
42
+ <div class="sidebar-header"><span id="sidebar-title">数据包</span><button id="sidebar-expand-btn" class="icon-btn-sm" aria-label="展开数据包导航" aria-expanded="false" title="展开/收起侧边栏" style="display:none">↔</button></div>
43
  <div id="sidebar-content" class="sidebar-content"><div class="sidebar-loading">加载中...</div></div>
44
 
45
  </aside>
 
47
  <main id="main-content" class="main-content">
48
  <div id="status-bar" class="status-bar">
49
  <div class="status-left">
50
+ <span id="result-count" class="result-count" role="status"></span>
51
  <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
52
  </div>
53
  <div class="status-right">
54
  <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
55
  <div class="sort-group">
56
  <span class="sort-label">排序:</span>
57
+ <select id="sort-select" class="sort-select" aria-label="排序方式">
58
  <option value="relevance">相关度</option>
59
  <option value="name">文件名</option>
60
  <option value="size">文件大小</option>
 
69
  <button id="multi-zip-download" class="multi-action-btn primary">合并下载</button>
70
  <span id="multi-selected-count" class="multi-selected-count"></span>
71
  </div>
72
+ <div id="results-container" class="results-container" aria-busy="false" role="region" aria-label="搜索结果">
73
+ <div id="results-list" class="results-list" role="list"></div>
74
  <div id="results-loading" class="loading-spinner" style="display:none"><div class="spinner"></div></div>
75
  <div id="empty-state" class="empty-state" style="display:none">
76
  <div class="empty-title">没有找到结果</div>
 
78
  <button id="empty-random-btn" class="random-big-btn">随机来一篇</button>
79
  </div>
80
  </div>
81
+ <div id="load-info" class="load-info" role="status" aria-live="polite" aria-atomic="true" style="display:none">已加载 <span id="loaded-count">0</span> / <span id="total-count">0</span> 条</div>
82
+ <div id="preview-panel" class="preview-panel" role="dialog" aria-modal="true" aria-label="文件预览" tabindex="-1" hidden style="display:none"></div>
83
 
84
  </main>
85
 
86
+ <aside id="right-sidebar" class="sidebar right-sidebar" aria-label="搜索过滤">
87
+ <div class="sidebar-header"><span>搜索过滤</span><button id="close-filters-btn" class="icon-btn-sm" aria-label="关闭搜索过滤">×</button></div>
88
  <div class="sidebar-content">
89
  <div class="filter-section" id="fulltext-toggle-section">
90
  <label class="toggle-row">
 
125
  <div class="filter-section">
126
  <div class="filter-section-title">文件大小</div>
127
  <div class="filter-size-row">
128
+ <input type="number" id="filter-min-size" class="size-input" aria-label="最小文件大小" placeholder="最小" min="0" step="0.1">
129
+ <select id="filter-min-unit" class="size-unit" aria-label="最小文件大小单位"><option value="B">B</option><option value="KB">KB</option><option value="MB" selected>MB</option><option value="GB">GB</option></select>
130
  <span class="size-sep">—</span>
131
+ <input type="number" id="filter-max-size" class="size-input" aria-label="最大文件大小" placeholder="最大" min="0" step="0.1">
132
+ <select id="filter-max-unit" class="size-unit" aria-label="最大文件大小单位"><option value="B">B</option><option value="KB">KB</option><option value="MB" selected>MB</option><option value="GB">GB</option></select>
133
  </div>
134
  </div>
135
  </div>
 
137
  </aside>
138
  </div>
139
 
140
+ <footer id="footer" class="footer">
141
  <div class="hitokoto"></div>
142
  <button id="random-book-btn" class="random-book-btn" title="随机来一篇">
143
  <span class="ui-icon ui-icon-book" aria-hidden="true"></span>
static/style.css CHANGED
@@ -321,12 +321,23 @@ ul, li {
321
  }
322
 
323
  .search-history-dropdown .history-text {
324
- flex: 1;
325
  overflow: hidden;
326
  text-overflow: ellipsis;
327
  white-space: nowrap;
328
  }
329
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  .search-history-dropdown .history-del {
331
  flex-shrink: 0;
332
  width: 22px;
@@ -713,6 +724,10 @@ body.preview-open #preview-panel .preview-header {
713
  }
714
 
715
  .sidebar-breadcrumb .crumb-item {
 
 
 
 
716
  color: var(--primary);
717
  cursor: pointer;
718
  padding: 2px 4px;
@@ -743,6 +758,11 @@ body.preview-open #preview-panel .preview-header {
743
  }
744
 
745
  .back-to-global {
 
 
 
 
 
746
  display: flex;
747
  align-items: center;
748
  gap: 6px;
@@ -767,6 +787,11 @@ body.preview-open #preview-panel .preview-header {
767
  }
768
 
769
  .browser-item {
 
 
 
 
 
770
  display: flex;
771
  align-items: center;
772
  gap: 8px;
@@ -853,6 +878,11 @@ body.light .left-sidebar.expanded-wide .browser-item:nth-child(even) {
853
  }
854
 
855
  .repo-list-item {
 
 
 
 
 
856
  display: flex;
857
  align-items: center;
858
  gap: 10px;
@@ -1313,6 +1343,14 @@ body.mobile .multi-toggle { padding: 1px 4px; font-size: 11px; }
1313
  }
1314
 
1315
  .result-path .path-folder {
 
 
 
 
 
 
 
 
1316
  cursor: pointer;
1317
  color: var(--secondary);
1318
  transition: color var(--transition);
 
321
  }
322
 
323
  .search-history-dropdown .history-text {
 
324
  overflow: hidden;
325
  text-overflow: ellipsis;
326
  white-space: nowrap;
327
  }
328
 
329
+ .search-history-dropdown .history-select {
330
+ flex: 1;
331
+ min-width: 0;
332
+ padding: 0;
333
+ border: 0;
334
+ background: none;
335
+ color: inherit;
336
+ font: inherit;
337
+ text-align: left;
338
+ overflow: hidden;
339
+ }
340
+
341
  .search-history-dropdown .history-del {
342
  flex-shrink: 0;
343
  width: 22px;
 
724
  }
725
 
726
  .sidebar-breadcrumb .crumb-item {
727
+ border: 0;
728
+ background: none;
729
+ font: inherit;
730
+ text-align: left;
731
  color: var(--primary);
732
  cursor: pointer;
733
  padding: 2px 4px;
 
758
  }
759
 
760
  .back-to-global {
761
+ width: 100%;
762
+ border: 0;
763
+ background: none;
764
+ font: inherit;
765
+ text-align: left;
766
  display: flex;
767
  align-items: center;
768
  gap: 6px;
 
787
  }
788
 
789
  .browser-item {
790
+ width: 100%;
791
+ border: 0;
792
+ background: none;
793
+ font: inherit;
794
+ text-align: left;
795
  display: flex;
796
  align-items: center;
797
  gap: 8px;
 
878
  }
879
 
880
  .repo-list-item {
881
+ width: 100%;
882
+ border: 0;
883
+ background: none;
884
+ font: inherit;
885
+ text-align: left;
886
  display: flex;
887
  align-items: center;
888
  gap: 10px;
 
1343
  }
1344
 
1345
  .result-path .path-folder {
1346
+ display: inline;
1347
+ appearance: none;
1348
+ -webkit-appearance: none;
1349
+ border: 0;
1350
+ background: none;
1351
+ padding: 0;
1352
+ font: inherit;
1353
+ text-align: left;
1354
  cursor: pointer;
1355
  color: var(--secondary);
1356
  transition: color var(--transition);