Skip to content

Commit 7ec22d0

Browse files
committed
[FIX] website: Restore arrow key navigation in search bar
Issue: Arrow key navigation for search result items regressed with the new search bar layout. The previous logic in onKeydown was no longer triggered due to the updated DOM structure. Fix: Introduce a dedicated onSearchResultKeydown handler and wire it to search result items. The new method restores ArrowUp and ArrowDown focus management while keeping the existing input keydown behavior unchanged. Added a complete test case to cover arrow key navigation in search results. task-5424392
1 parent eac9afc commit 7ec22d0

File tree

21 files changed

+246
-87
lines changed

21 files changed

+246
-87
lines changed

addons/website/static/src/scss/website.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2357,6 +2357,10 @@ $ribbon-padding: 100px;
23572357
color: inherit;
23582358
}
23592359
}
2360+
2361+
&:focus, &:hover {
2362+
background: var(--tertiary-bg) !important;
2363+
}
23602364
}
23612365

23622366
ul.o_checklist > li.o_checked::after {

addons/website/static/src/snippets/s_searchbar/000.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
<t t-name="website.search_items_page">
1010
<t t-foreach="bucket.data" t-as="result" t-key="result_index">
11-
<div t-attf-class="#{row_classes} p-2">
11+
<div t-attf-class="#{row_classes} p-2" tabindex="0">
1212
<a t-att-href="result['website_url']"
1313
class="o_cc o_cc1 text-decoration-none d-flex align-items-center">
1414
<div class="o_search_result_item_content p-2">

addons/website/static/src/snippets/s_searchbar/search_bar.js

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export class SearchBar extends Interaction {
2323
"t-on-keydown": this.onKeydown,
2424
"t-on-search": this.onSearch,
2525
},
26+
".o_search_result_item": {
27+
"t-on-keydown": this.onSearchResultKeydown,
28+
},
2629
};
2730
autocompleteMinWidth = 300;
2831

@@ -198,16 +201,21 @@ export class SearchBar extends Interaction {
198201
this.render();
199202
break;
200203
case "ArrowUp":
204+
ev.preventDefault();
205+
if (this.menuEl) {
206+
const allResults = [...this.menuEl.querySelectorAll(".o_search_result_item")];
207+
if (allResults.length > 0) {
208+
allResults[allResults.length - 1].focus();
209+
}
210+
}
211+
break;
201212
case "ArrowDown":
202213
ev.preventDefault();
203214
if (this.menuEl) {
204-
const focusableEls = [this.inputEl, ...this.menuEl.children];
205-
const focusedEl = document.activeElement;
206-
const currentIndex = focusableEls.indexOf(focusedEl) || 0;
207-
const delta = ev.key === "ArrowUp" ? focusableEls.length - 1 : 1;
208-
const nextIndex = (currentIndex + delta) % focusableEls.length;
209-
const nextFocusedEl = focusableEls[nextIndex];
210-
nextFocusedEl.focus();
215+
const firstResult = this.menuEl.querySelector(".o_search_result_item");
216+
if (firstResult) {
217+
firstResult.focus();
218+
}
211219
}
212220
break;
213221
case "Enter":
@@ -216,6 +224,133 @@ export class SearchBar extends Interaction {
216224
}
217225
}
218226

227+
/**
228+
* Handle keyboard navigation within search results
229+
* @param {KeyboardEvent} ev
230+
*/
231+
onSearchResultKeydown(ev) {
232+
const allResults = [...this.menuEl.querySelectorAll(".o_search_result_item")];
233+
const currentResult = ev.currentTarget;
234+
const currentIndex = allResults.indexOf(currentResult);
235+
236+
switch (ev.key) {
237+
case "Escape":
238+
this.render();
239+
break;
240+
case "ArrowUp": {
241+
ev.preventDefault();
242+
// Check if current element is in the first row
243+
const currentRect = currentResult.getBoundingClientRect();
244+
const isFirstRow = allResults.every((el, idx) => {
245+
if (idx === currentIndex) {
246+
return true;
247+
}
248+
const rect = el.getBoundingClientRect();
249+
return rect.top >= currentRect.top - 5; // Allow small tolerance
250+
});
251+
252+
if (isFirstRow) {
253+
// Focus back to input when in first row
254+
this.inputEl.focus();
255+
} else {
256+
this.navigateByDirection(currentIndex, allResults, "up");
257+
}
258+
break;
259+
}
260+
case "ArrowDown":
261+
ev.preventDefault();
262+
this.navigateByDirection(currentIndex, allResults, "down");
263+
break;
264+
case "ArrowLeft":
265+
ev.preventDefault();
266+
this.navigateByDirection(currentIndex, allResults, "left");
267+
break;
268+
case "ArrowRight":
269+
ev.preventDefault();
270+
this.navigateByDirection(currentIndex, allResults, "right");
271+
break;
272+
}
273+
}
274+
275+
/**
276+
* Navigate through search results based on their visual position
277+
* @param {number} currentIndex
278+
* @param {Array} allResults
279+
* @param {string} direction - "up", "down", "left", "right"
280+
*/
281+
navigateByDirection(currentIndex, allResults, direction) {
282+
const currentRect = allResults[currentIndex].getBoundingClientRect();
283+
const currentCenterX = currentRect.left + currentRect.width / 2;
284+
const currentCenterY = currentRect.top + currentRect.height / 2;
285+
286+
let nextIndex = -1;
287+
let bestDistance = Infinity;
288+
289+
allResults.forEach((el, index) => {
290+
if (index === currentIndex) {
291+
return;
292+
}
293+
294+
const rect = el.getBoundingClientRect();
295+
const centerX = rect.left + rect.width / 2;
296+
const centerY = rect.top + rect.height / 2;
297+
298+
let isInDirection = false;
299+
let distance = 0;
300+
301+
switch (direction) {
302+
case "down":
303+
if (centerY > currentCenterY) {
304+
isInDirection = true;
305+
distance = Math.sqrt(
306+
Math.pow(centerY - currentCenterY, 2) +
307+
Math.pow(Math.abs(centerX - currentCenterX) / 2, 2)
308+
);
309+
}
310+
break;
311+
case "up":
312+
if (centerY < currentCenterY) {
313+
isInDirection = true;
314+
distance = Math.sqrt(
315+
Math.pow(currentCenterY - centerY, 2) +
316+
Math.pow(Math.abs(centerX - currentCenterX) / 2, 2)
317+
);
318+
}
319+
break;
320+
case "right":
321+
if (centerX > currentCenterX) {
322+
isInDirection = true;
323+
distance = Math.sqrt(
324+
Math.pow(centerX - currentCenterX, 2) +
325+
Math.pow(Math.abs(centerY - currentCenterY) / 2, 2)
326+
);
327+
}
328+
break;
329+
case "left":
330+
if (centerX < currentCenterX) {
331+
isInDirection = true;
332+
distance = Math.sqrt(
333+
Math.pow(currentCenterX - centerX, 2) +
334+
Math.pow(Math.abs(centerY - currentCenterY) / 2, 2)
335+
);
336+
}
337+
break;
338+
}
339+
340+
if (isInDirection && distance < bestDistance) {
341+
bestDistance = distance;
342+
nextIndex = index;
343+
}
344+
});
345+
346+
// If no element found in direction and moving down, wrap to input
347+
if (nextIndex >= 0) {
348+
allResults[nextIndex].focus();
349+
} else if (direction === "down") {
350+
this.inputEl.focus();
351+
}
352+
}
353+
219354
/**
220355
* @param {MouseEvent} ev
221356
*/

addons/website/static/tests/interactions/snippets/search_bar.test.js

Lines changed: 73 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers";
22

33
import { describe, expect, test } from "@odoo/hoot";
4-
import { click, press, queryAll } from "@odoo/hoot-dom";
4+
import { click, press, queryAll, queryOne } from "@odoo/hoot-dom";
55
import { advanceTime } from "@odoo/hoot-mock";
66

77
import { onRpc } from "@web/../tests/web_test_helpers";
@@ -30,37 +30,31 @@ const searchTemplate = /* html */ `
3030
</form>
3131
`;
3232

33-
function supportAutocomplete() {
33+
function supportAutocomplete(numberOfResults = 3) {
3434
onRpc("/website/snippet/autocomplete", async (args) => {
3535
const json = JSON.parse(new TextDecoder().decode(await args.arrayBuffer()));
3636
expect(json.params.search_type).toBe("test");
3737
expect(json.params.term).toBe("xyz");
3838
expect(json.params.order).toBe("test desc");
3939
expect(json.params.limit).toBe(3);
40+
41+
const allData = [
42+
{ _fa: "fa-file-o", name: "Xyz 1", website_url: "/website/test/xyz-1" },
43+
{ _fa: "fa-file-o", name: "Xyz 2", website_url: "/website/test/xyz-2" },
44+
{ _fa: "fa-file-o", name: "Xyz 3", website_url: "/website/test/xyz-3" },
45+
{ _fa: "fa-file-o", name: "Xyz 4", website_url: "/website/test/xyz-1" },
46+
{ _fa: "fa-file-o", name: "Xyz 5", website_url: "/website/test/xyz-2" },
47+
{ _fa: "fa-file-o", name: "Xyz 6", website_url: "/website/test/xyz-3" },
48+
];
49+
4050
return {
4151
results: {
4252
pages: {
4353
groupName: "Pages",
4454
templateKey: "website.search_items_page",
4555
search_count: 3,
4656
limit: 3,
47-
data: [
48-
{
49-
_fa: "fa-file-o",
50-
name: "Xyz 1",
51-
website_url: "/website/test/xyz-1",
52-
},
53-
{
54-
_fa: "fa-file-o",
55-
name: "Xyz 2",
56-
website_url: "/website/test/xyz-2",
57-
},
58-
{
59-
_fa: "fa-file-o",
60-
name: "Xyz 3",
61-
website_url: "/website/test/xyz-3",
62-
},
63-
],
57+
data: allData.slice(0, numberOfResults),
6458
},
6559
},
6660
results_count: 3,
@@ -87,40 +81,66 @@ test("searchbar triggers a search when text is entered", async () => {
8781
expect(queryAll("form .o_search_result_item")).toHaveLength(3);
8882
});
8983

90-
// Arrow key nevigation is no more working with new searchbar.
91-
// TODO: Bring back the arrow key nevigation. Here task-5424392.
92-
93-
// test("searchbar selects first result on cursor down", async () => {
94-
// supportAutocomplete();
95-
// await startInteractions(searchTemplate);
96-
// const inputEl = queryOne("form input[type=search]");
97-
// await click(inputEl);
98-
// await press("x");
99-
// await press("y");
100-
// await press("z");
101-
// await advanceTime(400);
102-
// const resultEls = queryAll("form .o_search_result_item");
103-
// expect(resultEls).toHaveLength(3);
104-
// expect(document.activeElement).toBe(inputEl);
105-
// await press("down");
106-
// expect(document.activeElement).toBe(resultEls[0]);
107-
// });
108-
109-
// test("searchbar selects last result on cursor up", async () => {
110-
// supportAutocomplete();
111-
// await startInteractions(searchTemplate);
112-
// const inputEl = queryOne("form input[type=search]");
113-
// await click(inputEl);
114-
// await press("x");
115-
// await press("y");
116-
// await press("z");
117-
// await advanceTime(400);
118-
// const resultEls = queryAll("form a:has(.o_search_result_item)");
119-
// expect(resultEls).toHaveLength(3);
120-
// expect(document.activeElement).toBe(inputEl);
121-
// await press("up");
122-
// expect(document.activeElement).toBe(resultEls[2]);
123-
// });
84+
/**
85+
* Test keyboard navigation in search results.
86+
*
87+
* Verifies that:
88+
* 1. ArrowDown from input focuses the first result
89+
* 2. ArrowUp from input focuses the last result
90+
* 3. ArrowLeft/Right navigate horizontally within the grid
91+
* 4. ArrowDown wraps around rows to next row's first column
92+
*/
93+
test("search results keyboard navigation with arrow keys", async () => {
94+
supportAutocomplete(6);
95+
await startInteractions(searchTemplate);
96+
const inputEl = queryOne("form input[type=search]");
97+
98+
// Setup: Type search query to trigger autocomplete
99+
await click(inputEl);
100+
await press("x");
101+
await press("y");
102+
await press("z");
103+
await advanceTime(400);
104+
105+
const resultEls = queryAll("form .o_search_result_item");
106+
expect(resultEls).toHaveLength(6);
107+
expect(document.activeElement).toBe(inputEl);
108+
109+
// ArrowDown from input focuses first result
110+
await press("down");
111+
expect(document.activeElement).toBe(resultEls[0]);
112+
113+
// ArrowDown moves to next row, same column
114+
await press("down");
115+
expect(document.activeElement).toBe(resultEls[3]);
116+
117+
// ArrowLeft navigates to adjacent result (same row)
118+
await press("right");
119+
expect(document.activeElement).toBe(resultEls[4]);
120+
121+
// ArrowUp moves to previous row, same column
122+
await press("up");
123+
expect(document.activeElement).toBe(resultEls[1]);
124+
125+
// ArrowDown from input focuses first result
126+
await press("left");
127+
expect(document.activeElement).toBe(resultEls[0]);
128+
129+
// ArrowUp moves back to input
130+
await press("up");
131+
expect(document.activeElement).toBe(inputEl);
132+
133+
// ArrowUp from input focuses last result
134+
await press("up");
135+
expect(document.activeElement).toBe(resultEls[5]);
136+
137+
// Here element is in last row, pressing down from any position should
138+
// set focus back to input
139+
await press("left");
140+
expect(document.activeElement).toBe(resultEls[4]);
141+
await press("down");
142+
expect(document.activeElement).toBe(inputEl);
143+
});
124144

125145
test("searchbar removes results on escape", async () => {
126146
supportAutocomplete();

addons/website/views/website_templates.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3168,7 +3168,7 @@ Sitemap: <t t-out="url_root"/>sitemap.xml
31683168

31693169
<template id="website.search_items_page_view">
31703170
<t t-foreach="bucket['data']" t-as="result">
3171-
<div t-attf-class="#{row_classes}">
3171+
<div t-attf-class="#{row_classes}" tabindex="0">
31723172
<a t-att-href="result['website_url']"
31733173
class="o_cc o_cc1 text-decoration-none d-flex align-items-center">
31743174
<div class="o_search_result_item_content p-2">
@@ -3233,7 +3233,7 @@ Sitemap: <t t-out="url_root"/>sitemap.xml
32333233

32343234
<template id="one_hybrid" name="Single any Search Results">
32353235
<a t-att-href="result.get('website_url')" class="dropdown-item p-2 text-wrap">
3236-
<div class="d-flex align-items-center flex-wrap o_search_result_item o_cc o_cc1 text-decoration-none">
3236+
<div class="d-flex align-items-center flex-wrap o_search_result_item o_cc o_cc1 text-decoration-none" tabindex="0">
32373237
<img t-if="result.get('image_url')" t-att-src="result.get('image_url')" role="presentation" alt="" class="flex-shrink-0 o_image_64_contain"/>
32383238
<i t-else="" t-att-class="'o_image_64_contain text-center pt16 fa %s' % result.get('_fa')" style="font-size: 34px;"/>
32393239
<div class="o_search_result_item_content p-2">

addons/website_blog/static/src/snippets/s_searchbar/000.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<templates xml:space="preserve">
44
<t t-name="website_blog.search_items_blog_post">
55
<t t-foreach="bucket.data" t-as="result" t-key="result_index">
6-
<div t-attf-class="#{row_classes} p-2">
6+
<div t-attf-class="#{row_classes} p-2" tabindex="0">
77
<a t-att-href="result['website_url']" class="o_cc o_cc1 text-decoration-none d-flex align-items-center">
88
<t t-call="website.search_image_item"/>
99
<div class="o_search_result_item_content p-2">
@@ -26,7 +26,7 @@
2626

2727
<t t-name="website_blog.search_items_blog_blog">
2828
<t t-foreach="bucket.data" t-as="result" t-key="result_index">
29-
<div t-attf-class="#{row_classes} p-2">
29+
<div t-attf-class="#{row_classes} p-2" tabindex="0">
3030
<a t-att-href="result['website_url']" class="o_cc o_cc1 text-decoration-none d-flex align-items-center">
3131
<t t-call="website.search_image_item"/>
3232
<div class="o_search_result_item_content p-2">

0 commit comments

Comments
 (0)