Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions addons/website/static/src/scss/website.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2363,6 +2363,11 @@ $ribbon-padding: 100px;
color: inherit;
}
}
a {
&:focus, &:hover {
background: var(--tertiary-bg) !important;
}
}
}

ul.o_checklist > li.o_checked::after {
Expand Down
93 changes: 84 additions & 9 deletions addons/website/static/src/snippets/s_searchbar/search_bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export class SearchBar extends Interaction {
"t-on-keydown": this.onKeydown,
"t-on-search": this.onSearch,
},
".o_search_result_item a": {
"t-on-keydown": this.onKeydown,
},
};
autocompleteMinWidth = 300;

Expand Down Expand Up @@ -131,6 +134,7 @@ export class SearchBar extends Interaction {
this.services["public.interactions"].stopInteractions(this.menuEl);
}
const prevMenuEl = this.menuEl;
this.resultEls = null;
if (res && this.limit) {
const results = res.results;
let template = "website.s_searchbar.autocomplete";
Expand All @@ -151,6 +155,8 @@ export class SearchBar extends Interaction {
},
this.el
)[0];
// Cache resultEls to avoid repeated DOM queries on each keypress
this.resultEls = [...this.menuEl.querySelectorAll(".o_search_result_item a")];
}
this.hasDropdown = !!res;
prevMenuEl?.remove();
Expand Down Expand Up @@ -199,15 +205,19 @@ export class SearchBar extends Interaction {
break;
case "ArrowUp":
case "ArrowDown":
ev.preventDefault();
if (this.menuEl) {
const focusableEls = [this.inputEl, ...this.menuEl.children];
const focusedEl = document.activeElement;
const currentIndex = focusableEls.indexOf(focusedEl) || 0;
const delta = ev.key === "ArrowUp" ? focusableEls.length - 1 : 1;
const nextIndex = (currentIndex + delta) % focusableEls.length;
const nextFocusedEl = focusableEls[nextIndex];
nextFocusedEl.focus();
case "ArrowLeft":
case "ArrowRight":
if (this.resultEls.length) {
ev.preventDefault();
if (document.activeElement === this.inputEl) {
if (ev.key === "ArrowDown") {
this.resultEls[0]?.focus();
}
return;
}
const currentIndex = this.resultEls.indexOf(document.activeElement);
const direction = ev.key.replace("Arrow", "").toLowerCase();
this.navigateByDirection(currentIndex, direction);
}
break;
case "Enter":
Expand All @@ -216,6 +226,71 @@ export class SearchBar extends Interaction {
}
}

/**
* Move focus to the closest search result in the given direction based on
* visual (screen) position.
* @param {number} currentIndex
* Index of the currently focused result in `this.resultEls`
* @param {"up"|"down"|"left"|"right"} direction"
* Direction of navigation triggered by arrow keys.
*/
navigateByDirection(currentIndex, direction) {
const resultEls = this.resultEls;
const currentRect = resultEls[currentIndex].getBoundingClientRect();
const currentCenterX = currentRect.left + currentRect.width / 2;
const currentCenterY = currentRect.top + currentRect.height / 2;
let nextIndex = -1;
let bestDistance = Infinity;

const scoreCandidate = (direction, dx, dy, height) => {
const AXIS_WEIGHT = 1000; // Prioritize row/column movement to avoid jumps
switch (direction) {
case "down":
if (dy > 0) {
return Math.abs(dy) * AXIS_WEIGHT + Math.abs(dx);
}
break;
case "up":
if (dy < 0) {
return Math.abs(dy) * AXIS_WEIGHT + Math.abs(dx);
}
break;
case "right":
if (dx > 0 && Math.abs(dy) < height) {
return Math.abs(dx) * AXIS_WEIGHT + Math.abs(dy);
}
break;
case "left":
if (dx < 0 && Math.abs(dy) < height) {
return Math.abs(dx) * AXIS_WEIGHT + Math.abs(dy);
}
break;
}
return Infinity;
};

resultEls.forEach((el, index) => {
if (index === currentIndex) {
return;
}
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = centerX - currentCenterX;
const dy = centerY - currentCenterY;
const distance = scoreCandidate(direction, dx, dy, currentRect.height);
if (distance < bestDistance) {
bestDistance = distance;
nextIndex = index;
}
});
if (nextIndex >= 0) {
resultEls[nextIndex].focus();
} else if (direction === "up") {
this.inputEl.focus();
}
}

/**
* @param {MouseEvent} ev
*/
Expand Down
115 changes: 62 additions & 53 deletions addons/website/static/tests/interactions/snippets/search_bar.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers";

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

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

function supportAutocomplete() {
function supportAutocomplete(numberOfResults = 3) {
onRpc("/website/snippet/autocomplete", async (args) => {
const json = JSON.parse(new TextDecoder().decode(await args.arrayBuffer()));
expect(json.params.search_type).toBe("test");
expect(json.params.term).toBe("xyz");
expect(json.params.order).toBe("test desc");
expect(json.params.limit).toBe(3);

const allData = [
{ _fa: "fa-file-o", name: "Xyz 1", website_url: "/website/test/xyz-1" },
{ _fa: "fa-file-o", name: "Xyz 2", website_url: "/website/test/xyz-2" },
{ _fa: "fa-file-o", name: "Xyz 3", website_url: "/website/test/xyz-3" },
{ _fa: "fa-file-o", name: "Xyz 4", website_url: "/website/test/xyz-1" },
{ _fa: "fa-file-o", name: "Xyz 5", website_url: "/website/test/xyz-2" },
{ _fa: "fa-file-o", name: "Xyz 6", website_url: "/website/test/xyz-3" },
];

return {
results: {
pages: {
groupName: "Pages",
templateKey: "website.search_items_page",
search_count: 3,
limit: 3,
data: [
{
_fa: "fa-file-o",
name: "Xyz 1",
website_url: "/website/test/xyz-1",
},
{
_fa: "fa-file-o",
name: "Xyz 2",
website_url: "/website/test/xyz-2",
},
{
_fa: "fa-file-o",
name: "Xyz 3",
website_url: "/website/test/xyz-3",
},
],
data: allData.slice(0, numberOfResults),
},
},
results_count: 3,
Expand All @@ -87,40 +81,55 @@ test("searchbar triggers a search when text is entered", async () => {
expect(queryAll("form .o_search_result_item")).toHaveLength(3);
});

// Arrow key nevigation is no more working with new searchbar.
// TODO: Bring back the arrow key nevigation. Here task-5424392.

// test("searchbar selects first result on cursor down", async () => {
// supportAutocomplete();
// await startInteractions(searchTemplate);
// const inputEl = queryOne("form input[type=search]");
// await click(inputEl);
// await press("x");
// await press("y");
// await press("z");
// await advanceTime(400);
// const resultEls = queryAll("form .o_search_result_item");
// expect(resultEls).toHaveLength(3);
// expect(document.activeElement).toBe(inputEl);
// await press("down");
// expect(document.activeElement).toBe(resultEls[0]);
// });

// test("searchbar selects last result on cursor up", async () => {
// supportAutocomplete();
// await startInteractions(searchTemplate);
// const inputEl = queryOne("form input[type=search]");
// await click(inputEl);
// await press("x");
// await press("y");
// await press("z");
// await advanceTime(400);
// const resultEls = queryAll("form a:has(.o_search_result_item)");
// expect(resultEls).toHaveLength(3);
// expect(document.activeElement).toBe(inputEl);
// await press("up");
// expect(document.activeElement).toBe(resultEls[2]);
// });
/**
* Test keyboard navigation in search results.
*
* Verifies that:
* 1. ArrowDown from input focuses the first result
* 2. ArrowUp from input focuses the last result
* 3. ArrowLeft/Right navigate horizontally within the grid
* 4. ArrowDown wraps around rows to next row's first column
*/
test("search results keyboard navigation with arrow keys", async () => {
supportAutocomplete(6);
await startInteractions(searchTemplate);
const inputEl = queryOne("form input[type=search]");

// Setup: Type search query to trigger autocomplete
await click(inputEl);
await press("x");
await press("y");
await press("z");
await advanceTime(400);

const resultEls = queryAll("form .o_search_result_item > a");
expect(resultEls).toHaveLength(6);
expect(document.activeElement).toBe(inputEl);

// ArrowDown from input focuses first result
await press("down");
expect(document.activeElement).toBe(resultEls[0]);

// ArrowDown moves to next row, same column
await press("down");
expect(document.activeElement).toBe(resultEls[3]);

// ArrowRight navigates to adjacent result (same row)
await press("right");
expect(document.activeElement).toBe(resultEls[4]);

// ArrowUp moves to previous row, same column
await press("up");
expect(document.activeElement).toBe(resultEls[1]);

// ArrowLeft navigates to adjacent result (same row)t
await press("left");
expect(document.activeElement).toBe(resultEls[0]);

// ArrowUp moves back to input
await press("up");
expect(document.activeElement).toBe(inputEl);
});

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