commit 0b5f51198c472b75663a009774b5356cd9e6ce72
parent 7f7b981ad6048b870aaa0eadcb6e2c264b3c78b1
Author: Irene Ni <ini@mozilla.com>
Date: Sat, 18 Oct 2025 06:52:11 +0000
Bug 1993363 - Add unit tests for New Tab story cards keyboard navigation. r=home-newtab-reviewers,nbarrett
Differential Revision: https://phabricator.services.mozilla.com/D268149
Diffstat:
3 files changed, 237 insertions(+), 0 deletions(-)
diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx
@@ -199,6 +199,127 @@ describe("<CardGrid>", () => {
// confrim that the next child is the trending search widget
assert.ok(grid.childAt(2).find(".trending-searches-list-view").exists());
});
+
+ describe("Keyboard navigation", () => {
+ beforeEach(() => {
+ const commonProps = {
+ items: 3,
+ data: {
+ recommendations: [{}, {}, {}],
+ },
+ Prefs: INITIAL_STATE.Prefs,
+ DiscoveryStream: INITIAL_STATE.DiscoveryStream,
+ };
+
+ wrapper = mount(
+ <WrapWithProvider>
+ <CardGrid {...commonProps} />
+ </WrapWithProvider>
+ );
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ it("should pass tabIndex={0} to the first card and tabIndex={-1} to other cards", () => {
+ const firstCard = wrapper.find(DSCard).at(0);
+ const secondCard = wrapper.find(DSCard).at(1);
+ const thirdCard = wrapper.find(DSCard).at(2);
+
+ assert.equal(firstCard.prop("tabIndex"), 0);
+ assert.equal(secondCard.prop("tabIndex"), -1);
+ assert.equal(thirdCard.prop("tabIndex"), -1);
+ });
+
+ it("should update focused index when onFocus is called", () => {
+ const secondCard = wrapper.find(DSCard).at(1);
+ const onFocus = secondCard.prop("onFocus");
+
+ onFocus();
+ wrapper.update();
+
+ assert.equal(wrapper.find(DSCard).at(1).prop("tabIndex"), 0);
+ assert.equal(wrapper.find(DSCard).at(0).prop("tabIndex"), -1);
+ });
+
+ describe("handleCardKeyDown", () => {
+ let sandbox;
+ let grid;
+ let mockLink;
+ let mockTargetCard;
+ let mockCurrentCard;
+ let mockEvent;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ grid = wrapper.find(".ds-card-grid");
+
+ mockLink = { focus: sandbox.spy() };
+ mockTargetCard = {
+ matches: sandbox.stub().returns(true),
+ querySelector: sandbox.stub().returns(mockLink),
+ };
+ mockCurrentCard = {};
+ mockEvent = {
+ preventDefault: sandbox.spy(),
+ target: {
+ closest: sandbox.stub().returns(mockCurrentCard),
+ },
+ };
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should navigate to next card with ArrowRight", () => {
+ mockEvent.key = "ArrowRight";
+ mockCurrentCard.nextElementSibling = mockTargetCard;
+
+ grid.prop("onKeyDown")(mockEvent);
+
+ assert.calledOnce(mockEvent.preventDefault);
+ assert.calledOnce(mockTargetCard.querySelector);
+ assert.calledWith(mockTargetCard.querySelector, "a.ds-card-link");
+ assert.calledOnce(mockLink.focus);
+ });
+
+ it("should navigate to previous card with ArrowLeft", () => {
+ mockEvent.key = "ArrowLeft";
+ mockCurrentCard.previousElementSibling = mockTargetCard;
+
+ grid.prop("onKeyDown")(mockEvent);
+
+ assert.calledOnce(mockEvent.preventDefault);
+ assert.calledOnce(mockTargetCard.querySelector);
+ assert.calledWith(mockTargetCard.querySelector, "a.ds-card-link");
+ assert.calledOnce(mockLink.focus);
+ });
+
+ it("should return early if no current card found", () => {
+ mockEvent.key = "ArrowRight";
+ mockEvent.target.closest.returns(null);
+
+ grid.prop("onKeyDown")(mockEvent);
+
+ assert.calledOnce(mockEvent.preventDefault);
+ assert.notCalled(mockTargetCard.querySelector);
+ });
+
+ it("should handle case where no matching sibling card is found", () => {
+ mockEvent.key = "ArrowRight";
+ mockCurrentCard.nextElementSibling = {
+ matches: sandbox.stub().returns(false),
+ };
+
+ grid.prop("onKeyDown")(mockEvent);
+
+ assert.calledOnce(mockEvent.preventDefault);
+ assert.notCalled(mockLink.focus);
+ });
+ });
+ });
});
// Build IntersectionObserver class with the arg `entries` for the intersect callback.
diff --git a/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardSections.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardSections.test.jsx
@@ -448,4 +448,97 @@ describe("<CardSections />", () => {
assert.equal(highlight.length, 1);
assert.isTrue(wrapper.html().includes("follow-section-button-highlight"));
});
+
+ describe("Keyboard navigation", () => {
+ beforeEach(() => {
+ // Mock window.innerWidth to return a value that will make getActiveColumnLayout return "col-1"
+ Object.defineProperty(window, "innerWidth", {
+ writable: true,
+ configurable: true,
+ value: 500,
+ });
+ });
+
+ it("should pass tabIndex={0} to the first card and tabIndex={-1} to other cards", () => {
+ const firstCard = wrapper.find(DSCard).at(0);
+ const secondCard = wrapper.find(DSCard).at(1);
+ const thirdCard = wrapper.find(DSCard).at(2);
+
+ assert.equal(firstCard.prop("tabIndex"), 0);
+ assert.equal(secondCard.prop("tabIndex"), -1);
+ assert.equal(thirdCard.prop("tabIndex"), -1);
+ });
+
+ it("should update focused index when onFocus is called", () => {
+ const secondCard = wrapper.find(DSCard).at(1);
+ const onFocus = secondCard.prop("onFocus");
+
+ onFocus();
+ wrapper.update();
+
+ assert.equal(wrapper.find(DSCard).at(1).prop("tabIndex"), 0);
+ assert.equal(wrapper.find(DSCard).at(0).prop("tabIndex"), -1);
+ });
+
+ describe("handleCardKeyDown", () => {
+ let grid;
+ let mockLink;
+ let mockTargetCard;
+ let mockGridElement;
+ let mockCurrentCard;
+ let mockEvent;
+
+ beforeEach(() => {
+ grid = wrapper.find(".ds-section-grid.ds-card-grid");
+ mockLink = { focus: sandbox.spy() };
+ mockTargetCard = {
+ querySelector: sandbox.stub().returns(mockLink),
+ };
+ mockGridElement = {
+ querySelector: sandbox.stub().returns(mockTargetCard),
+ };
+ mockCurrentCard = {
+ parentElement: mockGridElement,
+ };
+ mockEvent = {
+ preventDefault: sandbox.spy(),
+ target: {
+ closest: sandbox.stub().returns(mockCurrentCard),
+ },
+ };
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should navigate to next card with ArrowRight", () => {
+ mockEvent.key = "ArrowRight";
+ mockCurrentCard.classList = ["col-1-position-0"];
+
+ grid.prop("onKeyDown")(mockEvent);
+
+ assert.calledOnce(mockEvent.preventDefault);
+ assert.calledWith(
+ mockGridElement.querySelector,
+ "article.ds-card.col-1-position-1"
+ );
+ assert.calledOnce(mockLink.focus);
+ });
+
+ it("should navigate to previous card with ArrowLeft", () => {
+ mockEvent.key = "ArrowLeft";
+ mockCurrentCard.classList = ["col-1-position-1"];
+
+ grid.prop("onKeyDown")(mockEvent);
+
+ assert.calledOnce(mockEvent.preventDefault);
+ assert.calledWith(
+ mockGridElement.querySelector,
+ "article.ds-card.col-1-position-0"
+ );
+ assert.calledOnce(mockLink.focus);
+ });
+ });
+ });
});
diff --git a/browser/extensions/newtab/test/unit/content-src/lib/utils.test.jsx b/browser/extensions/newtab/test/unit/content-src/lib/utils.test.jsx
@@ -3,6 +3,7 @@ import { mount } from "enzyme";
import {
useIntersectionObserver,
getActiveCardSize,
+ getActiveColumnLayout,
useConfetti,
selectWeatherPlacement,
} from "content-src/lib/utils.jsx";
@@ -165,6 +166,28 @@ describe("getActiveCardSize", () => {
});
});
+describe("getActiveColumnLayout", () => {
+ it("returns 'col-4' for screen width 1920", () => {
+ const result = getActiveColumnLayout(1920);
+ assert.equal(result, "col-4");
+ });
+
+ it("returns 'col-3' for screen width 1200", () => {
+ const result = getActiveColumnLayout(1200);
+ assert.equal(result, "col-3");
+ });
+
+ it("returns 'col-2' for screen width 800", () => {
+ const result = getActiveColumnLayout(800);
+ assert.equal(result, "col-2");
+ });
+
+ it("returns 'col-1' for screen width 500", () => {
+ const result = getActiveColumnLayout(500);
+ assert.equal(result, "col-1");
+ });
+});
+
describe("useConfetti hook", () => {
let sandbox;
let rafStub;