Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8b49158
wip
LilyL0u Jul 16, 2026
0fca8e3
prettier bug added an extra semicolon
LilyL0u Jul 16, 2026
afd4605
put back default of nothing highlighted for article not in bottom nav
LilyL0u Jul 16, 2026
4e86887
make home default highlighted
LilyL0u Jul 17, 2026
6f5acc7
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
LilyL0u Jul 17, 2026
4897172
Updated snapshots and fixed linting issues
Nabeel1276 Jul 21, 2026
5ca1ba8
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
Nabeel1276 Jul 21, 2026
fd4797a
Updated integration snapshots
Nabeel1276 Jul 21, 2026
71a422d
Merge branch 'WS-2117-add-lightweight-contextual-navigation-attributi…
Nabeel1276 Jul 21, 2026
73c945b
a11y fix for current page announcements
LilyL0u Jul 24, 2026
90a2988
new snaps
LilyL0u Jul 24, 2026
5f16a0f
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
LilyL0u Jul 24, 2026
a26db35
let's try this snapshot update
LilyL0u Jul 27, 2026
f885d06
snapshots after stopping timeout
LilyL0u Jul 27, 2026
209417d
snapshots after running command to match github action
LilyL0u Jul 27, 2026
eccbe97
manually change snapshots to match github action integration test run
LilyL0u Jul 27, 2026
9020e2d
i think this removes snashots that were obsolete
LilyL0u Jul 27, 2026
bf372c0
more attempt at obsolete removal
LilyL0u Jul 27, 2026
45be34c
comment fix
LilyL0u Jul 28, 2026
f73b8e1
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
LilyL0u Jul 28, 2026
63612df
make shouldAnnounceCurrentPage into explicit boolean
LilyL0u Jul 28, 2026
c2e768b
update snapshot for css changes from latest
LilyL0u Jul 28, 2026
56b0963
prettier?
LilyL0u Jul 28, 2026
5bde2d7
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
LilyL0u Jul 29, 2026
1a7bc8e
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
pvaliani Jul 29, 2026
a926415
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
holchris Jul 29, 2026
1bd2b99
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
LilyL0u Jul 30, 2026
04d1f81
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
holchris Jul 30, 2026
412810f
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
LilyL0u Jul 30, 2026
3cd16d1
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
louisearchibald Aug 11, 2026
cc506d6
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
louisearchibald Aug 12, 2026
e622883
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
louisearchibald Aug 13, 2026
76dd6be
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
louisearchibald Aug 13, 2026
e2542a3
Merge branch 'WS-2117-add-lightweight-contextual-navigation-attributi…
louisearchibald Aug 13, 2026
f921d59
update Navigation type to include type as a property
louisearchibald Aug 13, 2026
0ae1bd4
update logic to use type field
louisearchibald Aug 13, 2026
6bc8ff4
remove unused field
louisearchibald Aug 13, 2026
7d081fe
update index check
louisearchibald Aug 13, 2026
dc94a2c
update test fixture to include type watch and listen
louisearchibald Aug 13, 2026
40a5b7e
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
louisearchibald Aug 13, 2026
f31e7e3
Merge branch 'WS-2117-add-lightweight-contextual-navigation-attributi…
louisearchibald Aug 13, 2026
4f5bef3
Merge branch 'latest' into WS-2117-add-lightweight-contextual-navigat…
louisearchibald Aug 14, 2026
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
2,292 changes: 2,148 additions & 144 deletions src/app/components/Navigation/__snapshots__/index.test.tsx.snap

Large diffs are not rendered by default.

462 changes: 461 additions & 1 deletion src/app/components/Navigation/index.test.tsx

Large diffs are not rendered by default.

151 changes: 98 additions & 53 deletions src/app/components/Navigation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,19 @@ import useViewTracker from '#app/hooks/useViewTracker';
import { RequestContext } from '#contexts/RequestContext';
import { ServiceContext } from '#contexts/ServiceContext';
import { Direction, Navigation, PageTypes } from '#app/models/types/global';
import {
TV_PAGE,
LIVE_TV_PAGE,
AUDIO_PAGE,
LIVE_RADIO_PAGE,
MEDIA_ARTICLE_PAGE,
ARTICLE_PAGE,
} from '#app/routes/utils/pageTypes';
import { TopStoryItem } from '#app/pages/ArticlePage/PagePromoSections/TopStoriesSection/types';
import Canonical from './index.canonical';
import Amp from './index.amp';
import styles from './index.styles';

const getTopItemA11yProps = ({
item,
index,
active,
pageType,
}: {
item: Navigation;
index: number;
active: boolean;
pageType?: PageTypes;
}) => {
const shouldAnnounceCurrentPage =
pageType === 'home' && active && index === 0;

if (!active || shouldAnnounceCurrentPage) {
return {};
}

return {
'aria-current': undefined,
'aria-label': item.title,
'aria-labelledby': undefined,
};
};

/**
* EXPECTED DATA SHAPE (from server):
* navItems: Navigation[] where each item is:
Expand All @@ -58,8 +41,14 @@ type RenderListItemsArgs = {
activeIndex: number;
clickTracker: ReturnType<typeof useClickTrackerHandler>;
viewTracker?: ReturnType<typeof useViewTracker>;
pageType?: PageTypes;
navType?: 'top' | 'bottom' | 'dropdown';
// For the top nav: controls whether "current page" is announced to screen
// readers. True when activeIndex reflects a genuine URL match or a real
// Watch/Listen category attribution. False when Home is highlighted only as
// a last-resort default (no match, or categorized Watch/Listen doesn't exist
// in nav so falls back to Home). The bottom nav only marks items active on
// genuine URL matches and always announces. The dropdown nav never marks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe i've misunderstood but does this match the acceptance criteria? The ticket says secondary navigation items should not be highlighted, but this says matching bottom navigation items remain active and are announced

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the ticket might be wrong there but will check. I would have to remove current functionality, that we only recently added, to fulfil that criteria!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine to keep the sub-navigation functionality that already existed 👍

// anything active.
shouldAnnounce?: boolean;
};

const renderListItems = ({
Expand All @@ -70,25 +59,26 @@ const renderListItems = ({
activeIndex,
clickTracker,
viewTracker,
pageType,
navType,
shouldAnnounce = true,
}: RenderListItemsArgs) =>
navigation.map((item, index) => {
const { title, url } = item;
const active = index === activeIndex;
const a11yProps =
getTopItemA11yProps({ item, index, active, pageType }) ?? {};
// Only announce "current page" to screen readers when the highlight
// genuinely reflects the user's location, not when Home is highlighted
// purely as a last-resort default/fallback categorisation.
const announceCurrentPage = active && shouldAnnounce;

return (
<Li
key={`${title}-${url}`}
url={url}
active={active}
currentPageText={currentPage}
announceCurrentPage={announceCurrentPage}
dir={dir}
clickTracker={clickTracker}
{...(viewTracker && { viewTracker })}
{...(navType === 'top' ? a11yProps : {})}
>
{title}
</Li>
Expand Down Expand Up @@ -119,31 +109,46 @@ const matchesUrl = ({

/**
* Find which top item should be active:
* - If current page matches a top item url -> that index is active
* - If current page URL matches a top item url -> that index is active
* - Else if it matches any subItem url -> parent index is active
* - Else if pageType === 'home' -> 0
* - Else -> -1 (no active)
* - Else use page-type attribution:
* - Video page (tv, liveTV), video mediaArticle, or article with video primaryMediaType -> index 1 (Watch)
* - Audio page (audio, liveRadio), audio mediaArticle, or article with audio primaryMediaType -> index 2 (Listen)
* - Any other type (non-media article, topic, home, etc.) -> index 0 (Home)
* Nav items are hopefully always ordered: 0=Home, 1=Watch, 2=Listen, otherwise it won't be possible to know which one to highlight when we aren't matching on url
* primaryMediaType must be explicitly 'video' or 'audio' to trigger Watch/Listen attribution.
* Home is the default/fallback for every page that isn't a Watch or Listen match.
* Returns shouldAnnounce: true when the active index genuinely reflects the
* user's location - a direct/subItem URL match, or a real Watch/Listen
* category attribution - false when Home is highlighted only as a
* last-resort default (no match at all, or the categorised Watch/Listen item
* doesn't exist in the nav so it falls back to Home). Used to decide whether
* to announce "current page" to screen readers.
*/
const getActiveTopIndex = ({
topItems,
canonicalLink,
origin,
pageType,
primaryMediaType,
}: {
topItems: Navigation[];
origin: string;
canonicalLink?: string;
pageType?: PageTypes;
}) => {
if (!topItems?.length) return -1;
primaryMediaType?: 'audio' | 'video';
}): { index: number; shouldAnnounce: boolean } => {
if (!topItems?.length) return { index: -1, shouldAnnounce: false };

// try to find a direct match on the top-level items with the current page URL
// it returns the index of the first item that matches or -1 if none match
const directMatchIndex = topItems.findIndex(item =>
matchesUrl({ canonicalLink, origin, navUrl: item.url }),
);
// if a match is found, return the index of the matching top-level item (this is the active item)
if (directMatchIndex > -1) return directMatchIndex;
if (directMatchIndex > -1) {
return { index: directMatchIndex, shouldAnnounce: true };
}

// if no direct match in the top level items, check if any of the subItems match the current page URL
// this is so that if a subItem matches the current page, its parent top-level item will be marked as active in the navigation
Expand All @@ -156,20 +161,60 @@ const getActiveTopIndex = ({
matchesUrl({ canonicalLink, origin, navUrl: child.url }),
),
);
if (parentIndexByChild > -1) return parentIndexByChild;
if (parentIndexByChild > -1) {
// Home's subItems are just categorisation (e.g. topic links), not
// pages that represent "being on the home page" itself, so a subItem
// match on Home shouldn't announce "current page" - only a direct match
// on Home's own URL should. Watch/Listen subItem matches (e.g. a video
// page nested under Watch) do genuinely represent being on that section,
// so those should still announce.
return {
index: parentIndexByChild,
shouldAnnounce: parentIndexByChild !== 0,
};
}

const watchIndex = topItems.findIndex(item => item.type === 'watch');
const listenIndex = topItems.findIndex(item => item.type === 'listen');

// Video pages, video mediaArticles, and article pages with a video primaryMediaType.
// If there's no Watch item to attribute to, this falls back to highlighting
// Home as a default categorisation only, so it shouldn't be announced.
if (
pageType === TV_PAGE ||
pageType === LIVE_TV_PAGE ||
(pageType === MEDIA_ARTICLE_PAGE && primaryMediaType === 'video') ||
(pageType === ARTICLE_PAGE && primaryMediaType === 'video')
Comment thread
eagerterrier marked this conversation as resolved.
) {
return watchIndex > -1
? { index: watchIndex, shouldAnnounce: true }
: { index: 0, shouldAnnounce: false };
}

// We always want the first top level nav item to be active on the home page,
// and the first nav item should always be 'Home'
if (pageType === 'home') return 0;
// Audio pages, audio mediaArticles, and article pages with an audio primaryMediaType
// If there's no Listen item to attribute to, this falls back to highlighting
// Home as a default categorisation only, so it shouldn't be announced.
if (
pageType === AUDIO_PAGE ||
pageType === LIVE_RADIO_PAGE ||
(pageType === MEDIA_ARTICLE_PAGE && primaryMediaType === 'audio') ||
(pageType === ARTICLE_PAGE && primaryMediaType === 'audio')
) {
return listenIndex > -1
? { index: listenIndex, shouldAnnounce: true }
: { index: 0, shouldAnnounce: false };
}

return -1;
// All other page types (article, topic, home, live, etc.) default to Home (index 0).
return { index: 0, shouldAnnounce: false };
};

type NavigationContainerProps = {
navItems?: Navigation[];
propsForTopBarOJComponent?: {
blocks?: TopStoryItem[];
};
primaryMediaType?: 'audio' | 'video';
};

const navEventTrackingMetadata = { componentName: 'scrollable-navigation' };
Expand All @@ -178,6 +223,7 @@ const dropdownNavEventTrackingData = { componentName: 'dropdown-navigation' };
const NavigationContainer: React.FC<NavigationContainerProps> = ({
navItems,
propsForTopBarOJComponent,
primaryMediaType,
}) => {
const { isAmp, isLite, pageType, canonicalLink, origin } =
use(RequestContext);
Expand Down Expand Up @@ -223,12 +269,14 @@ const NavigationContainer: React.FC<NavigationContainerProps> = ({
);

// Compute which top item is active based on current URL
const topActiveIndex = getActiveTopIndex({
topItems: navigationItems,
canonicalLink,
origin,
pageType,
});
const { index: topActiveIndex, shouldAnnounce: topShouldAnnounce } =
getActiveTopIndex({
topItems: navigationItems,
canonicalLink,
origin,
pageType,
primaryMediaType,
});

const topScrollableListItems = (
<NavigationUl>
Expand All @@ -240,8 +288,7 @@ const NavigationContainer: React.FC<NavigationContainerProps> = ({
activeIndex: topActiveIndex,
clickTracker: topNavClickTrackerHandler,
viewTracker: topNavViewTracker,
pageType,
navType: 'top',
shouldAnnounce: topShouldAnnounce,
})}
</NavigationUl>
);
Expand Down Expand Up @@ -270,7 +317,6 @@ const NavigationContainer: React.FC<NavigationContainerProps> = ({
dir,
activeIndex: activeBottomIndex,
clickTracker: bottomNavClickTrackerHandler,
pageType,
})}
</NavigationUl>
);
Expand All @@ -293,7 +339,6 @@ const NavigationContainer: React.FC<NavigationContainerProps> = ({
activeIndex: -1,
clickTracker: dropdownNavClickTrackerHandler,
viewTracker: dropdownNavViewTracker,
pageType,
})}
</DropdownUl>
);
Expand Down
16 changes: 16 additions & 0 deletions src/app/components/PageLayoutWrapper/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Props = {
type: PageTypes;
topics?: { topicName: string }[];
};
blockTypes?: string[];
content?: { model?: ModelType };
secondaryColumn?: { topStories: TopStoryItem[] };
mostRead?: { items: (OptimoMostReadRecord | CPSMostReadRecord)[] };
Expand All @@ -54,6 +55,20 @@ const PageLayoutWrapper = ({

const isErrorPage = ![200].includes(status) || !status;
const pageType = pageData?.metadata?.type;

const primaryMediaType = (() => {
const blockTypes = pageData?.blockTypes ?? [];
if (blockTypes.includes('audio')) return 'audio' as const;
if (blockTypes.includes('video')) return 'video' as const;
// Fallback: scan top-level content blocks for an audio or video block.
// This covers SFV articles where blockTypes may not be populated.
const contentBlocks = (pageData?.content?.model?.blocks ?? []) as {
type: string;
}[];
if (contentBlocks.some(b => b.type === 'audio')) return 'audio' as const;
if (contentBlocks.some(b => b.type === 'video')) return 'video' as const;
return undefined;
})();
const reportingPageType = pageType?.replace(/ /g, '');
const isOfflinePage = pageType === OFFLINE_PAGE;
const isWindowValid = typeof window !== 'undefined';
Expand Down Expand Up @@ -233,6 +248,7 @@ const PageLayoutWrapper = ({
<div id="main-wrapper" css={styles.wrapper}>
<HeaderContainer
navItems={navItems}
primaryMediaType={primaryMediaType}
propsForTopBarOJComponent={{
blocks: pageData?.secondaryColumn?.topStories || [],
}}
Expand Down
7 changes: 6 additions & 1 deletion src/app/legacy/containers/Header/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ const Header = ({
);
};

const HeaderContainer = ({ navItems, propsForTopBarOJComponent }) => {
const HeaderContainer = ({
navItems,
propsForTopBarOJComponent,
primaryMediaType,
}) => {
const { isAmp, isApp, pageType, isLite } = use(RequestContext);
const { service, translations, dir, scriptLink, lang, serviceLang } =
use(ServiceContext);
Expand Down Expand Up @@ -146,6 +150,7 @@ const HeaderContainer = ({ navItems, propsForTopBarOJComponent }) => {
<NavigationComponent
navItems={navItems}
propsForTopBarOJComponent={propsForTopBarOJComponent}
primaryMediaType={primaryMediaType}
/>
</header>
);
Expand Down
Loading
Loading