commit 7f7b981ad6048b870aaa0eadcb6e2c264b3c78b1 parent 8907898a3a55eee4d67dcadbbefeac3c0dcceb39 Author: Irene Ni <ini@mozilla.com> Date: Sat, 18 Oct 2025 06:52:11 +0000 Bug 1993343 - Implement arrow key navigation between New Tab story cards. r=home-newtab-reviewers,accessibility-frontend-reviewers,nbarrett,ayeddi Differential Revision: https://phabricator.services.mozilla.com/D268148 Diffstat:
8 files changed, 360 insertions(+), 110 deletions(-)
diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -70,6 +70,48 @@ export function IntersectionObserver({ } export class _CardGrid extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + focusedIndex: 0, + }; + this.onCardFocus = this.onCardFocus.bind(this); + this.handleCardKeyDown = this.handleCardKeyDown.bind(this); + } + + onCardFocus(index) { + this.setState({ focusedIndex: index }); + } + + handleCardKeyDown(e) { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + + const currentCardEl = e.target.closest("article.ds-card"); + if (!currentCardEl) { + return; + } + + let targetCardEl = currentCardEl; + + // Walk through siblings to find the target card element + while (targetCardEl) { + targetCardEl = + e.key === "ArrowLeft" + ? targetCardEl.previousElementSibling + : targetCardEl.nextElementSibling; + + if (targetCardEl && targetCardEl.matches("article.ds-card")) { + const link = targetCardEl.querySelector("a.ds-card-link"); + if (link) { + link.focus(); + } + break; + } + } + } + } + // eslint-disable-next-line max-statements renderCards() { const prefs = this.props.Prefs.values; @@ -98,19 +140,25 @@ export class _CardGrid extends React.PureComponent { const recs = this.props.data.recommendations.slice(0, items); const cards = []; + let cardIndex = 0; for (let index = 0; index < items; index++) { const rec = recs[index]; - cards.push( + const isPlaceholder = topicsLoading || - this.props.placeholder || - !rec || - rec.placeholder || - (rec.flight_id && - !spocsStartupCacheEnabled && - this.props.App.isForStartupCache.DiscoveryStream) ? ( - <PlaceholderDSCard key={`dscard-${index}`} /> - ) : ( + this.props.placeholder || + !rec || + rec.placeholder || + (rec.flight_id && + !spocsStartupCacheEnabled && + this.props.App.isForStartupCache.DiscoveryStream); + + if (isPlaceholder) { + cards.push(<PlaceholderDSCard key={`dscard-${index}`} />); + } else { + const currentCardIndex = cardIndex; + cardIndex++; + cards.push( <DSCard key={`dscard-${rec.id}`} pos={rec.pos} @@ -154,9 +202,11 @@ export class _CardGrid extends React.PureComponent { format={rec.format} alt_text={rec.alt_text} isTimeSensitive={rec.isTimeSensitive} + tabIndex={currentCardIndex === this.state.focusedIndex ? 0 : -1} + onFocus={() => this.onCardFocus(currentCardIndex)} /> - ) - ); + ); + } } if (widgets?.positions?.length && widgets?.data?.length) { @@ -271,7 +321,13 @@ export class _CardGrid extends React.PureComponent { const gridClassName = this.renderGridClassName(); return ( - <>{cards?.length > 0 && <div className={gridClassName}>{cards}</div>}</> + <> + {cards?.length > 0 && ( + <div className={gridClassName} onKeyDown={this.handleCardKeyDown}> + {cards} + </div> + )} + </> ); } diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardSections/CardSections.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardSections/CardSections.jsx @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import React, { useCallback } from "react"; +import React, { useCallback, useState } from "react"; import { DSEmptyState } from "../DSEmptyState/DSEmptyState"; import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard"; import { useSelector } from "react-redux"; @@ -10,6 +10,7 @@ import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { selectWeatherPlacement, useIntersectionObserver, + getActiveColumnLayout, } from "../../../lib/utils"; import { SectionContextMenu } from "../SectionContextMenu/SectionContextMenu"; import { InterestPicker } from "../InterestPicker/InterestPicker"; @@ -161,6 +162,59 @@ function CardSection({ ); const { isForStartupCache } = useSelector(state => state.App); + const [focusedIndex, setFocusedIndex] = useState(0); + + const onCardFocus = index => { + setFocusedIndex(index); + }; + + const handleCardKeyDown = e => { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + + const currentCardEl = e.target.closest("article.ds-card"); + if (!currentCardEl) { + return; + } + + const activeColumn = getActiveColumnLayout(window.innerWidth); + + // Extract current position from classList + let currentPosition = null; + const positionPrefix = `${activeColumn}-position-`; + for (let className of currentCardEl.classList) { + if (className.startsWith(positionPrefix)) { + currentPosition = parseInt( + className.substring(positionPrefix.length), + 10 + ); + break; + } + } + + if (currentPosition === null) { + return; + } + + const targetPosition = + e.key === "ArrowLeft" ? currentPosition - 1 : currentPosition + 1; + + // Find card with target position + const parentEl = currentCardEl.parentElement; + if (parentEl) { + const targetSelector = `article.ds-card.${activeColumn}-position-${targetPosition}`; + const targetCardEl = parentEl.querySelector(targetSelector); + + if (targetCardEl) { + const link = targetCardEl.querySelector("a.ds-card-link"); + if (link) { + link.focus(); + } + } + } + } + }; + const showTopics = prefs[PREF_TOPICS_ENABLED]; const mayHaveSectionsCards = prefs[PREF_SECTIONS_CARDS_ENABLED]; const mayHaveSectionsCardsThumbsUpDown = @@ -359,7 +413,10 @@ function CardSection({ </div> {mayHaveSectionsPersonalization ? sectionContextWrapper : null} </div> - <div className={`ds-section-grid ds-card-grid`}> + <div + className={`ds-section-grid ds-card-grid`} + onKeyDown={handleCardKeyDown} + > {section.data.slice(0, maxTile).map((rec, index) => { const layoutData = getLayoutData( responsiveLayouts, @@ -434,6 +491,8 @@ function CardSection({ sectionFollowed={following} sectionLayoutName={layoutName} isTimeSensitive={rec.isTimeSensitive} + tabIndex={index === focusedIndex ? 0 : -1} + onFocus={() => onCardFocus(index)} /> ); return index === 0 && diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx @@ -112,6 +112,7 @@ export const DefaultMeta = ({ showTopics, icon_src, refinedCardsLayout, + tabIndex, }) => { const shouldHaveThumbs = format !== "rectangle" && mayHaveSectionsCards && mayHaveThumbsUpDown; @@ -155,6 +156,7 @@ export const DefaultMeta = ({ sponsor={sponsor} isThumbsDownActive={state.isThumbsDownActive} isThumbsUpActive={state.isThumbsUpActive} + tabIndex={tabIndex} /> )} {(shouldHaveFooterSection || refinedCardsLayout) && ( @@ -181,6 +183,7 @@ export const DefaultMeta = ({ isThumbsDownActive={state.isThumbsDownActive} isThumbsUpActive={state.isThumbsUpActive} refinedCardsLayout={refinedCardsLayout} + tabIndex={tabIndex} /> )} {showTopics && ( @@ -884,6 +887,8 @@ export class _DSCard extends React.PureComponent { url={this.props.url} title={this.props.title} isSponsored={!!this.props.flightId} + tabIndex={this.props.tabIndex} + onFocus={this.props.onFocus} > {this.props.showTopics && !this.props.mayHaveSectionsCards && @@ -969,6 +974,7 @@ export class _DSCard extends React.PureComponent { topic={this.props.topic} icon_src={faviconSrc} refinedCardsLayout={refinedCardsLayout} + tabIndex={this.props.tabIndex} /> )} </SafeAnchor> @@ -1017,6 +1023,7 @@ export class _DSCard extends React.PureComponent { isSectionsCard={this.props.mayHaveSectionsCards} topic={this.props.topic} selected_topics={this.props.selected_topics} + tabIndex={this.props.tabIndex} /> )} </div> diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx @@ -45,6 +45,7 @@ export class _DSLinkMenu extends React.PureComponent { tooltip={"newtab-menu-content-tooltip"} tooltipArgs={{ title }} onUpdate={this.props.onMenuUpdate} + tabIndex={this.props.tabIndex} > <LinkMenu dispatch={dispatch} diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSThumbsUpDownButtons/DSThumbsUpDownButtons.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/DSThumbsUpDownButtons/DSThumbsUpDownButtons.jsx @@ -11,6 +11,7 @@ function DSThumbsUpDownButtons({ isThumbsUpActive, isThumbsDownActive, refinedCardsLayout, + tabIndex, }) { let thumbsButtons = ( <> @@ -20,6 +21,7 @@ function DSThumbsUpDownButtons({ isThumbsUpActive ? "is-active" : null }`} data-l10n-id="newtab-pocket-thumbs-up-tooltip" + tabIndex={tabIndex} ></button> <button onClick={onThumbsDownClick} @@ -27,6 +29,7 @@ function DSThumbsUpDownButtons({ isThumbsDownActive ? "is-active" : null }`} data-l10n-id="newtab-pocket-thumbs-down-tooltip" + tabIndex={tabIndex} ></button> </> ); @@ -42,6 +45,7 @@ function DSThumbsUpDownButtons({ }`} data-l10n-id="newtab-pocket-thumbs-up-tooltip" type="icon ghost" + tabIndex={tabIndex} ></moz-button> <moz-button iconsrc="chrome://global/skin/icons/thumbs-down-20.svg" @@ -51,6 +55,7 @@ function DSThumbsUpDownButtons({ }`} data-l10n-id="newtab-pocket-thumbs-down-tooltip" type="icon ghost" + tabIndex={tabIndex} ></moz-button> </> ); diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx @@ -54,7 +54,7 @@ export class SafeAnchor extends React.PureComponent { } render() { - const { url, className, title, isSponsored } = this.props; + const { url, className, title, isSponsored, onFocus } = this.props; let anchor = ( <a @@ -69,6 +69,7 @@ export class SafeAnchor extends React.PureComponent { tabIndex: this.props.tabIndex, } : {})} + {...(onFocus ? { onFocus } : {})} > {this.props.children} </a> diff --git a/browser/extensions/newtab/content-src/lib/utils.jsx b/browser/extensions/newtab/content-src/lib/utils.jsx @@ -58,6 +58,21 @@ function useIntersectionObserver(callback, threshold = 0.3) { } /** + * Determines which column layout is active based on the screen width + * @param {number} screenWidth - The current window width (in pixels) + * @returns {string} The active column layout (e.g. "col-3", "col-2", "col-1") + */ +function getActiveColumnLayout(screenWidth) { + const breakpoints = [ + { min: 1374, column: "col-4" }, // $break-point-sections-variant + { min: 1122, column: "col-3" }, // $break-point-widest + { min: 724, column: "col-2" }, // $break-point-layout-variant + { min: 0, column: "col-1" }, // (default layout) + ]; + return breakpoints.find(bp => screenWidth >= bp.min).column; +} + +/** * Determines the active card size ("small", "medium", or "large") based on the screen width * and class names applied to the card element at the time of an event (example: click) * @@ -86,19 +101,10 @@ function getActiveCardSize(screenWidth, classNames, sectionsEnabled, flightId) { } const classList = classNames.split(" "); - - // Each breakpoint corresponds to a minimum screen width and its associated column class - const breakpoints = [ - { min: 1374, column: "col-4" }, // $break-point-sections-variant - { min: 1122, column: "col-3" }, // $break-point-widest - { min: 724, column: "col-2" }, // $break-point-layout-variant - { min: 0, column: "col-1" }, // (default layout) - ]; - const cardTypes = ["small", "medium", "large"]; // Determine which column is active based on the current screen width - const currColumnCount = breakpoints.find(bp => screenWidth >= bp.min).column; + const currColumnCount = getActiveColumnLayout(screenWidth); // Match the card type for that column count for (let type of cardTypes) { @@ -317,6 +323,7 @@ function selectWeatherPlacement(state) { export { useIntersectionObserver, getActiveCardSize, + getActiveColumnLayout, useConfetti, selectWeatherPlacement, }; diff --git a/browser/extensions/newtab/data/content/activity-stream.bundle.js b/browser/extensions/newtab/data/content/activity-stream.bundle.js @@ -2494,7 +2494,8 @@ class _DSLinkMenu extends (external_React_default()).PureComponent { tooltipArgs: { title }, - onUpdate: this.props.onMenuUpdate + onUpdate: this.props.onMenuUpdate, + tabIndex: this.props.tabIndex }, /*#__PURE__*/external_React_default().createElement(LinkMenu, { dispatch: dispatch, index: index, @@ -2592,6 +2593,35 @@ function useIntersectionObserver(callback, threshold = 0.3) { } /** + * Determines which column layout is active based on the screen width + * @param {number} screenWidth - The current window width (in pixels) + * @returns {string} The active column layout (e.g. "col-3", "col-2", "col-1") + */ +function getActiveColumnLayout(screenWidth) { + const breakpoints = [{ + min: 1374, + column: "col-4" + }, + // $break-point-sections-variant + { + min: 1122, + column: "col-3" + }, + // $break-point-widest + { + min: 724, + column: "col-2" + }, + // $break-point-layout-variant + { + min: 0, + column: "col-1" + } // (default layout) + ]; + return breakpoints.find(bp => screenWidth >= bp.min).column; +} + +/** * Determines the active card size ("small", "medium", or "large") based on the screen width * and class names applied to the card element at the time of an event (example: click) * @@ -2619,32 +2649,10 @@ function getActiveCardSize(screenWidth, classNames, sectionsEnabled, flightId) { return null; } const classList = classNames.split(" "); - - // Each breakpoint corresponds to a minimum screen width and its associated column class - const breakpoints = [{ - min: 1374, - column: "col-4" - }, - // $break-point-sections-variant - { - min: 1122, - column: "col-3" - }, - // $break-point-widest - { - min: 724, - column: "col-2" - }, - // $break-point-layout-variant - { - min: 0, - column: "col-1" - } // (default layout) - ]; const cardTypes = ["small", "medium", "large"]; // Determine which column is active based on the current screen width - const currColumnCount = breakpoints.find(bp => screenWidth >= bp.min).column; + const currColumnCount = getActiveColumnLayout(screenWidth); // Match the card type for that column count for (let type of cardTypes) { @@ -3140,7 +3148,8 @@ class SafeAnchor extends (external_React_default()).PureComponent { url, className, title, - isSponsored + isSponsored, + onFocus } = this.props; let anchor = /*#__PURE__*/external_React_default().createElement("a", SafeAnchor_extends({ href: this.safeURI(url), @@ -3151,6 +3160,8 @@ class SafeAnchor extends (external_React_default()).PureComponent { }, this.props.tabIndex === 0 || this.props.tabIndex ? { ref: this.props.setRef, tabIndex: this.props.tabIndex + } : {}, onFocus ? { + onFocus } : {}), this.props.children); return anchor; } @@ -3387,16 +3398,19 @@ function DSThumbsUpDownButtons({ onThumbsDownClick, isThumbsUpActive, isThumbsDownActive, - refinedCardsLayout + refinedCardsLayout, + tabIndex }) { let thumbsButtons = /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, /*#__PURE__*/external_React_default().createElement("button", { onClick: onThumbsUpClick, className: `card-stp-thumbs-button icon icon-thumbs-up ${isThumbsUpActive ? "is-active" : null}`, - "data-l10n-id": "newtab-pocket-thumbs-up-tooltip" + "data-l10n-id": "newtab-pocket-thumbs-up-tooltip", + tabIndex: tabIndex }), /*#__PURE__*/external_React_default().createElement("button", { onClick: onThumbsDownClick, className: `card-stp-thumbs-button icon icon-thumbs-down ${isThumbsDownActive ? "is-active" : null}`, - "data-l10n-id": "newtab-pocket-thumbs-down-tooltip" + "data-l10n-id": "newtab-pocket-thumbs-down-tooltip", + tabIndex: tabIndex })); if (refinedCardsLayout) { thumbsButtons = /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, /*#__PURE__*/external_React_default().createElement("moz-button", { @@ -3404,13 +3418,15 @@ function DSThumbsUpDownButtons({ onClick: onThumbsUpClick, className: `card-stp-thumbs-button icon icon-thumbs-up refined-layout ${isThumbsUpActive ? "is-active" : null}`, "data-l10n-id": "newtab-pocket-thumbs-up-tooltip", - type: "icon ghost" + type: "icon ghost", + tabIndex: tabIndex }), /*#__PURE__*/external_React_default().createElement("moz-button", { iconsrc: "chrome://global/skin/icons/thumbs-down-20.svg", onClick: onThumbsDownClick, className: `card-stp-thumbs-button icon icon-thumbs-down ${isThumbsDownActive ? "is-active" : null}`, "data-l10n-id": "newtab-pocket-thumbs-down-tooltip", - type: "icon ghost" + type: "icon ghost", + tabIndex: tabIndex })); } return /*#__PURE__*/external_React_default().createElement("div", { @@ -3529,7 +3545,8 @@ const DefaultMeta = ({ isSectionsCard, showTopics, icon_src, - refinedCardsLayout + refinedCardsLayout, + tabIndex }) => { const shouldHaveThumbs = format !== "rectangle" && mayHaveSectionsCards && mayHaveThumbsUpDown; const shouldHaveFooterSection = isSectionsCard && (shouldHaveThumbs || showTopics); @@ -3556,7 +3573,8 @@ const DefaultMeta = ({ onThumbsUpClick: onThumbsUpClick, sponsor: sponsor, isThumbsDownActive: state.isThumbsDownActive, - isThumbsUpActive: state.isThumbsUpActive + isThumbsUpActive: state.isThumbsUpActive, + tabIndex: tabIndex }), (shouldHaveFooterSection || refinedCardsLayout) && /*#__PURE__*/external_React_default().createElement("div", { className: "sections-card-footer" }, refinedCardsLayout && format !== "rectangle" && format !== "spoc" && /*#__PURE__*/external_React_default().createElement(DSSource, { @@ -3574,7 +3592,8 @@ const DefaultMeta = ({ sponsor: sponsor, isThumbsDownActive: state.isThumbsDownActive, isThumbsUpActive: state.isThumbsUpActive, - refinedCardsLayout: refinedCardsLayout + refinedCardsLayout: refinedCardsLayout, + tabIndex: tabIndex }), showTopics && /*#__PURE__*/external_React_default().createElement("span", { className: "ds-card-topic", "data-l10n-id": `newtab-topic-label-${topic}` @@ -4139,7 +4158,9 @@ class _DSCard extends (external_React_default()).PureComponent { onLinkClick: !this.props.placeholder ? this.onLinkClick : undefined, url: this.props.url, title: this.props.title, - isSponsored: !!this.props.flightId + isSponsored: !!this.props.flightId, + tabIndex: this.props.tabIndex, + onFocus: this.props.onFocus }, this.props.showTopics && !this.props.mayHaveSectionsCards && this.props.topic && !refinedCardsLayout && /*#__PURE__*/external_React_default().createElement("span", { className: "ds-card-topic", "data-l10n-id": `newtab-topic-label-${this.props.topic}` @@ -4211,7 +4232,8 @@ class _DSCard extends (external_React_default()).PureComponent { format: format, topic: this.props.topic, icon_src: faviconSrc, - refinedCardsLayout: refinedCardsLayout + refinedCardsLayout: refinedCardsLayout, + tabIndex: this.props.tabIndex })), /*#__PURE__*/external_React_default().createElement("div", { className: "card-stp-button-hover-background" }, /*#__PURE__*/external_React_default().createElement("div", { @@ -4248,7 +4270,8 @@ class _DSCard extends (external_React_default()).PureComponent { format: format ? format : getActiveCardSize(window.innerWidth, this.props.sectionsClassNames, this.props.section, this.props.flightId), isSectionsCard: this.props.mayHaveSectionsCards, topic: this.props.topic, - selected_topics: this.props.selected_topics + selected_topics: this.props.selected_topics, + tabIndex: this.props.tabIndex })))); } } @@ -5129,6 +5152,42 @@ function CardGrid_IntersectionObserver({ }, children); } class _CardGrid extends (external_React_default()).PureComponent { + constructor(props) { + super(props); + this.state = { + focusedIndex: 0 + }; + this.onCardFocus = this.onCardFocus.bind(this); + this.handleCardKeyDown = this.handleCardKeyDown.bind(this); + } + onCardFocus(index) { + this.setState({ + focusedIndex: index + }); + } + handleCardKeyDown(e) { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + const currentCardEl = e.target.closest("article.ds-card"); + if (!currentCardEl) { + return; + } + let targetCardEl = currentCardEl; + + // Walk through siblings to find the target card element + while (targetCardEl) { + targetCardEl = e.key === "ArrowLeft" ? targetCardEl.previousElementSibling : targetCardEl.nextElementSibling; + if (targetCardEl && targetCardEl.matches("article.ds-card")) { + const link = targetCardEl.querySelector("a.ds-card-link"); + if (link) { + link.focus(); + } + break; + } + } + } + } + // eslint-disable-next-line max-statements renderCards() { const prefs = this.props.Prefs.values; @@ -5154,54 +5213,64 @@ class _CardGrid extends (external_React_default()).PureComponent { const trendingVariant = prefs[PREF_TRENDING_SEARCH_VARIANT]; const recs = this.props.data.recommendations.slice(0, items); const cards = []; + let cardIndex = 0; for (let index = 0; index < items; index++) { const rec = recs[index]; - cards.push(topicsLoading || this.props.placeholder || !rec || rec.placeholder || rec.flight_id && !spocsStartupCacheEnabled && this.props.App.isForStartupCache.DiscoveryStream ? /*#__PURE__*/external_React_default().createElement(PlaceholderDSCard, { - key: `dscard-${index}` - }) : /*#__PURE__*/external_React_default().createElement(DSCard, { - key: `dscard-${rec.id}`, - pos: rec.pos, - flightId: rec.flight_id, - image_src: rec.image_src, - raw_image_src: rec.raw_image_src, - icon_src: rec.icon_src, - word_count: rec.word_count, - time_to_read: rec.time_to_read, - title: rec.title, - topic: rec.topic, - features: rec.features, - showTopics: showTopics, - selectedTopics: selectedTopics, - excerpt: rec.excerpt, - availableTopics: availableTopics, - url: rec.url, - id: rec.id, - shim: rec.shim, - fetchTimestamp: rec.fetchTimestamp, - type: this.props.type, - context: rec.context, - sponsor: rec.sponsor, - sponsored_by_override: rec.sponsored_by_override, - dispatch: this.props.dispatch, - source: rec.domain, - publisher: rec.publisher, - pocket_id: rec.pocket_id, - context_type: rec.context_type, - bookmarkGuid: rec.bookmarkGuid, - ctaButtonSponsors: ctaButtonSponsors, - ctaButtonVariant: ctaButtonVariant, - recommendation_id: rec.recommendation_id, - firstVisibleTimestamp: this.props.firstVisibleTimestamp, - mayHaveThumbsUpDown: mayHaveThumbsUpDown, - mayHaveSectionsCards: mayHaveSectionsCards, - corpus_item_id: rec.corpus_item_id, - scheduled_corpus_item_id: rec.scheduled_corpus_item_id, - recommended_at: rec.recommended_at, - received_rank: rec.received_rank, - format: rec.format, - alt_text: rec.alt_text, - isTimeSensitive: rec.isTimeSensitive - })); + const isPlaceholder = topicsLoading || this.props.placeholder || !rec || rec.placeholder || rec.flight_id && !spocsStartupCacheEnabled && this.props.App.isForStartupCache.DiscoveryStream; + if (isPlaceholder) { + cards.push(/*#__PURE__*/external_React_default().createElement(PlaceholderDSCard, { + key: `dscard-${index}` + })); + } else { + const currentCardIndex = cardIndex; + cardIndex++; + cards.push(/*#__PURE__*/external_React_default().createElement(DSCard, { + key: `dscard-${rec.id}`, + pos: rec.pos, + flightId: rec.flight_id, + image_src: rec.image_src, + raw_image_src: rec.raw_image_src, + icon_src: rec.icon_src, + word_count: rec.word_count, + time_to_read: rec.time_to_read, + title: rec.title, + topic: rec.topic, + features: rec.features, + showTopics: showTopics, + selectedTopics: selectedTopics, + excerpt: rec.excerpt, + availableTopics: availableTopics, + url: rec.url, + id: rec.id, + shim: rec.shim, + fetchTimestamp: rec.fetchTimestamp, + type: this.props.type, + context: rec.context, + sponsor: rec.sponsor, + sponsored_by_override: rec.sponsored_by_override, + dispatch: this.props.dispatch, + source: rec.domain, + publisher: rec.publisher, + pocket_id: rec.pocket_id, + context_type: rec.context_type, + bookmarkGuid: rec.bookmarkGuid, + ctaButtonSponsors: ctaButtonSponsors, + ctaButtonVariant: ctaButtonVariant, + recommendation_id: rec.recommendation_id, + firstVisibleTimestamp: this.props.firstVisibleTimestamp, + mayHaveThumbsUpDown: mayHaveThumbsUpDown, + mayHaveSectionsCards: mayHaveSectionsCards, + corpus_item_id: rec.corpus_item_id, + scheduled_corpus_item_id: rec.scheduled_corpus_item_id, + recommended_at: rec.recommended_at, + received_rank: rec.received_rank, + format: rec.format, + alt_text: rec.alt_text, + isTimeSensitive: rec.isTimeSensitive, + tabIndex: currentCardIndex === this.state.focusedIndex ? 0 : -1, + onFocus: () => this.onCardFocus(currentCardIndex) + })); + } } if (widgets?.positions?.length && widgets?.data?.length) { let positionIndex = 0; @@ -5292,7 +5361,8 @@ class _CardGrid extends (external_React_default()).PureComponent { } const gridClassName = this.renderGridClassName(); return /*#__PURE__*/external_React_default().createElement((external_React_default()).Fragment, null, cards?.length > 0 && /*#__PURE__*/external_React_default().createElement("div", { - className: gridClassName + className: gridClassName, + onKeyDown: this.handleCardKeyDown }, cards)); } renderGridClassName() { @@ -11939,6 +12009,47 @@ function CardSection({ const { isForStartupCache } = (0,external_ReactRedux_namespaceObject.useSelector)(state => state.App); + const [focusedIndex, setFocusedIndex] = (0,external_React_namespaceObject.useState)(0); + const onCardFocus = index => { + setFocusedIndex(index); + }; + const handleCardKeyDown = e => { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + const currentCardEl = e.target.closest("article.ds-card"); + if (!currentCardEl) { + return; + } + const activeColumn = getActiveColumnLayout(window.innerWidth); + + // Extract current position from classList + let currentPosition = null; + const positionPrefix = `${activeColumn}-position-`; + for (let className of currentCardEl.classList) { + if (className.startsWith(positionPrefix)) { + currentPosition = parseInt(className.substring(positionPrefix.length), 10); + break; + } + } + if (currentPosition === null) { + return; + } + const targetPosition = e.key === "ArrowLeft" ? currentPosition - 1 : currentPosition + 1; + + // Find card with target position + const parentEl = currentCardEl.parentElement; + if (parentEl) { + const targetSelector = `article.ds-card.${activeColumn}-position-${targetPosition}`; + const targetCardEl = parentEl.querySelector(targetSelector); + if (targetCardEl) { + const link = targetCardEl.querySelector("a.ds-card-link"); + if (link) { + link.focus(); + } + } + } + } + }; const showTopics = prefs[CardSections_PREF_TOPICS_ENABLED]; const mayHaveSectionsCards = prefs[CardSections_PREF_SECTIONS_CARDS_ENABLED]; const mayHaveSectionsCardsThumbsUpDown = prefs[PREF_SECTIONS_CARDS_THUMBS_UP_DOWN_ENABLED]; @@ -12096,7 +12207,8 @@ function CardSection({ }, subtitle)), showWeather && /*#__PURE__*/external_React_default().createElement(Weather_Weather, { isInSection: true })), mayHaveSectionsPersonalization ? sectionContextWrapper : null), /*#__PURE__*/external_React_default().createElement("div", { - className: `ds-section-grid ds-card-grid` + className: `ds-section-grid ds-card-grid`, + onKeyDown: handleCardKeyDown }, section.data.slice(0, maxTile).map((rec, index) => { const layoutData = getLayoutData(responsiveLayouts, index, refinedCardsLayout, shouldShowTrendingSearch && sectionKey); const { @@ -12160,7 +12272,9 @@ function CardSection({ sectionPosition: sectionPosition, sectionFollowed: following, sectionLayoutName: layoutName, - isTimeSensitive: rec.isTimeSensitive + isTimeSensitive: rec.isTimeSensitive, + tabIndex: index === focusedIndex ? 0 : -1, + onFocus: () => onCardFocus(index) }); return index === 0 && shouldShowTrendingSearch && sectionKey === "top_stories_section" ? [card, /*#__PURE__*/external_React_default().createElement(TrendingSearches, { key: "trending"