OpenCode commited on
Commit
ec2c9ec
·
1 Parent(s): a297b28

Fix search controls and query clear state

Browse files
static/app.js CHANGED
@@ -258,11 +258,11 @@ function resetPositionControlScroll() {
258
  positionControlScroll = { key: getSearchViewKey(), last: getResultScrollTop(), distance: 0, until: 0 };
259
  }
260
 
261
- function prepareReturnPosition(key) {
262
  resetPositionControlScroll();
263
  const saved = searchPositions.get(key);
264
  setReturnPositionTarget(validSearchPosition(saved) && (saved.index > 0 || saved.offset > 0) ? saved : null);
265
- updateSearchPositionControls();
266
  }
267
 
268
  function updateSearchPositionControls() {
@@ -291,7 +291,7 @@ function prepareSearchPositionNavigation({ fromStart, restorePosition }) {
291
  const key = getSearchViewKey();
292
  if (fromStart) saveSearchPosition();
293
  cancelPositionRestore();
294
- prepareReturnPosition(key);
295
  if (!restorePosition) return false;
296
  setReturnPositionTarget();
297
  return restoreSearchViewSnapshot(key);
@@ -1605,6 +1605,7 @@ const DOM = {};
1605
  function cacheDOM() {
1606
  DOM.headerTitle = $("#header-title");
1607
  DOM.headerLogo = $("#header-logo");
 
1608
  DOM.searchInput = $("#search-input");
1609
  DOM.hamburgerBtn = $("#hamburger-btn");
1610
  DOM.settingsBtn = $("#settings-btn");
@@ -1677,6 +1678,19 @@ function cacheDOM() {
1677
  DOM.multiDeselect = $("#multi-deselect");
1678
  }
1679
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1680
  const HTML_ESCAPE_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
1681
 
1682
  function escapeHTML(str) {
@@ -2570,7 +2584,7 @@ function syncStateToURL(replace = true) {
2570
  function syncURLToState() {
2571
  const params = new URLSearchParams(window.location.search);
2572
  STATE.query = params.get("q") || "";
2573
- DOM.searchInput.value = STATE.query;
2574
  STATE.filterRepos = params.getAll("repo").map(r => `VoiceOfML/${r}`);
2575
  STATE.browserPath = params.get("path") || "";
2576
  const sort = params.get("sort") || "relevance";
@@ -2818,7 +2832,7 @@ function createSidebarBreadcrumb(path) {
2818
  function submitSearchQuery(query, { restore = false, record = false, refreshHistory = false, clearResults = false, blur = false, updateInput = restore } = {}) {
2819
  cancelPendingSearchControls();
2820
  saveSearchViewSnapshot();
2821
- if (updateInput) DOM.searchInput.value = query;
2822
  STATE.query = query;
2823
  STATE.page = 1;
2824
  if (clearResults) { STATE.results = []; keyboardResultIndex = -1; }
@@ -4826,6 +4840,7 @@ function setupResultDelegation() {
4826
 
4827
  async function init() {
4828
  cacheDOM();
 
4829
  await initSearchPositions();
4830
  setupSearchPositionControls();
4831
  setupSearchPositionSaving();
@@ -4839,7 +4854,10 @@ async function init() {
4839
  else if (savedMobile === "desktop") STATE.isMobile = false;
4840
  else STATE.isMobile = autoDetectMobile();
4841
  applyMobileMode();
4842
- DOM.searchInput.addEventListener("input", debouncedSearch);
 
 
 
4843
  DOM.searchInput.addEventListener("compositionstart", () => {
4844
  searchComposing = true;
4845
  });
 
258
  positionControlScroll = { key: getSearchViewKey(), last: getResultScrollTop(), distance: 0, until: 0 };
259
  }
260
 
261
+ function prepareReturnPosition(key, updateControls = true) {
262
  resetPositionControlScroll();
263
  const saved = searchPositions.get(key);
264
  setReturnPositionTarget(validSearchPosition(saved) && (saved.index > 0 || saved.offset > 0) ? saved : null);
265
+ if (updateControls) updateSearchPositionControls();
266
  }
267
 
268
  function updateSearchPositionControls() {
 
291
  const key = getSearchViewKey();
292
  if (fromStart) saveSearchPosition();
293
  cancelPositionRestore();
294
+ prepareReturnPosition(key, !fromStart);
295
  if (!restorePosition) return false;
296
  setReturnPositionTarget();
297
  return restoreSearchViewSnapshot(key);
 
1605
  function cacheDOM() {
1606
  DOM.headerTitle = $("#header-title");
1607
  DOM.headerLogo = $("#header-logo");
1608
+ DOM.searchBox = $("#search-box");
1609
  DOM.searchInput = $("#search-input");
1610
  DOM.hamburgerBtn = $("#hamburger-btn");
1611
  DOM.settingsBtn = $("#settings-btn");
 
1678
  DOM.multiDeselect = $("#multi-deselect");
1679
  }
1680
 
1681
+ function syncSearchInputState() {
1682
+ if (!DOM.searchInput) return;
1683
+ const hasQuery = !!DOM.searchInput.value.trim();
1684
+ DOM.searchInput.dataset.hasQuery = hasQuery ? "true" : "false";
1685
+ DOM.searchBox?.classList.toggle("has-query", hasQuery);
1686
+ }
1687
+
1688
+ function setSearchInputValue(value) {
1689
+ if (!DOM.searchInput) return;
1690
+ DOM.searchInput.value = value || "";
1691
+ syncSearchInputState();
1692
+ }
1693
+
1694
  const HTML_ESCAPE_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
1695
 
1696
  function escapeHTML(str) {
 
2584
  function syncURLToState() {
2585
  const params = new URLSearchParams(window.location.search);
2586
  STATE.query = params.get("q") || "";
2587
+ setSearchInputValue(STATE.query);
2588
  STATE.filterRepos = params.getAll("repo").map(r => `VoiceOfML/${r}`);
2589
  STATE.browserPath = params.get("path") || "";
2590
  const sort = params.get("sort") || "relevance";
 
2832
  function submitSearchQuery(query, { restore = false, record = false, refreshHistory = false, clearResults = false, blur = false, updateInput = restore } = {}) {
2833
  cancelPendingSearchControls();
2834
  saveSearchViewSnapshot();
2835
+ if (updateInput) setSearchInputValue(query);
2836
  STATE.query = query;
2837
  STATE.page = 1;
2838
  if (clearResults) { STATE.results = []; keyboardResultIndex = -1; }
 
4840
 
4841
  async function init() {
4842
  cacheDOM();
4843
+ syncSearchInputState();
4844
  await initSearchPositions();
4845
  setupSearchPositionControls();
4846
  setupSearchPositionSaving();
 
4854
  else if (savedMobile === "desktop") STATE.isMobile = false;
4855
  else STATE.isMobile = autoDetectMobile();
4856
  applyMobileMode();
4857
+ DOM.searchInput.addEventListener("input", () => {
4858
+ syncSearchInputState();
4859
+ debouncedSearch();
4860
+ });
4861
  DOM.searchInput.addEventListener("compositionstart", () => {
4862
  searchComposing = true;
4863
  });
static/style.css CHANGED
@@ -288,8 +288,9 @@ body.light .header-logo-icon-light {
288
  min-width: 0;
289
  }
290
 
291
- /* Override the browser's inline hiding when the search field is empty. */
292
- .search-input::-webkit-search-cancel-button { opacity: 1 !important; pointer-events: auto !important; cursor: pointer; }
 
293
 
294
  .search-input::placeholder {
295
  color: var(--on-surface-variant);
 
288
  min-width: 0;
289
  }
290
 
291
+ /* Keep the native clear control available only for a non-empty query. */
292
+ .search-input::-webkit-search-cancel-button { opacity: 0 !important; pointer-events: none !important; cursor: default; }
293
+ .search-box.has-query .search-input::-webkit-search-cancel-button { opacity: 1 !important; pointer-events: auto !important; cursor: pointer; }
294
 
295
  .search-input::placeholder {
296
  color: var(--on-surface-variant);
tests/test_browser.py CHANGED
@@ -98,6 +98,22 @@ class BrowserInteractionTests(unittest.TestCase):
98
  finally:
99
  context.close()
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  def test_malformed_api_envelopes_and_folder_errors_are_rejected(self):
102
  context, page, errors = self.open_page()
103
  try:
 
98
  finally:
99
  context.close()
100
 
101
+ def test_search_clear_control_tracks_query_state(self):
102
+ context, page, errors = self.open_page()
103
+ try:
104
+ search = page.locator("#search-input")
105
+ self.assertEqual(search.get_attribute("data-has-query"), "false")
106
+ self.assertFalse(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')"))
107
+ search.fill("手机")
108
+ page.wait_for_function("document.getElementById('search-input').dataset.hasQuery === 'true'")
109
+ self.assertTrue(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')"))
110
+ search.fill("")
111
+ page.wait_for_function("document.getElementById('search-input').dataset.hasQuery === 'false'")
112
+ self.assertFalse(page.locator("#search-box").evaluate("el => el.classList.contains('has-query')"))
113
+ self.assertEqual(errors, [])
114
+ finally:
115
+ context.close()
116
+
117
  def test_malformed_api_envelopes_and_folder_errors_are_rejected(self):
118
  context, page, errors = self.open_page()
119
  try:
tests/test_position_controls.py CHANGED
@@ -214,6 +214,29 @@ class PositionControlTests(unittest.TestCase):
214
  if width < 460:
215
  self.assertTrue(self.page.locator('#restart-position-btn').evaluate('e=>e.getBoundingClientRect().right<=document.querySelector(".result-position-count").getBoundingClientRect().left'))
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  def test_start_over_after_cached_reload_stays_and_goes_to_top(self):
218
  self.jump(95001)
219
  self.wait_position(95000)
 
214
  if width < 460:
215
  self.assertTrue(self.page.locator('#restart-position-btn').evaluate('e=>e.getBoundingClientRect().right<=document.querySelector(".result-position-count").getBoundingClientRect().left'))
216
 
217
+ def test_back_to_top_does_not_flash_beside_return_button(self):
218
+ self.jump(51)
219
+ self.wait_position(50)
220
+ self.page.evaluate('''()=>{
221
+ window.positionButtonStates=[];
222
+ const capture=()=>positionButtonStates.push({
223
+ top:DOM.backToTopBtn.hidden,
224
+ returnButton:DOM.returnToPositionBtn.hidden,
225
+ });
226
+ window.positionButtonObserver=new MutationObserver(capture);
227
+ for(const button of [DOM.backToTopBtn,DOM.returnToPositionBtn])
228
+ positionButtonObserver.observe(button,{attributes:true,attributeFilter:['hidden']});
229
+ capture();
230
+ }''')
231
+ self.page.locator('#restart-position-btn').click()
232
+ self.wait_position(0)
233
+ self.page.wait_for_timeout(50)
234
+ states = self.page.evaluate('''()=>{
235
+ positionButtonObserver.disconnect();
236
+ return positionButtonStates;
237
+ }''')
238
+ self.assertFalse(any(not state['top'] and not state['returnButton'] for state in states))
239
+
240
  def test_start_over_after_cached_reload_stays_and_goes_to_top(self):
241
  self.jump(95001)
242
  self.wait_position(95000)
tests/test_static_contract.py CHANGED
@@ -62,6 +62,12 @@ class StaticFrontendContractTests(unittest.TestCase):
62
  self.assertIn(value, self.js)
63
  self.assertIn("DOMContentLoaded", self.js)
64
 
 
 
 
 
 
 
65
  def test_desktop_random_actions_match_labels_and_empty_state_behavior(self):
66
  self.assertIn('id="random-book-btn" class="random-txt-btn" title="随机下本书"', self.html)
67
  self.assertIn('id="random-txt-btn" class="random-book-btn" title="随机来本书"', self.html)
 
62
  self.assertIn(value, self.js)
63
  self.assertIn("DOMContentLoaded", self.js)
64
 
65
+ def test_search_clear_control_follows_query_state(self):
66
+ self.assertIn("function syncSearchInputState()", self.js)
67
+ self.assertIn('DOM.searchBox?.classList.toggle("has-query", hasQuery)', self.js)
68
+ self.assertIn(".search-input::-webkit-search-cancel-button { opacity: 0 !important; pointer-events: none !important;", self.css)
69
+ self.assertIn(".search-box.has-query .search-input::-webkit-search-cancel-button { opacity: 1 !important; pointer-events: auto !important;", self.css)
70
+
71
  def test_desktop_random_actions_match_labels_and_empty_state_behavior(self):
72
  self.assertIn('id="random-book-btn" class="random-txt-btn" title="随机下本书"', self.html)
73
  self.assertIn('id="random-txt-btn" class="random-book-btn" title="随机来本书"', self.html)