OpenCode commited on
Commit ·
ec2c9ec
1
Parent(s): a297b28
Fix search controls and query clear state
Browse files- static/app.js +24 -6
- static/style.css +3 -2
- tests/test_browser.py +16 -0
- tests/test_position_controls.py +23 -0
- tests/test_static_contract.py +6 -0
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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
| 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 |
-
|
| 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)
|
| 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",
|
|
|
|
|
|
|
|
|
|
| 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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
| 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 |
-
/*
|
| 292 |
-
.search-input::-webkit-search-cancel-button { opacity:
|
|
|
|
| 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)
|